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 (로컬 전체 실행, orgE2ETEST)
검증 결과 요약 (2026-06-11 로컬 E2E 실행)
STEP 1→12 전체 경로가 로컬에서 끝까지 통과했다 (e-sign은 외부 credential 없어 제외). 실행 중 발견된 사실/버그 — 본문 각 STEP 에 ⚠️/🐛 로 반영:
- 🐛 (수정됨)
compute.tsovertime 입력 조립에서...sums스프레드가 derivedhourlyRate를 0 으로 덮어씀 — 연장근무 보유 직원의 정산액이 항상 0 이 되는 버그. 스프레드 순서로 수정. - 🐛 (수정됨)
compute.tsfixed_incentive 집계 필드명totalvs 모듈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.tscommitSheet, 컬럼 스키마commitTargetSheetSlug). - 레코드: 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 경고(중복 급여대장 가드, 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 — 로컬
make setup # install + DB + migrations + seed
make dev # 개발 서버 기동| 항목 | 배포(dev) | 로컬 |
|---|---|---|
| 앱 진입 | https://okbest.hereby.me/ | (앱별 포트) |
| API base | https://okbest.hereby.me/trpc | http://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을 발급)
{
"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 로만 실질 검증 가능.
| 절차 | 입력 (필수) | 비고 |
|---|---|---|
createAllowanceCode | code(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 | aggregatesourceTable+sourceFilter: 행 자동 채우기 sourcecomputeSourceModule+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):
// 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 실측 반영)
payroll.generateForPeriod는 근로계약 보유 직원만 대상 → 계약 없으면 조용히 skip.- e-sign(STEP 6) 으로 근로계약서에 서명해도 EmploymentContract 가 자동 활성화되지 않는다. 계약은 별도로 생성/활성화해야 함 (e-sign 은 문서 발송·서명 추적일 뿐).
- 연장근무 승인(STEP 8)은 TEAM_LEADER 역할 부여만으로는 부족 — 팀의
leaderId(관리자)까지 지정해야 team-manager 에 팀이 보인다 (team.update { id, data: { leaderId } }).- 주민등록번호는 체크섬 검증됨 — 더미 값(예:
850302-1111111)은Invalid resident registration number로 거부. 테스트는 체크섬 유효 값 사용 (예:850302-1234561,920715-2234567).- 위저드 역할 드롭다운의 첫 "일반 직원"은 역할 없음 항목(중복 표기) — 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 | 조회 |
openContract | uuid | 재오픈 로그인 토큰(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)
워크플로:
- 직원:
overtime.request({ workDate, overtimeHours(0.5–12), category, startTime?, endTime? })→PENDING(category:weekday|saturday|holiday|night) - 팀장/HR:
overtime.approve({ ids[] })→APPROVED(approvedBy/approvedAt) - (선택) 직원:
submitReport({ id, actualHours, summary })→REPORT_PENDING - (선택) 팀장:
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)
sheetTemplate.applyToPeriod(또는 시드된overtime_all) → 해당 월 시트sheetView.queryRows(sheet-view.router.ts:933) →runAutoPopulate+materialize+resolve로 승인된 overtime 행 채움- 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)
sheetView.runCompute(sheet-view.router.ts:1037) (커밋이 수식을 강제 평가하므로 선택)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_incentivecomputed 컬럼({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)
generateForPeriod({ period:{year,month} })- 활성+기간내 퇴사 직원 중 근로계약 보유자만 대상 (계약 없으면 skip)
- 기존 DRAFT 삭제·재스냅샷, AR/DR 은 삭제 않고
payrollRecordIdunlink 후 재연결 - 직원별 AllowanceRecord 합 = grossPay → PayrollRecord upsert (공제는 이 테스트에서 미커밋이라 0/외부 처리)
- 이미 CONFIRMED 면 차단(PRECONDITION_FAILED, 412)
- ⚠️
approve({ payrollIds[] })필수 — DRAFT 상태로는 confirm 불가 (validate가 "승인(approved) 상태여야 확정 가능" 에러 반환). 흐름: generate → approve → validate(선택) → confirm. 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 | 팀장 역할 부여 | 연장근무 승인 권한 |
| 10 | source 시트 ≠ 커밋 시트 | 시드 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카탈로그가 비어 있는 신규 조직에서는 검증 불가 — 카탈로그 시드/등록 경로 확인 필요.