diff --git a/M02_MasterTemplete/M02_Doc_Sk_Path.py b/M02_MasterTemplete/M02_Doc_Sk_Path.py index e740a5101..b662f415c 100644 --- a/M02_MasterTemplete/M02_Doc_Sk_Path.py +++ b/M02_MasterTemplete/M02_Doc_Sk_Path.py @@ -26,6 +26,8 @@ from M02_MasterTemplete import M02_MyMachine_Draft as draft from M02_MasterTemplete import M02_Sangeun_Copy as skc from M02_MasterTemplete import M02_Sangeun_Doc as sd +import master_doc_cover as mcv # noqa: E402 — 기계 덮음(836-1 · scripts 길 = dc) + MAX = 2_000_000 # 문서 글 크기(바이트) 상한 JOBS = (skc.PRICE, skc.REGEN) @@ -133,8 +135,19 @@ def edited(copy: dict, body: dict, key: str, sk: dict | None = None) -> dict: *dc.calls(out["문서"]), *(c for d in out.get("끊음", {}).values() for c in dc.calls(d)), ] + try: # 기계 덮음(836-1) — 몸 검사 · 원본과 같은 칸 · 안 부르는 기계 뺌(가져온 식 = 검사만) + if dim.is_imported(sk): + got = mcv.clean(out["문서"].get(mcv.COVER)) + rest = {k: v for k, v in out["문서"].items() if k != mcv.COVER} + out["문서"] = {**rest, mcv.COVER: got} if got else rest + except ValueError as e: + raise _bad(str(e)) from e if not dim.is_imported(sk): out["부름"] = dc.closure(dc.over(out), calls, copy.get("부름")) + try: + out["문서"] = mcv.prune_doc(out["문서"], dc.ml.Links(dc.over(out), dc.docs_of(out))) + except ValueError as e: + raise _bad(str(e)) from e out = gap.fill(out) # 셈이 읽은 줄 가운데 사본 없는 것 = 지금 값으로 박음(822-1) out = draft.renew(out) # 기계 창 초안으로 만든 · 고친 내 기계 = 새 값(827-1) out["따로"] = True diff --git a/common_util/common_util_formula_doc_calc.ts b/common_util/common_util_formula_doc_calc.ts index 5aafeccab..fe33fc79d 100644 --- a/common_util/common_util_formula_doc_calc.ts +++ b/common_util/common_util_formula_doc_calc.ts @@ -25,6 +25,7 @@ import { type Node, type Rule, } from "./common_util_formula_doc"; +import { coverDoc, coverOf as machineCover, type Cover } from "./common_util_formula_doc_cover"; const EPS = 1e-7; // B09_Estimation_Calc_Base EPS 와 같음 const DEEP = 20; @@ -137,6 +138,7 @@ class Run { readonly given: Record, readonly stack: string[], readonly memo: Memo, + readonly cover: Cover, // 기계 덮음(836-1) — 맨 위 문서 칸 · 부름에 얹음 ) { for (const v of doc.변수 ?? []) this.vars.set(varId(v), v); for (const l of doc.식 ?? []) this.lines.set(String(l.기호 ?? ""), l.식); @@ -364,8 +366,13 @@ class Run { .sort() .map((k) => `${k}=${plainText(given[k])}`) .join("|")}`; - if (!this.memo.has(memoKey)) - this.memo.set(memoKey, calcDoc(doc, this.links, given, [...this.stack, key], this.memo)); + if (!this.memo.has(memoKey)) { + const shaped = coverDoc(doc, given, this.cover); + this.memo.set( + memoKey, + calcDoc(shaped, this.links, given, [...this.stack, key], this.memo, this.cover), + ); + } const got = this.memo.get(memoKey) as DocAnswer; if (!got.셈됨) { // 값 없음(결과 빈 입력 · 표 줄 없음) = 빈 값 · 그 밖(표지) = 멈춤 @@ -565,16 +572,17 @@ export function rulesOf(doc: FormulaDoc): Record { const STOP_KINDS = ["기호 꼴", "연결 꼴", "금액 줄", "끝수", "겹침"]; -/** 수식 문서 셈 — given = {변수 열쇠: 값}(단가산출 부름이 넘긴 입력) · 예외 없음 */ +/** 수식 문서 셈 — given = {변수 열쇠: 값}(단가산출 부름이 넘긴 입력) · cover = 기계 덮음(없음 = 문서 칸) · 예외 없음 */ export function calcDoc( doc: FormulaDoc, links: DocLinks, given: Record = {}, stack: string[] = [], memo: Memo = new Map(), + cover?: Cover, ): DocAnswer { if (!stack.length && doc.키) stack = [String(doc.키)]; // 제 키 — 저를 부르면 순환 - const run = new Run(doc, links, given, stack, memo); + const run = new Run(doc, links, given, stack, memo, cover ?? machineCover(doc)); for (const one of check(doc)) if (STOP_KINDS.includes(one.갈래)) run.marks.push(one); const rules = rulesOf(doc); for (const name of [...run.lines.keys(), ...run.vars.keys()]) if (name) run.get(name); diff --git a/common_util/common_util_formula_doc_cover.ts b/common_util/common_util_formula_doc_cover.ts new file mode 100644 index 000000000..b1093e794 --- /dev/null +++ b/common_util/common_util_formula_doc_cover.ts @@ -0,0 +1,298 @@ +/* ============================================================================= + * common_util_formula_doc_cover.ts + * 기계 덮음(PLAN 836-1 · 요구 846 · 849 · 850 · 853) — 단가산출 안 기계 내부 값 고침(사본 줄은 그대로 · 고친 것만 따로) + * 자리 = 문서 칸 `기계덮음` {기계 입력 글: {값?: {칸: 값}, 조종원?: [{키, 인}], 잡품?: [{줄? | 키?, 이름?, 수량?, 율}]}} + * 기계 입력 글 = 부름 입력 `기계` 값(따옴표 뗌 · 원문번호 · 개인 EQP…) — 같은 기계 줄 여럿 = 한 벌 + * 값 = 기계 식이 읽는 기계 칸(요소 `EQ:{S_기계}` 칸 — 조종원 · 조종원직종 · 잡재료율 빼고) · 조종원 = 인력 줄(노임 키 · 인/일) · + * 잡품 = 재료 줄(율 % · 줄 = 그 금액 줄 단가 · 수량 따름(기본) · 키 = 고른 재료 요소) · 원본과 같은 칸 = 없음(`prune`) + * 셈 규칙(`coverDoc`) = 계산기가 기계 식을 부를 때 그 기계 덮음을 얹은 문서로 셈 — 덮음 없음 = 문서 그대로(금액 전후 같음) + * 파이썬 · TS 짝 — 짝 = resources/master_data/scripts/master_doc_cover.py · 거울 시험 test_836_1_machine_cover.py + * · 화면 · DOM · fetch 없음 + * ========================================================================== */ + +import { + names, + parse, + symId, + varId, + type DocVar, + type FormulaDoc, + type MoneyLine, +} from "./common_util_formula_doc"; + +export const COVER = "기계덮음"; +/** 값 칸에서 빼는 기계 칸 — 줄(조종원 · 잡품)로 다룸 */ +export const NOT_VALUE = ["조종원", "조종원직종", "잡재료율"]; +const HOUR = "Q_시간환산"; +/** 조종원 줄을 새로 넣을 때(조종원 없는 식) 시간환산 — GC000267 과 같은 꼴(건설품셈 공통 1-1-3 2) */ +const HOUR_VARS: DocVar[] = [ + { + 열쇠: "Q_분자^표", 기호: "Q", 아래: "분자", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분자", 단위: "", + 연결: { 갈래: "표", 표: "CC000336", 조건: { 구분: "'시간환산'" }, 칸: "분자" }, + }, + { + 열쇠: "Q_분모^표", 기호: "Q", 아래: "분모", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분모", 단위: "", + 연결: { 갈래: "표", 표: "CC000336", 조건: { 구분: "'시간환산'" }, 칸: "분모" }, + }, + { 열쇠: "시간환산", 기호: "Q", 아래: "시간환산", 위: "", 이름: "시간환산", 단위: "", 연결: { 갈래: "식" } }, +]; // prettier-ignore +const HOUR_LINE = { 기호: HOUR, 식: "Q_분자^표 ÷ Q_분모^표" }; + +export interface CrewRow { + 키: string; + 인: number; +} +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` = 그 기계로 셈한 원본 답 `값`) */ +export function defaultsOf(doc: FormulaDoc, vals: Vals): 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; + const 잡품 = r ? parts.base.map((i) => ({ 줄: String(lines[i].이름 ?? ""), 율: r })) : []; + return { 값, 조종원, 잡품 }; +} + +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(([, y]) => y !== undefined && y !== null && y !== "") + .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; +} + +function crewLines(doc: FormulaDoc, rows: CrewRow[]): MoneyLine[] { + 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) => ({ + 이름: "조종원", + 단위: "인", + 비목: "노무비", + 수량: `${plain(r.인)} × ${HOUR}`, + 단가: addVar(doc, "w", "조종원", "조종원 노임", r.키), + })); +} + +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 + }); +} + +/** 기계 식 문서에 그 기계 덮음을 얹음(새 문서) — 덮음 없음 = 그대로(같은 물건) */ +export function coverDoc( + doc: FormulaDoc, + given: Record | null | undefined, + cover: Cover | null | undefined, +): FormulaDoc { + const entry = cover?.[machineKey(given)]; + if (!entry || !machineKey(given)) 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 crew = entry.조종원 ? crewLines(out, entry.조종원) : 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; +} diff --git a/resources/master_data/scripts/master_doc_calc.py b/resources/master_data/scripts/master_doc_calc.py index 6f0b661b3..8c13f2f2d 100644 --- a/resources/master_data/scripts/master_doc_calc.py +++ b/resources/master_data/scripts/master_doc_calc.py @@ -16,6 +16,7 @@ import math from decimal import ROUND_DOWN, ROUND_HALF_UP, ROUND_UP, Decimal, InvalidOperation import master_doc as md +import master_doc_cover as mcv COSTS, BUNDLE = md.COSTS, md.BUNDLE EPS = 1e-7 # B09_Estimation_Calc_Base.EPS 와 같음 @@ -79,8 +80,9 @@ def number(v): # ── 셈 한 벌 ────────────────────────────────────────────────────────── class _Run: - def __init__(self, doc: dict, links, given: dict | None, stack: tuple, memo: dict): + def __init__(self, doc: dict, links, given: dict | None, stack: tuple, memo: dict, cover=None): self.doc, self.links, self.stack, self.memo = doc, links, stack, memo + self.cover = cover # 기계 덮음(836-1) — 맨 위 문서 칸 · 부름에 얹음 self.given = given or {} self.vars = {md.var_id(v): v for v in doc.get("변수") or []} self.lines = {str(x.get("기호") or ""): x for x in doc.get("식") or []} @@ -296,7 +298,10 @@ class _Run: given = payload.get("입력") or {} memo_key = (key, tuple(sorted((k, plain_text(v)) for k, v in given.items()))) if memo_key not in self.memo: - self.memo[memo_key] = calc(doc, self.links, given, (*self.stack, key), self.memo) + doc = mcv.cover_doc(doc, given, self.cover) + self.memo[memo_key] = calc( + doc, self.links, given, (*self.stack, key), self.memo, self.cover + ) got = self.memo[memo_key] if not got["셈됨"]: # 값 없음(결과 빈 입력 · 표 줄 없음) = 빈 값 · 그 밖(표지) = 멈춤 why = got.get("까닭") or " · ".join(m["글"] for m in got["표지"]) @@ -505,14 +510,19 @@ def rules_of(doc: dict) -> dict: def calc( - doc: dict, links, given: dict | None = None, stack: tuple = (), memo: dict | None = None + doc: dict, + links, + given: dict | None = None, + stack: tuple = (), + memo: dict | None = None, + cover: dict | None = None, ) -> dict: """수식 문서 셈 → {값, 빈값, 줄, 노무비 · 재료비 · 경비 · 계 | 결과, 묶음, 머리, 단계?, 표지, 셈됨, 끝수}. - given = {변수 열쇠: 값}(단가산출 부름이 넘긴 입력) · 예외 없음.""" + given = {변수 열쇠: 값}(단가산출 부름이 넘긴 입력) · cover = 기계 덮음(없음 = 문서 칸) · 예외 없음.""" memo = {} if memo is None else memo stack = stack or ((str(doc["키"]),) if doc.get("키") else ()) # 제 키 — 저를 부르면 순환 - run = _Run(doc, links, given, stack, memo) + run = _Run(doc, links, given, stack, memo, mcv.cover_of(doc) if cover is None else cover) for one in md.check(doc): if one["갈래"] in ("기호 꼴", "연결 꼴", "금액 줄", "끝수", "겹침"): run.marks.append(one) diff --git a/resources/master_data/scripts/master_doc_cover.py b/resources/master_data/scripts/master_doc_cover.py new file mode 100644 index 000000000..b7fe9b260 --- /dev/null +++ b/resources/master_data/scripts/master_doc_cover.py @@ -0,0 +1,348 @@ +# -*- coding: utf-8 -*- +"""기계 덮음 (PLAN 836-1 · 요구 846 · 849 · 850 · 853) — 단가산출 안 기계 내부 값 고침. + +자리 = 문서 칸 `기계덮음` {기계 입력 글: {값?: {칸: 값}, 조종원?: [{키, 인}], 잡품?: [{줄? | 키?, 이름?, 수량?, 율}]}} + 기계 입력 글 = 부름 입력 `기계` 값(따옴표 뗌) — 같은 기계 줄 여럿 = 한 벌 · 원본과 같은 칸 = 없음(`prune`). +셈 규칙(`cover_doc`) = 계산기(`master_doc_calc.calc`)가 기계 식을 부를 때 그 기계 덮음을 얹은 문서로 셈 — + 덮음 없음 = 문서 그대로(금액 전후 같음). +파이썬 · TS 짝 — 짝 = `common_util/common_util_formula_doc_cover.ts` · 거울 시험 +`resources/tester/test_836_1_machine_cover.py`. +""" + +from __future__ import annotations + +import copy as cp +import json +import re +from decimal import Decimal + +import master_doc as md + +COVER = "기계덮음" +NOT_VALUE = ( + "조종원", + "조종원직종", + "잡재료율", +) # 값 칸에서 빼는 기계 칸 — 줄(조종원 · 잡품)로 다룸 +HOUR = "Q_시간환산" +_HOUR_VARS = [ + {"열쇠": "Q_분자^표", "기호": "Q", "아래": "분자", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", + "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분자"}}, + {"열쇠": "Q_분모^표", "기호": "Q", "아래": "분모", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", + "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분모"}}, + {"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "연결": {"갈래": "식"}}, +] # fmt: skip +_HOUR_LINE = {"기호": HOUR, "식": "Q_분자^표 ÷ Q_분모^표"} +MAX_ROWS = 30 + + +def _plain(n) -> str: + d = Decimal(str(n)).normalize() + return format(d, "f") + + +def cover_of(doc) -> dict: + got = doc.get(COVER) if isinstance(doc, dict) else None + return got if isinstance(got, dict) else {} + + +def machine_key(given) -> str: + """부름 입력 → 기계 열쇠(따옴표 뗌) · 기계 입력 없음 = "".""" + v = (given or {}).get("기계") + if v is None: + return "" + t = str(v).strip() + return t[1:-1] if len(t) >= 2 and t[0] == "'" and t[-1] == "'" else t + + +def _names(text) -> list[str]: + if text is None or str(text).strip() == "": + return [] + try: + return md.names(md.parse(text)) + except md.DocError: + return [] + + +def parts_of(doc: dict) -> dict: + """기계 식 짜임 — {fields [{칸, ids}], crew [줄 차례], crew_qty {차례: 기호}, misc, rate, base}.""" + eq: dict[str, str] = {} + for v in doc.get("변수") or []: + link = v.get("연결") or {} + key = str(link.get("키") or "") + if ( + link.get("갈래") == "요소" + and key.startswith("EQ:") + and "{S_기계}" in key + and link.get("칸") + ): + eq[md.var_id(v)] = link["칸"] + fields: list[dict] = [] + for id_, col in eq.items(): + if col in NOT_VALUE: + continue + at = next((f for f in fields if f["칸"] == col), None) + if at: + at["ids"].append(id_) + else: + fields.append({"칸": col, "ids": [id_]}) + lines = doc.get("금액") or [] + crew, crew_qty, misc, rate = [], {}, None, None + for i, line in enumerate(lines): + q = next((n for n in _names(line.get("수량")) if eq.get(n) == "조종원"), None) + if line.get("비목") == "노무비" and q: + crew.append(i) + crew_qty[i] = q + p = _names(line.get("단가")) + other = [n for n in p if n != "재료비"] + if misc is None and line.get("비목") == "재료비" and "재료비" in p and len(other) == 1: + misc, rate = i, other[0] + base = ( + [] + if misc is None + else [ + i + for i, x in enumerate(lines) + if i < misc and x.get("비목") == "재료비" and not x.get("합밖") + ] + ) + return {"fields": fields, "crew": crew, "crew_qty": crew_qty, "misc": misc, "rate": rate, "base": base} # fmt: skip + + +def _num(v): + if isinstance(v, bool) or not isinstance(v, (int, float, Decimal)): + return None + return v + + +def _fill_key(text: str, vals: dict) -> str: + def one(m): + v = vals.get(m.group(1)) + return _plain(v) if _num(v) is not None else str("" if v is None else v) + + return re.sub(r"\{([^{}]+)\}", one, text) + + +def defaults_of(doc: dict, vals: dict) -> dict: + """원본(덮음 없음) 기본값 — `vals` = 그 기계로 셈한 원본 답 `값`.""" + parts = parts_of(doc) + lines = doc.get("금액") or [] + vars_ = {md.var_id(v): v for v in doc.get("변수") or []} + values = {} + for f in parts["fields"]: + v = vals.get(f["ids"][0]) + if _num(v) is not None or isinstance(v, str): + values[f["칸"]] = v + crew = [] + for i in parts["crew"]: + n = _num(vals.get(parts["crew_qty"][i])) + sym = (_names(lines[i].get("단가")) or [""])[0] + key = str(((vars_.get(sym) or {}).get("연결") or {}).get("키") or "") + if n and key: + crew.append({"키": _fill_key(key, vals), "인": n}) + r = _num(vals.get(parts["rate"])) if parts["rate"] else None + misc = [{"줄": str(lines[i].get("이름") or ""), "율": r} for i in parts["base"]] if r else [] + return {"값": values, "조종원": crew, "잡품": misc} + + +def _canon(v) -> str: + def norm(x): + if isinstance(x, dict): + return {k: norm(y) for k, y in sorted(x.items()) if y not in (None, "")} + if isinstance(x, list): + return [norm(y) for y in x] + if _num(x) is not None: + return float(f"{float(x):.12g}") + return x + + return json.dumps(norm(v), ensure_ascii=False, sort_keys=True) + + +def _same(a, b) -> bool: + if _num(a) is not None and _num(b) is not None: + a, b = float(a), float(b) + return abs(a - b) <= 1e-9 * max(1.0, abs(a), abs(b)) + return str(a) == str(b) + + +def prune(entry: dict, base: dict) -> dict | None: + """원본과 같은 칸 · 줄을 뺀 덮음(바꿨다 되돌림 = 안 고침) · 다 빠지면 None.""" + out: dict = {} + vals = { + k: v + for k, v in (entry.get("값") or {}).items() + if v not in (None, "") and not (k in base["값"] and _same(v, base["값"][k])) + } + if vals: + out["값"] = vals + for part in ("조종원", "잡품"): + if entry.get(part) is not None and _canon(entry[part]) != _canon(base[part]): + out[part] = entry[part] + return out or None + + +# ── 셈 규칙 ─────────────────────────────────────────────────────────── +def _add_var(doc: dict, sign: str, low: str, name: str, key: str) -> str: + have = {md.var_id(v) for v in doc.get("변수") or []} | { + str(x.get("기호") or "") for x in doc.get("식") or [] + } + n = 1 + while md.sym_id(sign, f"{low}{n}") in have: + n += 1 + under = f"{low}{n}" + id_ = md.sym_id(sign, under) + doc.setdefault("변수", []).append( + {"열쇠": id_, "기호": sign, "아래": under, "위": "", "이름": name, "단위": "", + "연결": {"갈래": "요소", "키": key}} + ) # fmt: skip + return id_ + + +def _crew_lines(doc: dict, rows: list) -> list[dict]: + have = any(md.var_id(v) == HOUR for v in doc.get("변수") or []) or any( + x.get("기호") == HOUR for x in doc.get("식") or [] + ) + if rows and not have: + ids = {md.var_id(v) for v in doc.get("변수") or []} + doc.setdefault("변수", []).extend( + cp.deepcopy(v) for v in _HOUR_VARS if md.var_id(v) not in ids + ) + doc.setdefault("식", []).append(dict(_HOUR_LINE)) + return [ + {"이름": "조종원", "단위": "인", "비목": "노무비", "수량": f"{_plain(r['인'])} × {HOUR}", + "단가": _add_var(doc, "w", "조종원", "조종원 노임", r["키"])} + for r in rows + ] # fmt: skip + + +def _misc_lines(doc: dict, rows: list, lines: list) -> list[dict]: + out = [] + for r in rows: + label = r.get("이름") or "" + if r.get("키"): + price = _add_var(doc, "P", "잡품", label or r["키"], r["키"]) + qty = _plain(r["수량"] if r.get("수량") is not None else 1) + else: + b = next((x for x in lines if str(x.get("이름") or "") == r.get("줄")), None) + if not b or not b.get("단가"): + continue + price = str(b["단가"]) + qty = _plain(r["수량"]) if r.get("수량") is not None else f"({b.get('수량') or 1})" + label = label or str(b.get("이름") or "") + out.append( + {"이름": "잡품", "규격": label, "단위": "", "비목": "재료비", + "수량": f"{qty} × {_plain(r['율'])} ÷ 100", "단가": price} + ) # fmt: skip + return out + + +def cover_doc(doc: dict, given, cover) -> dict: + """기계 식 문서에 그 기계 덮음을 얹음(새 문서) — 덮음 없음 = 그대로(같은 물건).""" + key = machine_key(given) + entry = (cover or {}).get(key) if key else None + if not entry: + return doc + out = cp.deepcopy(doc) + out.pop("쌓기", None) # 금액 차례가 바뀜 — 기본 차림 + parts = parts_of(out) + values = entry.get("값") or {} + for f in parts["fields"]: + if f["칸"] in values: + for v in out.get("변수") or []: + if md.var_id(v) in f["ids"]: + v["연결"] = {"갈래": "숫자", "값": values[f["칸"]]} + lines = out.get("금액") or [] + crew = _crew_lines(out, entry["조종원"]) if entry.get("조종원") is not None else None + want = entry.get("잡품") + misc = ( + _misc_lines(out, want, lines) + if want is not None and (parts["misc"] is not None or want) + else None + ) + gone = set(parts["crew"] if crew is not None else []) | ( + {parts["misc"]} if misc is not None and parts["misc"] is not None else set() + ) + tail = next((i for i, x in enumerate(lines) if x.get("비목") == "경비"), len(lines)) + crew_at = parts["crew"][0] if parts["crew"] else tail + misc_at = parts["misc"] if parts["misc"] is not None else tail + new = [] + for i in range(len(lines) + 1): + if crew is not None and i == crew_at: + new += crew + if misc is not None and i == misc_at: + new += misc + if i < len(lines) and i not in gone: + new.append(lines[i]) + out["금액"] = new + return out + + +# ── 서버 검사 · 정리 ────────────────────────────────────────────────── +def _nonneg(v, where: str): + if isinstance(v, bool) or not isinstance(v, (int, float, Decimal)) or v < 0: + raise ValueError(f"{where} — 0 이상 수") + return v + + +def clean(raw) -> dict: + """몸 검사 — 틀리면 ValueError(까닭) · 빈 칸 · 빈 기계 = 뺌.""" + if raw is None: + return {} + if not isinstance(raw, dict): + raise ValueError(f"{COVER} — 묶음 아님") + out: dict = {} + for key, entry in raw.items(): + if not isinstance(entry, dict): + raise ValueError(f"{COVER} 「{key}」 — 묶음 아님") + one: dict = {} + values = entry.get("값") + if values is not None: + if not isinstance(values, dict): + raise ValueError(f"{COVER} 「{key}」 값 — 묶음 아님") + got = {} + for col, v in values.items(): + if v in (None, ""): + continue + if col in NOT_VALUE: + raise ValueError(f"{COVER} 「{key}」 값 「{col}」 — 줄로 고침") + got[col] = v.strip() if isinstance(v, str) else _nonneg(v, f"「{key}」 {col}") + if got: + one["값"] = got + for part, need in (("조종원", ("키", "인")), ("잡품", ("율",))): + rows = entry.get(part) + if rows is None: + continue + if not isinstance(rows, list) or len(rows) > MAX_ROWS: + raise ValueError(f"{COVER} 「{key}」 {part} — 목록({MAX_ROWS} 줄까지)") + for r in rows: + if not isinstance(r, dict) or any(r.get(k) in (None, "") for k in need): + raise ValueError(f"{COVER} 「{key}」 {part} 줄 — {' · '.join(need)} 필요") + for k in ("인", "율", "수량"): + if r.get(k) is not None: + _nonneg(r[k], f"「{key}」 {part} {k}") + if part == "잡품" and not (r.get("줄") or r.get("키")): + raise ValueError(f"{COVER} 「{key}」 잡품 줄 — 재료(키) 또는 줄 필요") + one[part] = rows + if one: + out[str(key)] = one + return out + + +def prune_doc(doc: dict, links) -> dict: + """서버 정리 — 덮음을 검사하고 원본과 같은 칸 · 줄 · 기계를 뺀 문서(새 문서 · 덮음 없음 = 그대로). + 원본 = 덮음 뺀 문서 셈의 부름 메모(그 기계 식 · 입력 · 원본 답).""" + import master_doc_calc as mc + + cover = clean(doc.get(COVER)) + rest = {k: v for k, v in doc.items() if k != COVER} + if not cover: + return rest + memo: dict = {} + mc.calc(rest, links, None, (), memo) + kept: dict = {} # 문서가 안 부르는 기계 덮음 = 뺌 + for (key, given), ans in memo.items(): + m = machine_key(dict(given)) + sub = links.doc(key) if m in cover and m not in kept else None + if sub and (one := prune(cover[m], defaults_of(sub, ans.get("값") or {}))): + kept[m] = one + return {**rest, COVER: kept} if kept else rest diff --git a/resources/tester/helper_836_1_machine_cover.cjs b/resources/tester/helper_836_1_machine_cover.cjs new file mode 100644 index 000000000..3b4c13d28 --- /dev/null +++ b/resources/tester/helper_836_1_machine_cover.cjs @@ -0,0 +1,63 @@ +/* 836-1 기계 덮음 거울 헬퍼 — common_util_formula_doc_cover.ts · calc 를 그 자리에서 트랜스파일해 Node 로 돌림. + * stdin = {문서들: {키: 문서}, 대상: [{문서, 덮음?}], 기록: {열쇠: {값 | 빈 | 끊김}}, 짜임: [{문서, 입력, 덮음, 값}]} + * stdout = {답: [calcDoc 답], 짜임: [{얹음: coverDoc, 기본: defaultsOf, 정리: prune}]} + * 기록 열쇠 = 갈래 + "|" + 값 글(파이썬 `_canon` 과 같은 꼴 · helper_802_1 과 같음). */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const cache = new Map(); +function load(at) { + if (cache.has(at)) return cache.get(at); + const js = ts.transpileModule(fs.readFileSync(at, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(at, box.exports); + const need = (p) => + p.startsWith(".") ? load(path.resolve(path.dirname(at), `${p}.ts`)) : require(p); + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} +const root = path.join(__dirname, "..", "..", "common_util"); +const calc = load(path.join(root, "common_util_formula_doc_calc.ts")); +const cover = load(path.join(root, "common_util_formula_doc_cover.ts")); + +function canon(v) { + if (typeof v === "number") return calc.plainText(v); + if (Array.isArray(v)) return v.map(canon); + if (v && typeof v === "object") { + const out = {}; + for (const k of Object.keys(v).sort()) out[k] = canon(v[k]); + return out; + } + return v; +} +const input = JSON.parse(fs.readFileSync(0, "utf8")); +const record = input.기록 || {}; +function answer(kind, p) { + const k = `${kind}|${JSON.stringify(canon(p))}`; + if (!(k in record)) throw new calc.Broken(`기록 없음 ${k}`); + const got = record[k]; + if ("빈" in got) throw new calc.Gap(got.빈); + if ("끊김" in got) throw new calc.Broken(got.끊김); + return got.값; +} +const links = { + element: (p) => answer("요소", p), + table: (p) => answer("표", p), + doc: (key) => (input.문서들 || {})[key] || null, +}; +const out = { 답: [], 짜임: [] }; +for (const one of input.대상 || []) + out.답.push(calc.calcDoc(one.덮음 ? { ...one.문서, 기계덮음: one.덮음 } : one.문서, links)); +for (const one of input.짜임 || []) { + const base = cover.defaultsOf(one.문서, one.값); + const key = cover.machineKey(one.입력); + out.짜임.push({ + 얹음: cover.coverDoc(one.문서, one.입력, one.덮음), + 기본: base, + 정리: one.덮음[key] ? cover.prune(one.덮음[key], base) : null, + }); +} +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_836_1_machine_cover.py b/resources/tester/test_836_1_machine_cover.py new file mode 100644 index 000000000..55941ff82 --- /dev/null +++ b/resources/tester/test_836_1_machine_cover.py @@ -0,0 +1,336 @@ +"""836-1 기계 덮음 — 셈 규칙 · 기본값 · 정리 · 몸 검사 · 거울(파이썬 ↔ TS) 지킴 시험. + +짝 = `resources/master_data/scripts/master_doc_cover.py` ↔ `common_util/common_util_formula_doc_cover.ts` +(계산기 부름 자리 = `master_doc_calc` ↔ `common_util_formula_doc_calc.ts`). +① 덮음 없음 = 문서 그대로(같은 물건 · 같은 금액) · 남의 기계 덮음 = 안 얹음. +② 굴착기(무한궤도) 1.0㎥(0201-0100 · GC000267) — 값 · 조종원 · 잡품 덮음 = 손셈(M01 값). +③ 조종원 없는 식에 조종원 더함(시간환산 표 더함) · 잡품 없는 식에 잡품 더함 · 연료 + 전력 식 기본 잡품 둘. +④ 정리 — 원본과 같으면 지움 · 안 부르는 기계 지움 · 몸 검사 틀림. +⑤ 거울 — 같은 연결 값으로 파이썬 · TS 답 · 얹은 문서 · 기본값 · 정리가 같음. +""" + +import importlib +import json +import os +import subprocess +from decimal import Decimal as D + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 # scripts 길을 둠 + +mc = importlib.import_module("master_doc_calc") +ml = importlib.import_module("master_doc_link") +mcv = importlib.import_module("master_doc_cover") + +HERE = os.path.dirname(os.path.abspath(__file__)) +COSTS = ("노무비", "재료비", "경비", "계") +NO = "0201-0100" # 굴착기(무한궤도) 1.0㎥ — 조사 846 1 장 견본 + + +@pytest.fixture(scope="module") +def links(): + _, whole = m01.loaded() + return ml.Links(whole) + + +def _one(logic: str, no: str, cover: dict | None = None) -> dict: + """기계 한 줄 문서(1 hr) — 덮음 = 문서 칸.""" + var = {"열쇠": "P_기계", "기호": "P", "아래": "기계", "위": "", "이름": "기계", "단위": "", + "연결": {"갈래": "단가산출", "키": logic, "입력": {"기계": f"'{no}'"}}} # fmt: skip + line = {"이름": "기계", "단위": "hr", "비목": "묶음", "수량": "1", "단가": "P_기계"} + doc = {"꼴": "수식문서", "판": 1, "키": "_t", "이름": "t", "결과단위": "원", + "변수": [var], "식": [], "금액": [line]} # fmt: skip + return {**doc, mcv.COVER: cover} if cover else doc + + +def _money(ans): + return {k: ans.get(k) for k in COSTS} + + +def _first(links, logic: str) -> str: + var = next(v for v in links.doc(logic)["변수"] if v.get("열쇠") == "기계") + return str(var["연결"]["목록"][0]) + + +def _orig(links, logic: str, no: str) -> dict: + memo: dict = {} + mc.calc(_one(logic, no), links, None, (), memo) + return next(a for (k, _), a in memo.items() if k == logic) + + +# ── ① 덮음 없음 ──────────────────────────────────────────────────────── +def test_덮음_없음_같은_물건_같은_금액(links): + doc = links.doc("GC000267") + assert mcv.cover_doc(doc, {"기계": NO}, {}) is doc + assert mcv.cover_doc(doc, {"기계": NO}, {"0201-0120": {"값": {"가격": 1}}}) is doc + assert mcv.cover_doc(doc, {}, {NO: {"값": {"가격": 1}}}) is doc + base = mc.calc(_one("GC000267", NO), links) + other = mc.calc(_one("GC000267", NO, {"0201-0120": {"값": {"가격": 1}}}), links) + assert _money(base) == _money(other) + assert base["계"] == D(108958) or base["셈됨"] # M01 값이 바뀌면 금액은 달라도 됨(값 대조는 ②) + + +# ── ② 손셈 ──────────────────────────────────────────────────────────── +def _cut1(x): + return D(str(x)).quantize(D("0.1"), rounding="ROUND_DOWN") + + +def test_굴착기_덮음_손셈(links): + v = _orig(links, "GC000267", NO)["값"] + price, fuel, oil = v["P_기계"], v["P_기계주연료"], v["P_자동차용경유"] + wage, rate = v["w_기계운전사"], v["r_기계잡재료"] + k = (v["k_기계상각비"] + v["k_기계정비비"]) * (1 + v["r_가산"] / 100) + v["k_기계관리비"] + + def loss(p): + return (D(p) * 1000 * k / D(10) ** 7).to_integral_value(rounding="ROUND_DOWN") + + def total(labor, mats, exp): + return {"노무비": int(labor), "재료비": int(mats), "경비": int(exp)} + + fuel_amt = _cut1(fuel * oil) + crew1 = _cut1(D(1) / 8 * wage) + misc = _cut1(fuel_amt * rate / 100) + want = total(crew1, fuel_amt + misc, loss(price)) + got = mc.calc(_one("GC000267", NO), links) + assert {c: int(got[c]) for c in ("노무비", "재료비", "경비")} == want + + def run(cover): + a = mc.calc(_one("GC000267", NO, {NO: cover}), links) + assert a["셈됨"], a["표지"] + return {c: int(a[c]) for c in ("노무비", "재료비", "경비")} + + assert run({"값": {"가격": 150000}}) == total(crew1, fuel_amt + misc, loss(150000)) + f20 = _cut1(20 * oil) + assert run({"값": {"주연료": 20}}) == total(crew1, f20 + _cut1(f20 * rate / 100), loss(price)) + assert run({"조종원": [{"키": "LB:1048", "인": 2}]}) == total( + _cut1(D(2) / 8 * wage), fuel_amt + misc, loss(price) + ) + assert run({"조종원": []}) == total(0, fuel_amt + misc, loss(price)) + assert run({"잡품": [{"줄": "주연료", "율": 30}]}) == total( + crew1, fuel_amt + _cut1(fuel * oil * 30 / 100), loss(price) + ) + assert run({"잡품": []}) == total(crew1, fuel_amt, loss(price)) + # 다른 직종 · 사람 둘 = 두 줄 + two = run({"조종원": [{"키": "LB:1048", "인": 1}, {"키": "LB:1002", "인": 1}]}) + labor = mc.calc(_one("GC000267", NO, {NO: {"조종원": [{"키": "LB:1002", "인": 1}]}}), links) + assert two["노무비"] == int(crew1) + int(labor["노무비"]) + + +# ── ③ 줄 더함 ───────────────────────────────────────────────────────── +def test_조종원_없는_식에_조종원_잡품_없는_식에_잡품(links): + no = _first(links, "GC000272") + base = mc.calc(_one("GC000272", no), links) + got = mc.calc(_one("GC000272", no, {no: {"조종원": [{"키": "LB:1048", "인": 1}]}}), links) + assert got["셈됨"], got["표지"] + assert got["노무비"] > 0 and got["재료비"] == base["재료비"] and got["경비"] == base["경비"] + no3 = _first(links, "GC000273") + base3 = mc.calc(_one("GC000273", no3), links) + got3 = mc.calc( + _one("GC000273", no3, {no3: {"잡품": [{"키": "LB:1048", "수량": 1, "율": 10}]}}), links + ) + assert got3["셈됨"] and got3["재료비"] > base3["재료비"] == 0 + + +def test_연료_전력_식_기본_잡품_둘(links): + doc = links.doc("GC001200") + parts = mcv.parts_of(doc) + assert parts["misc"] is not None and len(parts["base"]) == 2 + + +# ── ④ 정리 · 검사 ───────────────────────────────────────────────────── +def test_원본과_같으면_지움_안_부르는_기계_지움(links): + v = _orig(links, "GC000267", NO)["값"] + same = {NO: {"값": {"가격": v["P_기계"]}, "조종원": [{"키": "LB:1048", "인": 1}], + "잡품": [{"줄": "주연료", "율": v["r_기계잡재료"]}]}} # fmt: skip + assert mcv.COVER not in mcv.prune_doc(_one("GC000267", NO, same), links) + mixed = {NO: {"값": {"가격": v["P_기계"], "주연료": 20}}, "9999-9999": {"값": {"가격": 1}}} + got = mcv.prune_doc(_one("GC000267", NO, mixed), links)[mcv.COVER] + assert got == {NO: {"값": {"주연료": 20}}} + + +@pytest.mark.parametrize( + "bad", + [ + [], + {NO: 1}, + {NO: {"값": {"가격": -1}}}, + {NO: {"값": {"조종원": 2}}}, + {NO: {"조종원": [{"키": "", "인": 1}]}}, + {NO: {"조종원": [{"키": "LB:1048", "인": -1}]}}, + {NO: {"잡품": [{"율": 10}]}}, + {NO: {"잡품": [{"줄": "주연료", "율": "x"}]}}, + ], +) +def test_몸_검사_틀림(bad): + with pytest.raises(ValueError): + mcv.clean(bad) + + +def test_몸_검사_빈_칸_뺌(): + assert mcv.clean({NO: {"값": {"가격": ""}}}) == {} + assert mcv.clean({NO: {"값": {"가격": 0}}}) == {NO: {"값": {"가격": 0}}} + + +# ── ⑤ 거울 ──────────────────────────────────────────────────────────── +def _canon(v): + if isinstance(v, (D, int, float)) and not isinstance(v, bool): + return mc.plain_text(D(str(v))) + if isinstance(v, list): + return [_canon(x) for x in v] + if isinstance(v, dict): + return {k: _canon(v[k]) for k in sorted(v)} + return v + + +class Rec: + def __init__(self, links): + self.links, self.log = links, {} + + def _keep(self, kind, p, fn): + key = f"{kind}|{json.dumps(_canon(p), ensure_ascii=False, separators=(',', ':'))}" + try: + got = fn(p) + except mc.Gap as e: + self.log[key] = {"빈": str(e)} + raise + except mc.Broken as e: + self.log[key] = {"끊김": str(e)} + raise + self.log[key] = {"값": got} + return got + + def element(self, p): + return self._keep("요소", p, self.links.element) + + def table(self, p): + return self._keep("표", p, self.links.table) + + def doc(self, key): + return self.links.doc(key) + + +def _same(a, b, where=""): + if isinstance(a, dict): + assert isinstance(b, dict) and set(a) == set(b), (where, sorted(a), sorted(b or {})) + for k in a: + _same(a[k], b[k], f"{where}.{k}") + elif isinstance(a, list): + assert isinstance(b, list) and len(a) == len(b), where + for i, (x, y) in enumerate(zip(a, b)): + _same(x, y, f"{where}[{i}]") + elif isinstance(a, (D, int, float)) and not isinstance(a, bool): + assert isinstance(b, (int, float)) and not isinstance(b, bool), (where, a, b) + assert float(a) == pytest.approx(b, rel=1e-9, abs=1e-9), (where, a, b) + else: + assert a == b, (where, a, b) + + +COVERS = [ + None, + {"값": {"가격": 150000, "주연료종류": "휘발유"}}, + {"조종원": [{"키": "LB:1048", "인": 2}, {"키": "LB:1002", "인": 0.5}]}, + {"조종원": []}, + {"잡품": [{"줄": "주연료", "율": 30}, {"키": "LB:1048", "이름": "시험", "수량": 2, "율": 5}]}, + {"잡품": [{"줄": "주연료", "수량": 10, "율": 22}]}, +] + + +def test_거울_파이썬_TS_같은_답(links): + rec = Rec(links) + picks = [("GC000267", NO)] + [ + (k, _first(links, k)) + for k in ("GC000272", "GC000273", "GC000275", "GC000276", "GC001196", "GC001197") + ] + cases, want, shapes, shape_want = [], [], [], [] + for logic, no in picks: + orig = _orig(Rec(links), logic, no) # 기록에 안 섞임(원본 셈은 아래에서 다시) + for one in COVERS: + cover = {no: one} if one else None + doc = _one(logic, no) + cases.append({"문서": doc, "덮음": cover}) + want.append(mc.calc({**doc, mcv.COVER: cover} if cover else doc, rec)) + if cover: + base = mcv.defaults_of(links.doc(logic), orig["값"]) + shapes.append({"문서": links.doc(logic), "입력": {"기계": no}, "덮음": cover, + "값": orig["값"]}) # fmt: skip + shape_want.append({"얹음": mcv.cover_doc(links.doc(logic), {"기계": no}, cover), + "기본": base, "정리": mcv.prune(cover[no], base)}) # fmt: skip + docs = {k: links.doc(k) for k, _ in picks} + payload = {"문서들": docs, "대상": cases, "기록": rec.log, "짜임": shapes} + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_836_1_machine_cover.cjs")], + input=json.dumps(ml._norm(payload), ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + got = json.loads(proc.stdout) + for i, (a, b) in enumerate(zip(want, got["답"])): + _same(ml._norm(_money(a)), _money(b), f"답{i}") + _same(ml._norm(a["값"]), b["값"], f"값{i}") + assert len(got["짜임"]) == len(shape_want) + for i, (a, b) in enumerate(zip(shape_want, got["짜임"])): + _same(ml._norm(a), b, f"짜임{i}") + + +# ── ⑥ 서버 저장 길(임시 층 · 수식 길) ──────────────────────────────────── +@pytest.fixture +def world(tmp_path, monkeypatch): + from resources.tester.test_m02_labor_ref import world as make + + return make.__wrapped__(tmp_path, monkeypatch) + + +def test_저장_다시_열기_되돌리면_덮음_없음(world): + import copy as cp + + from M02_MasterTemplete import M02_Doc_Sk_Path as path + from M02_MasterTemplete import M02_Labor_Ref as refs + from M02_MasterTemplete import M02_MasterTemplete_Store as store + + me = world["personal"] + + def at(fn, *args): + with store.at(me): + return fn(*args) + + key = at( + lambda: refs.create("sangeun", {"이름": "sub3임시 덮음", "단위": "시간", "더한줄": []}) + ) + key = key["이름"] + got = at(path.read, key) + doc = cp.deepcopy(got["문서"]) + link = {"갈래": "단가산출", "키": "GC000267", "입력": {"기계": f"'{NO}'"}} + for s in ("기계", "기계둘"): + doc.setdefault("변수", []).append( + {"기호": "P", "아래": s, "위": "", "이름": s, "단위": "", "연결": link} + ) + doc.setdefault("금액", []).append( + {"이름": s, "단위": "hr", "비목": "묶음", "수량": "1", "단가": f"P_{s}"} + ) + at(path.save, key, {"문서": doc, "판": got["판"]}) + base = at(path.read, key) + was = base["셈"]["계"] + one = base["셈"]["줄"][0]["금액"] + doc = cp.deepcopy(base["문서"]) + doc[mcv.COVER] = {NO: {"값": {"가격": 150000}}, "9999-9999": {"값": {"가격": 1}}} + after = at(path.save, key, {"문서": doc, "판": base["판"]}) + assert after["문서"][mcv.COVER] == {NO: {"값": {"가격": 150000}}} # 안 부르는 기계 = 뺌 + lines = after["셈"]["줄"] + assert lines[0]["금액"] == lines[1]["금액"] > one # 같은 기계 두 줄 = 같이 바뀜 + again = at(path.read, key) + assert again["문서"][mcv.COVER] == {NO: {"값": {"가격": 150000}}} + assert again["셈"]["계"] == after["셈"]["계"] > was + v = _orig(ml.Links(m01.loaded()[1]), "GC000267", NO)["값"] + doc = cp.deepcopy(again["문서"]) + doc[mcv.COVER] = {NO: {"값": {"가격": v["P_기계"]}}} # 되돌림 = 원본 값 + back = at(path.save, key, {"문서": doc, "판": again["판"]}) + assert mcv.COVER not in back["문서"] and back["셈"]["계"] == was + with store.at(me): + file = store.read("sangeun", key)["문서"] + assert mcv.COVER not in file["수식"]["문서"] + doc[mcv.COVER] = {NO: {"값": {"가격": -5}}} + with pytest.raises(store.StoreError): + at(path.save, key, {"문서": doc, "판": back["판"]})