Skip to content

E2E Test Scenario — 시스템 템플릿 참조 수식(formula) 수당 계산의 월 전환 안정성 ​

Status: Verified · 2026-06-11 로컬 전체 실행 (org SHEETRH, M=2026-06 → M+1=2026-07 — 전 단계 통과)

검증 결과 요약 (2026-06-11 실행) ​

  • 참조 방식 ①②③ 전부 기대값 일치 (박치료 · 시급 14,383.56 파생): wd 215,754 → PENDING 4h 승인 후 302,055 (게이트 반영 즉시 재계산 확인) · cross-sheet posAlw 30,000 / ntMeal 8,000 · 수식→수식 체인 otPay = ROUND(302,055+14,384+8,000) = 324,439 · IF 분기 posPay 30,000/0.
  • 수식 컬럼 커밋: 대상 급여대장 aggregate 컬럼 페어(targetSheetSlug/Key) 스탬프 + inputSnapshot.formula 에 수식 원문 기록 (항목코드는 그 aggregate 컬럼에 — 2026-06-19 모델) + 재커밋 idempotent(insert 0/update 4). 0원 수식 레코드는 생성됨(기존 동작 — eCount 출력 제외).
  • 🆕 4차 수정 1차 검증 통과 — 이월(carry-forward in-batch) · 사용자 템플릿 적용 · JSON import 세 경로 모두 수식 안 [<uuid>:colKey] 가 7월 "수당 기준값" uuid 로 재작성, 6월 uuid 잔존 없음. 음성 케이스(대상 기간에 동명 시트 부재 — 8월 적용)는 원본 uuid 유지.
  • 7월 재계산: 행/값 미복사(0행) → 7월 입력만으로 wd 64,727 (승인 3h만 — pending 5h/1h 제외) · posAlw 35,000 (7월에 새로 입력한 값 — 재매핑된 참조가 새 달 시트를 실제로 읽음) · 신규 입사자 40,274 합류 · lineage 7월 시트.
  • 격리/회귀: 6월 레코드(324,439/30,000)·수식 uuid·재열람 값 전부 불변 · 수식 셀 직접 편집 서버 400.
  • UI 수식 에디터: ƒ 수식 편집 팝오버에 수식 원문 표시, 편집 중 다른 컬럼 헤더 클릭 → [colKey] 토큰 삽입(onHeaderInjectRef), 취소 시 DB 미반영 — 동작 확인.
  • 📌 부록 C 1번 실측 답: 재매핑 미해석(stale uuid) 시 수식은 전월 시트 값을 조용히 그대로 읽는다 (8월 음성 케이스에서 posAlw = 6월 값 30,000) — null 이 아니라 조용한 오답이므로, 참조 대상 시트를 대상 기간에 먼저 만드는 운영 순서(STEP 5)가 중요하다.

Branch context: develop — sheet-rooted records 선행 문서: test-scenario-sheets-monthly-reuse.md (Verified 2026-06-11, org SHEETRH) — 시트 복제 4경로·편집 정책·일반 함정은 그 문서가 소유한다. 본 문서는 그 위에 "수식이 시스템 템플릿을 참조해 수당을 계산하고, 월이 바뀌어도·이월해도 계속 동작하는가" 한 가지를 깊게 검증한다.

목적 ​

관리자가 시스템 템플릿(연장근무 내역 전체 등)을 데이터 소스로 두고, 수식(formula) 컬럼으로 수당을 직접 설계하는 시나리오:

  1. M월 — 수식 컬럼이 시스템 템플릿/타 시트의 값을 참조해 수당 금액을 산출하고, 그 수식 컬럼을 커밋해 AllowanceRecord 를 만든다.
  2. M+1월 — 같은 구성을 "이전 월에서 가져오기"(carry-forward)로 가져왔을 때, 수식의 시트 참조가 새 달의 시트로 재매핑되어 새 달 데이터로 재계산·재커밋된다.
  3. 시스템 템플릿 재적용(slug idempotent)과 수식 참조가 함께 동작한다.

테스트 기간: M = 2026-06, M+1 = 2026-07. 조직은 선행 문서의 SHEETRH 재사용(권장 — 직원·계약·항목코드 이미 있음) 또는 신규 프로비저닝.

