Skip to content

E2E Test Scenario — 병원 시트 구성(M월) → 익월(M+1월) 복제·재사용 ​

Status: Verified · 2026-06-11 로컬 전체 실행 (org SHEETRH, M=2026-06 → M+1=2026-07) Branch context: develop — sheet-rooted records (시트 커밋 파이프라인)

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

STEP 1→10 전체 통과. 재사용 4경로 모두 columnLayout(커밋 설정 포함) 완전 보존 + 행 미복사 + 기간 격리 + 신규 입사자 자동 수용 + 6월 레코드 불변(6건/530,138원) 확인. 셀 편집 매트릭스는 "구현 완료" 주장 중 UI 절반이 죽어 있었고(아래 1번) 이번 실행에서 수정 후 전부 검증됨.

실행 중 발견·수정한 버그 (4건):

  1. 🐛 (수정) workbook-adapter.ts 가 kind:"ref" 컬럼을 editable=false 로 마킹 — data-editable 미부여로 ref 셀 더블클릭이 무반응 (2026-06 정책의 UI 진입점이 explicit-kind ref 컬럼에서 사문화). _isCellEditableForRow 만 정책 반영되고 정적 플래그가 누락된 것. 수정 후: confirm 고지 → overtime.update 라우팅 → 새 버전(v2 10h)+구버전 supersededAt → materializer 행 재바인딩 → 시트·team-manager 동일 표시까지 전 체인 검증.
  2. 🐛 (수정) team-manager.router.ts overtime.list/incentive.list 에 supersededAt IS NULL 필터 누락 — ref 편집으로 새 버전이 생기면 구버전(8h)과 신버전(10h)이 목록에 같이 표시됨. 필터 추가.
  3. 🐛 (수정) sheet-cell.router.ts raw SQL 13곳이 스키마를 hereby. 로 하드코딩 — 실제 스키마는 hereby_works 라 치료 단가 write-back 전체가 500 (relation "hereby.sheet_cells" does not exist). bare 테이블명(search_path)으로 수정 후 수동 행 bootstrap → treatment_items 카탈로그 생성 → 7월 템플릿 적용 시 갱신 단가로 시드까지 검증.
  4. 🐛 (수정) assertColumnHandEditable 이 aggregate 컬럼을 가드하지 않음 — UI 는 토스트로 막지만 tRPC 직접 호출은 literal 저장 200 (집계 표시를 영구 가림). computed/formula 와 동일하게 서버 400 거부 추가.

발견 → 후속 수정 완료 (2026-06-11 2차, 제품 결정 반영):

  • 🐛 (수정) 급여대장(employee 행 시트)이 hireDate 를 무시 — 7/1 입사자가 6월 시트에도 노출되던 버그. ① materializer 의 employee 소스에 기간 컷오프(hireDate < 익월 1일) 추가, ② sheetRow.create/batchCreate/update 도 기간 이후 입사자를 400 으로 거부 ("…이후 입사한 직원은 이 기간의 시트에 추가할 수 없습니다"). 7월 시트 추가는 계속 허용 확인.
  • 🐛 (수정) compute 집계 status 필터 — 승인 게이트를 통과한 건만 정산 (결정: "최종 컨펌 후 정산"). overtime 은 approved/report_pending/report_approved/report_rejected, fixed_incentive 는 approved/paid 만 집계. PENDING 5h 를 만들어 재계산해도 금액 불변 확인. (overtime_all 시트의 전 상태 표시는 설계 유지 — 정산 집계만 필터)
  • 🐛 (수정) PAYROLL_ADMIN 시드에 allowance.read/deduction.read 추가 (시트 화면 권한 모달 제거). ⚠️ 기존 조직 backfill 필요 — 로컬 SHEETRH/E2ETEST 는 적용 완료.
  • 🐛 (수정) employee.create 가 팀 배정 시 org 의 EMPLOYEE 역할을 기본 배정 — 별도 role 지정 없이도 신규 직원이 self-scoped 기능(근태/연장/인센티브) 사용 가능. 위저드의 명시적 역할 선택(bulkAssignRole)은 이를 덮어씀.
  • 🐛 (수정) overtime 시간 필드에 clockTimeSchema (HH:MM, 00:00~24:00) zod 검증 — 26:00 이 PG 500 대신 zod 400 으로 거부, 24:00(야간 자정 경계)은 계속 허용.
  • ℹ️ HR System mustChangePassword "signin 직후 미강제" 관찰은 테스트 아티팩트로 판명 — 클린 재현에서 즉시 /change-password redirect 확인 (코드 수정 불필요).

2026-06-11 3차 — 이월 시 시트 간 참조 재매핑:

  • 🐛 (수정) aggregate 컬럼의 sources[].sourceSheetId(uuid 핀 — slug 없는 사용자 시트를 가리킬 때 UI 가 쓰는 방식)가 이월/템플릿 적용/JSON import 후에도 전월 시트를 가리킴 — 새 달의 커밋 레코드는 새 시트 lineage 를 가지므로 집계가 조용히 0 이 되는 버그. 라이브 재현(6월 핀 → 7월 사본 집계 0) 후 sheet-ref-remap.ts 헬퍼 신설: 참조 시트의 이름으로 대상 기간의 동명 시트를 찾아 uuid 를 재매핑. carryForward(배치 내 from→to 맵 우선) · sheetTemplate.applyToPeriod · sheetView.importTemplate 세 경로 전부 적용, 세 경로 모두 7월 시트 재지정 + 7월 커밋 집계(50,000) 검증 완료.
    • sourceSheetSlug 참조는 resolver 가 쿼리 시점에 host 기간 안에서 재해석하므로 원래 월-안정 (수정 불필요). formula 컬럼은 row-local([colKey])이라 시트 간 참조 자체가 없음 ← 오류였음 (4차 정정): 수식 에디터의 cross-sheet 빌더가 [<sheetId>:colKey] uuid 토큰을 수식에 박는다 — 3차 헬퍼는 이를 재매핑하지 않아 이월 후 수식이 전월 시트를 계속 읽는 갭이 남아 있었고, 4차에서 헬퍼를 remapSheetRefs 로 확장해 수식 uuid 도 재작성. 검증 시나리오: test-scenario-system-template-formula.md
    • ⚠️ 한 기간에 동명 시트가 2개 이상이면 가장 먼저 생성된 시트로 결정적 매핑 (slugless 템플릿 재적용/JSON 재import 가 만드는 중복 — 부록 A 함정 4 와 동일 뿌리). 해석 불가(타 조직 import 등) 시 원본 uuid 유지 — 컬럼 에디터에서 재선택 가능.
  • ℹ️ 신규 조직(빈 카탈로그)에서는 치료 단가 시트가 행 0 으로 시드됨 — "카탈로그 행 시드"는 기존 카탈로그의 미러일 뿐. 첫 항목은 수동 행 추가 + write-back 으로 부트스트랩.
  • ℹ️ ref 편집으로 원본을 수정해도 APPROVED 상태는 유지됨 (재승인 회귀 없음 — 부록 C 의 승인 워크플로 충돌 질문에 대한 현재 동작 실측).

