Skip to content

E2E Test Scenario — 조직 프로비저닝부터 이카운트 급여 엑셀 내보내기까지 ​

Status: Verified · 개발 테스트(development test)용 전체 시나리오 Branch context: develop — sheet-rooted records + eCount export (구 test/ecount 머지됨) Last reviewed: 2026-06-04 · Last verified: 2026-06-11 (로컬 전체 실행, org E2ETEST)

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

STEP 1→12 전체 경로가 로컬에서 끝까지 통과했다 (e-sign은 외부 credential 없어 제외). 실행 중 발견된 사실/버그 — 본문 각 STEP 에 ⚠️/🐛 로 반영:

  • 🐛 (수정됨) compute.ts overtime 입력 조립에서 ...sums 스프레드가 derived hourlyRate 를 0 으로 덮어씀 — 연장근무 보유 직원의 정산액이 항상 0 이 되는 버그. 스프레드 순서로 수정.
  • 🐛 (수정됨) compute.ts fixed_incentive 집계 필드명 total vs 모듈 IncentiveAgg.totalAmount 불일치 — 인센티브 computed 컬럼이 항상 null. 필드명 통일로 수정.
  • ℹ️ (의도된 동작) 급여 확정 후에도 시트 runCompute/commitToRecords 는 차단되지 않는다 — 잠금 가드는 제품 결정으로 제거됨 (sheet-guard.ts 헤더 참조: 마감 기간에도 소급 수정 허용, periodLocked/lockedAt 은 advisory). 단, 확정 후 커밋으로 AllowanceRecord 가 갱신되면 grossPay 와 어긋날 수 있으므로 확정 취소(cancelConfirm) → 재생성 → 재확정 절차를 따라야 한다.
  • 🐛 (수정됨) employee-app attendance.checkIn/checkOut/todayStatus 가 date 를 UTC 기준으로 계산 — KST 오전 출근이 전날 날짜로 기록되던 버그. org timezone 기반 orgToday() 로 수정.
  • ℹ️ (2026-06-19 모델 변경) 커밋 게이트는 이제 commitToKind + commitTargetSheetSlug/commitTargetColumnKey(대상 급여대장 aggregate 컬럼 포인터)로 동작한다. 옛 allowanceItemCodeId/deductionItemCodeId 는 레거시 브리지로만 남았다 — 아래 "🆕 2026-06-19 모델 변경" 절 참조.
  • 🐛 (수정됨) EMPLOYEE 기본 역할에 incentive.write.self 누락 → 자가 등록 403 — default-permissions 시드에 추가. 기존 조직은 role_permissions backfill 필요 (신규 프로비저닝부터 자동 적용).
  • 🐛 (수정됨) 자동 발급 임시 비밀번호로 등록된 직원의 mustChangePassword 가 false — employee.create 에서 임시 비밀번호 발급 시 true 로 설정하도록 수정 (완료 모달 문구와 일치).
  • 🐛 (수정됨) 직원 추가 위저드: 역할 드롭다운의 no-role 항목이 실제 EMPLOYEE 역할과 같은 "일반 직원" 라벨로 표시되던 함정 — placeholder 를 "미지정" 으로 변경하고, "기본 역할이 적용돼요" 라던 잘못된 힌트 문구도 "역할 미지정 직원은 권한이 없으니 등록 후 지정하라" 로 정정 (공용 위저드 + admin 모달 + ko/en locale).
  • ⚠️ 연장근무 승인 권한은 team_members 의 TEAM_LEADER 역할만으로는 부족 — team.leaderId(팀 관리자) 설정이 있어야 team-manager 에서 팀이 보인다.

신규 조직을 만들고 → 설정하고 → 직원을 온보딩하고 → 한 달치 근태·연장근무를 쌓고 → 시트로 정산(커밋)해 수당/공제 레코드를 만들고 → 급여를 확정해 → 이카운트 엑셀로 내보내는 전체 경로를 단계별로 검증한다.

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

  • AllowanceRecord / DeductionRecord 는 입력이 아니라 출력이다. 오직 시트의 ✓ 커밋 으로만 생성된다. 직접 생성 API는 차단됨 ("수당 레코드는 시트의 '✓ 커밋' 작업을 통해서만 생성됩니다"). → 이 문서에서 "정산 처리(정산)"란 곧 시트 컬럼을 커밋해 레코드를 만드는 행위다.
  • (2026-06-19 모델) 금액 분류는 커밋 시점에 컬럼의 commitTargetSheetSlug/commitTargetColumnKey 가 레코드에 스탬프되어, 그 레코드가 어느 급여대장 aggregate 컬럼으로 들어가는지를 가리킨다. eCount 항목코드는 그 급여대장 aggregate 컬럼에 달려 있고, export 시점에 (targetSheetSlug, targetColumnKey)로 해석된다 (커밋 게이트 = commitToKind + 대상 페어. 옛 itemCode FK 는 레거시 브리지 — 아래 절 참조).
  • OvertimeRecord 는 금액이 없다 (raw observation: workDate, hours, category, status). 금액은 시트 compute 컬럼이 시급 × 시간 × 배율로 계산하고, 커밋 시 AllowanceRecord 가 된다.
  • 공제(deduction)는 이 테스트 범위 밖이다. 4대보험·소득세 등 공제는 외부 eCount 솔루션이 계산·소유한다. 따라서 본 시나리오는 수당(allowance, A코드)만 커밋·내보내며, eCount export 는 mode:"allowance" (A코드만) 를 사용한다. (mode:"both" 와 deductionImport 라운드트립은 out of scope.)
  • 시드된 source 시트(연장근무/인센티브/계약급여/출퇴근/휴직/퇴사/병동)는 직접 커밋하지 않는다 — 전부 reference 다. 정산(커밋)은 이 source 들을 참조하는 별도의 computed 시트(commitToKind 컬럼 보유)에서 일어난다. → STEP 10 참조.
  • 로그인/직원 식별자(email · phone · employeeNumber)는 조직 단위 스코프 (commit 29fa16c8).
  • eCount 내보내기는 CONFIRMED/PAID PayrollRecord 가 있어야만 동작한다. → 정산(커밋) 후 반드시 급여 생성 + 확정을 거쳐야 한다.

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

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

  • 커밋 컬럼 (source 시트): commitToKind("allowance"|"deduction") + commitTargetSheetSlug + commitTargetColumnKey — 이 커밋이 들어갈 급여대장 aggregate 컬럼을 (시트 slug, 컬럼 key)로 가리킨다. 이 페어가 커밋 게이트의 핵심이다 (compute.ts commitSheet, 컬럼 스키마 commitTargetSheetSlug).
  • 레코드: 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 경고(중복 급여대장 가드, hr-system).

