E2E Test Scenario — 연장근무(overtime_record) · 고정 인센티브(fixed_incentive) 정산 파이프라인
Status: Verified · 2026-06-24 자동화 테스트 통과 (
packages/test/src/scenarios/10-overtime-incentive-pipeline.test.ts, 11/11 — 유저 신청·보고서 승인 전체 흐름 포함) 🖥️ 브라우저 수동 검증 (2026-06-24, okbest dev, orgb6692a6b,test.admin) — 전 단계 통과:
- 연장근무 유저 신청(employee-app, PENDING) → 팀장 승인(team-manager, APPROVED·4h) → 유저 보고서 제출(REPORT_PENDING·시간 불변) → 팀장 보고서 승인(REPORT_APPROVED·overtimeHours 4→6=actualHours).
- 인센티브 등록(100k 승인 + 50k PENDING) → 승인.
- HR computed 시트:
overtime_record/weekday_base= 129,453 (= ⌈시급 14,383.56 × 6h × 1.5⌉, 보고서 승인 6h 기준) ·fixed_incentive/total_amount= 100,000 (PENDING 50k 게이트 제외 — 150k 아님).- 커밋 → AllowanceRecord(
targetSheetSlug/targetColumnKey스탬프, status approved) → payroll grossPay 229,453(129,453+100,000) → 확정 8건 → eCount export 성공(ecount_202606.xlsx,excludedRecords.allowance=2— self-target 시트라 의도적 제외). Branch context:develop— sheet-rooted records + 2026-06-19 컬럼↔레코드 모델선행 문서: test-scenario-e2e.md(프로비저닝~eCount 전체) · test-scenario-system-template-formula.md(수식 참조). 본 문서는 두 source 코드모듈(overtime_record·fixed_incentive)이 computed 컬럼으로 금액을 산출하고, 승인 게이트를 지키며, 커밋→급여대장 집계→eCount 까지 흐르는가 한 가지를 깊게 본다.
목적
병원/기업 급여에서 연장근무수당과 고정 인센티브(평가·체력단련 등)는 직원이 신청·등록하고 관리자가 승인하면, 시트의 computed 컬럼이 source 테이블을 집계해 금액을 만들고, 그 컬럼을 커밋해 AllowanceRecord 가 된다. 이 경로의 두 가지 핵심 성질을 검증한다:
- 승인 게이트 (2026-06-11 결정 — "최종 컨펌 후 정산"):
overtime_record는approved/report_*만,fixed_incentive는approved/paid만 집계한다. PENDING 건은 computed 금액에 새어들면 안 되고, 승인하면 즉시 반영돼야 한다. - 커밋 → 레코드 (2026-06-19 모델): computed 컬럼을 커밋하면 AllowanceRecord 가 (a) computed 셀 값과 일치하고, (b) 시트 lineage(
sourceSheetId/RowId/ColumnKey)를 갖고, (c)targetSheetSlug/targetColumnKey페어를 갖는다(레코드는 더 이상 항목코드 FK 를 들지 않는다 — migration 1816).
🆕 2026-06-19 모델 변경 — 컬럼 ↔ 수당/공제 관계 (필독)
커밋 게이트와 금액 분류 기준이 항목코드(itemCode) FK 를 레코드에 직접 스탬프하던 방식에서 (targetSheetSlug, targetColumnKey) 컬럼 포인터로 바뀌었다.
- 커밋 컬럼 (computed 포함):
commitToKind("allowance"|"deduction") +commitTargetSheetSlug+commitTargetColumnKey— 이 커밋이 들어갈 급여대장 aggregate 컬럼을 (시트 slug, 컬럼 key)로 가리킨다. computed 컬럼도 커밋 가능하다 (kind:"computed"+ 위 페어). - 레코드: AllowanceRecord 는
allowanceItemCodeId를 들지 않는다 — 커밋 시targetSheetSlug/targetColumnKey스탬프, 업서트 키는(sourceSheetId, sourceSheetRowId, sourceSheetColumnKey). - 항목코드(eCount A 코드)는 급여대장 aggregate 컬럼에 산다 — 레코드 코드는 export 시점에
(targetSheetSlug, targetColumnKey)로 그 aggregate 컬럼에서 해석된다. - 레거시 브리지: 2026-06-19 이전 컬럼(itemCode FK 만)은 commit·export 가 그 코드로 같은 코드의 급여대장 aggregate 컬럼을 찾아
(slug, key)를 유도한다.
핵심 전제 — 두 코드모듈의 산식·게이트
| 모듈 | computedKey (대표) | 산식 | 승인 게이트 (집계 대상 status) |
|---|---|---|---|
overtime_record (overtime-record.ts) | weekday_base·night_base·holiday_base·weekend_base·total_base·*_hours·*_days | weekday_base = ⌈시급 × 평일시간 × 1.5⌉ · night_base = ⌈시급 × 야간시간 × 0.5⌉ · holiday ×1.5 · weekend ×0.5 · total_base 는 합 + 야간식대단가 × 야간일수 | approved · report_pending · report_approved · report_rejected (compute.ts) — PENDING/취소/반려 제외 |
fixed_incentive (fixed-incentive.ts) | total_amount·count·{category}_total·{category}_count | total_amount = Σ totalAmount(전 category) · category별 {cat}_total | approved · paid — PENDING/cancelled 제외 |
시급(hourlyRate) 파생 (compute.ts): 계약의
hourlyRate가 있으면 그 값, 없으면기본급 ÷ 월소정시간(((주소정시간+8)×365)/7/12, 40시간 기준 ≈ 208.57). 그래서 연장근무 금액 검증에는 활성 근로계약이 선행돼야 한다(없으면 시급 0 → 금액 0).
전체 흐름
■ PHASE A — 사전 준비
1. 조직 + 직원(활성 근로계약) + 항목코드(예: 10 연장근무수당, 23 평가인센티브) + 급여대장(aggregate 컬럼)
■ PHASE B — source 데이터 + 승인
2. 연장근무: 직원 신청(overtime.request) → 팀장/HR 승인(approve) · PENDING 1건 남겨 둠
3. 고정 인센티브: 등록(fixedIncentive.create) → 승인(approve) · PENDING 1건 남겨 둠
■ PHASE C — computed 시트로 금액 산출 + 게이트 검증
4. "연장근무 정산" 시트: computed(overtime_record/weekday_base) + 커밋 컬럼 → runCompute
→ PENDING 추가해도 금액 불변 → 승인하면 증가 (게이트)
5. "인센티브 정산" 시트: computed(fixed_incentive/total_amount) + 커밋 컬럼 → 동일 게이트 검증
■ PHASE D — 커밋 → 집계 → eCount
6. commitToRecords → AllowanceRecord (셀 값 일치 · target 페어 스탬프)
7. 급여대장 aggregate 가 항목코드별 합계 노출
8. payroll.generateForPeriod → approve → confirm → exportEcount (mode:"allowance")
→ sheetViewId 로 한 급여대장 스코프 · 미앵커 레코드는 excludedRecordsPHASE A — 사전 준비
STEP 1 — 조직·직원·항목코드·급여대장
선행 문서 STEP 1~5 수행:
- [ ] 직원 2명 이상 + 활성 근로계약(기본급 또는 시급) — 시급 파생의 입력
- [ ] 수당 항목코드: 예
10연장근무수당,23평가인센티브 (eCount 코드) - [ ] 급여대장 시트 1개 — 항목코드별 aggregate 컬럼(
kind:"aggregate"+allowanceItemCodeId) 보유. 커밋 컬럼이commitTargetSheetSlug/commitTargetColumnKey로 이 컬럼들을 가리킨다. - [ ] 한 기간에 급여대장은 1개만 (2개째 생성 시 confirm 경고 — 중복 급여대장 가드)
PHASE B — source 데이터 + 승인
STEP 2 — 연장근무 신청 → 승인
Router: overtime-record(직원 신청) · team-manager.overtime.*(승인) · 또는 overtime.create/overtime.approve
- 직원:
overtime.request({ workDate, overtimeHours(0.5–12), category })→PENDING- category:
weekday(평일) ·night(야간) ·saturday(주말) ·holiday(휴일)
- category:
- 팀장:
teamManager.approveOvertime({ ids })→APPROVED(overtimeHours= 신청 시간) - (보고서 흐름) 직원:
overtime.submitReport(id, actualHours, summary)→REPORT_PENDING—report.actualHours저장,overtimeHours는 아직 신청값 유지 - (보고서 흐름) 팀장:
teamManager.approveOvertimeReport({ ids })→REPORT_APPROVED—overtimeHours = report.actualHours로 확정 (team-manager.router.ts:809)
🤖 자동화: 신청→승인→보고서 제출→보고서 승인 전 단계를 실제 직원 self-service 클라이언트 + 팀장(team leader) 클라이언트로 수행한다 (
10.1·10.2). 게이트는approved/report_*만 집계하므로, 신청 직후 PENDING 은 금액 0, 승인하면 신청시간 기준, 보고서 승인하면 actualHours 기준으로 금액이 바뀐다.
✅ 검증
- [ ] 직원 self-service
overtime.request로 신청 (PENDING) - [ ] PENDING 은 정산 금액에 미반영 (게이트 제외) → 팀장 승인 시 반영
- [ ] 보고서 제출(REPORT_PENDING)은
overtimeHours불변, 보고서 승인 시actualHours로 확정 → 금액 변동 - [ ] OvertimeRecord 에 금액 필드 없음 (raw observation: workDate/hours/category/status)
STEP 3 — 고정 인센티브 등록 → 승인
Router: fixed-incentive (fixed-incentive.router.ts)
fixedIncentive.create({ employeeId, year, month, category, incentiveType, workDate, unitCount, unitPrice, totalAmount })→PENDING(append-only chainId/version)fixedIncentive.approve({ ids })→APPROVED- PENDING 1건 남겨 둔다.
✅ 검증
- [ ] 승인 건만
approved· 남긴 건pending - [ ]
supersededAt IS NULL인 현재 버전만 집계 대상
PHASE C — computed 시트로 금액 산출 + 게이트
STEP 4 — "연장근무 정산" 시트 (computed) + 게이트 검증
직원 행 시트에 computed 커밋 컬럼 1개:
// columnLayout
{ "key": "amount", "kind": "computed", "label": "연장근무수당", "format": "number",
"computeSourceModule": "overtime_record", "computedKey": "weekday_base",
"commitToKind": "allowance",
"commitTargetSheetSlug": "<급여대장 slug>", "commitTargetColumnKey": "<aggregate 컬럼 key>" }sheetRow.create 로 직원 행 추가 → sheetView.runCompute → 셀 값 확인.
✅ 검증 (게이트)
- [ ] 승인 연장근무만으로
amount> 0 (=⌈시급 × 승인평일시간 × 1.5⌉) - [ ] PENDING 연장근무를 추가하고 재계산 →
amount불변 (게이트가 PENDING 제외) - [ ] 그 PENDING 을 승인하고 재계산 →
amount증가 (게이트 통과 즉시 반영)
🤖 자동화:
10.1(신청 PENDING → 팀장 승인) +10.2(보고서 제출 → 보고서 승인) 가 금액 하드코딩 없이 행위로 검증한다(PENDING 0 → 승인 시 신청시간 → 보고서 승인 시 actualHours).
STEP 5 — "인센티브 정산" 시트 (computed) + 게이트 검증
동일 패턴, 모듈만 교체:
{ "key": "amount", "kind": "computed", "label": "평가인센티브", "format": "number",
"computeSourceModule": "fixed_incentive", "computedKey": "total_amount",
"commitToKind": "allowance",
"commitTargetSheetSlug": "<급여대장 slug>", "commitTargetColumnKey": "<aggregate 컬럼 key>" }✅ 검증 (게이트)
- [ ] 승인 인센티브만으로
amount= 승인 totalAmount 합 - [ ] PENDING 인센티브 추가 → 재계산 시 불변 · 승인 → 증가
🤖 자동화: 10.4 가 동일 행위로 검증.
PHASE D — 커밋 → 집계 → eCount
STEP 6 — 커밋 → AllowanceRecord (2026-06-19 모델)
각 정산 시트 sheetView.commitToRecords.
✅ 검증
- [ ] AllowanceRecord.amount == 커밋 직전 computed 셀 값
- [ ]
targetSheetSlug/targetColumnKey스탬프(= 컬럼의commitTargetSheetSlug/commitTargetColumnKey) ·allowanceItemCodeId없음(레코드엔 코드 없음) - [ ] 시트 lineage
(sourceSheetId, sourceSheetRowId, sourceSheetColumnKey)NOT NULL - [ ] 재커밋 → upsert (
recordsInserted=0, recordsUpdated>0)
🤖 자동화: 10.3 / 10.5 가
amount == computed+ target 페어 + lineage 를 검증.
STEP 7 — 급여대장 aggregate 집계
급여대장의 aggregate 컬럼이 커밋된 레코드를 항목코드별로 직원 행에 합산.
✅ 검증
- [ ] 연장근무수당(10)·평가인센티브(23) aggregate 컬럼이 STEP 6 레코드를 정확히 합산
- [ ] aggregate 컬럼 셀 편집 시도 → 거부(집계 표시값)
STEP 8 — 급여 생성·확정 + eCount 내보내기
payroll.generateForPeriod→ 직원별 AllowanceRecord 합 = grossPayapprove→confirmpayroll.exportEcount({ year, month, mode:"allowance", sheetViewId:<급여대장 id> })
✅ 검증
- [ ] 확정 전 export → "확정된 급여 데이터가 없습니다…" (400)
- [ ] 확정 후 export → A코드 출력(연장근무수당·평가인센티브), 금액 = grossPay 일치
- [ ] 🆕
sheetViewId스코프 → 그 급여대장에 앵커된 레코드만 집계 · 미앵커 레코드는excludedRecords:{allowance,deduction}카운트 - [ ] 🆕 중복 급여대장(같은 코드)에서 스코프 없는 export 는 합산되고,
sheetViewId지정 시 한 시트 값만 (합계 부풀림 방지)
🤖 자동화: 10.6 이 generate → confirm → export 성공 +
excludedRecords보고를 검증(정밀 A코드 분류는 본 수동 시나리오가 소유 — self-target 시트는 급여대장이 아니라 의도적으로 excluded 로 떨어진다).
부록 A — 함정 요약
| # | 함정 | 회피 |
|---|---|---|
| 1 | 근로계약 없으면 시급 0 → 연장근무 금액 0 | STEP 1 에서 활성 근로계약 선행 |
| 2 | PENDING source 가 금액에 새어듦 | 게이트(approved/report_*, approved/paid)로 차단 — STEP 4·5 검증 |
| 3 | 레코드에서 itemCode 를 찾으려 함 | 2026-06-19 모델 — 코드는 급여대장 aggregate 컬럼에, 레코드는 target 페어 |
| 4 | 중복 급여대장이 같은 코드 합산 → eCount 부풀림 | sheetViewId 스코프 + 급여대장 1개 유지(중복 가드 confirm) |
| 5 | report_approved 시 overtimeHours = actualHours 로 확정 | 리포트 승인 후 금액이 actualHours 기준으로 바뀜 — 기대값 재계산 |
| 6 | 🆕 팀장이 비활성(isActive=false) 팀의 신청을 못 본다 | team-manager myTeams·권한 TEAM 스코프 모두 isActive:true 필터(trpc.ts requireScopedPermission). 직원을 활성 팀에 배정 — 비활성 팀이면 리더 대시보드에 "배정된 팀이 없습니다"로 신청이 숨음 (2026-06-24 브라우저 실측) |
| 7 | 🆕 역할은 TeamMember 행에 붙는다 — 팀을 바꾸면 역할이 떨어진다 | bulkAssignRole 은 (employee, team) 멤버십에 역할을 부여. 직원의 팀을 옮기면 새 멤버십엔 역할이 없어 self-service 권한(예: overtime.submitReport)이 사라짐 → 팀 변경 후 역할 재배정 필요 (2026-06-24 실측 — seed/권한 자체는 정상, overtime.write.self→create+update 확장 정상) |
부록 B — 빠른 호출 순서 (스모크)
employee.create (+contract) ×2 / itemCode.* / 급여대장(aggregate+itemCode) 생성
overtime.request → overtime.approve # PENDING 1건 남김
fixedIncentive.create → fixedIncentive.approve # PENDING 1건 남김
sheetView.create (computed overtime_record/weekday_base + commit) / sheetRow.create
sheetView.runCompute → (PENDING 추가 → 불변 → 승인 → 증가) # 게이트
sheetView.create (computed fixed_incentive/total_amount + commit) → 동일 게이트
sheetView.commitToRecords ×2 # → AllowanceRecord (target 페어)
payroll.generateForPeriod → approve → confirm
payroll.exportEcount { year, month, mode:"allowance", sheetViewId }부록 C — 자동화 테스트 대응표
자동화 (10-overtime-incentive-pipeline.test.ts) | 본 문서 STEP |
|---|---|
| 10.1 유저 연장근무 신청(PENDING 미반영) → 팀장 승인 | STEP 2·4 |
| 10.2 보고서 제출(REPORT_PENDING 불변) → 보고서 승인(actualHours 확정) | STEP 2 |
| 10.3 연장근무 커밋 → 레코드(target 페어·셀 일치) | STEP 6 |
| 10.4 인센티브 게이트 (불변→증가) | STEP 3·5 |
| 10.5 인센티브 커밋 → 레코드 | STEP 6 |
| 10.6 payroll generate→confirm→eCount + excludedRecords | STEP 8 |
자동화는 실제 직원 self-service 신청 + 팀장 보고서 승인 흐름을 그대로 태운다(
createAndLogin으로 계약 보유 직원·팀장 로그인 클라이언트 생성). 행위(behavioral) 검증 — 시급 파생·반올림에 의존하는 하드코딩 원 금액 대신 "PENDING 미반영, 승인 시 신청시간, 보고서 승인 시 actualHours"·"레코드 == 셀"을 본다. 정밀 원 금액·급여대장 aggregate·eCount A코드 분류는 본 수동 시나리오가 소유한다.