Reference workbook: non-password.xlsx (베스트재활병원 · BESTRH · 47 sheets) — 형식(format) 참고용으로만 사용한다. 어떤 탭이 있고 컬럼 구조가 어떤지를 보고 앱 안에서 같은 모양의 시트를 손으로 만든다. importer 는 사용하지 않는다 (importer 검증은 별도 시나리오). 선행 문서: test-scenario-e2e.md — 프로비저닝~eCount 내보내기 전체 경로. 본 문서는 그중 STEP 4(시트 설정)를 두 달에 걸쳐 심화한다.

목적 ​

병원 실무자는 매달 엑셀에서 전월 시트를 복사해 새 달 급여 작업을 시작한다 (non-password.xlsx 의 급여 참고(2024.09)~급여 참고(2025.08) 월별 탭이 그 흔적). 이 시나리오는 그 운영 습관을 앱으로 옮겼을 때의 전체 경로를 검증한다:

  1. M월 — 직원을 앱에서 신규 등록하고, 엑셀 탭 형식을 본뜬 병원 시트 일습을 구성한다 (시스템 템플릿 + 병원 고유 사용자 시트).
  2. M월 정산(커밋)까지 마친다.
  3. M+1월 — 시트를 새로 만들지 않고 세 가지 재사용 경로로 전월 구성을 복제한다.
  4. 복제본의 컬럼 구성(커밋 설정 포함)은 보존되고, 행/데이터는 복사되지 않으며, M+1월 데이터로 다시 채워져 독립적으로 커밋되는지 확인한다.
  5. M+1월에 신규 입사자를 등록해, 복제된 시트가 새 직원을 자동으로 받아들이는지(전월에는 없던 직원) 확인한다.

테스트 기간 예: M = 2026-06, M+1 = 2026-07. 모든 데이터(직원·근태·수당 금액)는 앱 UI/tRPC 로 직접 입력한다 — 엑셀은 보고 베끼는 원본일 뿐, 적재하지 않는다.

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

커밋 게이트와 금액 분류 기준이 항목코드(itemCode) FK 를 레코드에 직접 스탬프하던 방식에서 (targetSheetSlug, targetColumnKey) 컬럼 포인터로 바뀌었다. 본 문서의 옛 allowanceItemCodeId/deductionItemCodeId 중심 서술은 이 절을 기준으로 읽는다.

  • 커밋 컬럼 (source 시트): commitToKind("allowance"|"deduction") + commitTargetSheetSlug + commitTargetColumnKey — 이 커밋이 들어갈 급여대장 aggregate 컬럼을 (시트 slug, 컬럼 key)로 가리킨다. 이 페어가 커밋 게이트의 핵심이다 (compute.ts commitSheet).
  • 레코드: AllowanceRecord/DeductionRecord 는 더 이상 allowanceItemCodeId/deductionItemCodeId 를 들지 않는다 (migration 1815 targetSheetSlug/targetColumnKey 추가 + 백필 → 1816 옛 FK 컬럼 DROP). 커밋 시 targetSheetSlug + targetColumnKey 가 스탬프되고, 업서트 키는 그대로 (sourceSheetId, sourceSheetRowId, sourceSheetColumnKey).
  • 항목코드(eCount A/D 코드)는 이제 급여대장 aggregate 컬럼에 산다 — kind:"aggregate" + allowanceItemCodeId(또는 deductionItemCodeId). 레코드의 코드는 export 시점에 (targetSheetSlug, targetColumnKey) 로 그 aggregate 컬럼을 찾아 해석한다 (레코드 자체엔 코드 없음).
  • 레거시 브리지: 2026-06-19 이전 커밋 컬럼(itemCode FK 만 있고 target 페어 없음)은 commit·export 가 그 itemCode 로 같은 코드를 단 급여대장 aggregate 컬럼을 찾아 (slug, key)를 유도한다 — 옛 시트도 재설정 없이 계속 커밋·내보내진다.
  • eCount export 변경: 선택적 sheetViewId 로 한 급여대장에만 스코프할 수 있다(없으면 기간 전체 급여대장 합산 — 중복 급여대장/[E2E] 시트가 같은 코드를 공유하면 합계가 부풀던 버그의 원인). 급여대장에 앵커되지 않은 레코드는 excludedRecords:{allowance,deduction} 로 집계 제외. 한 기간에 급여대장을 2개째 만들 때 confirm 경고(중복 급여대장 가드).

