diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index 273f709ad..9a4be5281 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -16,6 +16,7 @@ from M01_MasterData import M01_MasterData_Store as store from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M01_MasterData import M01_MasterData_Store_Flow as flow from M01_MasterData import M01_MasterData_Store_Make as make +from M01_MasterData import M01_MasterData_Store_Sheet as sheet import master_basis as mbs # noqa: E402 (`store` 가 scripts 를 길에 둠) @@ -269,7 +270,7 @@ def post_text(body: CalcBody) -> dict: if got is not None: return got with store.cm.mf.mp.choosing(body.고름): - return _call(store.text, body.key, body.inputs, body.row, body.file) + return _call(sheet.text, body.key, body.inputs, body.row, body.file) # 줄 덧칸(714-1) @router.post("/save") diff --git a/M01_MasterData/M01_MasterData_Store_Sheet.py b/M01_MasterData/M01_MasterData_Store_Sheet.py new file mode 100644 index 000000000..7d893f62b --- /dev/null +++ b/M01_MasterData/M01_MasterData_Store_Sheet.py @@ -0,0 +1,27 @@ +"""M01 `/text` 계산서 줄 덧칸(PLAN 714-1) — `store.text` 답에 줄마다 쓰는 입력 · 묶음 · 기준단위 · +환산 근거(`master_sheet_line.annotate` — M02 `/logic-lib/sheet` 와 한 벌) · 서버 단독(③) · 저장 안 함. +`M01_MasterData_Store.py` 는 700 줄 근처라 여기서 감쌈.""" + +from __future__ import annotations + +from M01_MasterData import M01_MasterData_Store as store + +mf = store.cm.mf +import master_sheet_line as msl # noqa: E402 — `store` 가 scripts 를 길에 둔 뒤 + + +def text(key: str, inputs: dict, row: dict | None = None, file: str | None = None) -> dict: + got = store.text(key, inputs, row, file) + if not isinstance(got, dict) or not got.get("ok"): + return got + given = {k: store._dec(v) for k, v in inputs.items()} + try: + if row is None: + master = store.loaded()[1] + else: # 저장 전 시험 — `store.text` 와 같은 메모리 사본 + files = store.cm.load(folder=store.FOLDER) + key = store._swap(files, str(row.get("키") or key), row, file) + master = mf.Master(files) + return msl.annotate(master, key, mf.fill_empty(master.logic(key), given), got) + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): + return got diff --git a/M02_MasterTemplete/M02_Logic_Standard.py b/M02_MasterTemplete/M02_Logic_Standard.py index df5b668b5..fd9959833 100644 --- a/M02_MasterTemplete/M02_Logic_Standard.py +++ b/M02_MasterTemplete/M02_Logic_Standard.py @@ -31,6 +31,9 @@ from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Logic_Settle as settle from M02_MasterTemplete import M02_Template_Layers as layers +mf = m01.cm.mf +import master_sheet_line as msl # noqa: E402 — 계산서 줄 덧칸(714-1) · scripts 길은 `m01` 이 둠 + SHAPE = 3 # 줄 꼴이 바뀌면 올림 — 옛 저장본을 안 씀 KIND = "m02_logic_standard" REGION = "지역" # 지역 = 자동값 한 벌(정함 8) @@ -331,7 +334,7 @@ def sheet(key: str, change: dict) -> dict: raise m01.StoreError(404, str(e)) from e given, ax = _given(whole, logic, change) filled = m01.cm.mf.fill_empty(logic, given) - got = m01.mtx.lines(files, key, filled) + got = msl.annotate(whole, key, filled, m01.mtx.lines(files, key, filled)) # 줄 덧칸(714-1) done = settle.settle(whole, key, filled) if "결과" not in done: # 돈 로직 — 줄마다 버림(B09 산근 규칙) settle.apply(got.get("groups") or [], m01.cm.mf.run(whole, key, filled), done) @@ -341,7 +344,7 @@ def sheet(key: str, change: dict) -> dict: "비목": g["비목"], "소계": g.get("소계"), "끝수": g.get("끝수") or "", - "줄": [{k: line.get(k) for k in ("이름", "글", "금액")} for line in g["줄"]], + "줄": [{k: line.get(k) for k in ("이름", "글", "금액", *msl.KEYS)} for line in g["줄"]], } for g in got.get("groups") or [] ] @@ -356,5 +359,6 @@ def sheet(key: str, change: dict) -> dict: "입력": given, "묶음": groups, "계": got.get("계"), + "기준단위": got.get("기준단위"), } ) diff --git a/resources/master_data/scripts/master_sheet_line.py b/resources/master_data/scripts/master_sheet_line.py new file mode 100644 index 000000000..1fe93a425 --- /dev/null +++ b/resources/master_data/scripts/master_sheet_line.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +"""계산서 줄 덧칸 — 「쓰는 입력」 · 묶음 · 기준단위 · 환산 근거(PLAN 714-1 · 요구서 4장 5 · 8). + +`master_text.lines` 답(비목 묶음 줄)에 줄마다 덧칸을 얹음 — M01 `/text` 와 M02 `/logic-lib/sheet` +두 길이 같은 함수(`annotate`)를 거쳐 같은 줄 꼴(`KEYS`)을 냄. 셈은 `master_formula.run` 한 벌. +- `쓰는입력` = 그 줄 값(수량 식 · 요소 · 덧줄 식)에 쓰인 로직 입력 이름 — 중간값을 거슬러 감 · + 덧줄이 부르는 비목 합 · 줄 금액은 거스르지 않음(율 식에 직접 쓴 입력만). +- `묶음` · `장비` = 712-1 `master_bundle`(엔진 줄 덧칸 그대로). +- `기준단위` = 그 줄 수량 식이 찾는 표의 기준(「10㎡」 · 712-3 `master_basis.basis`) · 답 머리 + `기준단위` = 로직 결과단위 분모(「10㎡」 · 여럿이면 「㎡ · ㎥」). +- `환산근거` = 표 기준배 ↔ 결과단위 배(「10㎡당 → 1㎡당 ÷10」) · 재료 품목 단위 ↔ 줄 단위 + (712-2 `master_unit.note`) · 없으면 「」. +줄 차례 맞춤 = `master_text.lines` 와 같은 차례(호표 줄마다 `_shares` 비목 · 뒤 덧줄). +""" + +from __future__ import annotations + +import re +from decimal import Decimal + +import master_basis as mbs +import master_bundle as mb +import master_formula as mf +import master_text as mtx +import master_unit as mu + +KEYS = ("쓰는입력", "묶음", "장비", "기준단위", "환산근거") +_HOLE = re.compile(r"\{([^{}]+)\}") +_ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) + + +def _names(expr) -> tuple[set[str], set[str]]: + """식 하나의 (이름 · 찾는 표 키) — `{입력}` 구멍 이름도 셈.""" + if isinstance(expr, dict): # 재료 고르기 조건 묶음 — 값 글의 구멍만 + return {h for v in expr.values() for h in _HOLE.findall(str(v))}, set() + if expr in (None, ""): + return set(), set() + try: + tree = mf.parse(expr) + except mf.FormulaError: + return set(_HOLE.findall(str(expr))), set() + names, tables = set(), set() + for node in mf.walk(tree): + if node[0] == "name": + names.add(node[1]) + elif node[0] in ("find", "logic", "ref"): + names |= set(_HOLE.findall(node[1])) + if node[0] == "find": + tables.add(node[1]) + return names, tables + + +def _closure(row: dict, exprs: list) -> tuple[list[str], list[str]]: + """식들이 쓰는 (입력 이름 · 표 키) — 중간값은 그 식으로 거슬러 감 · 입력 차례대로.""" + steps = {s["이름"]: s["식"] for s in row.get("중간", [])} + inputs = [s["이름"] for s in row.get("입력", [])] + seen, tables, todo = set(), [], list(exprs) + while todo: + names, found = _names(todo.pop()) + tables += [t for t in sorted(found) if t not in tables] + for name in names - seen: + seen.add(name) + if name in steps: + todo.append(steps[name]) + return [n for n in inputs if n in seen], tables + + +def _times(x: Decimal) -> str: + return format(x.normalize(), "f") + + +def _per_text(per: list[tuple[Decimal, str]]) -> str: + return " · ".join(dict.fromkeys(f"{_times(n) if n != 1 else ''}{u}" for n, u in per)) + + +def _basis(master, tables: list[str], env: dict) -> dict | None: + for ref in tables: + try: + got = mbs.basis(master.get(mf.fill(ref, env) if "{" in ref else ref)) + except (*_ERRORS, mf.EmptyInput): + continue + if got["갈래"] == "단위": + return got + return None + + +def _note(basis: dict | None, per: list, line: dict) -> str: + notes = [] + if basis and len(per) == 1: + times, one = per[0] + if mu.plain(basis["기준단위"]) == mu.plain(mbs.unit(one)) and basis["기준배"] != times: + ratio = basis["기준배"] / times + how = f"÷{_times(ratio)}" if ratio > 1 else f"×{_times(1 / ratio)}" + notes.append( + f"{_times(basis['기준배'])}{basis['기준단위']}당 → {_times(times)}{one}당 {how}" + ) + goods = line.get("품목") or {} + if goods.get("단위") and line.get("단위"): + notes.append(mu.note(goods["단위"], line["단위"])) + return " · ".join(n for n in notes if n) + + +def _fields(master, row, exprs, env, per, line: dict, bundle: tuple[str, bool] | None) -> dict: + uses, tables = _closure(row, exprs) + basis = _basis(master, tables, env) + out = { + "쓰는입력": uses, + "기준단위": f"{_times(basis['기준배']) if basis['기준배'] != 1 else ''}{basis['기준단위']}" + if basis + else None, + "환산근거": _note(basis, per, line), + } + if bundle: + out["묶음"] = bundle[0] + if bundle[1]: + out["장비"] = True + return out + + +def annotate(master, key: str, given: dict, answer: dict) -> dict: + """`master_text.lines` 답에 줄마다 `KEYS` 덧칸 · 머리 `기준단위` — 못 셈이면 답 그대로.""" + if not answer.get("ok") or "groups" not in answer: + return answer + row = master.logic(key) + per = mbs.result_per(row.get("결과단위")) + answer["기준단위"] = _per_text(per) or None + try: + env = mtx._env_of(master, row, given) + except _ERRORS: + env = {} + if "결과" in row: + for line in answer["groups"][0]["줄"]: + line.update(_fields(master, row, [row["결과"]["식"]], env, per, {}, None)) + return answer + try: + result = mf.run(master, key, given) + except _ERRORS: + result = None + slots = {g["비목"]: iter(g["줄"]) for g in answer["groups"]} + items = row.get("호표") or [] + for n, item in enumerate(items): + line = result["줄"][n] if result else {} + if result: + parts = [p for p, _ in mtx._shares(line)] + elif item.get("종류") == "로직" and "비목" not in item: + parts = list(mf.COST_ITEMS) + else: + parts = [item.get("비목")] + exprs = [item.get("수량"), item.get("요소")] + got = _fields(master, row, exprs, env, per, line, mb.bundle(master, item, env)) + for part in parts: + target = next(slots.get(part, iter(())), None) + if target is not None: + target.update(got) + for extra in row.get("덧줄") or []: + target = next(slots.get(extra["비목"], iter(())), None) + if target is not None: + target.update( + _fields(master, row, [extra["식"]], env, per, {}, mb.bundle(master, extra)) + ) + return answer diff --git a/resources/tester/test_714_1_sheet_line.py b/resources/tester/test_714_1_sheet_line.py new file mode 100644 index 000000000..f2266a8e5 --- /dev/null +++ b/resources/tester/test_714_1_sheet_line.py @@ -0,0 +1,62 @@ +"""714-1 지킴 — 계산서 줄 덧칸(쓰는 입력 · 묶음 · 기준단위 · 환산 근거) · M01 `/text` 와 +M02 `/logic-lib/sheet` 가 같은 줄 꼴(`master_sheet_line`).""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from M01_MasterData import M01_MasterData_Store as store # noqa: E402 +from M01_MasterData import M01_MasterData_Store_Sheet as sheet # noqa: E402 +from M02_MasterTemplete import M02_Logic_Standard as standard # noqa: E402 + +msl = sheet.msl + + +def _text(key: str) -> dict: + auto = {k: v for k, v in store.auto(key)["값"].items() if v is not None} + return sheet.text(key, auto) + + +def _lines(got: dict, groups: str = "groups") -> list[dict]: + return [line for g in got[groups] for line in g["줄"]] + + +def test_line_keys_and_bundle(): + got = _text("GF000103") # 구조물터파기 — 인력 + 굴착기 기계경비 줄 + assert got["ok"] and got["기준단위"] == "㎥" + lines = _lines(got) + assert all({"쓰는입력", "묶음", "기준단위", "환산근거"} <= set(line) for line in lines) + machine = [line for line in lines if line.get("장비")] + assert machine and all(line["묶음"] == "경비" for line in machine) + assert {"지역", "보정작업"} <= set(machine[0]["쓰는입력"]) + assert any(line["묶음"] == "인력" for line in lines) + + +def test_uses_follow_middle_values(): + """중간값을 거슬러 입력까지 — 돌쌓기 석공 품은 높이 · 쌓기 · 돌 입력을 씀.""" + lines = _lines(_text("GF000219")) + assert {"높이", "쌓기", "돌"} <= set(lines[0]["쓰는입력"]) + + +def test_basis_note_tenfold(): + """표 10㎡당 → 결과 원/㎡ 「÷10」 · 결과도 원/10㎡ 면 근거 없음.""" + one = _lines(_text("GC000037")) + assert one[0]["기준단위"] == "10㎡" and one[0]["환산근거"] == "10㎡당 → 1㎡당 ÷10" + ten = _text("GF000124") + assert ten["기준단위"] == "10㎡" and all(not x["환산근거"] for x in _lines(ten)) + + +def test_two_routes_same_shape(): + """M01 `/text` 와 M02 계산서 — 줄 차례 · 덧칸 값이 같음(금액 끝수만 다를 수 있음).""" + for key in ("GF000169", "GF000103", "GF000219"): + m01 = _lines(_text(key)) + m02 = _lines(standard.sheet(key, {}), "묶음") + assert len(m01) == len(m02) + for a, b in zip(m01, m02): + assert set(b) == {"이름", "글", "금액", *msl.KEYS} + assert a["이름"] == b["이름"] + assert [a.get(k) for k in msl.KEYS] == [b.get(k) for k in msl.KEYS]