전체 흐름 (Flow Overview) ​

■ PHASE A — 프로비저닝 & 설정 (operator / admin)
  1. system-admin: 조직 + 관리자 프로비저닝
  2. 관리자 첫 로그인 + 비밀번호 변경            ← (빠지기 쉬움)
  3. register 수당/공제 목록 (항목코드)
  4. sheet 설정 (템플릿/컬럼 구성)

■ PHASE B — 직원 온보딩
  5. 직원 등록 + 팀 + 근로계약            ← (빠지기 쉬움: 계약 없으면 급여 skip)
  6. 전자계약 서명 처리 (e-sign)

■ PHASE C — 한 달치 활동 누적
  7. app 출퇴근 처리 (employee-app)
  8. 야근/연장근무 승인 (team-manager)

■ PHASE D — 정산 → 급여 → 내보내기
  9.  야근 등 정산 처리   (overtime 시트 → compute → ✓커밋 → AllowanceRecord)
  10. 수당 정산 처리      (source-sheet variants: 인센티브/치료/계약급여 → 참조 computed 시트 → ✓커밋)
  11. 급여 생성 + 확정     (payroll.generateForPeriod → confirm)  ← (빠지기 쉬움)
  12. export eCount      (XLSX "항목별금액직접입력", mode:"allowance" — A코드만)

🔎 목록에서 빠져 있던 연결 단계 (없으면 시나리오가 끝까지 안 돌아감): #2 비밀번호 변경, #5 직원/근로계약 생성, #11 급여 생성·확정. 아래 각 단계에 ⚠️ 로 표시. 🔇 범위 제외: 공제(deduction) 정산 — 외부 eCount 가 소유. A코드(수당)만 다룬다.


사전 준비 (Prerequisites) ​

옵션 A — 배포된 개발 테스트 환경 (권장) ​

테스트 엔드포인트: https://okbest.hereby.me/

배포된 dev 환경에서 바로 시나리오를 돌릴 수 있다. 조직 코드는 OKBEST 계열로 이미 프로비저닝되어 있을 수 있으니 STEP 1(system-admin 프로비저닝)은 신규 조직을 만들 때만 수행하고, 기존 조직으로 테스트하면 STEP 2(로그인)부터 시작한다.

옵션 B — 로컬 ​

bash
make setup          # install + DB + migrations + seed
make dev            # 개발 서버 기동
항목배포(dev)로컬
앱 진입https://okbest.hereby.me/(앱별 포트)
API basehttps://okbest.hereby.me/trpchttp://localhost:3000/trpc
system-admin(운영자 전용)API :4100 / UI :4101 (sysadmin / changeme)
HR System (시트/급여)dev 호스트http://localhost:4000
Admin Panel (직원/항목코드/e-sign)dev 호스트http://localhost:4001
Employee App (출퇴근)dev 호스트http://localhost:4002
Team Manager (승인)dev 호스트http://localhost:4004
인증 토큰 헬퍼—TOKEN=$(./scripts/get-auth-token.sh)

PHASE A — 프로비저닝 & 설정 ​

STEP 1 — system-admin 으로 조직 + 관리자 프로비저닝 ​

Tool: system-admin (tools/system-admin/server/routes/authorized-reads.ts) Endpoint: POST /api/provision/organization (System Admin 로컬 세션·CSRF 및 선택된 endpoint 필요; 서버가 service token을 발급)

jsonc
{
  "organization": { "name": "베스트재활의원", "code": "BESTRH", "type": "CLINIC",
                    "timezone": "Asia/Seoul",
                    "settings": { "workHours": {"weeklyLimit":52,"dailyStandard":8},
                                  "payroll": {"payDay":25,"cutoffDay":20} } },
  "admin": { "email": "...", "password": "...", "firstName": "...", "lastName": "...",
             "employeeNumber": "BESTRH0001", "phone": "..." }
}