핵심 아키텍처 전제 (시작 전 필독) ​

  • 시트(SheetView)는 (organizationId, year, month) 에 묶인다 (sheet-view.entity.ts:27, 기간 스키마 sheet-view.schema.ts:373). 별도 Period 엔티티는 없다 — 월을 바꾸면 그 월의 시트 목록을 새로 본다.
  • 새 달은 빈 상태로 시작한다. 자동 carry-forward 는 의도적으로 꺼져 있다 (sheet-view.router.ts:143 주석). 전월 구성을 가져오는 것은 전부 명시적 사용자 행위다.
  • 어떤 재사용 경로도 행(row)·셀(cell) 데이터를 복사하지 않는다. 복사되는 것은 구성(컬럼 레이아웃·필터·보기 모드)뿐이다. 행은 새 달에 sourceTable 바인딩(materializer)으로 그 달의 source 데이터에서 다시 채워지거나, literal 시트는 빈 상태에서 다시 입력한다.
  • 커밋 설정은 columnLayout 안에 산다 — commit 설정 = commitToKind + commitTargetSheetSlug/commitTargetColumnKey 가 컬럼 항목에 들어 있으므로(옛 allowanceItemCodeId/deductionItemCodeId 는 레거시 브리지로만 동작), columnLayout 이 복사되면 이 페어도 함께 복사되어 커밋 설정이 보존된다 (컬럼 스키마: sheet-view.schema.ts:217).
  • 모든 시트는 관리자(payroll:manage)가 손으로 고칠 수 있다 — 시스템 템플릿 출신 시트도 예외가 아니다. 셀 편집은 sheetCell.set/batchSet (sheet-cell.router.ts:337), 컬럼 구성 변경은 sheetView.update — 어느 쪽에도 isSystem/slug 차단 가드가 없고, 기간/시트 잠금도 advisory 다 (sheet-guard.ts:45). 단 셀 단위 편집의 의미와 가능 여부는 컬럼 kind 가 정한다 — 아래 셀 편집 매트릭스 참조 (2026-06-11 제품 결정 반영: ref 편집 = 원본 레코드 직접 수정 + 고지, computed/formula = 경고 후 거부).
  • 공제(deduction)는 선행 문서와 동일하게 범위 밖 — 수당(A코드)만 다룬다.

셀 편집 매트릭스 (컬럼 kind 별) — 2026-06-11 제품 결정, 같은 날 구현 완료 ​

본 시나리오는 아래 동작을 기대값으로 검증한다 (구현 커밋: sheet-cell.router 서버 가드 + workbook 편집 라우팅).

컬럼 kind동작 (구현됨)
literal✅ 입력값이 셀에 그대로 저장
ref (auto 행 = source 레코드 기반)✅ 편집 = 원본 source 레코드를 직접 수정 (override 폐기). 편집 커밋 시 confirm — "이 값은 원본 연장근무 기록에서 가져온 값입니다. 수정하면 시트만 바뀌는 것이 아니라 원본 자체가 변경됩니다 (이전 값은 이력으로 보존됨)" — 후 source 라우터 update 로 라우팅(append-only 새 버전). 시트·원본·타 앱(team-manager 등)이 항상 같은 값. 서버도 literal override 를 거부 ("원본 기록을 직접 수정해야 합니다", sheet-cell.router.ts:68). 지원 source: overtime·incentive·allowance·deduction — 그 외(출퇴근/휴직 등)는 "원본 화면에서 수정해 주세요" 토스트로 차단
ref (수동 행 — source 레코드 없음)✅ literal 셀로 저장 (덮어쓸 원본이 없음)
formula / computed❌ 편집 시도 시 "이 셀은 다른 데이터에서 수식/계산으로 만들어진 값이라 직접 수정할 수 없어요. 원본 값을 수정하면 자동으로 다시 계산됩니다" 토스트 + 서버도 거부 (sheetCell.set/batchSet 컬럼 kind 검증 — tRPC 직접 호출로도 화면-계산 분기 불가)
identity❌ 직원 join 파생 (이름/사번)
aggregate❌ 편집 시도 시 "커밋된 레코드를 집계해 보여주는 값" 토스트
(특수) treatmentField 컬럼✅ + write-back — 치료 단가 편집이 treatment_items 카탈로그로 역기록 (sheet-cell.router.ts:159). 서버 ref 가드에서 면제 (write-back 자체가 공인된 원본 직접 수정)

구현 메모: source 레코드(OvertimeRecord·FixedIncentive)는 append-only 버전 체인이라 셀 편집은 기존 update 라우터를 호출해 새 버전을 만든다 (edit/overtime.ts 디스패처, refField→필드 매핑 포함). 수정 후 materializer 가 시트 행을 새 버전으로 재바인딩하는지가 검증 대상. 갭 클로즈 (2026-06-11 2차): ① legacy override 셀 — resolver 가 ref 컬럼(auto 행)에서 literal 셀을 무시하도록 변경 (resolver.ts refMirrorsSource) → 시트는 항상 원본 레코드를 표시, 옛 override 데이터는 무해하게 남음(마이그레이션 불필요, treatmentField 컬럼은 예외 유지). ② 클립보드 붙여넣기/드래그 채우기도 ref 셀 포함 시 배치당 1회 confirm (confirmSourceEditOnce, 일괄 변경 모달은 자체 confirm 후 중복 질문 없음). ③ PAYROLL_ADMIN 시드에 overtime.read/write + 신설 org-wide incentive.read/write 부여 — ⚠️ 기존 조직은 permissions·role_permissions backfill 필요 (신규 프로비저닝부터 자동, incentive.write.self 때와 동일한 주의).

행 단위: 수동 행 추가는 모든 시트에서 가능 (sheetRow.create, auto-populate 시트 포함) · auto 행은 직접 삭제 불가 ("원본 레코드 또는 자동 생성 규칙을 수정하세요", sheet-row.router.ts:173) · 수동 행은 삭제 가능.

재사용 경로 4종 비교 (무엇이 복사되는가) ​

경로UIRouter복사됨복사 안 됨대상
① 이전 월에서 가져오기 (carry-forward)시트 탭 툴바 "📋 이전 월에서 가져오기" (carry-forward.html)sheetView.carryForwardPreview (sheet-view.router.ts:803) → sheetView.carryForward (:852)name, viewKind, groupId, visibility, filters, columnLayout, groupBy, sortrows · slug · autoPopulate · rowBasisSHARED 시트만, 일괄
② 템플릿으로 저장 → 적용시트 메뉴 "💾 템플릿으로 저장" (save-as-template.html) → 새 시트 모달 "📁 내 템플릿"sheetTemplate.saveSheetAsTemplate (sheet-template.router.ts:105) → applyToPeriod (:150)viewKind, filters, columnLayout, groupIdrows · slug(사용자 템플릿엔 없음) · autoPopulate · rowBasis · visibility(PRIVATE 로 생성)시트 1개씩, 조직 스코프 템플릿
③ 시스템 템플릿 재적용새 시트 모달 "🛠️ 시스템 템플릿" / "📄 템플릿" 관리 (template-manager.html)sheetTemplate.applyToPeriod — slug 보유 템플릿은 월별 idempotent (:168)+ defaultAutoPopulate, defaultSlug (:201)rows시드된 source 시트 (overtime_all 등)
④ JSON 내보내기/가져오기새 시트 모달 "📁 파일에서 가져오기" (new-sheet-modal.ts)sheetView.exportTemplate (:302) / importTemplate (:383)columnLayout 등 (portable JSON, schemaVersion 1)rows조직 간 이동·백업용 (본 시나리오에서는 스모크만)

