/* ============================================================================= * common_util_formula_doc_cover.ts * 기계 덮음(PLAN 836-1 · 요구 846 · 849 · 850 · 853) — 단가산출 안 기계 내부 값 고침(사본 줄은 그대로 · 고친 것만 따로) * 자리 = 문서 칸 `기계덮음` {기계 입력 글: {값?: {칸: 값}, 조종원?: [{키, 인}], 잡품?: [{줄? | 키?, 이름?, 수량?, 율}]}} * 기계 입력 글 = 부름 입력 `기계` 값(따옴표 뗌 · 원문번호 · 개인 EQP…) — 같은 기계 줄 여럿 = 한 벌 * 값 = 기계 식이 읽는 기계 칸(요소 `EQ:{S_기계}` 칸 — 조종원 · 조종원직종 · 잡재료율 빼고) · 조종원 = 인력 줄(노임 키 · 인/일) · * 잡품 = 재료 줄(율 % · 줄 = 그 금액 줄 단가 · 수량 따름(기본) · 키 = 고른 재료 요소) · 원본과 같은 칸 = 없음(`prune`) * 셈 규칙(`coverDoc`) = 계산기가 기계 식을 부를 때 그 기계 덮음을 얹은 문서로 셈 — 덮음 없음 = 문서 그대로(금액 전후 같음) * 기계 줄 목록(842-3 · `common_util_machine_crew`) = 바탕 덮음(`rowEntry` — 식 한 줄로 안 풀리는 부분만) · * 단가산출 덮음이 그 위에 부분별로 덮음(`mergeCover` — 값 = 칸별 · 조종원 · 잡품 = 목록 통째) * 파이썬 · TS 짝 — 짝 = resources/master_data/scripts/master_doc_cover.py · 거울 시험 test_836_1_machine_cover.py · * test_842_3_machine_crew.py * · 화면 · DOM · fetch 없음 * ========================================================================== */ import { names, parse, symId, varId, type DocVar, type FormulaDoc, type MoneyLine, } from "./common_util_formula_doc"; import { CREW, MISC, code as jobOf } from "./common_util_machine_crew"; export const COVER = "기계덮음"; /** 값 칸에서 빼는 기계 칸 — 줄(조종원 · 잡품)로 다룸 */ export const NOT_VALUE = ["조종원", "조종원직종", "잡재료율"]; const HOUR = "Q_시간환산"; /** 조종원 줄을 새로 넣을 때(조종원 없는 식) 시간환산 — GC000267 과 같은 꼴(1/8 × 16/12 × 25/20 · 건협기술-2230) */ const HOUR_VARS: DocVar[] = [ { 열쇠: "Q_분자^표", 기호: "Q", 아래: "분자", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분자", 단위: "", 연결: { 갈래: "표", 표: "CC000336", 조건: { 구분: "'시간환산'" }, 칸: "분자" }, }, { 열쇠: "Q_분모^표", 기호: "Q", 아래: "분모", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분모", 단위: "", 연결: { 갈래: "표", 표: "CC000336", 조건: { 구분: "'시간환산'" }, 칸: "분모" }, }, { 열쇠: "Q_분자2^표", 기호: "Q", 아래: "분자2", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분자", 단위: "", 연결: { 갈래: "표", 표: "CC000336", 조건: { 구분: "'상여계수'" }, 칸: "분자" }, }, { 열쇠: "Q_분모2^표", 기호: "Q", 아래: "분모2", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분모", 단위: "", 연결: { 갈래: "표", 표: "CC000336", 조건: { 구분: "'상여계수'" }, 칸: "분모" }, }, { 열쇠: "Q_분자3^표", 기호: "Q", 아래: "분자3", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분자", 단위: "", 연결: { 갈래: "표", 표: "CC000336", 조건: { 구분: "'휴지계수'" }, 칸: "분자" }, }, { 열쇠: "Q_분모3^표", 기호: "Q", 아래: "분모3", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분모", 단위: "", 연결: { 갈래: "표", 표: "CC000336", 조건: { 구분: "'휴지계수'" }, 칸: "분모" }, }, { 열쇠: "시간환산", 기호: "Q", 아래: "시간환산", 위: "", 이름: "시간환산", 단위: "", 연결: { 갈래: "식" } }, ]; // prettier-ignore const HOUR_LINE = { 기호: HOUR, 식: "Q_분자^표 ÷ Q_분모^표 × Q_분자2^표 ÷ Q_분모2^표 × Q_분자3^표 ÷ Q_분모3^표", }; export interface CrewRow { 키: string; 인: number; /** 화면 이름(직종) — 셈 · 원본 대조 안 봄 */ 이름?: string; } export interface MiscRow { 줄?: string; 키?: string; 이름?: string; 단위?: string; 수량?: number; 율: number; } export interface MachineCover { 값?: Record; 조종원?: CrewRow[]; 잡품?: MiscRow[]; } export type Cover = Record; type Holder = FormulaDoc & { [COVER]?: Cover }; const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; const plain = (n: number): string => String(Number(n.toPrecision(15))); export const coverOf = (doc: FormulaDoc | null | undefined): Cover => { const got = (doc as Holder | null | undefined)?.[COVER]; return got && typeof got === "object" && !Array.isArray(got) ? got : {}; }; /** 부름 입력 → 기계 열쇠(따옴표 뗌) · 기계 입력 없음 = "" */ export function machineKey(given: Record | null | undefined): string { const v = given?.기계; if (v === undefined || v === null) return ""; const t = String(v).trim(); return /^'.*'$/.test(t) ? t.slice(1, -1) : t; } const namesOf = (text: unknown): string[] => { if (text === null || text === undefined || String(text).trim() === "") return []; try { return names(parse(text)); } catch { return []; } }; export interface CoverParts { /** 값 칸 — 칸 이름 · 그 칸을 읽는 기호(차례 = 문서 변수 차례) */ fields: { 칸: string; ids: string[] }[]; /** 조종원 금액 줄 차례 · 사람 수 기호 */ crew: number[]; crewQty: Record; /** 잡품(잡재료비) 금액 줄 차례 · 율 기호 · 밑 재료 줄 차례 */ misc: number | null; rate: string | null; base: number[]; } /** 기계 식 짜임 — 기계 칸 요소(`EQ:…{S_기계}…`) · 조종원 줄(노무비 · 수량이 칸 「조종원」) · 잡품 줄(재료비 누계 × 율) */ export function partsOf(doc: FormulaDoc): CoverParts { const eq = new Map(); for (const v of doc.변수 ?? []) { const l = v.연결 ?? {}; const k = String(l.키 ?? ""); if (l.갈래 === "요소" && k.startsWith("EQ:") && k.includes("{S_기계}") && l.칸) eq.set(varId(v), l.칸); } const fields: CoverParts["fields"] = []; for (const [id, col] of eq) { if (NOT_VALUE.includes(col)) continue; const at = fields.find((f) => f.칸 === col); if (at) at.ids.push(id); else fields.push({ 칸: col, ids: [id] }); } const lines = doc.금액 ?? []; const crew: number[] = []; const crewQty: Record = {}; let misc: number | null = null; let rate: string | null = null; lines.forEach((l, i) => { const q = namesOf(l.수량).find((n) => eq.get(n) === "조종원"); if (l.비목 === "노무비" && q) { crew.push(i); crewQty[i] = q; } const p = namesOf(l.단가); const other = p.filter((n) => n !== "재료비"); if (misc === null && l.비목 === "재료비" && p.includes("재료비") && other.length === 1) { misc = i; rate = other[0]; } }); const base = misc === null ? [] : lines.flatMap((l, i) => (i < misc! && l.비목 === "재료비" && !l.합밖 ? [i] : [])); return { fields, crew, crewQty, misc, rate, base }; } type Vals = Record; const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null); /** 요소 키 글의 구멍을 값으로 채움(`LB:{S_직종}` → `LB:1048`) */ const fillKey = (text: string, vals: Vals): string => text.replace(/\{([^{}]+)\}/g, (_, h: string) => { const v = vals[h]; return typeof v === "number" ? plain(v) : String(v ?? ""); }); /** 원본(단가산출 덮음 없음) 기본값 — 셈 답 값(`vals` = 그 기계로 셈한 원본 답 `값`) · * `base` = 기계 줄 바탕 덮음(`rowEntry`) — 있는 부분은 그 목록(빈 직종 키 = 식이 고른 직종) */ export function defaultsOf( doc: FormulaDoc, vals: Vals, base?: MachineCover | null, ): Required { const parts = partsOf(doc); const lines = doc.금액 ?? []; const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v])); const 값: Record = {}; for (const f of parts.fields) { const v = vals[f.ids[0]]; if (typeof v === "number" || typeof v === "string") 값[f.칸] = v; } const 조종원 = parts.crew.flatMap((i) => { const n = num(vals[parts.crewQty[i]]); const sym = namesOf(lines[i].단가)[0]; const key = String(vars.get(sym ?? "")?.연결?.키 ?? ""); return n && key ? [{ 키: fillKey(key, vals), 인: n }] : []; }); const r = parts.rate ? num(vals[parts.rate]) : null; let 잡품: MiscRow[] = r ? parts.base.map((i) => ({ 줄: String(lines[i].이름 ?? ""), 율: r })) : []; let crew: CrewRow[] = 조종원; if (base?.조종원) { const own = 조종원[0]?.키 ?? ""; crew = base.조종원.filter((x) => x.키 || own).map((x) => ({ ...x, 키: x.키 || own })); } if (base?.잡품) 잡품 = base.잡품; return { 값, 조종원: crew, 잡품 }; } /** 원본 대조 꼴 — 이름 · 단위(화면 글) · 빈 칸 뺌 · 수 12 자리 */ const LABEL = ["이름", "단위"]; const canon = (v: unknown): string => JSON.stringify(v, (_, x: unknown) => x && typeof x === "object" && !Array.isArray(x) ? Object.fromEntries( Object.entries(x as Record) .filter(([k, y]) => y !== undefined && y !== null && y !== "" && !LABEL.includes(k)) .sort(([a], [b]) => (a < b ? -1 : 1)), ) : typeof x === "number" ? Number(x.toPrecision(12)) : x, ); const sameVal = (a: unknown, b: unknown): boolean => typeof a === "number" && typeof b === "number" ? Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b)) : String(a) === String(b); /** 원본과 같은 칸 · 줄을 뺀 덮음(바꿨다 되돌림 = 안 고침) · 다 빠지면 null */ export function prune(entry: MachineCover, base: Required): MachineCover | null { const out: MachineCover = {}; const vals = Object.entries(entry.값 ?? {}).filter( ([k, v]) => v !== null && v !== undefined && v !== "" && !(k in base.값 && sameVal(v, base.값[k])), ); if (vals.length) out.값 = Object.fromEntries(vals); if (entry.조종원 && canon(entry.조종원) !== canon(base.조종원)) out.조종원 = entry.조종원; if (entry.잡품 && canon(entry.잡품) !== canon(base.잡품)) out.잡품 = entry.잡품; return Object.keys(out).length ? out : null; } /** 덮음 한 기계 고침 → 문서 칸(원본 대조로 정리 · 비면 칸 지움) */ export function putCover( doc: FormulaDoc, machine: string, entry: MachineCover, base: Required, ): void { const box = doc as Holder; const all = { ...coverOf(doc) }; const kept = prune(entry, base); if (kept) all[machine] = kept; else delete all[machine]; if (Object.keys(all).length) box[COVER] = all; else delete box[COVER]; } // ── 셈 규칙 ─────────────────────────────────────────────────────────── /** 새 요소 기호(`w_조종원1` · `P_잡품1` — 문서에 없는 첫 번호) */ function addVar(doc: FormulaDoc, sign: string, low: string, name: string, key: string): string { const have = new Set([ ...(doc.변수 ?? []).map(varId), ...(doc.식 ?? []).map((l) => String(l.기호 ?? "")), ]); let n = 1; while (have.has(symId(sign, `${low}${n}`))) n++; const 아래 = `${low}${n}`; const id = symId(sign, 아래); (doc.변수 ??= []).push({ 열쇠: id, 기호: sign, 아래, 위: "", 이름: name, 단위: "", 연결: { 갈래: "요소", 키: key }, }); // prettier-ignore return id; } /** 조종원 줄 — 빈 키 줄 = 식의 조종원 단가 기호(`own` · 식 입력 직종) · 없으면 뺌 */ function crewLines(doc: FormulaDoc, all: CrewRow[], own = ""): MoneyLine[] { const rows = all.filter((r) => r.키 || own); const have = (doc.변수 ?? []).some((v) => varId(v) === HOUR) || (doc.식 ?? []).some((l) => l.기호 === HOUR); // prettier-ignore if (rows.length && !have) { const ids = new Set((doc.변수 ?? []).map(varId)); for (const v of HOUR_VARS) if (!ids.has(varId(v))) (doc.변수 ??= []).push(clone(v)); (doc.식 ??= []).push({ ...HOUR_LINE }); } return rows.map((r) => ({ 이름: "조종원", ...(r.이름 ? { 규격: r.이름 } : {}), 단위: "인", 비목: "노무비", 수량: `${plain(r.인)} × ${HOUR}`, 단가: r.키 ? addVar(doc, "w", "조종원", "조종원 노임", r.키) : own, })); } function miscLines(doc: FormulaDoc, rows: MiscRow[], lines: MoneyLine[]): MoneyLine[] { return rows.flatMap((r) => { let price: string; let qty: string; let label = r.이름 ?? ""; if (r.키) { price = addVar(doc, "P", "잡품", label || r.키, r.키); qty = plain(r.수량 ?? 1); } else { const b = lines.find((l) => String(l.이름 ?? "") === r.줄); if (!b || !b.단가) return []; price = String(b.단가); qty = r.수량 !== undefined && r.수량 !== null ? plain(r.수량) : `(${b.수량 ?? 1})`; label ||= String(b.이름 ?? ""); } return [{ 이름: "잡품", 규격: label, 단위: "", 비목: "재료비", 수량: `${qty} × ${plain(r.율)} ÷ 100`, 단가: price } as MoneyLine]; // prettier-ignore }); } /** 기계 식 문서에 그 기계 덮음을 얹음(새 문서) — `base` = 기계 줄 바탕 덮음(`rowEntry`) · 덮음 없음 = 그대로(같은 물건) */ export function coverDoc( doc: FormulaDoc, given: Record | null | undefined, cover: Cover | null | undefined, base?: MachineCover | null, ): FormulaDoc { const key = machineKey(given); const entry = mergeCover(base, key ? cover?.[key] : undefined); if (!entry) return doc; const out = clone(doc) as FormulaDoc & { 쌓기?: unknown }; delete out.쌓기; // 금액 차례가 바뀜 — 기본 차림 const parts = partsOf(out); for (const f of parts.fields) if (entry.값 && f.칸 in entry.값) for (const v of out.변수 ?? []) if (f.ids.includes(varId(v))) v.연결 = { 갈래: "숫자", 값: entry.값[f.칸] }; const lines = out.금액 ?? []; const own = parts.crew.length ? String(lines[parts.crew[0]].단가 ?? "") : ""; const crew = entry.조종원 ? crewLines(out, entry.조종원, own) : null; const misc = entry.잡품 && (parts.misc !== null || entry.잡품.length) ? miscLines(out, entry.잡품, lines) : null; // prettier-ignore const gone = new Set([...(crew ? parts.crew : []), ...(misc && parts.misc !== null ? [parts.misc] : [])]); // prettier-ignore const tail = lines.findIndex((l) => l.비목 === "경비"); const end = tail < 0 ? lines.length : tail; const crewAt = parts.crew.length ? parts.crew[0] : end; const miscAt = parts.misc ?? end; const next: MoneyLine[] = []; for (let i = 0; i <= lines.length; i++) { if (crew && i === crewAt) next.push(...crew); if (misc && i === miscAt) next.push(...misc); if (i < lines.length && !gone.has(i)) next.push(lines[i]); } out.금액 = next; return out; } // ── 기계 줄 목록 → 바탕 덮음(842-3) ───────────────────────────────────── /** 기계 시간당 식 — 결과 원/시간 · 요소 `EQ:{S_기계}` 칸을 읽음 */ export const isMachine = (doc: FormulaDoc | null | undefined): boolean => !!doc && doc.결과단위 === "원/시간" && (doc.변수 ?? []).some( (v) => v.연결?.갈래 === "요소" && String(v.연결?.키 ?? "").includes("{S_기계}"), ); /** 기계 줄 목록(`인력` · `잡품`) 가운데 식 한 줄로 안 풀리는 부분 = 바탕 덮음 · 다 풀림 = null · * 식에 그 줄이 없음 = 안 얹음(손료만 식을 일부러 부른 단가산출) */ export function rowEntry(doc: FormulaDoc, vals: Vals | null | undefined): MachineCover | null { if (!vals || !isMachine(doc)) return null; const parts = partsOf(doc); const lines = doc.금액 ?? []; const out: MachineCover = {}; const crew = vals[CREW]; if (Array.isArray(crew) && parts.crew.length) { const rows = crew as CrewRow[]; const jobs = new Set(rows.map((r) => jobOf(r?.키))); if (!(rows.length && jobs.size === 1)) out.조종원 = rows; } const misc = vals[MISC]; if (Array.isArray(misc) && misc.length && parts.misc !== null) { const rows = misc as MiscRow[]; const names = parts.base.map((i) => String(lines[i].이름 ?? "")).sort(); const plainMisc = rows.every((r) => r && r.줄 && !r.키) && rows.every((r) => r.수량 === undefined || r.수량 === null) && new Set(rows.map((r) => canon(r.율))).size === 1 && JSON.stringify(rows.map((r) => String(r.줄)).sort()) === JSON.stringify(names); if (!plainMisc) out.잡품 = rows; } return Object.keys(out).length ? out : null; } /** 바탕 덮음 위에 단가산출 덮음 — 값 = 칸별 · 조종원 · 잡품 = 목록 통째(있는 부분만) */ export function mergeCover( base: MachineCover | null | undefined, top: MachineCover | null | undefined, ): MachineCover | null { if (!base || !Object.keys(base).length) return top && Object.keys(top).length ? top : null; if (!top || !Object.keys(top).length) return base; const out: MachineCover = { ...base, ...top }; const 값 = { ...(base.값 ?? {}), ...(top.값 ?? {}) }; if (Object.keys(값).length) out.값 = 값; else delete out.값; return out; } /** 셈 연결로 그 기계 줄 목록 읽기 — {인력?, 잡품?} · 못 읽음 = 빈 묶음(요소 `EQ:<기계>` 칸) */ export function listsOf( element: (p: { 키: string; 칸: string }) => unknown, given: Record | null | undefined, ): Vals { const key = machineKey(given); const out: Vals = {}; if (key) for (const col of [CREW, MISC]) try { out[col] = element({ 키: `EQ:${key}`, 칸: col }); } catch { /* 못 읽음 = 그 목록 없음 */ } return out; }