생성물: Organization · 94 Permissions (UI 안내문구 "51 Permissions"는 stale) · 6 Roles(SUPER_ADMIN/HR_ADMIN/PAYROLL_ADMIN/GENERAL_AFFAIRS_ADMIN/TEAM_LEADER/EMPLOYEE) · 관리자 Employee (isSystemAdmin=true, mustChangePassword=true).

ℹ️ system-admin vs auth.register 차이

  • system-admin: 운영자가 통제, mustChangePassword=true, 팀은 생성 안 함, 항목코드는 별도 POST /api/provision/seed-item-codes/:orgId 호출(수당 21 + 공제 12).
  • auth.register (auth.router.ts:358): 셀프가입, 기본 팀 생성, 항목코드 inline(수당 11 + 공제 7), 비밀번호 변경 강제 없음.
  • 본 시나리오는 system-admin 경로를 기준으로 한다. 항목코드는 STEP 3에서 보강.

✅ 검증 ​

  • [x] 조직/관리자 생성, 권한 94·역할 6 시드 (2026-06-11 확인 — 문서·UI의 "51"은 stale)
  • [ ] 관리자 mustChangePassword=true
  • [ ] (선택) seed-item-codes 호출로 수당 21 + 공제 12 시드
  • [ ] system-admin 의 attendance viewer 등 운영 화면 동작

STEP 2 — ⚠️ 관리자 첫 로그인 + 비밀번호 변경 (목록 누락 단계) ​

system-admin 프로비저닝 관리자는 mustChangePassword=true 라 첫 로그인 후 비밀번호 변경이 강제된다.

  • auth.login (auth.router.ts:290) → 응답에 변경 필요 플래그
  • auth.changePassword → 변경, mustChangePassword=false

✅ 검증 ​

  • [ ] 임시 비밀번호로 로그인 → 변경 강제됨
  • [ ] 변경 후 정상 로그인 / 토큰 발급, auth.refreshToken 동작

STEP 3 — register 수당/공제 목록 (항목코드) ​

Router: item-code (item-code.router.ts:17) · UI: ⚠️ 항목코드 전용 화면은 settings 의 codes 서브탭인데 "legacy, hidden" 상태로 LNB 에서 진입 불가 (settings.html:666). 따라서 항목코드 CRUD 는 현재 tRPC 로만 실질 검증 가능.

절차입력 (필수)비고
createAllowanceCodecode(1–4자), name, displayOrder?eCount A-code 분류
createDeductionCode동일eCount D-code 분류
listAllowanceCodes / listDeductionCodes—
bulkCreate* / update* / delete*soft-delete

엔티티: AllowanceItemCode(code≤4, name, displayOrder, isActive, organizationId) — (organizationId, code) unique.

이 항목코드는 STEP 4 급여대장 aggregate 컬럼의 allowanceItemCodeId/deductionItemCodeId 로 달리고, 커밋된 레코드는 (targetSheetSlug, targetColumnKey) 로 그 aggregate 컬럼을 가리킨다. eCount export 는 그 경로를 따라 A/D 코드를 해석한다 (2026-06-19 모델 — 레코드 자체엔 코드 없음).

✅ 검증 ​

  • [ ] 수당/공제 항목 생성 (예: 01 월급여, 13 야간근무수당, 소득세, 국민연금 …)
  • [ ] 같은 조직 내 code 중복 거부
  • [ ] 목록 정렬(displayOrder)·비활성 토글 동작

STEP 4 — sheet 설정 (템플릿 / 컬럼 구성) ​

UI: HR System → 기간 열기 → "📄 템플릿" (template-manager.html) Router: sheet-template (sheet-template.router.ts:36) · 컬럼 스키마 (sheet-view.schema.ts:217)

시드된 시스템 템플릿 (seed-sheet-templates.ts): 급여대장 · 연장근무 내역 전체(overtime_all) · 인센티브 세션 전체(incentive_all) · 휴직/퇴사 전체 · 전자서명 발송 전체(e_sign_all) · 출퇴근 내역 전체(attendance_all) · 병동 일별 환자수 · 계약급여내역 · 치료 단가/치료일 인센티브.

컬럼(columnLayout[]) 핵심 필드:

  • kind: identity | literal | ref | formula | computed | aggregate
  • sourceTable + sourceFilter: 행 자동 채우기 source
  • computeSourceModule + computedKey: codeModule 결과 참조 (예: overtime → night_base)
  • commitToKind: "allowance" | "deduction" + commitTargetSheetSlug / commitTargetColumnKey(대상 급여대장 aggregate 컬럼 포인터) ← 커밋 대상 컬럼 (2026-06-19 모델. 옛 allowanceItemCodeId/deductionItemCodeId 는 레거시 브리지)
  • aggregate(급여대장 집계 컬럼): kind:"aggregate" + allowanceItemCodeId/deductionItemCodeId(eCount 코드가 여기 달림) + sources[]
  • formula(≤2000자), format(number/date/time/…)

커밋 컬럼 예시 (seed-allowance-sheet.ts:112):

jsonc
// source 시트의 커밋 컬럼 — 대상 급여대장 aggregate 컬럼을 (slug,key)로 가리킨다
{ "key": "alw_01", "label": "월급여", "kind": "literal", "format": "number",
  "commitToKind": "allowance",
  "commitTargetSheetSlug": "<급여대장 sheet slug>", "commitTargetColumnKey": "<aggregate 컬럼 key>" }