ℹ️ 같은 달 안의 "복제" 메뉴(duplicateActiveSheet, workbook/templates.ts)는 현재 기간 안에서만 동작하며 employee/team 필터를 초기화한다 — 익월 복제 수단이 아니다.

⚠️ 알려진 함정 (검증 항목에 반영) ​

  1. carry-forward 는 SHARED 시트만 가져온다 (:872). applyToPeriod 가 만든 시트와 손으로 만든 시트는 기본 PRIVATE → M월에 공유 전환을 안 해두면 M+1월 가져오기 목록에 안 나타난다.
  2. carry-forward 는 slug·autoPopulate·rowBasis 를 복사하지 않는다 (:900-913 save 필드 목록 참조). 결과:
    • slug 시트(예: overtime_all)를 carry-forward 로 가져오면 slug 없는 사본이 생기고, 이후 같은 시스템 템플릿을 applyToPeriod 하면 같은 이름의 시트가 하나 더 생긴다 (slug idempotency 가 사본을 못 본다). → 시드 source 시트는 ①이 아니라 ③으로 재생성하라.
    • rowBasis="free" 시트(치료 단가·치료일 인센티브)를 ①로 가져오면 free 기준이 풀린다. → 이 둘도 ③으로.
  3. 이름 충돌은 조용히 skip — carry-forward 는 대상 월에 같은 name 의 시트가 있으면 건너뛴다 (:877). created 수가 기대보다 작으면 이것부터 의심.
  4. 사용자 템플릿(②)에는 slug 가 없어 재적용 시 중복 시트가 생긴다 — 시스템 템플릿 중에도 slug 없는 것(치료 단가·급여대장)은 동일 (선행 문서 STEP 4 와 같은 함정).
  5. 권한: ①preview 는 payroll:read, ①commit·②·③은 payroll:manage — PAYROLL_ADMIN/HR_ADMIN 계정으로 진행.
  6. carry-forward 는 대상 월 기간 freeze 가드를 통과해야 한다 (:861, advisory — sheet-guard.ts).

전체 흐름 (Flow Overview) ​

■ PHASE A — 사전 준비 (importer 미사용 — 전부 앱에서 신규 등록)
  1. BESTRH 조직 프로비저닝 + 항목코드 + 직원 신규 등록(위저드) + 근로계약

■ PHASE B — M월(2026-06): 병원 시트 일습 구성 + 정산
  2. 시스템 템플릿 적용 (overtime_all · incentive_all · 치료 단가 · 치료일 인센티브 · 급여대장)
  3. 병원 고유 사용자 시트 생성 (특별수당 · 기타수당 — Excel 탭 형식 미러)
  4. M월 데이터 입력 + 🆕 관리자 수동 편집(전 시트 — 시스템 템플릿 포함)
     ref 편집 = 원본 레코드 직접 수정(confirm) · computed/formula = 토스트 경고 + 거부
     (2026-06-11 구현 완료) → runCompute → ✓커밋 (시트=원본=레코드 일관)
  5. ⚠️ 재사용 준비: 사용자 시트 SHARED 전환 + 템플릿으로 저장

■ PHASE C — M+1월(2026-07): 세 경로로 복제
  6. 경로① carry-forward — 공유 사용자 시트 일괄 가져오기
  7. 경로③ 시스템 템플릿 재적용 — slug idempotency 확인
  8. 경로② 내 템플릿 적용 — 단건 재사용 + (스모크) ④ JSON 왕복

■ PHASE D — M+1월 검증: 구성 보존 · 데이터 격리 · 신규 입사자
  9.  컬럼/커밋 설정 보존 + 행 미복사 + M+1 데이터로 재채움
  9-A. 🆕 신규 입사자 등록(7월) → 복제된 시트가 새 직원을 자동 수용
  10. M+1월 커밋 → 새 AllowanceRecord (M월 레코드 불변, 신규 입사자 포함)

PHASE A — 사전 준비 ​

STEP 1 — BESTRH 조직 프로비저닝 + 신규 직원 등록 ​

선행 문서 STEP 1~3 을 수행 — system-admin 프로비저닝(BESTRH) → 관리자 비밀번호 변경 → 항목코드 등록. 직원은 importer 없이 전부 신규 등록한다:

UI: Admin Panel(:4001) → 직원 추가 위저드 · Router: employee.create (employee.router.ts:93)

엑셀 탭의 직군을 본떠 M월(6월) 직원 2~3명 등록:

직원모델 (Excel 탭)팀필수
행정직 1명행정시간외행정팀 (팀장 겸임 — team.leaderId 지정)근로계약(기본급/시급)
치료사 1명재활 · 스포츠치료(인센)치료팀근로계약
간호직 1명 (선택)간호부간호팀근로계약

⚠️ 선행 문서 STEP 5 함정 그대로 적용: 근로계약 없으면 급여 생성에서 skip · 역할 드롭다운의 첫 "일반 직원"은 역할-없음 항목 · 주민등록번호는 체크섬 유효 값 사용 · 팀장은 역할 부여 + team.leaderId 둘 다 필요.

🆕 M+1월 신규 입사자 1명은 여기서 만들지 않는다 — STEP 9-A 에서 7월에 등록해 "복제된 시트가 새 직원을 받아들이는가"를 본다.

✅ 검증 ​

  • [ ] PAYROLL_ADMIN(또는 HR_ADMIN) 계정으로 HR System(:4000) 로그인 가능
  • [ ] 수당 항목코드 + 급여대장 aggregate 컬럼 존재 (커밋 컬럼이 commitTargetSheetSlug/Key 로 가리킬 대상; 항목코드는 그 aggregate 컬럼에 달림)
  • [ ] 신규 등록 직원 2~3명 + 각자 활성 근로계약, 사번 자동 증가
  • [ ] 임시 비밀번호 직원의 mustChangePassword=true → 첫 로그인 시 변경 강제

