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건):
- 🐛 (수정) 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 동일 표시까지 전 체인 검증. - 🐛 (수정) team-manager.router.ts
overtime.list/incentive.list에supersededAt IS NULL필터 누락 — ref 편집으로 새 버전이 생기면 구버전(8h)과 신버전(10h)이 목록에 같이 표시됨. 필터 추가. - 🐛 (수정) 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월 템플릿 적용 시 갱신 단가로 시드까지 검증. - 🐛 (수정)
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-passwordredirect 확인 (코드 수정 불필요).
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(← 오류였음 (4차 정정): 수식 에디터의 cross-sheet 빌더가[colKey])이라 시트 간 참조 자체가 없음[<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) 월별 탭이 그 흔적). 이 시나리오는 그 운영 습관을 앱으로 옮겼을 때의 전체 경로를 검증한다:
- M월 — 직원을 앱에서 신규 등록하고, 엑셀 탭 형식을 본뜬 병원 시트 일습을 구성한다 (시스템 템플릿 + 병원 고유 사용자 시트).
- M월 정산(커밋)까지 마친다.
- M+1월 — 시트를 새로 만들지 않고 세 가지 재사용 경로로 전월 구성을 복제한다.
- 복제본의 컬럼 구성(커밋 설정 포함)은 보존되고, 행/데이터는 복사되지 않으며, M+1월 데이터로 다시 채워져 독립적으로 커밋되는지 확인한다.
- 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.tscommitSheet). - 레코드: AllowanceRecord/DeductionRecord 는 더 이상
allowanceItemCodeId/deductionItemCodeId를 들지 않는다 (migration 1815targetSheetSlug/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-wideincentive.read/write부여 — ⚠️ 기존 조직은 permissions·role_permissions backfill 필요 (신규 프로비저닝부터 자동, incentive.write.self 때와 동일한 주의).
행 단위: 수동 행 추가는 모든 시트에서 가능 (sheetRow.create, auto-populate 시트 포함) · auto 행은 직접 삭제 불가 ("원본 레코드 또는 자동 생성 규칙을 수정하세요", sheet-row.router.ts:173) · 수동 행은 삭제 가능.
재사용 경로 4종 비교 (무엇이 복사되는가)
| 경로 | UI | Router | 복사됨 | 복사 안 됨 | 대상 |
|---|---|---|---|---|---|
| ① 이전 월에서 가져오기 (carry-forward) | 시트 탭 툴바 "📋 이전 월에서 가져오기" (carry-forward.html) | sheetView.carryForwardPreview (sheet-view.router.ts:803) → sheetView.carryForward (:852) | name, viewKind, groupId, visibility, filters, columnLayout, groupBy, sort | rows · slug · autoPopulate · rowBasis | SHARED 시트만, 일괄 |
| ② 템플릿으로 저장 → 적용 | 시트 메뉴 "💾 템플릿으로 저장" (save-as-template.html) → 새 시트 모달 "📁 내 템플릿" | sheetTemplate.saveSheetAsTemplate (sheet-template.router.ts:105) → applyToPeriod (:150) | viewKind, filters, columnLayout, groupId | rows · 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 필터를 초기화한다 — 익월 복제 수단이 아니다.
⚠️ 알려진 함정 (검증 항목에 반영)
- carry-forward 는 SHARED 시트만 가져온다 (:872).
applyToPeriod가 만든 시트와 손으로 만든 시트는 기본 PRIVATE → M월에 공유 전환을 안 해두면 M+1월 가져오기 목록에 안 나타난다. - carry-forward 는
slug·autoPopulate·rowBasis를 복사하지 않는다 (:900-913 save 필드 목록 참조). 결과:- slug 시트(예:
overtime_all)를 carry-forward 로 가져오면 slug 없는 사본이 생기고, 이후 같은 시스템 템플릿을applyToPeriod하면 같은 이름의 시트가 하나 더 생긴다 (slug idempotency 가 사본을 못 본다). → 시드 source 시트는 ①이 아니라 ③으로 재생성하라. - rowBasis="free" 시트(치료 단가·치료일 인센티브)를 ①로 가져오면 free 기준이 풀린다. → 이 둘도 ③으로.
- slug 시트(예:
- 이름 충돌은 조용히 skip — carry-forward 는 대상 월에 같은
name의 시트가 있으면 건너뛴다 (:877).created수가 기대보다 작으면 이것부터 의심. - 사용자 템플릿(②)에는 slug 가 없어 재적용 시 중복 시트가 생긴다 — 시스템 템플릿 중에도 slug 없는 것(치료 단가·급여대장)은 동일 (선행 문서 STEP 4 와 같은 함정).
- 권한: ①preview 는
payroll:read, ①commit·②·③은payroll:manage— PAYROLL_ADMIN/HR_ADMIN 계정으로 진행. - 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_all | source (reference) |
| 재활 · 스포츠치료(인센) · 간호부 | 인센티브 세션 전체 | incentive_all | source (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 특별수당/기타수당 탭 미러 — 구분·성명·사번·금액 구조):
// 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.batchSet | literal 컬럼은 한 호출로 저장 (최대 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월에서 미리 해야 하는 것)
- 공유 전환: STEP 3 사용자 시트 + STEP 2 중 carry-forward 로 가져갈 시트를 SHARED 로 (
sheetView.update { id, visibility: "SHARED" }또는 시트 메뉴 공유 토글). PRIVATE 시트는 STEP 6 목록에 안 나온다. - 템플릿 저장(경로② 준비): 연장근무 정산용 computed 시트를
sheetTemplate.saveSheetAsTemplate { sheetId, name: "연장근무 정산 (BESTRH)", description: ... }으로 저장. - (스모크)
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
- 새 시트 모달 "📁 내 템플릿" → STEP 5-2 의 "연장근무 정산 (BESTRH)" 적용 (
applyToPeriod { templateId, year:2026, month:7 }). - (스모크) 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 — 🆕 신규 입사자 등록 → 복제된 시트가 자동 수용
본 시나리오의 두 번째 핵심 검증. 전월 구성을 복제한 시트는 "그때 있던 직원"이 아니라 "지금 있는 직원"을 봐야 한다 — 시트가 직원 명단을 박제하지 않고 매달 살아있는 조직 상태를 반영하는지 확인한다.
- 7월 신규 입사자 1명 등록 — Admin Panel 직원 추가 위저드 (
employee.create),hireDate: 2026-07-01, 치료팀 배정, 근로계약 포함 (없으면 STEP 10 연장 시 급여 skip — 선행 문서 함정). - 신규 직원으로 7월 연장근무 1건 신청 → 팀장 승인 (
overtime.request→teamManager.overtime.approve). - 복제된 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 — 함정 요약
| # | 함정 | 회피 |
|---|---|---|
| 1 | carry-forward 는 SHARED 만 | M월에 공유 전환 먼저 (STEP 5) · ②③으로 만든 시트는 PRIVATE 로 시작함 |
| 2 | carry-forward 가 slug/autoPopulate/rowBasis 누락 | 시드 source 시트·free 시트는 ③ 시스템 템플릿 재적용으로 |
| 3 | 이름 충돌 조용히 skip | created 수 확인, 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-wideincentive.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).