// 급여대장 쪽 aggregate 컬럼 — eCount 항목코드는 여기 달린다
{ "key": "agg_01", "label": "월급여", "kind": "aggregate",
  "allowanceItemCodeId": "<AllowanceItemCode.id>", "sources": [ /* slug 또는 uuid 핀 */ ] }

✅ 검증 ​

  • [x] 시스템 템플릿 목록 조회 / applyToPeriod 로 해당 월 시트 생성 (slug 기준 idempotent — ⚠️ slug 없는 템플릿(치료 단가·급여대장 등)은 재적용 시 중복 시트가 생성됨)
  • [x] 커밋 컬럼에 commitToKind + commitTargetSheetSlug/commitTargetColumnKey 설정·저장 (이 페어로 커밋 게이트 통과 — 옛 itemCode FK 없이도. sheetView.update 입력은 { id, columnLayout } 평면 구조), 급여대장 aggregate 컬럼에는 allowanceItemCodeId(eCount 코드) 설정
  • [ ] 사용자 시트를 saveSheetAsTemplate 로 재사용 템플릿화 (이번 실행에서는 미검증)

PHASE B — 직원 온보딩 ​

STEP 5 — ⚠️ 직원 등록 + 팀 + 근로계약 (목록 누락 단계) ​

Router: employee.create (employee.router.ts:93) · UI: Admin Panel

필드필수비고
firstName, lastName, email✅email 조직 내 유일
hireDate✅연차 산정 기준
teamIds⬜승인 라우팅·스코프에 필요
contract⬜근로계약(기본급/시급) — 급여 생성에 필수

⚠️ 함정 (2026-06-11 실측 반영)

  1. payroll.generateForPeriod 는 근로계약 보유 직원만 대상 → 계약 없으면 조용히 skip.
  2. e-sign(STEP 6) 으로 근로계약서에 서명해도 EmploymentContract 가 자동 활성화되지 않는다. 계약은 별도로 생성/활성화해야 함 (e-sign 은 문서 발송·서명 추적일 뿐).
  3. 연장근무 승인(STEP 8)은 TEAM_LEADER 역할 부여만으로는 부족 — 팀의 leaderId(관리자)까지 지정해야 team-manager 에 팀이 보인다 (team.update { id, data: { leaderId } }).
  4. 주민등록번호는 체크섬 검증됨 — 더미 값(예: 850302-1111111)은 Invalid resident registration number 로 거부. 테스트는 체크섬 유효 값 사용 (예: 850302-1234561, 920715-2234567).
  5. 위저드 역할 드롭다운의 첫 "일반 직원"은 역할 없음 항목(중복 표기) — EMPLOYEE 역할을 주려면 두 번째 "일반 직원"을 선택해야 한다. 누락 시 attendance:read 권한 부재로 직원 앱이 막힌다.

✅ 검증 ​

  • [ ] 테스트 직원 2~3명 + 각자 근로계약 생성, 사번 자동 증가
  • [ ] 팀 배정, 팀장에 TEAM_LEADER 역할
  • [ ] employee.list 스코프 필터 동작

STEP 6 — 전자계약 서명 처리 (e-sign) ​

Router: e-sign (e-sign.router.ts:43) · UI: Admin Panel → "전자 서명" 탭 (apps/admin/src/views/tabs/e-sign.html) · 권한 EMPLOYEE.UPDATE

외부 Hereby 전자계약 서비스 연동. 관리자가 발송 → 직원이 Hereby 호스팅 페이지에서 서명 → 웹훅으로 상태 갱신.

절차입력동작
listHerebyTemplates—사용 가능 템플릿 조회
send{ employeeId, herebyTemplateId }3단계: ① placeholder(SENDING) ② Hereby createFromTemplate(필드 prefill) ③ contractId+redirectUrl 저장(SENT)
list / getById필터 / uuid조회
openContractuuid재오픈 로그인 토큰(5s 스로틀)
syncStatus{ id }Hereby 상태 수동 폴링

상태 흐름: SENDING → SENT → SIGNED (또는 REJECTED / EXPIRED / ERROR). 필드 prefill (esign-field-resolver.ts): 직원·활성 근로계약(baseSalary/startDate 등)·조직(ceoName)·system.today 를 템플릿 라벨에 매핑. 웹훅: POST /webhooks/hereby-contract (HMAC-SHA256 검증) → 상태/signedAt 갱신.

ℹ️ 시트 "전자서명 발송 전체"(e_sign_all)는 발송 현황 추적용 — 급여/정산과 무관한 컴플라이언스 기록이다.

✅ 검증 ​

  • [ ] 관리자가 근로계약서 템플릿으로 send → SENT, redirectUrl 발급
  • [ ] 직원 서명(또는 webhook/syncStatus) → SIGNED, signedAt 기록
  • [ ] 거부/만료 시 REJECTED/EXPIRED
  • [ ] 서명해도 EmploymentContract 가 자동 활성화되지 않음 확인 (STEP 5 함정)

PHASE C — 한 달치 활동 누적 ​

STEP 7 — app 출퇴근 처리 ​

UI: Employee App (shift-tracker.ts:243) · Router: attendance (attendance.router.ts:125)