PHASE B — M월(2026-06): 시트 구성 + 정산 ​

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

UI: HR System → 시트 탭 → 기간을 2026-06 으로 → "📄 템플릿" 또는 새 시트 모달의 "🛠️ 시스템 템플릿" Router: sheetTemplate.list { includeSystem: true } → applyToPeriod { templateId, year: 2026, month: 6 }

non-password.xlsx 탭 ↔ 앱 시트 대응 (시드: seed-sheet-templates.ts):

Excel 탭 (BESTRH)적용할 시스템 템플릿slug역할
행정시간외 · 간호부-시간외근무 · 스포츠치료-시간외 · 치료시간외(토요일/토외) · NDT(토요일)연장근무 내역 전체overtime_allsource (reference)
재활 · 스포츠치료(인센) · 간호부인센티브 세션 전체incentive_allsource (reference)
재활 단가표 부분치료 단가❌ 없음카탈로그 write-back
재활(치료사 fan-out)치료일 인센티브❌ 없음🔄 인센 동기화
급여대장(전체)급여대장❌ 없음aggregate 표시용

위 표의 Excel 탭은 형식을 본뜨는 원본일 뿐, 데이터를 가져오지 않는다. 호봉 탭(기능직_호봉 등)은 SalaryGrade 마스터 데이터 영역, 공제내역은 범위 밖, 급여 참고(YYYY.MM) 월별 탭은 본 시나리오가 대체하려는 수동 복사 관행 그 자체라 다루지 않는다.

✅ 검증 ​

  • [ ] 2026-06 에 위 5종 시트 생성
  • [ ] slug 보유 템플릿(overtime/incentive)을 같은 달에 한 번 더 적용 → 기존 시트 재사용(중복 생성 없음, :168)
  • [ ] slug 없는 템플릿(치료 단가·급여대장) 재적용 → ⚠️ 중복 생성됨 (알려진 동작 확인)
  • [ ] 치료 단가 시트는 rowBasis="free" + 카탈로그 행 시드됨 (:211)

STEP 3 — 병원 고유 사용자 시트 생성 (Excel 탭 미러) ​

핵심 대상. 시스템 템플릿에 없는 병원 고유 탭을 사용자 시트로 만든다 — 매달 재사용해야 하는 것이 바로 이 시트들이다.

Router: sheetView.create (sheet-view.router.ts:413) 또는 UI "✏️ 처음부터 만들기"

최소 2개 생성 (Excel 특별수당/기타수당 탭 미러 — 구분·성명·사번·금액 구조):

jsonc
// sheetView.create
{
  "year": 2026, "month": 6,
  "name": "특별수당",
  "visibility": "SHARED",            // ⚠️ STEP 6 carry-forward 대상이 되려면 SHARED
  "rowBasis": "employee",
  "columnLayout": [
    { "key": "emp",    "label": "성명", "kind": "identity", "width": 120, "visible": true },
    { "key": "reason", "label": "구분", "kind": "literal", "width": 160, "visible": true },
    { "key": "amount", "label": "금액", "kind": "literal", "format": "number", "width": 120, "visible": true,
      // 커밋 게이트 = commitToKind + (commitTargetSheetSlug, commitTargetColumnKey) — 이 페어가 커밋을 구동
      "commitToKind": "allowance",
      "commitTargetSheetSlug": "<급여대장 slug>", "commitTargetColumnKey": "<급여대장 aggregate 컬럼 key>",
      "allowanceItemCodeId": "<특별수당 AllowanceItemCode.id>" }  // 이제 optional — 레거시 브리지용 (항목코드는 대상 aggregate 컬럼에서 해석)
  ]
}

추가로 연장근무 정산용 참조 computed 시트 1개 — 선행 문서 STEP 9 와 동일 패턴 (computeSourceModule: "overtime_record" + computedKey + commitToKind 컬럼). 이 시트가 "구성이 복잡해 매달 다시 만들기 싫은 시트"의 대표 사례다.

✅ 검증 ​

  • [ ] 사용자 시트 2~3개 생성, 직원 행 추가(sheetRow.create 또는 "+ 행 추가")
  • [ ] 커밋 컬럼에 commitToKind + commitTargetSheetSlug/commitTargetColumnKey 저장 확인 (레거시 itemCode 는 optional; sheetView.get 으로 columnLayout 회독)

STEP 4 — M월 데이터 입력 + 🆕 관리자 수동 편집 (전 시트) → 정산(커밋) ​

선행 문서 STEP 7~10 압축판으로 6월분 데이터를 쌓되(연장근무 승인 2~3건, 인센티브 1~2건, 특별수당 literal 금액 입력), 커밋 전에 모든 시트를 관리자가 손으로 고쳐 본다 — 시스템 템플릿 출신 시트 포함. 이것이 본 시나리오의 추가 요구사항이다: 시트는 자동화 결과를 보여주기만 하는 게 아니라, 관리자가 임의 값으로 커스터마이즈할 수 있어야 한다.

4-1. 시트별 수동 편집 매트릭스 (전부 PAYROLL_ADMIN 계정으로) ​

기대 동작은 위 "셀 편집 매트릭스" 기준 (2026-06-11 구현 완료 — 본 실행이 첫 검증).