🆕 4차 수정 (2026-06-11, 본 문서 작성 중 선수정) — 본 시나리오의 1차 검증 대상 ​

수식 에디터의 cross-sheet 참조 빌더는 [<sheetId>:colKey] 형태로 시트 uuid 를 수식 문자열 안에 박는다. 3차 수정(sheet-ref-remap.ts)은 aggregate 의 sourceSheetId 핀만 재매핑했고 수식 안의 uuid 는 손대지 않았다 → 이월/템플릿 적용/JSON import 된 시트의 수식이 전월 시트를 계속 읽는 조용한 오답 경로가 남아 있었다.

수정: 헬퍼를 remapSheetRefs 로 확장 — columnLayout 의 formula 문자열에서 [<uuid>: 토큰을 수집해 aggregate 핀과 같은 규칙(같은 이름 → 대상 기간 시트, in-batch 사본 우선)으로 재작성. carryForward · sheetTemplate.applyToPeriod · sheetView.importTemplate 세 경로 모두 통과한다. (선행 문서 3차 메모의 "formula 는 시트 간 참조가 없다"는 서술은 오류였음 — 본 문서로 정정.)

핵심 전제 — 수식이 시스템 템플릿을 참조하는 4가지 방식 ​

#방식컬럼 구성월 전환 안정성
①computed 모듈kind:"computed" + computeSourceModule:"overtime_record" + computedKey(weekday_base/night_base/holiday_base/weekend_base/total_base/*_hours/*_days) — 시트가 아니라 source 테이블을 기간 스코프로 집계 (overtime-record.ts)✅ 설계상 안정 — host 시트의 (year,month)로 재집계. ⚠️ 승인 게이트 통과 건만 집계 (approved/report_* — 2026-06-11 결정, pending 제외)
②row-local 수식kind:"formula", [colKey] 로 같은 행의 identity/ref/computed/literal/타 수식 참조✅ 안정 — 행 안에서 닫힘
③cross-sheet 수식수식 안 [<sheetId>:colKey] — 3형: 1-part(같은 직원 행 조회) · 2-part(amountCol:memberCol 분배합) · 3-part(VLOOKUP: valueCol:keyCol:myKeyCol) (resolver.ts:2704)🆕 4차 수정으로 안정화 — 이월/적용/import 시 uuid 가 같은 이름의 대상 기간 시트로 재작성. 본 시나리오가 첫 검증
④aggregate 컬럼kind:"aggregate" + itemCode 앵커 (레코드 코드의 출처) + sources[](slug 또는 uuid 핀) — 커밋된 레코드를 집계 (급여대장 패턴)✅ slug 는 쿼리 시점 재해석(월 안정) · uuid 핀은 3차 수정으로 재매핑 (선행 문서 Verified)

🆕 2026-06-19 모델 변경 — 컬럼 ↔ 수당/공제 관계 (필독) ​

커밋 게이트와 금액 분류 기준이 항목코드(itemCode) FK 를 레코드에 직접 스탬프하던 방식에서 (targetSheetSlug, targetColumnKey) 컬럼 포인터로 바뀌었다. 본 문서의 옛 allowanceItemCodeId 중심 서술(특히 "수식 컬럼도 commitToKind+allowanceItemCodeId 를 달면 커밋")은 이 절을 기준으로 읽는다 — 수식 커밋 컬럼도 동일하게 commitTargetSheetSlug/commitTargetColumnKey 페어가 필요하다.

  • 커밋 컬럼 (수식 포함): commitToKind("allowance"|"deduction") + commitTargetSheetSlug + commitTargetColumnKey — 이 커밋이 들어갈 급여대장 aggregate 컬럼을 (시트 slug, 컬럼 key)로 가리킨다. 수식 컬럼이어도 동일하며, inputSnapshot.formula 에 수식 원문이 함께 기록된다.
  • 레코드: AllowanceRecord/DeductionRecord 는 더 이상 allowanceItemCodeId/deductionItemCodeId 를 들지 않는다 (migration 1815 target 컬럼 추가 + 백필 → 1816 옛 FK 컬럼 DROP). 커밋 시 targetSheetSlug + targetColumnKey 스탬프, 업서트 키는 (sourceSheetId, sourceSheetRowId, sourceSheetColumnKey).
  • 항목코드(eCount A/D 코드)는 급여대장 aggregate 컬럼에 산다 — kind:"aggregate" + allowanceItemCodeId. 레코드 코드는 export 시점에 (targetSheetSlug, targetColumnKey) 로 그 aggregate 컬럼에서 해석한다.
  • 레거시 브리지: 2026-06-19 이전 커밋 컬럼(itemCode FK 만, target 페어 없음)은 commit·export 가 그 itemCode 로 같은 코드의 급여대장 aggregate 컬럼을 찾아 (slug, key) 를 유도 — 옛 시트도 계속 동작.
  • eCount export 변경: 선택적 sheetViewId 로 한 급여대장에만 스코프 가능(없으면 기간 전체 합산 — 중복 급여대장이 같은 코드 공유 시 합계 부풀던 버그 원인). 급여대장 미앵커 레코드는 excludedRecords 로 제외. 한 기간에 급여대장 2개째 생성 시 confirm 경고.

수식 평가 규칙 (검증 시 기대값 계산에 필요) ​

  • 평가 순서: 비수식 컬럼(identity/ref/computed/aggregate) 먼저 → 수식은 최대 5 패스 안정화 루프 (수식→수식 참조 가능, 순환·6단 이상 체인은 null) (resolver.ts FORMULA_PENDING).
  • null 전파: 기본은 참조 결측 → null. 단 format:"number" 수식 컬럼은 결측을 0 으로 취급 (커밋용 금액 컬럼은 number 권장). 0 나누기 = 0 (에러 아님).
  • 커밋 가능 kind: literal·ref·formula·computed ✅ / aggregate·identity ❌ — 수식 컬럼도 commitToKind+commitTargetSheetSlug/commitTargetColumnKey 를 달면 커밋되며 (옛 allowanceItemCodeId 는 레거시 브리지), inputSnapshot 에 { formula: "<식>" } 이 기록된다 (compute.ts pick).
  • 함수: ROUND/ROUNDUP/ROUNDDOWN(v,[d]) · FLOOR/CEILING/ABS · MIN/MAX/SUM/AVERAGE/COUNT · IF(c,t,f) · AND/OR/NOT · TODAY() · 비교연산(> < >= <= == != → 1/0). 날짜 문자열은 일수로, HH:MM - HH:MM 은 시간수로 자동 변환. 수식 최대 2000자.
  • ⚠️ 1-part cross-sheet 참조는 SUM 이 아니다 — 같은 직원의 행 중 "값이 있는 첫 행" 하나를 읽는다. 직원당 여러 행인 시트(overtime_all 등)에서 합계가 필요하면 ①(computed 모듈)이나 ④(aggregate)를 쓴다. 1-part 는 직원당 1행 시트(기준값 시트 등) 참조 전용으로 설계할 것.

UI 경로 ​

2026-09-22 정리: 아래 경로는 과거 검증 당시의 UI입니다. 참고용으로 남아 있던 옛 모달은 삭제했으며, 현재 화면은 열 설정 드로어와 급여 반영 드로어로 나뉩니다.

컬럼 헤더 우클릭 → "🔧 열 종류 설정" → "수식 (계산)" 선택 → "편집" 팝오버 (당시 열 설정 모달): 다른 컬럼 헤더 클릭으로 [colKey] 삽입, cross-sheet 빌더로 [sheetId:colKey] 삽입. 같은 모달에서 "이 열을 수당 레코드로 커밋" 체크 + 대상 급여대장 컬럼 지정 (항목코드는 그 급여대장 aggregate 컬럼에 달려 있음).


전체 흐름 ​

■ PHASE A — 사전 준비
  1. org SHEETRH 재사용 (직원·시급계약·항목코드·6월 승인 연장근무 확인)

■ PHASE B — M월(2026-06): 수식 시트 구성 + 정산
  2. 시스템 템플릿 적용: 연장근무 내역 전체(overtime_all)
  3. 사용자 시트 2개:
     "수당 기준값"  — 직원당 1행, literal(직책수당·야간식대단가)   ← cross-sheet 참조 대상
     "연장수당 계산" — ①computed(모듈) + ②row-local + ③cross-sheet 수식 + 커밋 컬럼
  4. 6월 데이터 → runCompute → 기대값 대조 → ✓커밋 → 급여대장(④aggregate) 확인

■ PHASE C — M+1월(2026-07): 이월 + 재계산
  5. ⚠️ 순서 주의: 시스템 템플릿 재적용 먼저 (재매핑 대상 시트가 존재해야 함)
  6. carry-forward (사용자 시트 2개 일괄) → 🆕 수식 uuid 재매핑 검증
  7. 7월 데이터 → runCompute → 7월 기대값 → ✓커밋

■ PHASE D — 격리·정확성 검증
  8. 6월 불변 · 수식-원본-레코드 일치 · 재커밋 idempotent · 회귀 체크

PHASE A — 사전 준비 ​

STEP 1 — 조직·데이터 확인 ​

선행 문서 Verified 실행의 SHEETRH 를 그대로 쓴다 (없으면 선행 문서 STEP 1 수행). 확인 사항:

  • [ ] 직원 2명 이상에 활성 근로계약 + 시급(hourlyRate) — identity [hourlyRate] 참조와 모듈 계산의 입력
  • [ ] 수당 항목코드 2개 이상 (예: 13 야간근무수당, 14 연장근무수당 — STEP 3 커밋 컬럼이 참조)
  • [ ] 6월 승인된 연장근무 2~3건 (평일 + 야간 섞어서) + PENDING 1건 (집계 제외 검증용)

PHASE B — M월(2026-06) ​

STEP 2 — 시스템 템플릿 적용 ​

sheetTemplate.applyToPeriod 로 연장근무 내역 전체(slug overtime_all) 를 2026-06 에 적용. (선행 문서에서 검증된 경로 — 여기서는 데이터 소스 역할만.)

✅ 검증 ​

  • [ ] 6월 overtime_all 시트에 승인 건 + PENDING 건 모두 표시됨 (표시는 전 상태 — 설계)

STEP 3 — 수식 수당 시트 구성 (핵심) ​

둘 다 visibility:"SHARED" (STEP 6 carry-forward 대상), rowBasis employee.

3-1. "수당 기준값" 시트 — cross-sheet 참조 대상 (직원당 1행) ​

keykind비고
empidentity성명
positionAllowanceliteral · number직책수당 (예: A 직원 30,000 / B 직원 0)
nightMealUnitliteral · number야간식대 단가 (예: 8,000)

3-2. "연장수당 계산" 시트 — 참조 방식 ①②③ 집약 ​

keykind구성참조 방식
empidentity성명—
rateidentityrefField hourlyRate—
wdcomputedmodule overtime_record · key weekday_base①
ntcomputedmodule overtime_record · key night_base①
ntDayscomputedmodule overtime_record · key night_days①
posAlwformula · number[<수당기준값ID>:positionAllowance]③ 1-part
ntMealformula · number[<수당기준값ID>:nightMealUnit] * [ntDays]③+②
otPayformula · number · 커밋ROUND([wd] + [nt] + [ntMeal], 0) + commitToKind:"allowance" + 대상 급여대장 aggregate 컬럼(연장근무수당 코드) — 이 target 페어가 커밋을 구동②
posPayformula · number · 커밋IF([posAlw] > 0, [posAlw], 0) + 대상 급여대장 aggregate 컬럼(직책수당 계열 코드) — target 페어가 커밋 구동②

수식 안의 <수당기준값ID> 는 cross-sheet 빌더가 삽입하는 6월 시트의 uuid — STEP 6 의 재매핑 검증 대상이 바로 이 토큰이다.

✅ 검증 ​

  • [ ] 수식 컬럼에 커밋 설정이 저장됨 (sheetView.get 으로 columnLayout 회독)
  • [ ] 수식 에디터에서 컬럼 헤더 클릭 → [colKey] 삽입, cross-sheet 빌더 → [sheetId:colKey] 삽입 동작

STEP 4 — 6월 계산 → 커밋 ​

  1. "수당 기준값"에 직원별 literal 입력 (A: 직책수당 30,000 · 야간식대 8,000 / B: 0 · 8,000).
  2. "연장수당 계산" runCompute → 기대값 대조. 기대값 예시 (A 직원: 시급 10,000 · 승인 평일연장 4h · 승인 야간 2h(1일) · PENDING 5h):
컬럼계산기대값
wd10,000 × 4h × 1.560,000
nt10,000 × 2h × 0.510,000
ntDays야간 근무일수1
ntMeal8,000 × 18,000
otPayROUND(60,000+10,000+8,000)78,000
posPayIF(30,000>0,…)30,000
  1. commitToRecords → AllowanceRecord 2종(otPay·posPay) 생성.
  2. 급여대장(시스템 템플릿)에 ④aggregate 로 항목코드별 합계 노출 확인.

✅ 검증 ​

  • [ ] PENDING 5h 가 wd/otPay 에 미반영 (승인 게이트 — 그 건을 승인하면 재계산 시 반영됨도 확인)
  • [ ] 수식 5-pass: ntMeal(수식)을 참조하는 otPay(수식) 정상 평가 (수식→수식 체인)
  • [ ] 커밋 레코드: 금액 78,000/30,000 · targetSheetSlug/targetColumnKey 스탬프 (항목코드는 대상 급여대장 aggregate 컬럼) · inputSnapshot.formula = 수식 원문
  • [ ] B 직원 posPay = 0 → 0원 레코드 생성 여부 기록 (선행 문서 미해결 질문 — eCount 는 0원 행 미출력)
  • [ ] 재커밋 → recordsUpdated>0, recordsInserted=0 (idempotent)
  • [ ] 6월 레코드 수·합계 기록 (STEP 8 비교 기준)

PHASE C — M+1월(2026-07) ​

STEP 5 — ⚠️ 시스템 템플릿 재적용 먼저 ​

2026-07 에 overtime_all 재적용 (slug idempotent). 순서가 중요하다: 재매핑은 "같은 이름의 시트가 대상 기간에 이미 있어야" 성립 — 수식/aggregate 가 시스템 시트를 직접 참조하는 구성이라면, carry-forward 전에 시스템 시트부터 만들어야 한다. (본 시나리오의 수식은 사용자 시트만 참조하므로 in-batch 매핑으로도 충분하지만, 순서 원칙 자체를 검증 항목으로 둔다.)


STEP 6 — carry-forward + 🆕 수식 uuid 재매핑 검증 ​

sheetView.carryForward { from: 2026-06 → to: 2026-07 } — "수당 기준값"과 "연장수당 계산"이 같은 배치로 복사된다 (in-batch fromId→toId 맵 우선).

✅ 검증 (4차 수정의 1차 검증) ​

  • [ ] 7월 "연장수당 계산"의 posAlw/ntMeal 수식 문자열에서 [<uuid>:...] 가 7월 "수당 기준값" 시트의 uuid 로 재작성됨 (sheetView.get 으로 formula 원문 비교 — 6월 uuid 가 남아 있으면 버그)
  • [ ] (경로 추가 검증) 6월 "연장수당 계산"을 saveSheetAsTemplate → 7월에 applyToPeriod, 그리고 JSON exportTemplate→importTemplate — 세 경로 모두 재매핑됨 (템플릿/JSON 경로는 in-batch 맵이 없어 이름 DB 조회로 해석: 7월에 동명 "수당 기준값"이 이미 있어야 함)
  • [ ] (음성 케이스) "수당 기준값"을 7월로 가져오기 전에 "연장수당 계산"만 단독 템플릿 적용 → uuid 미해석 시 원본(6월) uuid 유지 (드롭 아님) → 이후 컬럼 에디터에서 재선택 가능
  • [ ] computed 컬럼(wd/nt)·커밋 설정은 그대로 복사 (선행 문서 검증과 동일)

STEP 7 — 7월 재계산 → 커밋 ​

  1. 7월 승인 연장근무 입력 (6월과 다른 시간으로 — 예: A 평일 2h만). "수당 기준값" 7월 시트에 literal 재입력 (행/값은 복사되지 않음 — 선행 문서 전제).
  2. runCompute → 7월 기대값 (A: wd=30,000 · nt=0 · ntMeal=0 → otPay=30,000) → commitToRecords.

✅ 검증 ​

  • [ ] 수식이 7월 데이터만으로 재계산 (6월 값 잔존 없음 — 특히 cross-sheet 참조가 7월 기준값 시트의 새 입력을 읽는지)
  • [ ] 7월 AllowanceRecord lineage = 7월 시트 id
  • [ ] 급여대장(7월) aggregate 가 7월 커밋만 집계

PHASE D — 격리·정확성 ​

STEP 8 — 최종 검증 ​

  • [ ] 6월 불변: 레코드 수·합계 STEP 4 기록과 동일 · 6월 시트 수식 uuid 는 여전히 6월 기준값 시트
  • [ ] 6월 시트 열람 시 6월 값 그대로 재현 (수식 재평가가 기간 스코프를 지킴)
  • [ ] (회귀) 수식 컬럼 셀 직접 편집 시도 → "수식/계산으로 만들어진 값" 토스트 + 서버 400 (선행 문서 편집 정책)
  • [ ] (회귀) 동명 시트가 7월에 2개일 때(템플릿 재적용으로 중복 생성) 재매핑이 earliest-created 로 결정적 매핑

부록 A — 함정 요약 ​

#함정회피/비고
11-part cross-sheet = "값 있는 첫 행" 단일 조회 (SUM 아님)직원당 1행 시트만 1-part 로 참조. 합계는 computed 모듈/aggregate
2재매핑은 이름 기준참조 대상 시트가 대상 기간에 먼저 존재해야 함 (STEP 5 순서) · 시트 이름을 바꾸면 다음 이월에서 해석 실패(원본 uuid 유지)
3미승인(PENDING) 연장은 모듈 집계 제외표시는 되지만 금액 0 — 기대값 계산 시 승인 건만
4format:"number" 미지정 수식참조 결측 시 0 이 아니라 null → 커밋 skip (rowsSkippedDueToMissingAmount) — 금액 수식은 number 필수
5수식 체인 5단 초과·순환null — 깊은 체인은 중간 컬럼으로 분해
6동명 시트 중복earliest-created 매핑 (slugless 재적용이 중복을 만든 경우 — 선행 문서 함정 4)

부록 B — 빠른 호출 순서 (스모크) ​

# M월
sheetTemplate.applyToPeriod (overtime_all, 2026-06)
sheetView.create ×2 (수당 기준값 · 연장수당 계산 — formula+commit 컬럼, SHARED)
(기준값 literal 입력 · 연장 승인) → sheetView.runCompute → 기대값 대조 → commitToRecords

# M+1월
sheetTemplate.applyToPeriod (overtime_all, 2026-07)        # ⚠️ 먼저
sheetView.carryForwardPreview → carryForward (06→07)
sheetView.get ×2 → formula 원문에서 uuid 재매핑 확인       # 🆕 4차 수정 검증
(7월 데이터) → runCompute → commitToRecords
(회귀) saveSheetAsTemplate→applyToPeriod / exportTemplate→importTemplate 재매핑 동일 확인

부록 C — 미해결 / 확인 필요 ​

  • [x] 수식 cross-sheet 참조가 stale 상태로 읽힐 때의 동작 (재매핑 실패 시) — 실측 (2026-06-11): 전월 시트 값을 조용히 그대로 읽는다 (8월 음성 케이스에서 posAlw = 6월 기준값 30,000, null 아님). 조용한 오답 경로이므로 참조 대상 시트를 먼저 만드는 순서(STEP 5)가 운영 수칙. 장기적으로는 stale ref 감지 시 UI 경고 배지 검토 가치 있음.
  • [ ] 수식 에디터에 함수 목록/문법 도움말이 없음 (formulaFunctionCatalog 는 코드에만 존재) — 사용자 가이드 필요 여부.
  • [ ] cross-sheet 참조를 uuid 대신 slug/이름 기반 토큰으로 쓰는 방안 — 재매핑 자체가 불필요해짐 (장기 개선 후보).
  • [ ] 0원 수식 커밋 정책 (선행 문서와 공통 — eCount 출력에는 영향 없음).

부록 D — BESTRH 실데이터 Excel 미러 실측 (2026-06-11 추가 실행) ​

non-password.xlsx 의 계산을 BESTRH(베스트재활병원, yongku.lee 계정) 실데이터 위에서 시트로 재현·대조한 결과:

  • 시간외 수당 (행정시간외 탭): Excel L열 = ROUNDDOWN(연장시간 × 시급 × 1.5, -1) (10원 내림, 일별). 1월 "행정 시간외수당 (엑셀대조)" 시트에 김진호 5일치 일별 행 + cross-sheet 시급 참조([시급기준:rate] — Excel VLOOKUP 미러) + 동일 수식 → 5일 전부 Excel 캐시값과 1원 단위 일치 (35,970/23,980/95,940/35,970/71,950 · 합 263,810). 야간수당(M열 ×0.5) 패턴도 동일 구성.
  • 치료실 인센티브 (재활 탭): Excel = Σ(종목 건수 × 단가) = 청구금액, 병원수익 = ÷2. 치료 단가 시트 부트스트랩으로 Excel 단가 8종 등록(ratio 0.5) → upsertTreatmentSession 에 Excel 첫 행 건수 입력 → totalBilling 1,893,817 = Excel 청구금액 J4 정확 일치, hospital 946,909(=J/2), 치료사 인당 473,454 fan-out. 치료일 인센티브 시트 동기화로 billed/hospital/therapist 표시 확인.
  • 매니저 열람: yongku.lee 계정으로 1월 기간에서 imported 시트들(급여대장(전체)·공제내역·특별수당 등)과 함께 위 시트들 열람 가능, incentive_all(1월) 346건 materialize. 5월 출퇴근 내역 전체(attendance_all) 시트에 수당 수식 컬럼을 추가하는 것도 차단 없음.

실측에서 드러난 갭 → 2026-06-11 2차에서 수정:

  1. 🐛 (수정) timestamp ref 컬럼 간 산술 = epoch ms — [actualCheckOut] - [actualCheckIn] 이 시각이 아니라 epoch 밀리초로 계산되던 버그. resolver 의 수식 값 강제변환에 Date/ISO-datetime → 소수 일수(day fraction) 분기 추가 (resolver.ts) → Excel (퇴근-출근)*24 가 정확히 시간으로 평가됨. 실측: 김진호 5/20 22:16~익일 12:06 → workH 13.84h, otH 4.84h (야간 wrap 포함).
  2. 🐛 (수정) 치료 단가 code write-back 불가 — 두 가지 근본 원인: ① 시드 템플릿 code 컬럼에 treatmentField 태그 부재, ② treatmentField zod enum 에 "code" 가 빠져 sheetView.update 가 태그를 strip (기존 시트 수동 태깅도 불가했던 진짜 버그). seed + 스키마 enum + applyTreatmentWriteback 의 code 브랜치까지 추가 → 실측: 치료 단가 시트에서 code 입력 → treatment_items.code 갱신 → 세션 동기화 tally 정상 (billed 1,893,817 = Excel 일치).
  3. 🐛 (수정) number 포맷 컬럼이 소수(1.5h)를 정수(2)로 반올림 표시하던 버그 — formatAmount 가 정수는 그대로, 소수는 2자리까지 유지하도록 변경 (render.ts). 실측: 연장근무계 컬럼이 1.5 로 표시 (계산값은 원래 정확, 표시만 수정).
  4. ℹ️ 치료일 인센티브 동기화는 treatmentcount 컬럼의 treatmentCategoryId 바인딩이 선행돼야 함 (시드 직후엔 미설정 → sessions:0) — 의도된 동작, 실측 확인.

갭② — 2026-06-11 4차에서 backfill 로 해소:

  1. 🐛 (수정 — 데이터 backfill) 시컴 지문 근태의 야간 실근무가 시트에 안 잡히던 문제. 최종 진단: 코드(buildScheduledTimes/finalizer/timezone)는 전부 정상. 원인은 imported 데이터의 시프트 배정 — 야간/교대 근무자가 전부 기본 "일반근무(주간 09-18)"로 배정돼, scheduled-window 기준 산정(설계: 지문은 출결 증빙용)이 주간 창을 슬라이스 → night 0. 해소:
    • backfill-attendance-shifts.ts — 행 단위로 actual 체크인 시각에 원형거리 최근접 시프트를 추론(3교대는 일자별 가변이므로 직원 단위가 아닌 행 단위), buildScheduledTimes 재생성 + finalizeAttendanceHours 재계산. dry-run 기본 / --apply / 확정·지급 급여 기간 제외 가드 / 최근접 거리 3h 초과는 저신뢰로 보존.
    • BESTRH 실행 결과: 6,323건 스캔 → 3,515건 반영 (확정 6월 1,242건 보호, 출/퇴근 누락 431건 제외). 시프트 전환: 일반근무→주간3교대 2,391 · →오후3교대 648 · →야간3교대 116 등. night 합계 415h → 1,962h 회복, 5월 출퇴근 시트에 야간>0 행 751건 표시. 재실행 시 변경 0건 (idempotent).
    • 잔여(운영 확인 목록으로 출력): 저신뢰 271건 (21시 전후·새벽 4~5시 체크인 — 시프트 정의에 없는 출근대), 스케줄 중복 충돌 4건 (동일 직원·동일 창에 행 2개 = 중복 적재 의심).
    • 연장근무 수당은 설계대로 OvertimeRecord(신청→승인) 경로 — 이 backfill 은 출퇴근 시트의 야간/주말 시간 표시를 바로잡는 것.

오해 방지 안내 추가 (2026-06-11 3차) — "조용한 오답"이 생기던 지점마다 UI 고지:

  • 계약 시트 manual 행 편집: 저장 전 confirm — "시트에만 저장되는 것이 아니라 실제 근로계약에 기록됩니다 (없으면 v1 생성, 있으면 새 버전)" (auto 행은 기존 generic confirm 의 라벨을 "근로계약"으로 정정).
  • 치료일 인센티브 🔄 동기화: 코드 미등록 치료항목이 집계에서 빠지면 어떤 코드가 빠졌는지 toast 로 경고 (syncTreatmentSessions 가 unmatchedCodes[] 반환 — 실측: ["GAIT"]).
  • 이월(carry-forward): 참조 시트가 대상 월에 없어 전월을 계속 읽게 되는 사본이 생기면 시트 이름 목록을 toast 로 경고 (carryForward 가 refWarnings[] 반환 — 실측: ["수당 기준값 ('26.6)"]. 같은 배치로 함께 이월되면 in-batch 해석되어 경고 없음).
  • 출퇴근 내역 전체 템플릿 설명에 명시: "시프트 배정 직원의 시간은 스케줄 기준 산정(지문은 증빙용), 연장근무 수당은 신청·승인 내역으로 계산" — 갭② 오해(8h 캡)의 안내.
  • 수식 편집 팝오버에 날짜/시각 변환 힌트 추가: ([퇴근]-[출근])*24 = 시간, "09:00 - 18:00" → 9 등.

부록 D-2 — 근로계약 시스템 템플릿 시트 인라인 편집 (2026-06-11 신규) ​

importer 로 적재된 BESTRH 는 근로계약이 0건이라 수식 모듈의 시급 파생이 불가했다 (부록 D 말미). 이를 풀기 위해 계약급여내역(employment_contract_all) 시트에서 시급/기본급을 직접 편집하는 경로를 신설:

  • 신규 proc employee.upsertContract (employee.router.ts) — append-only 버전 관리(grade-promotion 패턴): 활성 계약을 supersede 하고 merge 된 v+1 생성, 계약이 없으면 v1 부트스트랩. 스키마 upsertEmploymentContractSchema, SDK employee.upsertContract.
  • UI 편집 핸들러 edit/employment-contract.ts — 시트의 autoPopulate source 가 employment_contract 면 라우팅 (계약 행은 legacy recordKind:"allowance" 형태라 source 기준으로 분기). _saveCellEdit 게이트도 계약 시트 전용 분기 추가 (auto 행 = 기존 계약 supersede, manual 행 = v1 부트스트랩 — "계약 없는 imported 조직" 케이스).
  • 실측 (BESTRH 5월 계약급여내역 시트):
    • auto 행: 김진호 시급 → tRPC v1(15,990)→v2(16,000), UI 더블클릭+confirm 으로 v3(15,990) → DB 버전 체인 + supersededAt 정확, 시트 즉시 재반영.
    • manual 행: 계약 0건이던 김영일에 "+ 행 추가" → 시급 12,400 입력 → v1 계약 부트스트랩 생성 (created:true).
    • 시급이 계약에 들어가므로 이후 연장근무 computed 모듈(overtime_record)의 시급 파생도 가능해짐.