절차입력출력/동작
checkIn{ employeeId?, location?, notes? }actualCheckIn = NOW(); 방식(secom/app/manual) 검증, app은 IP allowlist 체크
checkOut동일finalizeAttendanceHours() 호출 → regular/overtime/night/weekend/holiday 시간 버킷팅
todayStatus{ employeeId? }당일 상태 (proc 명은 attendance.todayStatus)
bulkCreate{ employeeIds[], date, checkInTime, checkOutTime, breakMinutes, reason }관리자 수동 일괄 입력
registerFieldWork{ date, startTime, endTime, location }외근 자가 등록

시간 정산 로직 (attendance-finalizer.service.ts): 야간 22:00–06:00(NIGHT_START=22,NIGHT_END=6), 주말 우선, 휴게 차감, 버킷별 floor. PENDING+APPROVED OvertimeRecord 를 같은 (employeeId, date)로 합산해 낙관적 반영.

✅ 검증 ​

  • [ ] employee-app 에서 check-in/out (GPS 선택), actualCheckIn/Out 기록
  • [ ] 야간/주말 근무가 nightHours/weekendHours 로 분류
  • [ ] 관리자 bulkCreate 로 한 달치 근태 시드 가능

STEP 8 — 야근 / 연장근무 승인 ​

UI: Team Manager → "연장근무" 탭 (overtime.html) · Router: team-manager.overtime.* (team-manager.router.ts:569), 직원 신청은 overtime-record (overtime-record.router.ts:352)

워크플로:

  1. 직원: overtime.request({ workDate, overtimeHours(0.5–12), category, startTime?, endTime? }) → PENDING (category: weekday|saturday|holiday|night)
  2. 팀장/HR: overtime.approve({ ids[] }) → APPROVED (approvedBy/approvedAt)
  3. (선택) 직원: submitReport({ id, actualHours, summary }) → REPORT_PENDING
  4. (선택) 팀장: approveReport({ ids[] }) → REPORT_APPROVED (overtimeHours = actualHours 로 확정)

OvertimeRecord 는 append-only 버전(chainId/version), 금액 필드 없음.

✅ 검증 ​

  • [ ] 직원 신청 → 팀장 승인 → APPROVED
  • [ ] (있으면) 리포트 제출/승인 시 overtimeHours 가 actualHours 로 갱신
  • [ ] 반려/취소 상태 전이
  • [ ] 승인자 권한 없으면 거부 (STEP 5 함정 검증)

PHASE D — 정산 → 급여 → 내보내기 ​

"정산 처리"는 시트에서 금액을 계산(compute)하고 커밋(✓)해 Allowance/Deduction 레코드를 만드는 것이다. STEP 9는 연장근무, STEP 10은 일반 수당/공제 — 패턴은 동일하다.

STEP 9 — 야근 등 정산 처리 (연장근무 → AllowanceRecord) ​

체인: APPROVED OvertimeRecord → "연장근무 내역 전체" 시트 자동 채움 → compute 컬럼이 금액 계산 → ✓ 커밋 → AllowanceRecord(APPROVED)

  1. sheetTemplate.applyToPeriod(또는 시드된 overtime_all) → 해당 월 시트
  2. sheetView.queryRows (sheet-view.router.ts:933) → runAutoPopulate+materialize+resolve 로 승인된 overtime 행 채움
  3. compute 컬럼이 overtime codeModule 로 금액 산출 (code-modules/.../overtime-record.ts):
    • weekday_base = ceil(시급 × 평일시간 × overtimeMultiplier(1.5))
    • night_base = ceil(시급 × 야간시간 × nightMultiplier(0.5))
    • holiday_base(1.5) / weekend_base(0.5) / total_base(+ nightMealPrice × nightDays)
  4. sheetView.runCompute (sheet-view.router.ts:1037) (커밋이 수식을 강제 평가하므로 선택)
  5. sheetView.commitToRecords (sheet-view.router.ts:1073) → commitSheet (compute.ts:770)
    • (sourceSheetId, sourceSheetRowId, sourceSheetColumnKey) 키로 upsert (재커밋=update, idempotent)
    • status=APPROVED, targetSheetSlug/targetColumnKey(컬럼의 commitTargetSheetSlug/commitTargetColumnKey에서) 스탬프, inputSnapshot 감사 기록 (eCount 코드는 대상 급여대장 aggregate 컬럼에서 해석)

✅ 검증 ​

  • [ ] 승인된 overtime 만 시트에 채워짐 (approved/report_* 만 — PENDING 제외)
  • [ ] 야간/주말 배율 계산값이 기대치와 일치
  • [ ] 커밋 후 AllowanceRecord 생성: status=APPROVED, targetSheetSlug/targetColumnKey 스탬프(항목코드는 대상 급여대장 aggregate 컬럼), 시트 lineage NOT NULL
  • [ ] 재커밋 시 중복 없이 update (recordsInserted=0, recordsUpdated>0)
  • [ ] 금액 없는 행은 rowsSkippedDueToMissingAmount

STEP 10 — 수당 정산 처리 (source-sheet variants) ​

STEP 9(연장근무)와 동일 패턴을 다른 source 들에 적용한다. 핵심 원칙을 다시 강조:

시드된 source 시트는 전부 reference 다 — 직접 커밋하지 않는다. 정산은 ① source 시트(또는 source 테이블)를 자동 채우고 → ② 그 값을 참조하는 별도 computed 시트에 commitToKind:"allowance" + commitTargetSheetSlug/commitTargetColumnKey(대상 급여대장 aggregate 컬럼) 컬럼을 두고 → ③ runCompute → commitToRecords 하는 흐름이다. (급여대장은 aggregate 컬럼으로 커밋된 레코드를 직원별 집계하며, eCount 항목코드도 그 aggregate 컬럼에 달린다.)

각 source-sheet variant 별 정산 기여 방식 (seed-sheet-templates.ts:487):

Source 시트 (slug)source 테이블행 의미금액 산출 (codeModule / computedKey)수당 커밋비고
연장근무 내역 전체 (overtime_all)overtime_record승인 연장근무overtime_record → weekday_base/night_base/holiday_base/weekend_base✅ 참조 computed 시트에서STEP 9
인센티브 세션 전체 (incentive_all)fixed_incentive인센티브 세션fixed_incentive → total_amount/{category}_total (순수 집계)✅아래 4-1
치료일 인센티브fixed_incentive (sessionId)치료 세션(치료사 fan-out)synthetic treatment_daily → billed/hospital/therapist✅ (therapist 금액)🔄 인센 동기화, 4-2
치료 단가treatment_items 카탈로그치료 항목 단가— (literal)❌ 카탈로그 write-back만4-3
계약급여내역 (employment_contract_all)employment_contract활성 계약employment_contract → baseSalary/hobongAllowance/totalSalary/meal…✅ (기본급 등 참조)reference
퇴사 내역 전체 (resignation_all)resignation_record퇴사resignation_record → pro_rata_salary/unused_leave_payout/severance_pay✅ (정산금)reference
휴직 내역 전체 (loa_all)leave_of_absence_record휴직—❌reference (퇴사 정산 입력)
출퇴근 내역 전체 (attendance_all)attendance일별 근태—❌reference (연장근무 입력)
병동 일별 환자수 (ward_daily_all)ward_daily_record병동×일자 (employeeId 없음)—❌reference (간호 스케줄)

codeModule 구현: overtime-record.ts · fixed-incentive.ts · employment-contract.ts · resignation-record.ts. 입력 집계는 compute.ts:152, 치료 계산은 resolver.ts:929.

4-1. 인센티브 세션 전체 (개인 자가 등록) ​

FixedIncentive 는 개인이 직접 등록한다 (fixed-incentive.router.ts:139):

  • fixedIncentive.create({ employeeId, year, month, category, incentiveType, workDate, unitCount, unitPrice, totalAmount, … }) → append-only(chainId/version), status=PENDING, 승인자에 알림
  • 승인 후 incentive_all 시트에 자동 채워지고, 참조 computed 시트의 fixed_incentive 컬럼(total_amount 등)이 금액을 잡아 커밋

4-2. 치료일 인센티브 (🔄 인센 동기화) ​

병원/치료사 수익 분배형 (fixed-incentive.router.ts:403, treatment-session.ts:71):

  • fixedIncentive.upsertTreatmentSession({ sessionId?, year, month, workDate, categoryId, hospitalRatio, breakdown:[{code,name,unitPrice,count}], therapists:[employeeId…] })
    • 서버 계산: totalBilling = Σ(count×unitPrice), hospitalShare = totalBilling×hospitalRatio, therapistShare = 나머지 ÷ 치료사 수 → 치료사별 FixedIncentive fan-out
  • 시트의 🔄 인센 동기화 버튼이 sessionId 기준으로 행을 채움 (idempotent)
  • computed 컬럼(treatment_daily → billed/hospital/therapist)은 표시(분배 내역) 전용이다. ⚠️ treatment_daily 컬럼은 커밋되지 않는다 — resolver 단계에서 read-time 으로만 계산되고 runCompute 가 셀을 쓰지 않아(cellsWritten:0), commitToRecords 가 "금액 없음"으로 스킵한다 (2026-06-24 실측: rowsSkippedDueToMissingAmount). commitToKind:"allowance" 로 두면 커밋 ← 오류 정정.
  • ✅ 치료사 수령액의 실제 커밋 경로: upsertTreatmentSession 이 치료사별로 FixedIncentive(rehabilitation) 를 fan-out 하므로, 그 인센티브를 승인 → fixed_incentive computed 컬럼({category}_total, 예 rehabilitation_total) 으로 잡아 급여대장 aggregate 컬럼으로 커밋한다 (2026-06-24 실측: 세션 88,591 → 치료사 44,295 → rehabilitation_total 커밋 → 급여대장 집계 44,295 → eCount 분류 성공).

4-3. 치료 단가 (카탈로그 write-back) ​

  • 이 시트는 treatment_items 카탈로그를 미러링. 셀 편집 시 treatmentField(unitPrice/hospitalRatio 등) 가 카탈로그로 역기록된다 (treatment-price.ts).
  • 수당 커밋 없음. 갱신된 단가는 이후 4-2 치료 인센티브 계산에 반영된다.