시트 (출신)편집 행위기대 동작
특별수당 (사용자)literal 금액·구분 셀 입력/수정, 행 추가·삭제그대로 저장/삭제
연장근무 내역 전체 (시스템, auto 행)ref 셀(연장시간)에 직접 입력 — 승인값 8h → 10h"원본 기록 직접 변경" confirm → 수락 시 OvertimeRecord 가 10h 로 갱신 (append-only 새 버전, 구버전 supersededAt) → 시트·team-manager 앱 모두 10h 표시 · confirm 거절 시 아무 변화 없음
〃수정 직후 team-manager 연장근무 탭 확인10h (시트와 원본이 단일 진실 — 분기 없음)
〃auto 행 삭제 시도❌ 거부 ("자동 생성된 행은 직접 삭제할 수 없습니다…") · 수동 행 추가는 ✅
인센티브 세션 전체 (시스템, auto 행)ref 셀(unitCount/unitPrice/totalAmount) 직접 입력overtime 과 동일 — confirm 후 FixedIncentive 원본 갱신(새 버전)
출퇴근 내역 전체 (시스템, auto 행)ref 셀 입력 시도❌ "원본 화면에서 수정해 주세요" 토스트 (출퇴근은 인라인 수정 경로 미지원 — 침묵 override 금지)
치료 단가 (시스템)단가 셀 수정셀 저장 + treatment_items 카탈로그 write-back → 치료일 인센티브 계산에 즉시 반영
급여대장 (시스템)aggregate 셀 더블클릭❌ "집계해 보여주는 값" 토스트 + 편집 거부
연장근무 정산 computed 시트 (사용자)computed/formula 셀 더블클릭❌ "수식/계산으로 만들어진 값" 토스트 + 편집 거부 (조용한 무시 아님) · 공식 경로: 금액을 바꾸려면 근원인 ref 셀(시간)을 수정 → 원본 갱신 → runCompute 재계산
〃 (회귀)tRPC 직접 sheetCell.set { kind:"literal" } 을 computed 컬럼에 호출❌ 서버 400 ("수식/계산으로 만들어진 값…") — 화면-계산 분기 차단. ref 컬럼(auto 행)에 literal 도 ❌ 400 ("원본 기록을 직접 수정해야 합니다")
모든 시트sheetView.update 로 컬럼 추가/라벨 변경/너비 조정시스템 템플릿 출신이어도 ✅ 차단 없음
일괄 변경 모달 (ref 컬럼 대상)"이 컬럼 일괄 변경" → 값 입력confirm 1회 ("일괄 변경하면 원본 기록 자체가 변경됩니다") → 대상 행들의 source 레코드 갱신
30+ 셀 일괄 수정 (fill/paste)sheetCell.batchSetliteral 컬럼은 한 호출로 저장 (최대 2000셀) · 클립보드 붙여넣기/채우기에 ref 셀(auto 행)이 포함되면 배치당 1회 confirm 후 원본 갱신 — 거절 시 해당 ref 셀들만 skip
레거시 override 잔존 데이터 (회귀)정책 이전에 저장된 override 셀이 있는 ref 셀 조회시트가 원본 레코드 값을 표시 (override 무시 — resolver refMirrorsSource)

4-2. 정산(커밋) ​

sheetView.queryRows → runCompute → commitToRecords. 손 수정이 커밋에 일관되게 흘러드는지가 핵심: ref 수정으로 원본이 10h 가 됐으므로 compute 도 10h 기반 금액을 내고, 그 금액이 AllowanceRecord 가 된다 — 시트·원본·레코드 세 곳의 값이 같아야 한다.

✅ 검증 ​

  • [ ] 위 매트릭스 전 행 수행 — 특히 시스템 템플릿 출신 시트의 셀·컬럼 편집에 어떤 차단도 없음 (isSystem 가드 부재 실측)
  • [ ] ref 편집 = 원본 갱신: confirm 노출 → OvertimeRecord 새 버전 10h + 구버전 supersededAt + 시트/타 앱 동일 표시 + materializer 가 시트 행을 새 버전으로 재바인딩
  • [ ] computed/formula/aggregate 편집 시도 → 이유가 보이는 토스트 + UI·서버 양쪽 거부 (서버는 tRPC 직접 호출로 확인)
  • [ ] 치료 단가 write-back: 카탈로그 갱신 확인 (treatment_items.unitPrice)
  • [ ] 수정값 기준으로 커밋 — 10h 기반 금액이 AllowanceRecord 에 반영 (8h 아님), inputSnapshot 에도 10h
  • [ ] 2026-06 AllowanceRecord 생성 (sourceSheetId = 6월 시트, targetSheetSlug/targetColumnKey 스탬프 — 항목코드는 대상 급여대장 aggregate 컬럼에서 해석, 2026-06-19 모델)
  • [ ] 레코드 수·합계를 기록해 둔다 → STEP 10 에서 "M월 불변" 비교 기준
  • [ ] (권한 역검증) payroll:manage 없는 계정(EMPLOYEE 등)으로 sheetCell.set → 403
  • [ ] (권한 교차) 신규 프로비저닝 조직의 PAYROLL_ADMIN 으로 ref 셀 편집 → 성공 (시드에 overtime/incentive write 포함됨, 2026-06-11 2차) · 기존 조직이면 backfill 전까지 403 이 정상 — backfill 필요 조직으로 기록

STEP 5 — ⚠️ 재사용 준비 (M월에서 미리 해야 하는 것) ​

  1. 공유 전환: STEP 3 사용자 시트 + STEP 2 중 carry-forward 로 가져갈 시트를 SHARED 로 (sheetView.update { id, visibility: "SHARED" } 또는 시트 메뉴 공유 토글). PRIVATE 시트는 STEP 6 목록에 안 나온다.
  2. 템플릿 저장(경로② 준비): 연장근무 정산용 computed 시트를 sheetTemplate.saveSheetAsTemplate { sheetId, name: "연장근무 정산 (BESTRH)", description: ... } 으로 저장.
  3. (스모크) sheetView.exportTemplate 로 특별수당 시트 JSON 다운로드 → STEP 8 에서 import.

✅ 검증 ​

  • [ ] 다른 PAYROLL_ADMIN 계정 로그인 시 SHARED 시트가 보임
  • [ ] sheetTemplate.list { includeSystem: false } 에 조직 템플릿(isSystem=false, organizationId 스탬프) 표시
  • [ ] 타 조직 계정에는 이 템플릿이 안 보임 (org 스코프, :51)

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

STEP 6 — 경로① carry-forward (일괄 가져오기) ​

UI: 기간을 2026-07 로 변경(시트 목록 비어 있음 확인) → 툴바 "📋 이전 월에서 가져오기" Router: carryForwardPreview { fromYear:2026, fromMonth:6, toYear:2026, toMonth:7 } → carryForward { …, includeCustoms: true }

