/* M02 일위대가 수량 · 반영 · 합(PLAN 39-1a · 설계_정함 1장) — 면적 칸 값이 바뀌면 화면이 바로 곱함. * * 짝(CLAUDE.md 5장 ①): `M02_Labor_Doc.py` `settle` 과 같은 규칙 — * 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`. * 기준(결과단위 그대로)은 서버 calc 길이 셈 · 여기는 받은 기준에 * 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만. * 별도계상 자재(222-1 · 빈 칸 = 기본 별도계상) = 반영은 보이되 합에 안 더함(짝 `apart`). * 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`) · * 고정 줄(705-1)은 「고정|이름|규격|단위」(짝 `M02_Labor_Fixed.fixed_id`) · 기준 = 서버가 낸 줄 단가. * 규격 열(704-4) = 문서 `규격` · 그 열 수량이 있는 줄 = `{손}` 자리(`atSpec` · 짝 `M02_Labor_Spec.at`). */ import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import type { LogicMat } from "./M02_Labor_LogicMats_Model"; export const MONEY_KEYS = ["노무비", "재료비", "경비", "계"] as const; export type Money = Record<(typeof MONEY_KEYS)[number], number>; export type QtySource = "손" | "칸" | "없음"; /** 문서의 수량 칸 — null = 면적산출 알약 · {손} = 손 입력 */ export type Qty = { 손: number } | null; /** 참조 — 단가승격(SP) · 사전조합(SC) 문서(50-3) */ export interface LaborRef { 층: string; 주인: string | null; 키: string; } /** 고정 줄(705-1) — 숫자 그대로 · 이음 = 이 공사 노임 · 자재 · 기계 키(못 이음 = null) */ export interface LaborFixed { 이름: string; 규격?: string; 단위?: string; 단가: Record<"노무비" | "재료비" | "경비", number>; 이음?: Record<"노임" | "자재" | "기계", string | null>; 출처?: Record; /** 비율 줄(705-3 · 엑셀 % 줄) — 수량 = 율 · 반영[비목] = 다른 줄 반영합[기준] × 율 ÷ 100 */ 비율?: { 기준: CostKey; 비목: CostKey }; } type CostKey = "노무비" | "재료비" | "경비"; export interface CalcLine { /** 참조 줄은 서버 답에 없음(빈 글로 다룸) */ 로직: string; 참조?: LaborRef; 고정?: LaborFixed; /** 고정 줄 — 단가 책 값을 쓴 비목 */ 이은?: string[]; 알약?: string | null; 분모?: string | null; 배: number; 기준: Money | null; 수량?: number | null; 수량출처?: QtySource; 반영?: Money | null; 까닭?: string | null; 결과?: unknown; /** 그 로직 재료 · 노무 · 경비 줄(M01 시험계산 표와 같은 모양 · 50-4) */ 줄?: LogicLine[]; /** 로직 자재(103-1) — 맨 위 층 재료 줄 · 금액은 `기준` 안(여기서 안 더함) */ 재료?: LogicMat[]; /** 참조 줄 — 가리킨 문서 이름 · 단가승격의 원 로직 키 · 사전조합 안 줄(한 층 · 50-3 ④ · * 참조 줄 = `참조` · 로직 줄(51-11) = `로직` · 자재 줄(175-2) = `자재` 코드) */ 이름?: string | null; 원로직?: string | null; 안?: { 참조?: LaborRef; 로직?: string; 자재?: string; 수량: number; 이름?: string | null; 단위?: string | null; 기준: Money | null; 까닭?: string | null; }[]; } export interface CalcMaterial { 코드: string; 알약?: string | null; 기준단가: number | null; /** M01 자재품목 값 열 다섯(물가자료 · 유통물가 · 물가정보 · 거래가격 · 관급 · 빈 값 null · 43-3) */ 가격?: Record; /** 기준단가가 된 열 이름 — 없으면 null */ 기준칸?: string | null; 수량?: number | null; 수량출처?: QtySource; 반영?: number | null; 까닭?: string | null; } export interface LaborCalc { 줄: CalcLine[]; 자재: CalcMaterial[]; 기준합?: Money; 반영합?: Money; } /** 규격 열(704-4) — 이름(H · Φ) · 열(화면 차례) · 기본 · 줄 id 마다 열 수량 */ export interface LaborSpec { 이름: string; 열: string[]; 기본: string; 수량: Record>; } /** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 · 규격 열 */ export interface LaborQtyDoc { 담은로직?: { 로직?: string; 참조?: LaborRef; 고정?: LaborFixed; 수량?: Qty }[]; 자재?: { 코드: string; 수량?: Qty; 별도계상?: string }[]; 규격?: LaborSpec | null; } function num(raw: unknown): number | null { if (raw === null || raw === undefined || typeof raw === "boolean") return null; const n = Number(String(raw).replace(/,/g, "").trim()); return String(raw).trim() !== "" && Number.isFinite(n) ? n : null; } /** 줄 가름 — 로직 키 · 참조 줄은 「참조|층|주인|키」 · 고정 줄은 「고정|이름|규격|단위」 * (짝 `M02_Labor_Doc.line_id`) */ export function lineId(one: { 로직?: string; 참조?: LaborRef; 고정?: LaborFixed; }): string | undefined { const fx = one.고정; if (fx) return `고정|${fx.이름 ?? ""}|${fx.규격 ?? ""}|${fx.단위 ?? ""}`; if (one.참조) return `참조|${one.참조.층}|${one.참조.주인 ?? ""}|${one.참조.키}`; return one.로직; } /** 쓸 규격 열 — 고른 열이 있으면 그것 · 없으면 기본 · 규격 없음 = null(짝 `M02_Labor_Spec.column`) */ export function specColumn(doc: LaborQtyDoc, col?: string | null): string | null { const cols = doc.규격?.열 ?? []; if (!cols.length) return null; return col && cols.includes(col) ? col : doc.규격?.기본 || cols[0]; } /** 열 끼우기 — 그 열 규격 수량이 있는 줄 · 자재의 수량을 `{손}` 으로(짝 `M02_Labor_Spec.at`) */ export function atSpec(doc: D, col?: string | null): D { const use = specColumn(doc, col); if (use === null) return doc; const qty = doc.규격?.수량 ?? {}; const put = (one: T, id: string | undefined): T => { const v = id !== undefined ? qty[id]?.[use] : undefined; return v !== undefined && v !== null ? { ...one, 수량: { 손: v } } : one; }; return { ...doc, 담은로직: (doc.담은로직 ?? []).map((one) => put(one, lineId(one))), 자재: (doc.자재 ?? []).map((one) => put(one, one.코드)), }; } /** 셈 답에서 칸 값 되살림 `{알약: 수량}` — 열마다 다시 셀 때(짝 `M02_Labor_Spec.given_of`) */ export function givenOf(result: LaborCalc): Record { const out: Record = {}; for (const r of [...result.줄, ...result.자재]) if (r.수량출처 === "칸" && r.알약 && r.수량 != null) out[r.알약] = r.수량; return out; } function zero(): Money { return { 노무비: 0, 재료비: 0, 경비: 0, 계: 0 }; } function pick( hand: Qty | undefined, key: string | null | undefined, given: Record, ): [number | null, QtySource] { const own = hand ? num(hand.손) : null; if (own !== null) return [own, "손"]; if (key && key in given) return [given[key], "칸"]; return [null, "없음"]; } /** 반영 = 기준 × 수량 ÷ 배 — 수량이 없으면 null */ export function reflect(base: Money, qty: number | null, times: number): Money | null { if (qty === null) return null; const out = zero(); for (const k of MONEY_KEYS) out[k] = (base[k] * qty) / times; return out; } /** 기준이 선 계산 답 + 문서 손 값 + 알약 값(`{키: 값}`) → 수량 · 반영 · 합을 새로 채운 답 · * `col` = 규격 열(없으면 기본 · 704-4) */ export function settle( result: LaborCalc, whole: LaborQtyDoc, given: Record, col?: string | null, ): LaborCalc { const doc = atSpec(whole, col); const have: Record = {}; for (const [k, raw] of Object.entries(given)) { const v = num(raw); if (v !== null) have[k] = v; } const hands = new Map((doc.담은로직 ?? []).map((one) => [lineId(one), one.수량 ?? null])); const mhands = new Map((doc.자재 ?? []).map((one) => [one.코드, one.수량 ?? null])); // 별도계상(222-1) — 「직접계산」 아니면(빈 · 옛 값 = 기본 별도계상) 합에서 뺌(짝 `M02_Labor_Doc.apart`) const aside = new Set( (doc.자재 ?? []).filter((m) => m.별도계상 !== "직접계산").map((m) => m.코드), ); const base = zero(); const done = zero(); const add = (sum: Money, m: Money | null): void => { if (m) for (const k of MONEY_KEYS) sum[k] += m[k]; }; const 줄 = result.줄.map((line) => { const [qty, source] = pick(hands.get(lineId(line)), line.알약, have); const own = line.기준 ? reflect(line.기준, qty, num(line.배) || 1) : null; add(base, line.기준); add(done, own); return { ...line, 수량: qty, 수량출처: source, 반영: own }; }); const 자재 = result.자재.map((one) => { const [qty, source] = pick(mhands.get(one.코드), one.알약, have); const price = num(one.기준단가); const cost = price !== null && qty !== null ? price * qty : null; if (price !== null && !aside.has(one.코드)) add(base, { ...zero(), 재료비: price, 계: price }); if (cost !== null && !aside.has(one.코드)) add(done, { ...zero(), 재료비: cost, 계: cost }); return { ...one, 수량: qty, 수량출처: source, 반영: cost }; }); return { 줄: ratioLines(줄, done, base), 자재, 기준합: base, 반영합: done }; } /** 비율 고정 줄(705-3 · 짝 `M02_Labor_Fixed.ratios`) — 밑 = 비율 줄 뺀 반영합 · 기준[비목] = 밑[기준] · * 배 100 · 반영 = 기준 × 율 ÷ 100 · `done` 에 더함 · 기준합(`base`)엔 안 셈 */ function ratioLines(lines: CalcLine[], done: Money, base: Money): CalcLine[] { for (const l of lines.filter((x) => x.고정?.비율)) { for (const k of MONEY_KEYS) { done[k] -= l.반영?.[k] ?? 0; base[k] -= l.기준?.[k] ?? 0; } } const under = { ...done }; return lines.map((line) => { const rate = line.고정?.비율; if (!rate) return line; const own = zero(); own[rate.비목] = own.계 = under[rate.기준]; const got = reflect(own, line.수량 ?? null, 100); if (got) for (const k of MONEY_KEYS) done[k] += got[k]; return { ...line, 기준: own, 배: 100, 반영: got }; }); }