✅ 검증 ​

  • [ ] fixedIncentive.create 로 인센티브 등록 → 승인 → incentive_all 자동 채움 → 참조 시트 커밋 → AllowanceRecord
  • [x] upsertTreatmentSession → 🔄 동기화 → 치료일 인센티브 행 생성, therapistShare 계산 (2026-06-24 실측 billed 88,591 / hospital 44,296 / therapist 44,296) — 표시 검증
  • [x] 치료사 수령액 커밋은 treatment_daily 컬럼이 아니라 fan-out 된 FixedIncentive 를 fixed_incentive(rehabilitation_total) 로 커밋 → 급여대장(slug 보유) aggregate 44,295 → eCount 분류 성공(2026-06-24). ⚠️ 슬러그 없는 급여대장은 eCount 가 못 읽음 — sheetView.create 의 slug 로 생성해야 함(update 로는 slug 설정 불가)
  • [ ] 치료 단가 셀 편집이 treatment_items 카탈로그를 갱신(커밋 아님)
  • [ ] 계약급여내역/퇴사 정산을 참조 컬럼으로 커밋 시 AllowanceRecord 생성
  • [ ] reference 시트(출퇴근/휴직/병동)는 커밋 컬럼이 없음 확인
  • [ ] 직접 allowanceRecord.create 호출 → 거부 (회귀 체크)
  • [ ] 모든 AllowanceRecord 에 targetSheetSlug/targetColumnKey 스탬프 확인 (해당 페어가 급여대장 aggregate 컬럼을 정확히 가리키는지 — 미앵커 레코드는 eCount 에서 제외됨)

STEP 11 — ⚠️ 급여 생성 + 확정 (목록 누락 단계, eCount 전제조건) ​

Router: payroll — generateForPeriod (payroll.router.ts:55), validate (:626), confirm (:730)

  1. generateForPeriod({ period:{year,month} })
    • 활성+기간내 퇴사 직원 중 근로계약 보유자만 대상 (계약 없으면 skip)
    • 기존 DRAFT 삭제·재스냅샷, AR/DR 은 삭제 않고 payrollRecordId unlink 후 재연결
    • 직원별 AllowanceRecord 합 = grossPay → PayrollRecord upsert (공제는 이 테스트에서 미커밋이라 0/외부 처리)
    • 이미 CONFIRMED 면 차단(PRECONDITION_FAILED, 412)
  2. ⚠️ approve({ payrollIds[] }) 필수 — DRAFT 상태로는 confirm 불가 (validate 가 "승인(approved) 상태여야 확정 가능" 에러 반환). 흐름: generate → approve → validate(선택) → confirm.
  3. validate({ year, month })(선택) → confirm({ year, month }) → status=CONFIRMED (재생성 차단. ℹ️ 시트 compute/commit 은 제품 결정상 계속 허용 — 상단 요약 참조)

✅ 검증 ​

  • [x] PayrollRecord.grossPay = 직원 APPROVED AllowanceRecord 합 (실측: 43,151+14,384+150,000=207,535 일치. 0원 레코드는 grossPay 에 영향 없음)
  • [x] AllowanceRecord 가 payrollRecordId 로 연결
  • [x] 확정 후 재생성 시 PRECONDITION_FAILED(412) — ℹ️ 시트 compute/commit 은 의도적으로 허용(advisory lock, sheet-guard.ts 참조)

STEP 12 — export eCount ​

Router: payroll.exportEcount (payroll.router.ts:1950)

입력비고
year, month✅
mode본 테스트는 "allowance" (A코드만 — 공제는 eCount 가 재계산). "both"(A+D)는 범위 밖
excludeConfigIds⬜ 특정 항목 제외
🆕 sheetViewId⬜ 한 급여대장에만 스코프 (생략 시 기간 전체 급여대장 합산). 급여대장 그리드의 내보내기 버튼은 자기 activeSheetId 를 넘긴다