✅ 검증 ​

  • [ ] preview 목록 = 6월 SHARED 시트 전부 (PRIVATE 시트 부재 확인 — 함정 1)
  • [ ] preview 의 columnCount 가 원본과 일치
  • [ ] commit 후 created = preview 개수, 7월 목록에 같은 이름 시트 생성
  • [ ] 재실행 → created: 0 (이름 기준 skip — idempotent, 함정 3)
  • [ ] 복제본의 slug/autoPopulate 가 null 인 것 확인 (함정 2 실측) — overtime_all 사본이 생겼다면 삭제하고 STEP 7 의 ③ 경로로 대체

STEP 7 — 경로③ 시스템 템플릿 재적용 (시드 source 시트) ​

2026-07 에서 overtime_all · incentive_all · 치료 단가 · 치료일 인센티브를 applyToPeriod { year:2026, month:7 } 로 다시 적용. 시드 source 시트의 월별 재생성은 이 경로가 정답이다 (slug·autoPopulate·rowBasis 보존).

✅ 검증 ​

  • [ ] 7월 overtime_all 시트 생성, slug 유지 → 재적용 시 idempotent
  • [ ] 7월 치료 단가: rowBasis="free" + 카탈로그 행이 6월에 갱신한 단가로 시드됨 (catalog 가 시점 데이터가 아님을 확인)

STEP 8 — 경로② 내 템플릿 적용 + (스모크) ④ JSON ​

  1. 새 시트 모달 "📁 내 템플릿" → STEP 5-2 의 "연장근무 정산 (BESTRH)" 적용 (applyToPeriod { templateId, year:2026, month:7 }).
  2. (스모크) STEP 5-3 JSON 을 "📁 파일에서 가져오기"로 import (sheetView.importTemplate { year:2026, month:7, payload }).

✅ 검증 ​

  • [ ] 적용된 시트는 PRIVATE 로 생성 (:192) → 공유하려면 다시 SHARED 전환 (함정 1 재발 지점)
  • [ ] 사용자 템플릿 재적용 → ⚠️ 같은 이름 중복 시트 생성 (slug 없음, 함정 4 실측)
  • [ ] JSON import 본의 columnLayout 동일 (schemaVersion 1 검증 통과)

PHASE D — M+1월 검증 ​

STEP 9 — 구성 보존 + 데이터 격리 ​

복제 경로별 7월 시트 각각에 대해:

✅ 검증 ​

  • [ ] columnLayout 완전 보존 — key/label/width/kind/formula/computeSourceModule/computedKey/commitToKind/commitTargetSheetSlug/commitTargetColumnKey 전부 6월 원본과 동일 (JSON diff)
  • [ ] 행 데이터는 복사 안 됨 — literal 시트(특별수당)는 빈 행, 셀 값 없음 · ref 컬럼은 7월 source 레코드를 새로 materialize (6월에 수정한 값은 6월 레코드의 것 — 기간별 데이터는 각자의 원본 레코드에 산다)
  • [ ] 🆕 복제본도 원본과 동일하게 수동 편집 가능 — STEP 4-1 매트릭스 중 최소 4행(literal 입력 · ref 직접 수정+고지 · computed 편집 거부 경고 · sheetView.update 컬럼 변경)을 7월 복제 시트에서 재실행, 경로①②③ 출신 모두 동일 동작
  • [ ] 🆕 6월에 수정한 OvertimeRecord(10h)는 6월 데이터로 그대로 — 7월 편집이 6월 레코드/시트에 영향 없음
  • [ ] source 시트(overtime_all)는 queryRows 시 7월 승인 레코드만 materialize (6월 행 미출현 — 7월 연장근무 1~2건을 먼저 승인해 둘 것)
  • [ ] 6월 시트를 다시 열면 6월 데이터 그대로 (기간 격리)

STEP 9-A — 🆕 신규 입사자 등록 → 복제된 시트가 자동 수용 ​

본 시나리오의 두 번째 핵심 검증. 전월 구성을 복제한 시트는 "그때 있던 직원"이 아니라 "지금 있는 직원"을 봐야 한다 — 시트가 직원 명단을 박제하지 않고 매달 살아있는 조직 상태를 반영하는지 확인한다.

  1. 7월 신규 입사자 1명 등록 — Admin Panel 직원 추가 위저드 (employee.create), hireDate: 2026-07-01, 치료팀 배정, 근로계약 포함 (없으면 STEP 10 연장 시 급여 skip — 선행 문서 함정).
  2. 신규 직원으로 7월 연장근무 1건 신청 → 팀장 승인 (overtime.request → teamManager.overtime.approve).
  3. 복제된 7월 시트들을 다시 연다 (queryRows).

✅ 검증 ​

  • [ ] source 시트(overtime_all): 신규 직원의 7월 승인 건이 행으로 materialize — 시트를 만들 때(STEP 6~8) 존재하지 않던 직원이어도 나타남
  • [ ] literal 사용자 시트(특별수당): "+ 행 추가" 직원 선택 목록에 신규 직원 표시 → 행 추가 + 금액 입력 가능
  • [ ] 급여대장(aggregate): 신규 직원 행 노출 (커밋 후 STEP 10 에서 금액 집계 확인)
  • [ ] 복제된 시트의 filters.employeeIds 가 비어 있음 — 전월에서 직원 필터가 복사돼 신규 직원이 걸러지지 않는지 (걸러진다면 함정으로 기록)
  • [ ] 6월 시트에는 신규 직원이 나타나지 않음 (hireDate 이전 기간 — 나타난다면 버그로 기록)

STEP 10 — M+1월 커밋 → 레코드 격리 ​

7월 시트에 7월 데이터 입력 → runCompute → commitToRecords.

✅ 검증 ​

  • [ ] 7월 AllowanceRecord 생성 — lineage sourceSheetId 가 7월 시트 id (6월 시트 아님)
  • [ ] targetSheetSlug/targetColumnKey 가 복제된 컬럼 설정(commitTargetSheetSlug/commitTargetColumnKey)에서 그대로 스탬프 (항목코드는 대상 급여대장 aggregate 컬럼에서 해석 — 2026-06-19 모델; 재설정 없이 커밋 동작 = 본 시나리오의 핵심 가치)
  • [ ] 🆕 신규 입사자(STEP 9-A)의 AllowanceRecord 도 생성 — 시트 구성을 한 번도 손대지 않고 "직원 등록 + 데이터 입력"만으로 신규 직원이 정산 흐름에 합류
  • [ ] 6월 레코드 불변 — STEP 4 에서 기록한 6월 레코드 수·합계 변동 없음 (신규 입사자의 6월 레코드 0건)
  • [ ] 7월 재커밋 → upsert (recordsInserted=0, recordsUpdated>0)
  • [ ] (선택) 선행 문서 STEP 11~12 로 7월 급여 생성·확정·eCount 내보내기까지 연장

