diff --git a/M01_MasterData/M01_MasterData_Store_Combo.py b/M01_MasterData/M01_MasterData_Store_Combo.py index 75ecedb5b..cb147ba2a 100644 --- a/M01_MasterData/M01_MasterData_Store_Combo.py +++ b/M01_MasterData/M01_MasterData_Store_Combo.py @@ -150,7 +150,9 @@ def combo(key: str) -> dict: } -_PER = re.compile(r"원\s*/\s*(\d*\.?\d*)\s*([^\s·(]+)") +_PER = re.compile( + r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?\s*([^\s·(\d,.][^\s·(]*)" +) # 「원/1,000본」 = 배 1000 _TAIL = re.compile(r"\s*\([^()]*\)\s*$") @@ -185,7 +187,7 @@ def combo_pills(row: dict) -> dict: (name, unit), {"키": f"{name}|{unit}", "이름": name, "단위": unit, "로직": []} ) pill["로직"].append(code) - times = per[0][0] + times = per[0][0].replace(",", "") if len(per) == 1 and times and float(times) != 1: pill["배"] = float(times) if "." in times else int(times) return {"키": row.get("키"), "이름": row.get("이름"), "물량": list(pills.values()), "조건": []} diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py new file mode 100644 index 000000000..8036a0d01 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -0,0 +1,287 @@ +"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장). + +문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 메모, 입력값, 수량}], +자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠). +수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}` +(손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값). +금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은 +반영 없음 · 자재 기준 단가 = `master_material.element` 기본값. +짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` — +거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`. +""" + +from __future__ import annotations + +import json +from decimal import Decimal, InvalidOperation +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 +from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +mf = m01.cm.mf +mm = mf.mp.mm +COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비 +MATERIAL_HEAD = "자재|" +_MAT_COLS = ("코드", "이름", "규격", "단위") + + +def _bad(text: str) -> store.StoreError: + return store.StoreError(400, text) + + +def _number(raw: Any) -> Decimal | None: + """칸 값 · 손 값 → Decimal — 수가 아니면 None(쉼표는 뗌).""" + if raw is None or isinstance(raw, bool): + return None + try: + got = Decimal(str(raw).replace(",", "").strip()) + except InvalidOperation: + return None + return got if got.is_finite() else None + + +def _specs(logic: dict) -> list[dict]: + """로직 입력 꼴 — 흐름 로직은 「설계 때 정할 것」.""" + if isinstance(logic.get("흐름"), dict): + return mf.inputs_of(logic) + return list(logic.get("입력") or []) + + +def _qty(value: Any, where: str) -> dict | None: + if value is None: + return None + hand = _number(value.get("손")) if isinstance(value, dict) else None + if hand is None: + raise _bad(f"{where} · 수량은 null 또는 {{손: 수}}") + return {"손": int(hand) if hand == hand.to_integral_value() else float(hand)} + + +# ── 정리 · 검사 ─────────────────────────────────────────────────────── +def clean(doc: Any) -> dict[str, Any]: + """서버가 받는 꼴로 — 모르는 로직 · 같은 로직 두 번 · 없는 자재 · 수량 꼴이 틀리면 400 · + 모르는 입력 이름 · 빈 입력값은 버림 · 자재 이름 · 규격 · 단위는 M01 것.""" + if not isinstance(doc, dict): + raise _bad("일위대가 문서가 묶음 아님") + whole = m01.loaded()[1] + lines, seen = [], set() + for at, one in enumerate(doc.get("담은로직") or [], start=1): + where = f"담은 로직 {at}째" + if not isinstance(one, dict): + raise _bad(f"{where} · 묶음 아님") + code = str(one.get("로직") or "") + try: + logic = whole.logic(code) + except mf.FormulaError as e: + raise _bad(f"{where} · {e}") from e + if code in seen: + raise _bad(f"{where} · 같은 로직을 두 번 담음 「{code}」") + seen.add(code) + names = {str(spec.get("이름")) for spec in _specs(logic)} + given = one.get("입력값") or {} + if not isinstance(given, dict): + raise _bad(f"{where} · 입력값이 묶음 아님") + memo = one.get("메모") + lines.append( + { + "로직": code, + "메모": str(memo) if memo else None, + "입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")}, + "수량": _qty(one.get("수량"), where), + } + ) + mats, codes = [], set() + for at, one in enumerate(doc.get("자재") or [], start=1): + where = f"자재 {at}째" + code = str(one.get("코드") or "") if isinstance(one, dict) else "" + try: + row = whole.get(code) if code[:2] == mm.MARKET else None + except mf.FormulaError: + row = None + if row is None: + raise _bad(f"{where} · M01 자재품목에 없는 코드 「{code}」") + if code in codes: + raise _bad(f"{where} · 같은 자재를 두 번 담음 「{code}」") + codes.add(code) + mats.append( + { + "코드": code, + **{k: row.get(k) or "" for k in _MAT_COLS[1:]}, + "수량": _qty(one.get("수량"), where), + } + ) + out = { + "종류": "일위대가", + "판": doc.get("판") or 1, + "열": doc.get("열"), + "단위": str(doc["단위"]) if doc.get("단위") else None, + "본뜬": doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None, + "담은로직": lines, + "자재": mats, + } + if doc.get("일위대가번"): + out["일위대가번"] = doc["일위대가번"] + return out + + +# ── 알약 · 면적 값 ──────────────────────────────────────────────────── +def material_key(code: str) -> str: + return f"{MATERIAL_HEAD}{code}" + + +def pills(doc: dict) -> list[dict[str, Any]]: + """면적산출 알약 — 로직 물량 알약(`combo_pills` 그대로) + 자재 알약 「자재|MT키」.""" + logic = combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"] + mats = [ + { + "키": material_key(m["코드"]), + "이름": m.get("이름") or m["코드"], + "단위": m.get("단위") or "", + "자재": {k: m.get(k) or "" for k in _MAT_COLS}, + } + for m in doc.get("자재") or [] + ] + return [*logic, *mats] + + +def area_values(area: Any) -> dict[str, Decimal]: + """면적산출 문서 `연결` 의 알약 → 칸 값(식 칸 = 서버 `계산값` · 그 밖 = 칸 값) · 수만.""" + out: dict[str, Decimal] = {} + if not isinstance(area, dict): + return out + calc = area.get("계산값") or {} + for sheet in area.get("시트") or []: + cells, got = sheet.get("칸") or {}, calc.get(sheet.get("id")) or {} + for link in sheet.get("연결") or []: + at = link.get("칸") + raw = (got.get(at) or {}).get("수") + if raw is None: + raw = (cells.get(at) or {}).get("값") + value = _number(raw) + if value is not None and link.get("키"): + out[str(link["키"])] = value + return out + + +def _area_of(column: Any) -> dict[str, Decimal]: + """지금 층 그 열 면적산출의 알약 값 — 파일이 없으면 빈 것.""" + try: + path = store._path("area", str(column or "")) + except store.StoreError: + return {} + return area_values(json.loads(path.read_bytes())) if path.is_file() else {} + + +# ── 계산 ────────────────────────────────────────────────────────────── +def _money(value: Any) -> dict[str, Decimal] | None: + if not isinstance(value, dict): + return None + return {k: _number(value.get(k)) or Decimal(0) for k in (*COSTS, "계")} + + +def reflect(base: dict[str, Decimal], qty: Decimal | None, times: Decimal) -> dict | None: + """반영 = 기준 × 수량 ÷ 배 — 수량이 없으면 None.""" + if qty is None: + return None + return {k: base[k] * qty / times for k in (*COSTS, "계")} + + +def _sum(rows: list[dict | None]) -> dict[str, Decimal]: + return {k: sum((r[k] for r in rows if r), Decimal(0)) for k in (*COSTS, "계")} + + +def _pick(hand: Any, key: Any, given: dict[str, Decimal]) -> tuple[Decimal | None, str]: + """(수량, 출처) — 손 · 칸(알약 키) · 없음.""" + if isinstance(hand, dict) and _number(hand.get("손")) is not None: + return _number(hand["손"]), "손" + if key and key in given: + return given[key], "칸" + return None, "없음" + + +def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]: + """기준이 선 계산 답에 수량 · 반영 · 합을 채움 — 수량만 바뀌면 이것만 다시 + (짝: `Labor_Calc.ts` `settle` · 자재 = 재료비).""" + have = {str(k): v for k, raw in given.items() if (v := _number(raw)) is not None} + hands = {one.get("로직"): one.get("수량") for one in doc.get("담은로직") or []} + mhands = {one.get("코드"): one.get("수량") for one in doc.get("자재") or []} + zero = dict.fromkeys((*COSTS, "계"), Decimal(0)) + lines, mats, bases, reflects = [], [], [], [] + for line in result.get("줄") or []: + qty, source = _pick(hands.get(line.get("로직")), line.get("알약"), have) + base = _money(line.get("기준")) + done = reflect(base, qty, _number(line.get("배")) or Decimal(1)) if base else None + bases.append(base) + reflects.append(done) + lines.append({**line, "수량": qty, "수량출처": source, "반영": done}) + for one in result.get("자재") or []: + qty, source = _pick(mhands.get(one.get("코드")), one.get("알약"), have) + price = _number(one.get("기준단가")) + cost = price * qty if price is not None and qty is not None else None + bases.append({**zero, "재료비": price, "계": price} if price is not None else None) + reflects.append({**zero, "재료비": cost, "계": cost} if cost is not None else None) + mats.append({**one, "수량": qty, "수량출처": source, "반영": cost}) + return _json({"줄": lines, "자재": mats, "기준합": _sum(bases), "반영합": _sum(reflects)}) + + +def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]: + """`doc` = 정리한 문서 · `given` = {알약 키: 값}(없으면 지금 층 면적산출 파일에서) — + 줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음).""" + whole = m01.loaded()[1] + by_logic = { + code: pill + for pill in combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"] + for code in pill["로직"] + } + lines = [] + for one in doc.get("담은로직") or []: + code = one["로직"] + pill = by_logic.get(code) or {} + line = { + "로직": code, + "알약": pill.get("키"), + "분모": pill.get("단위"), + "배": pill.get("배") or 1, + } + try: + logic = whole.logic(code) + auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None} + got = mf.run(whole, code, {**auto, **m01._dec(one.get("입력값") or {})}) + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: + lines.append({**line, "기준": None, "까닭": str(e) or type(e).__name__}) + continue + if "결과" in got: # 돈 아닌 로직 — 반영 없음 + lines.append({**line, "결과": got["결과"], "기준": None, "까닭": None}) + continue + lines.append({**line, "기준": {k: got[k] for k in (*COSTS, "계")}, "까닭": None}) + mats = [] + for one in doc.get("자재") or []: + try: + price, _ = mm.element(whole, one["코드"], {}, one.get("단위") or None) + except mf.FormulaError as e: + price, why = None, str(e) + else: + why = None if price is not None else "단가 없음" + mats.append( + {"코드": one["코드"], "알약": material_key(one["코드"]), "기준단가": price, "까닭": why} + ) + have = _area_of(doc.get("열")) if given is None else given + return settle(_json({"줄": lines, "자재": mats}), doc, have) + + +def _json(value: Any) -> Any: + """Decimal → JSON 수(정수면 int).""" + if isinstance(value, Decimal): + return int(value) if value == value.to_integral_value() else float(value) + if isinstance(value, list): + return [_json(v) for v in value] + if isinstance(value, dict): + return {k: _json(v) for k, v in value.items()} + return value + + +def server(doc: Any) -> dict[str, Any]: + """[저장] · 복사 때 서버 정본 — 정리 + 지금 면적 값으로 `계산값`(브라우저 값 버림).""" + got = clean(doc) + return {**got, "계산값": calc(got)} diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py new file mode 100644 index 000000000..233b1ae30 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -0,0 +1,64 @@ +"""M02 일위대가 계산 · 알약 길(PLAN 39-1a · 설계_정함 1장) — `/api/m02/layers/{층}/labor/{열}/…`. + +문서 읽기 · 쓰기는 양식 길(`GET/PUT /api/m02/layers/{층}/templates/labor/{열}`) 그대로 · +여기는 저장하지 않는 셈만. 층 권한 = `Router_Items._root`(읽기 권한 · `M02_Layer_Access` 한 벌). +⚠ 등록은 `main.py`(로그인만). +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root + +router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Labor"]) + + +class CalcBody(BaseModel): + """`doc` = 저장 전 화면 문서(없으면 저장본) · `수량` = {알약 키: 값}(없으면 저장된 면적산출).""" + + doc: dict[str, Any] | None = None + 수량: dict[str, Any] | None = None + + +def _doc(column: str, sent: dict[str, Any] | None) -> dict[str, Any]: + got = sent if sent is not None else store.read("labor", column)["문서"] + return labor.clean({**got, "열": column}) + + +def _calc(column: str, body: CalcBody) -> dict[str, Any]: + doc = _doc(column, body.doc) + return {**labor.calc(doc, body.수량), "알약": labor.pills(doc)} + + +def _pills(column: str) -> dict[str, Any]: + return {"열": column, "알약": labor.pills(_doc(column, None))} + + +@router.post("/layers/{layer}/labor/{column}/calc") +def calc_labor( + layer: Layer, + column: str, + body: CalcBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """줄마다 기준 · 반영 · 합 + 알약(저장 안 함 · 화면은 최신 응답만).""" + return _call(_root(session, layer, owner), _calc, column, body) + + +@router.get("/layers/{layer}/labor/{column}/pills") +def labor_pills( + layer: Layer, + column: str, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """면적산출 알약 — 로직 물량 알약 「이름|단위」 + 자재 알약 「자재|MT키」(저장본).""" + return _call(_root(session, layer, owner), _pills, column) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Items.py index 90f0dacd7..dba53b49a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Items.py @@ -151,8 +151,7 @@ def copy_item( have = store.read(kind, new_id)["문서"] body = {k: v for k, v in got.items() if k != "계산값"} body = {**body, "열": new_id, number: have.get(number)} - if kind in store.SHEETS: - body = store._server_values(body) + body = store._server(kind, body) with store._LOCK: atomic_write_json(store._path(kind, new_id), body) ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts new file mode 100644 index 000000000..dcff3da3c --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -0,0 +1,113 @@ +/* M02 일위대가 수량 · 반영 · 합(PLAN 39-1a · 설계_정함 1장) — 면적 칸 값이 바뀌면 화면이 바로 곱함. + * + * 짝(CLAUDE.md 5장 ①): `M02_Labor_Doc.py` `settle` 과 같은 규칙 — + * 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`. + * 기준(결과단위 그대로)은 서버 calc 길이 셈 · 여기는 받은 기준에 + * 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만. */ + +export const MONEY_KEYS = ["노무비", "재료비", "경비", "계"] as const; +export type Money = Record<(typeof MONEY_KEYS)[number], number>; +export type QtySource = "손" | "칸" | "없음"; +/** 문서의 수량 칸 — null = 면적산출 알약 · {손} = 손 입력 */ +export type Qty = { 손: number } | null; + +export interface CalcLine { + 로직: string; + 알약?: string | null; + 분모?: string | null; + 배: number; + 기준: Money | null; + 수량?: number | null; + 수량출처?: QtySource; + 반영?: Money | null; + 까닭?: string | null; + 결과?: unknown; +} + +export interface CalcMaterial { + 코드: string; + 알약?: string | null; + 기준단가: number | null; + 수량?: number | null; + 수량출처?: QtySource; + 반영?: number | null; + 까닭?: string | null; +} + +export interface LaborCalc { + 줄: CalcLine[]; + 자재: CalcMaterial[]; + 기준합?: Money; + 반영합?: Money; +} + +/** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 */ +export interface LaborQtyDoc { + 담은로직?: { 로직: string; 수량?: Qty }[]; + 자재?: { 코드: string; 수량?: Qty }[]; +} + +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; +} + +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; +} + +/** 기준이 선 계산 답 + 문서 손 값 + 알약 값(`{키: 값}`) → 수량 · 반영 · 합을 새로 채운 답 */ +export function settle( + result: LaborCalc, + doc: LaborQtyDoc, + given: Record, +): LaborCalc { + 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) => [one.로직, one.수량 ?? null])); + const mhands = new Map((doc.자재 ?? []).map((one) => [one.코드, one.수량 ?? null])); + 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(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) add(base, { ...zero(), 재료비: price, 계: price }); + if (cost !== null) add(done, { ...zero(), 재료비: cost, 계: cost }); + return { ...one, 수량: qty, 수량출처: source, 반영: cost }; + }); + return { 줄, 자재, 기준합: base, 반영합: done }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index ced3a7fc7..0ff11389e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -65,12 +65,13 @@ IN_USE = { # (종류, 문서 번호 칸, 머리 글자, 시트 이름) — 시트 이름이 None 이면 구조물 도면 PARTS = ( ("structure", "도번", "SD", None), + ("labor", "일위대가번", "SU", None), # 일위대가 — `M02_Labor_Doc`(39장) ("area", "면적번", "SA", "면적산출"), ("material", "재료번", "SM", "재료산출"), ) _KEY = {kind: key for kind, key, _, _ in PARTS} SHEETS = ("area", "material") # 통합문서 종류 — [저장] 때 서버가 계산값을 새로 적음 -_CODE = re.compile(r"^S[DAM](\d{8}|-\d{6})$") +_CODE = re.compile(r"^S[DAMU](\d{8}|-\d{6})$") _OLD_CODE = re.compile(r"^S[DCA](\d{6})$") # PLAN 12장 6자리 — 끝에 00 을 붙여 읽음 _BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.') # 도각 없는 빈 CAD 문서의 기본 도면층 — 없으면 B07 CAD 가 못 엶("Cannot read properties @@ -155,7 +156,8 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> 구조물집계표를 저장하면 — 새 구조물 열의 빈 구조물 도면 · 면적산출 · 재료산출을 번호와 같이 만들고 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 지움(잠금 밖에서 · 안에서 부르면 죽음). 통합문서(area · material)는 서버가 같은 TS 엔진을 Node 로 돌려 `계산값` 을 새로 적음 - (브라우저 값 버림 · 5장 ②) · 알약 `연결` 은 문서와 같이 그대로 적힘. + (브라우저 값 버림 · 5장 ②) · 알약 `연결` 은 문서와 같이 그대로 적힘 · 일위대가(labor)는 + `M02_Labor_Doc.server` 가 정리 · `계산값` 을 새로 적음(면적산출 저장 때 그 열 일위대가도). 번호 칸(도번 · 면적번 · 재료번)은 서버 것 — 있던 파일 번호를 그대로 둠(화면 값을 안 받음). 표 열의 `본뜬열`(분류 더하기로 복사한 새 열)은 적지 않음 — 집계표면 아직 파일이 없는 새 열이 원본 열의 일위대가 · 자재조합 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로). @@ -166,8 +168,7 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> origins = _take_origins(doc) if kind == "table" else {} if kind == "table" and name == STRUCTURE_TABLE: doc = tree.normalize_table(doc) # 경로 · 머리 줄 수 · 빈 폴더(표 저장 · 나무 고침 한 줄기) - if kind in SHEETS: - doc = _server_values(doc) + doc = _server(kind, doc) with _LOCK: have = version_of(path.read_bytes()) if path.is_file() else "" if have != (version or ""): @@ -179,6 +180,8 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> doc = {**doc, _KEY[kind]: number} if number else doc atomic_write_json(path, doc) info = _info(kind, path) + if kind == "area": + _refresh_labor(name) # 면적 칸 값이 바뀌면 일위대가 계산값(목록 합계)도 if kind == "table" and name == STRUCTURE_TABLE: for column, source in origins.items(): # 빈 파일 채우기 앞 — 원본 내용으로 만듦 for part in _KEY: @@ -282,6 +285,17 @@ def _retired() -> list[str]: def _blank(kind: str, column: str) -> dict[str, Any]: """빈 문서(번호는 부르는 쪽이 붙임) — 구조물 도면 = 도각 없음 · 기본 도면층 하나 / 면적산출 · 재료산출 = 진짜 빈 시트 하나(식 없어 `계산값` 없음 · 첫 [저장] 때 서버가 적음).""" + if kind == "labor": + return { + "종류": "일위대가", + "판": 1, + "열": column, + "단위": None, + "본뜬": None, + "담은로직": [], + "자재": [], + "계산값": {}, + } if kind == "structure": return { "양식": "구조물도면", @@ -311,9 +325,7 @@ def _ensure(kind: str, column: str, source: str | None = None) -> bool: base = _blank(kind, column) if source and _path(kind, source).is_file(): got = json.loads(_path(kind, source).read_bytes()) - base = {**{k: v for k, v in got.items() if k != "계산값"}, "열": column} - if kind in SHEETS: - base = _server_values(base) + base = _server(kind, {**{k: v for k, v in got.items() if k != "계산값"}, "열": column}) with _LOCK: if path.is_file(): return False @@ -343,13 +355,13 @@ def _take_origins(doc: Any) -> dict[str, str]: def create_structure(column: str) -> dict[str, Any]: """구조물 열 하나의 구조물 도면 새로 — 도번 SD + 새 일련(`-` + 6자리) · 이미 있으면 409. - 면적산출 · 재료산출 파일이 없으면 빈 통합문서도 같이 만듦(같은 뒷자리).""" + 일위대가 · 면적산출 · 재료산출 파일이 없으면 빈 문서도 같이 만듦(같은 뒷자리).""" if column not in _column_ids(): raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 구조물 열 「{column}」") if not _ensure("structure", column): raise StoreError(409, f"이미 있는 구조물 도면 「{column}」") - for kind in SHEETS: - _ensure(kind, column) + for kind in _KEY: + _ensure(kind, column) # 도면은 위에서 만듦 · 있는 것은 그대로 return read("structure", column) @@ -360,8 +372,7 @@ def copy_from(kind: str, column: str, source: str) -> dict[str, Any]: raise StoreError(404, f"없는 종류 「{kind}」") target = read(kind, column)["문서"] doc = {**read(kind, source)["문서"], **{k: target.get(k) for k in ("열", _KEY[kind])}} - if kind in SHEETS: - doc = _server_values({k: v for k, v in doc.items() if k != "계산값"}) + doc = _server(kind, {k: v for k, v in doc.items() if k != "계산값"}) with _LOCK: atomic_write_json(_path(kind, column), doc) return _info(kind, _path(kind, column)) @@ -457,6 +468,30 @@ def renumber() -> dict[str, list[str]]: return {"옮김": moved, "지움": gone, "바꿈": changed, "새로": made} +def _server(kind: str, doc: Any) -> Any: + """서버가 적는 값 — 통합문서 계산값(5장 ②) · 일위대가 정리 · 계산값(`M02_Labor_Doc`).""" + if kind in SHEETS: + return _server_values(doc) + if kind == "labor": + from M02_MasterTemplete import M02_Labor_Doc as labor + + return labor.server(doc) + return doc + + +def _refresh_labor(column: str) -> None: + """그 열 일위대가 `계산값` 을 지금 면적 값으로 다시 적음 — 문서가 틀어졌으면 그대로 둠.""" + path = _path("labor", column) + if not path.is_file(): + return + try: + doc = _server("labor", json.loads(path.read_bytes())) + except StoreError: + return + with _LOCK: + atomic_write_json(path, doc) + + def _server_values(doc: Any) -> dict[str, Any]: try: return spreadsheet.with_server_values(doc) diff --git a/M02_MasterTemplete/M02_Template_Layers.py b/M02_MasterTemplete/M02_Template_Layers.py index f56c48822..6080bb5b8 100644 --- a/M02_MasterTemplete/M02_Template_Layers.py +++ b/M02_MasterTemplete/M02_Template_Layers.py @@ -27,7 +27,8 @@ from config import config_system LAYERS = ("system", "company", "personal", "project") # area = 면적산출 · material = 재료산출 — 통합문서(구조물집계표 열마다 · `A00_Common/spreadsheet/`) -KINDS = ("table", "drawing", "structure", "area", "material") +# labor = 일위대가(구조물 열마다 · `M02_Labor_Doc`) +KINDS = ("table", "drawing", "structure", "labor", "area", "material") TEMPLATES_DIRNAME = "templates" INITIAL_DIRNAME = "_initial" MANIFEST_NAME = "manifest.json" @@ -168,6 +169,7 @@ _SKELETON = { "table": ("열",), "drawing": ("entities",), "structure": ("도면.entities",), + "labor": ("담은로직",), "area": ("시트",), "material": ("시트",), } diff --git a/main.py b/main.py index bd3266c1c..ed3216512 100644 --- a/main.py +++ b/main.py @@ -61,6 +61,7 @@ from B06_Section.B06_Section_Router_HaulPlan import ( from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router +from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router from M02_MasterTemplete.M02_MasterTemplete_Router_Combos import router as m02_combos_router from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import router as m02_drawing_router @@ -649,6 +650,7 @@ app.include_router(m02_layers_router, dependencies=protected) app.include_router(m02_items_router, dependencies=protected) app.include_router(m02_combos_router, dependencies=protected) app.include_router(m02_drawing_router, dependencies=protected) +app.include_router(m02_labor_router, dependencies=protected) # 프로젝트 집계 항목 복사본(PLAN 25-2b) — 로그인만 · 프로젝트 권한은 길 안에서. app.include_router(m02_project_items_router, dependencies=protected) # 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). diff --git a/resources/tester/helper_m02_labor_calc.cjs b/resources/tester/helper_m02_labor_calc.cjs new file mode 100644 index 000000000..939a86397 --- /dev/null +++ b/resources/tester/helper_m02_labor_calc.cjs @@ -0,0 +1,21 @@ +/* M02 일위대가 수량 · 반영 · 합(`M02_MasterTemplete_Labor_Calc.ts` `settle`) 거울 헬퍼 — + * 그 자리에서 트랜스파일해 Node 로 돌림 · stdin = {답, 문서, 값} · stdout = settle 결과 JSON. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join( + __dirname, + "..", + "..", + "M02_MasterTemplete", + "M02_MasterTemplete_Labor_Calc.ts", +); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); + +const input = JSON.parse(fs.readFileSync(0, "utf8")); +process.stdout.write(JSON.stringify(box.exports.settle(input.답, input.문서, input.값))); diff --git a/resources/tester/test_m02_item_keys.py b/resources/tester/test_m02_item_keys.py index f6c6a82ee..7ac0949b3 100644 --- a/resources/tester/test_m02_item_keys.py +++ b/resources/tester/test_m02_item_keys.py @@ -26,7 +26,7 @@ from resources.tester.test_m02_structure import write_sample_table from resources.tester.test_m02_tree import Tree, _app ROOT = Path(__file__).resolve().parents[2] -NEW = re.compile(r"^S[DAM]-\d{6}$") +NEW = re.compile(r"^S[DAMU]-\d{6}$") @pytest.fixture() @@ -164,7 +164,7 @@ def test_새_번호는_옛_번호와_모양이_달라_안_겹침(world): doc = json.loads(path.read_bytes()) path.write_text(json.dumps({**doc, key: f"{head}{code}"}), encoding="utf-8") old = {cid: _numbers(system, cid) for cid in store._column_ids()} - assert old["rv"] == {"SD01010100", "SA01010100", "SM01010100"} + assert old["rv"] == {"SD01010100", "SU01010100", "SA01010100", "SM01010100"} first = max(int(n[3:]) for nums in old.values() for n in nums if NEW.match(n)) + 1 with store.at(system): got = store.read("table", store.STRUCTURE_TABLE) @@ -191,7 +191,7 @@ def test_새_번호는_옛_번호와_모양이_달라_안_겹침(world): got["문서"]["열"].append({"id": "y", "머리": ["y"], "꼴": "수"}) store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"]) last = f"-{first + 120:06d}" # 지운 x119 번호(first + 119) 다음 - assert _numbers(system, "y") == {f"SD{last}", f"SA{last}", f"SM{last}"} + assert _numbers(system, "y") == {f"SD{last}", f"SU{last}", f"SA{last}", f"SM{last}"} def test_층마다_번호는_따로_파일은_열_id_로(world): diff --git a/resources/tester/test_m02_labor_calc_mirror.py b/resources/tester/test_m02_labor_calc_mirror.py new file mode 100644 index 000000000..ab8a53680 --- /dev/null +++ b/resources/tester/test_m02_labor_calc_mirror.py @@ -0,0 +1,71 @@ +"""일위대가 수량 · 반영 · 합 거울 시험 — `M02_Labor_Doc.settle`(파이썬) ↔ +`M02_MasterTemplete_Labor_Calc.ts` `settle`(TS · Node) 가 같은 답(CLAUDE.md 5장 ①). + +기준은 서버가 한 번 셈(`calc`) · 그 답에 다른 알약 값을 넣어 둘이 같은지 봄. +""" + +import json +import os +import subprocess + +import pytest + +from M02_MasterTemplete import M02_Labor_Doc as labor + +HERE = os.path.dirname(os.path.abspath(__file__)) +TEN, THOUSAND, PLAIN, MAT = "GC000421", "GF000017", "GC000422", "MT000008" + + +def _ts(answer: dict, doc: dict, values: dict) -> dict: + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_labor_calc.cjs")], + input=json.dumps({"답": answer, "문서": doc, "값": values}, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(proc.stdout) + + +def _same(a, b) -> None: + if isinstance(a, dict): + assert set(a) == set(b) + for k in a: + _same(a[k], b[k]) + elif isinstance(a, list): + assert len(a) == len(b) + for x, y in zip(a, b): + _same(x, y) + elif isinstance(a, (int, float)) and not isinstance(a, bool): + assert b == pytest.approx(a, rel=1e-9) + else: + assert a == b + + +@pytest.mark.parametrize( + "values", + [ + {}, # 수량 없음 — 반영 null · 손만 + {"사석 수상고르기|㎡": 2.5, f"자재|{MAT}": "3"}, + {"사석 수상고르기|㎡": "1,200", "나무식재|본": 7, f"자재|{MAT}": "글"}, # 쉼표 · 수 아님 + ], +) +def test_파이썬과_TS_가_같은_답(values): + doc = labor.clean( + { + "열": "rv", + "담은로직": [ + {"로직": TEN, "수량": None}, + {"로직": THOUSAND, "수량": {"손": 4}}, + {"로직": PLAIN, "수량": None}, + ], + "자재": [{"코드": MAT, "수량": None}], + } + ) + answer = labor.calc(doc, {}) + keys = {line["알약"] for line in answer["줄"]} + assert {"사석 수상고르기|㎡", "나무식재|본"} <= keys # 알약 키가 시험 값과 맞음 + py = labor.settle(answer, doc, values) + _same(py, _ts(answer, doc, values)) + assert py == labor.calc(doc, values) # settle 만 다시 = 처음부터 다시 diff --git a/resources/tester/test_m02_labor_doc.py b/resources/tester/test_m02_labor_doc.py new file mode 100644 index 000000000..d4ae1e3f8 --- /dev/null +++ b/resources/tester/test_m02_labor_doc.py @@ -0,0 +1,213 @@ +"""일위대가 문서(`labor/<열id>.json` · PLAN 39-1a) — 정리 · 검사 · 계산 · 알약 · 저장 때 계산값. + +로직 · 자재는 M01 마스터(읽기만) · 층은 견본 표(`write_sample_table`)를 임시 폴더에. +""" + +import json + +import pytest + +from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from resources.tester.test_m02_structure import write_sample_table + +TEN = "GC000421" # 원/10㎡ — 배 10 +THOUSAND = "GF000017" # 원/1,000본 — 배 1000 +PLAIN = "GC000422" # ㎡/시간 — 돈 아닌 로직 +STONE = "GF000219" # 원/㎡ · 입력 뒷길이 · 돌 · 쌓기 · 높이 · 초과증가율 +MAT = "MT000008" # 자재품목 · 물가정보 83000 + + +def _doc(**extra): + return {"열": "rv", "담은로직": [], "자재": [], **extra} + + +def test_정리_모르는_입력_빈_값_버림_자재_칸은_M01_것(): + got = labor.clean( + _doc( + 담은로직=[ + {"로직": STONE, "입력값": {"돌": "깬돌", "모름": 1, "높이": ""}, "수량": None} + ], + 자재=[{"코드": MAT, "이름": "아무 이름", "수량": {"손": "2"}}], + 단위="개소", + ) + ) + assert got["담은로직"] == [ + {"로직": STONE, "메모": None, "입력값": {"돌": "깬돌"}, "수량": None} + ] + assert got["자재"][0]["이름"] == "치 즐(0.2㎥)" and got["자재"][0]["수량"] == {"손": 2} + assert (got["종류"], got["단위"], got["본뜬"]) == ("일위대가", "개소", None) + + +@pytest.mark.parametrize( + "bad", + [ + _doc(담은로직=[{"로직": STONE}, {"로직": STONE}]), # 같은 로직 두 번 + _doc(담은로직=[{"로직": "GF999999"}]), # 없는 로직 + _doc(담은로직=[{"로직": STONE, "수량": {"손": "열"}}]), # 수 아님 + _doc(담은로직=[{"로직": STONE, "수량": 3}]), # 꼴 틀림 + _doc(자재=[{"코드": "LB000001"}]), # 자재품목 아님 + _doc(자재=[{"코드": MAT}, {"코드": MAT}]), # 같은 자재 두 번 + "문서 아님", + ], +) +def test_틀린_문서는_400(bad): + with pytest.raises(store.StoreError) as e: + labor.clean(bad) + assert e.value.status == 400 + + +def test_빈_일위대가도_됨(): + got = labor.server(_doc()) + assert got["계산값"]["기준합"]["계"] == 0 and got["계산값"]["반영합"]["계"] == 0 + + +def test_PER_천_단위_쉼표는_배(): + pills = combo_store.combo_pills({"담은로직": [{"로직": THOUSAND}, {"로직": TEN}]})["물량"] + assert {p["단위"]: p.get("배") for p in pills} == {"본": 1000, "㎡": 10} + + +def test_반영은_기준_곱_수량_나누기_배_돈_아닌_로직은_반영_없음(): + doc = labor.clean( + _doc(담은로직=[{"로직": TEN, "수량": {"손": 4}}, {"로직": PLAIN, "수량": None}]) + ) + got = labor.calc(doc, {}) + ten, plain = got["줄"] + assert (ten["배"], ten["수량"], ten["수량출처"]) == (10, 4, "손") + for k in ("노무비", "재료비", "경비", "계"): + assert ten["반영"][k] == pytest.approx(ten["기준"][k] * 4 / 10) + assert plain["기준"] is None and plain["반영"] is None and "결과" in plain + assert got["반영합"]["계"] == pytest.approx(ten["반영"]["계"]) + + +def test_수량_손이_칸보다_앞섬_칸_값은_알약_키로(): + doc = labor.clean( + _doc( + 담은로직=[{"로직": TEN, "수량": None}, {"로직": THOUSAND, "수량": {"손": 1}}], + 자재=[{"코드": MAT, "수량": None}], + ) + ) + keys = {p["키"] for p in labor.pills(doc)} + ten_key = next(k for k in keys if k.endswith("|㎡")) + thousand_key = next(k for k in keys if k.endswith("|본")) + assert f"자재|{MAT}" in keys + got = labor.calc(doc, {ten_key: "2.5", thousand_key: 99, f"자재|{MAT}": "3"}) + ten, thousand = got["줄"] + assert (ten["수량"], ten["수량출처"]) == (2.5, "칸") + assert (thousand["수량"], thousand["수량출처"]) == (1, "손") + mat = got["자재"][0] + assert (mat["기준단가"], mat["수량"], mat["반영"]) == (83000, 3, 249000) + nothing = labor.calc(doc, {})["줄"][0] + assert (nothing["수량출처"], nothing["반영"]) == ("없음", None) + + +def test_면적_값_식_칸은_계산값_그_밖은_칸_값(): + area = { + "시트": [ + { + "id": "s1", + "칸": {"B6": {"값": "1,200"}, "C1": {"값": "글"}}, + "연결": [{"키": "a", "칸": "B6"}, {"키": "b", "칸": "D2"}, {"키": "c", "칸": "C1"}], + } + ], + "계산값": {"s1": {"D2": {"수": "7.5"}}}, + } + got = labor.area_values(area) + assert {k: float(v) for k, v in got.items()} == {"a": 1200, "b": 7.5} + + +@pytest.fixture() +def system(tmp_path, monkeypatch): + write_sample_table(tmp_path) + monkeypatch.setattr(store, "FOLDER", tmp_path) + store.create_structure("rv") + return tmp_path + + +def test_구조물_만들면_빈_일위대가_같은_뒷자리(system): + doc = store.read("labor", "rv")["문서"] + assert doc["일위대가번"] == "SU" + store.read("structure", "rv")["문서"]["도번"][2:] + assert doc["담은로직"] == [] and doc["자재"] == [] + + +def test_저장_때_서버가_계산값_번호는_그대로_면적_저장이_계산값_새로(system): + have = store.read("labor", "rv") + sent = { + **have["문서"], + "일위대가번": "SU-999999", + "담은로직": [{"로직": TEN, "수량": None}], + "계산값": {"브라우저": "값"}, + } + store.write("labor", "rv", have["판"], sent) + doc = store.read("labor", "rv")["문서"] + assert doc["일위대가번"] == have["문서"]["일위대가번"] + assert doc["계산값"]["줄"][0]["수량출처"] == "없음" + pill = labor.pills(doc)[0]["키"] + area = store.read("area", "rv") + sheet = area["문서"]["시트"][0] + sheet["칸"] = {"B6": {"값": "5"}} + sheet["연결"] = [{"키": pill, "이름": "x", "칸": "B6", "단위": "㎡"}] + store.write("area", "rv", area["판"], area["문서"]) + line = store.read("labor", "rv")["문서"]["계산값"]["줄"][0] + assert (line["수량"], line["수량출처"]) == (5, "칸") + assert line["반영"]["계"] == pytest.approx(line["기준"]["계"] * 5 / 10) + + +def test_틀린_일위대가_저장은_400_파일_그대로(system): + have = store.read("labor", "rv") + with pytest.raises(store.StoreError) as e: + store.write("labor", "rv", have["판"], {**have["문서"], "담은로직": [{"로직": "GF999999"}]}) + assert e.value.status == 400 + assert json.loads((system / "labor" / "rv.json").read_bytes())["담은로직"] == [] + + +# ── 길(`M02_Labor_Router`) ───────────────────────────────────────────── +@pytest.fixture() +def client(tmp_path, monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from common_util.common_util_auth import verify_session + from config import config_system + from M02_MasterTemplete import M02_Labor_Router as labor_router + from M02_MasterTemplete import M02_Template_Layers as layers + + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + (tmp_path / "storage").mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + with store.at(system): + store.create_structure("rv") + app = FastAPI() + app.include_router(labor_router.router) + app.dependency_overrides[verify_session] = lambda: { + "user_id": 42, + "company_id": 7, + "role": "USER", + "is_master": False, + } + return TestClient(app) + + +def test_길_calc_는_저장_안_한_문서와_알약_값으로_셈_알약도_같이(client): + url = "/api/m02/layers/system/labor/rv/calc" + sent = {"담은로직": [{"로직": TEN, "수량": None}], "자재": [{"코드": MAT, "수량": {"손": 1}}]} + got = client.post(url, json={"doc": sent, "수량": {"사석 수상고르기|㎡": 20}}).json() + assert (got["줄"][0]["수량출처"], got["자재"][0]["반영"]) == ("칸", 83000) + assert {p["키"] for p in got["알약"]} == {"사석 수상고르기|㎡", f"자재|{MAT}"} + saved = client.post(url, json={}).json() # 저장본 = 빈 일위대가 + assert saved["줄"] == [] and saved["알약"] == [] + assert client.get("/api/m02/layers/system/labor/rv/pills").json() == {"열": "rv", "알약": []} + + +def test_길_틀린_문서_400_없는_열_404_남의_개인_층_403(client): + url = "/api/m02/layers/{}/labor/{}/calc" + bad = {"doc": {"담은로직": [{"로직": "GF999999"}]}} + assert client.post(url.format("system", "rv"), json=bad).status_code == 400 + assert client.post(url.format("system", "없는열"), json={}).status_code == 404 + other = client.post(url.format("personal", "rv") + "?owner=7/99", json={}) + assert other.status_code == 403 diff --git a/resources/tester/test_m02_structure.py b/resources/tester/test_m02_structure.py index c532c67ca..af31c66f7 100644 --- a/resources/tester/test_m02_structure.py +++ b/resources/tester/test_m02_structure.py @@ -64,8 +64,13 @@ def client(tmp_path, monkeypatch): def _num(code: str) -> dict: - """세 번호 — 같은 8자리.""" - return {"도번": f"SD{code}", "면적번": f"SA{code}", "재료번": f"SM{code}"} + """열 번호 — 같은 뒷자리(일위대가번 SU · 39장).""" + return { + "도번": f"SD{code}", + "일위대가번": f"SU{code}", + "면적번": f"SA{code}", + "재료번": f"SM{code}", + } def test_새로_도번_자동_409_없는_열(client): diff --git a/resources/tester/test_m02_structure_combo.py b/resources/tester/test_m02_structure_combo.py index 4333b2140..68022a738 100644 --- a/resources/tester/test_m02_structure_combo.py +++ b/resources/tester/test_m02_structure_combo.py @@ -86,7 +86,9 @@ def test_집계표_저장_때_새_열_지운_열_고친_열을_맞춤_키_유지 assert (rv["바인딩"], rv["펼침"]) == ({"종류": "revetment"}, True) # 번호는 세 파일과 같음 — 새 열도 [저장] 한 번에 SD · SA · SM 이 섬 numbers = client.get("/api/m02/structures/numbers").json() - assert {k: rows["new1"][k] for k in ("도번", "면적번", "재료번")} == numbers["new1"] + assert {k: rows["new1"][k] for k in ("도번", "일위대가번", "면적번", "재료번")} == numbers[ + "new1" + ] assert rows["new1"]["도번"].startswith("SD") # 또 저장해도(바뀐 것 없음) 키 · 다음 그대로