동작 (2026-06-19 모델): CONFIRMED/PAID PayrollRecord 없으면 차단("확정된 급여 데이터가 없습니다…"). AllowanceRecord 의 항목코드는 레코드 FK 가 아니라 (targetSheetSlug, targetColumnKey) 로 급여대장 aggregate 컬럼을 찾아 해석하고, (employeeId, itemCodeId) 로 집계한다. 급여대장에 앵커되지 않은 레코드(target 없음/비급여대장 대상)는 excludedRecords:{allowance,deduction} 로 집계 제외. sheetViewId 가 있으면 그 급여대장 slug 로만 코드 해석 → 중복 급여대장 간 합산 방지. 단일 시트 XLSX "항목별금액직접입력" — 컬럼 code(A###), dept, name, amount. 반환 { success, filename, data:<base64>, employeeCount, excludedRecords }.

⚠️ 중복 급여대장 주의: 한 기간에 급여대장이 2개 이상(예: slug 없는 템플릿 재적용, [E2E] 테스트 시트)이고 같은 항목코드를 공유하면, 스코프 없는 export 는 두 컬럼을 합산해 금액이 부풀어 나온다 (실측: 시트 3,500,000 → export 6,600,000). 해소: ① sheetViewId 로 한 급여대장만 내보내기, ② 중복 급여대장 정리. hr-system 은 한 기간에 급여대장을 2개째 만들 때 confirm 경고로 중복 생성을 막는다.

✅ 검증 (최종 · 2026-06-11 실측 통과 + 2026-06-19 모델 보강) ​

  • [x] 미확정 기간 내보내기 → "확정된 급여 데이터가 없습니다. 급여 확정 후 내보내기 가능합니다." (400)
  • [x] mode:"allowance" → A코드만 출력 (실측: A10/A13/A21, D코드 없음)
  • [x] base64 디코드 → 유효 XLSX, 시트명 항목별금액직접입력, 컬럼 코드/부서코드/사원명/금액
  • [x] 금액 합계 207,535 = PayrollRecord grossPay 합 일치, employeeCount=확정 레코드 수(2) — 단, 0원뿐인 직원은 행으로 출력되지 않음
  • [x] 공제는 출력에 없음 — eCount 측에서 계산
  • [ ] 🆕 sheetViewId 스코프 export 가 그 급여대장 컬럼에 앵커된 레코드만 집계 (다른 급여대장 대상 레코드는 excludedRecords 로 카운트)
  • [ ] 🆕 중복 급여대장 2개(같은 코드)에서 스코프 없는 export 는 합산되고, sheetViewId 지정 시 한 시트 값만 나옴
  • [ ] 🆕 급여대장이 이미 있는 기간에 급여대장 추가 시도 → confirm 경고 노출

부록 A — 빠지기 쉬운/누락된 단계 요약 ​

#단계왜 필요한가
2관리자 비밀번호 변경system-admin 프로비저닝 시 mustChangePassword=true
5직원 + 근로계약 생성계약 없으면 급여 생성에서 skip · e-sign prefill 소스
5팀장 역할 부여연장근무 승인 권한
10source 시트 ≠ 커밋 시트시드 source 시트는 reference — 별도 computed 시트에서 커밋
11급여 생성 + 확정eCount 내보내기 전제(CONFIRMED)

부록 B — 회귀 체크 (막혀 있어야 정상 · 2026-06-11 실측) ​

  • [x] allowanceRecord.create 직접 호출 → 404 NOT_FOUND (proc 자체가 제거됨 — 문서의 한글 에러 메시지보다 더 강한 차단)
  • [ ] allowance source 의 autoPopulate → no-op (순환 방지) (이번 실행에서는 미검증)
  • [x] OvertimeRecord 의 amount/hourlyRate/multiplier → 엔티티에 없음 (0 columns)
  • [x] reference 시트(출퇴근/휴직/병동/계약급여 원본)에는 commitToKind 컬럼 없음

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

[system-admin] POST /api/provision/organization
  → auth.login → auth.changePassword
  → itemCode.createAllowanceCode / createDeductionCode (×N)
  → sheetTemplate.list / applyToPeriod / (컬럼 commitToKind + commitTargetSheetSlug/Key 설정)
  → employee.create (×N, +contract) / 팀·역할 배정
  → eSign.send → (서명/webhook) → SIGNED
  → [employee-app] attendance.checkIn/checkOut (또는 bulkCreate)
  → overtime.request → teamManager.overtime.approve
  → sheetView.queryRows → runCompute → commitToRecords   # 야근 정산 (overtime)
  → fixedIncentive.create / upsertTreatmentSession → 🔄동기화 → commitToRecords  # 수당/인센티브 정산
  → payroll.generateForPeriod → validate → confirm
  → payroll.exportEcount { year, month, mode:"allowance" }   # A코드만

미해결 / 확인 필요 (Open Questions) ​

  • [x] 입력 스키마 확인 (2026-06-11): sheetView.commitToRecords { sheetViewId } · payroll.validate { year, month } (query) · payroll.confirm { year, month, force? } · payroll.approve { payrollIds[], approverNotes? } · sheetView.queryRows { id } · sheetView.runCompute { sheetViewId } · sheetView.update { id, ...평면 필드 }
  • [ ] e-sign 으로 서명한 근로계약서를 EmploymentContract 활성화와 연결할지 (현재 분리됨) — 제품 결정 필요. ⚠️ e-sign 은 조직별 Hereby Open API credential 등록이 선행 필요 (integration-credential.service) — 로컬 신규 조직은 미설정으로 차단됨.
  • [ ] dev 환경(okbest.hereby.me)의 기존 조직/시드 상태 확인 — 신규 프로비저닝 vs 기존 조직 재사용
  • [x] 인센티브/치료 정산용 참조 computed 시트는 시드로 제공되지 않음 (2026-06-11 확인) — 테스터가 새 시트에 computed 컬럼(computeSourceModule + computedKey + commitToKind + commitTargetSheetSlug/commitTargetColumnKey)을 직접 구성하고 sheetRow.create 로 직원 행을 추가해야 한다 (2026-06-19 모델 — 옛 allowanceItemCodeId 자리).
  • [ ] 🆕 0원 computed 셀도 커밋 시 0원 AllowanceRecord 가 생성됨 (rowsSkippedDueToMissingAmount 는 null 만 카운트) — 의도 여부 확인 필요. eCount export 는 0원 행을 출력에서 제외하므로 최종 결과에는 영향 없음.
  • [x] 🆕 EMPLOYEE 역할에 incentive.write.self 누락 — 2026-06-11 시드에 추가 완료. ⚠️ 기존 조직은 role_permissions backfill(마이그레이션 또는 운영 스크립트) 필요.
  • [ ] 🆕 치료 단가/치료일 인센티브(4-2·4-3)는 treatment_items 카탈로그가 비어 있는 신규 조직에서는 검증 불가 — 카탈로그 시드/등록 경로 확인 필요.