부록 A — 함정 요약 ​

#함정회피
1carry-forward 는 SHARED 만M월에 공유 전환 먼저 (STEP 5) · ②③으로 만든 시트는 PRIVATE 로 시작함
2carry-forward 가 slug/autoPopulate/rowBasis 누락시드 source 시트·free 시트는 ③ 시스템 템플릿 재적용으로
3이름 충돌 조용히 skipcreated 수 확인, preview 와 대조
4사용자 템플릿·slug 없는 시스템 템플릿 재적용 = 중복월 1회만 적용, 적용 전 목록 확인
5어떤 경로도 행을 복사하지 않음의도된 설계 — literal 값은 매달 재입력, source 행은 자동 재채움

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

# M월 (2026-06)
[system-admin] POST /api/provision/organization   # BESTRH (선행 문서 STEP 1~3)
employee.create ×2~3 (+contract, 팀/팀장)          # 직원 신규 등록 — importer 미사용
sheetTemplate.list → applyToPeriod ×5            # 시스템 템플릿
sheetView.create ×2~3 (visibility:"SHARED")      # 병원 고유 시트 (특별수당 등)
sheetCell.set / batchSet (literal 셀 손 편집)      # 🆕 전 시트 — 시스템 출신 포함
(ref 셀 편집 → confirm → overtime.update 등)       # 원본 직접 수정 (새 버전) — 구현됨
(computed/formula 셀 편집 시도 → 토스트 + 거부)    # UI·서버 양쪽 — 구현됨
sheetView.update { columnLayout }                 # 시스템 출신 시트 컬럼 변경 (차단 없음)
sheetView.runCompute → commitToRecords            # 수정값이 레코드로 (시트=원본=레코드)
sheetView.update { visibility:"SHARED" }          # 공유 안 된 시트 마저
sheetTemplate.saveSheetAsTemplate                 # 경로② 준비
sheetView.exportTemplate                          # 경로④ 준비

# M+1월 (2026-07)
sheetView.list { year:2026, month:7 }             # 빈 목록 확인
sheetView.carryForwardPreview → carryForward      # 경로① — 사용자 시트
sheetTemplate.applyToPeriod ×4                    # 경로③ — 시드 source 시트
sheetTemplate.applyToPeriod (내 템플릿)            # 경로②
sheetView.importTemplate                          # 경로④ 스모크
employee.create (hireDate:2026-07-01, +contract)  # 🆕 신규 입사자
overtime.request → teamManager.overtime.approve   # 신규 입사자 7월 활동
(7월 데이터) → runCompute → commitToRecords       # 레코드 격리 + 신규 직원 합류 확인

부록 C — 미해결 / 확인 필요 (Open Questions) ​

  • [ ] 🆕 carry-forward 의 slug/autoPopulate/rowBasis 누락이 의도인지 버그인지 — 의도라면 "시드 source 시트는 carry-forward 대상이 아니다"를 UI(preview 목록)에서 안내해야 하고, 버그라면 :900 save 필드에 추가 필요. 제품 결정 필요.
  • [ ] 사용자 템플릿에 defaultSlug 를 부여하는 경로가 없음 — 매달 적용하는 조직 템플릿도 idempotent 하게 만들 가치가 있는지.
  • [ ] applyToPeriod 가 항상 PRIVATE 로 생성 → "매달: 적용 후 공유 전환" 2-step 이 강제됨. visibility 입력 허용 여부 검토.
  • [ ] 12월→1월 연도 경계 (fromYear 2026/12 → toYear 2027/1) — previousPeriod 헬퍼(:56)는 처리하지만 E2E 미실측.
  • [ ] 🆕 신규 입사자가 월 중간 입사(예: 7/15)인 경우 — source 시트 materialize·급여 일할 계산이 hireDate 를 존중하는지 (STEP 9-A 는 7/1 입사 기준).
  • [x] computed/formula 셀 편집을 서버가 받아주는 문제 → 결정 + 구현 완료 (2026-06-11): 편집 시도 시 이유 토스트(더블클릭 핸들러 + _warnDerivedCellEdit) + 서버 sheetCell.set/batchSet 컬럼 kind 검증으로 400 거부 (assertColumnHandEditable, sheet-cell.router.ts:68). aggregate 도 동일 토스트.
  • [x] ref override 와 source 최신값의 분기 문제 → 결정 + 구현 완료 (2026-06-11, 2차까지): override 개념 폐기. ref 셀(auto 행) 편집 = confirm 고지 후 source 라우터 update 로 원본 직접 수정 (_saveCellEdit 라우팅 + refField→필드 매핑). 서버는 해당 셀의 literal 쓰기를 400 거부. 후속 갭도 같은 날 클로즈: ① resolver 가 ref(auto) 컬럼의 literal 셀을 무시 — legacy override 가 표시를 가로채지 못함 (마이그레이션 불필요) ② 클립보드 붙여넣기/채우기/일괄 변경 모두 배치당 1회 confirm ③ PAYROLL_ADMIN 시드에 overtime/incentive write 부여 + org-wide incentive.read/write 권한 신설 — 기존 조직 backfill 필요.
  • [ ] 🆕 ref 편집이 원본 수정이 되면 승인 워크플로와의 충돌 처리 — APPROVED 연장근무의 시간을 시트에서 고치면 상태를 유지할지, 재승인(PENDING 회귀)을 강제할지 제품 결정 필요. (2026-06-11 실측: 현재는 APPROVED 유지 — 새 버전도 approved 로 생성됨.)
  • [x] 급여대장 hireDate 무시 · compute status 필터 부재 · PAYROLL_ADMIN allowance.read 누락 → 2026-06-11 2차에서 전부 수정 (상단 "검증 결과 요약" 참조). 남은 운영 작업: 기존 조직 role_permissions backfill (allowance/deduction.read + incentive.write.self).