From a24e0d622cc52a84a9b1396e249da84316b5734d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 14:05:47 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(M02=C2=B7B09=20=EA=B3=A0=EC=A0=95=20?= =?UTF-8?q?=EC=A4=84):=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=A4=84?= =?UTF-8?q?=20=E3=80=8C=EA=B3=A0=EC=A0=95=E3=80=8D=20=EA=B0=88=EB=9E=98=20?= =?UTF-8?q?=C2=B7=20B09=20=EB=8C=80=EA=B0=80=20fixed=20=EC=A4=84=20=C2=B7?= =?UTF-8?q?=20=EC=9D=B4=EC=9D=8C=20=EB=8B=A8=EA=B0=80=20=C2=B7=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84=ED=91=9C=20=EB=B9=A0=EC=A7=90=20=EC=88=98=20(PLAN=207?= =?UTF-8?q?05-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02 일위대가 줄 {고정: 이름 · 규격 · 단위 · 단가 · 이음(노임 · 자재 · 기계) · 출처} — 정리 · 셈(새 파일 M02_Labor_Fixed) · 줄 가름 짝 TS lineId - 사본 단가 묶음 · B09 단가 책 수요에 이음 키 - B09 대가 줄 fixed — 이은 비목만 단가 책 값 · 못 이음 = 줄 단가 · 덮개 먹음 · 빈칸 안 됨(파이썬 · TS 짝) - 집계표 — 이은 고정 줄 = 자원 셈 · 이음 없는 줄 = 빠짐 수(summary.fixed · 안내 「고정 줄 n」) - B09 일위대가표 줄 꼬리표 「고정」 값 넘김 - 시험 test_705_1 12 · test_145_5 응답 칸 목록에 fixed(낡은 단정 고침) Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Calc_Labor.py | 22 ++ B09_Estimation/B09_Estimation_Calc_Labor.ts | 22 +- B09_Estimation/B09_Estimation_Labor_Source.py | 20 ++ B09_Estimation/B09_Estimation_Linked.py | 9 +- B09_Estimation/B09_Estimation_Summary.py | 24 +- .../B09_Estimation_UI_Tabs_Labor.ts | 2 +- .../B09_Estimation_UI_Tabs_Summary.ts | 14 +- M02_MasterTemplete/M02_Item_Source.py | 2 + M02_MasterTemplete/M02_Labor_Doc.py | 18 +- M02_MasterTemplete/M02_Labor_Fixed.py | 123 ++++++++ .../M02_MasterTemplete_Labor_Calc.ts | 29 +- resources/tester/test_145_5_b09_summary.py | 2 +- resources/tester/test_705_1_fixed_line.py | 282 ++++++++++++++++++ 13 files changed, 553 insertions(+), 16 deletions(-) create mode 100644 M02_MasterTemplete/M02_Labor_Fixed.py create mode 100644 resources/tester/test_705_1_fixed_line.py diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.py b/B09_Estimation/B09_Estimation_Calc_Labor.py index 3ebcdc119..93eb2a404 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.py +++ b/B09_Estimation/B09_Estimation_Calc_Labor.py @@ -11,6 +11,8 @@ 대가 줄 = 부른 블록 합계(노무 · 재료 · 경비) · 산근 줄 = 145-1 산근 단가(`sangeun` {키: 값}) 비고 = 대가 줄 「대가n」 · 산근 줄 「산근 n호표」(산근 목록 번호) % 줄 = 밑수(그 블록 노무 줄 금액 합) × % / 100 + 고정 줄(705-1) = 줄 단가(`own`) · 이음(`link`) 있으면 그 비목만 단가 책 값(노임 = 노무 · 자재 = + 재료(관급 · 별도 0원) · 기계 = 중기 세 비목) · 책에 없으면 줄 단가 그대로 · 빈칸 안 됨 끝수 = 엑셀 자리 그대로 — 줄 금액 = ROUNDDOWN(단가 × 수량, 1) · % 줄 = ROUNDDOWN(밑수 × % / 100, 1) · 관급 · 별도계상 자재 = 대가 안 0원 · 블록 비목 합 = ROUNDDOWN(Σ, 0) · 합계 = 세 비목 합. @@ -58,11 +60,31 @@ def _prices(line: dict[str, Any], books: dict[str, Any], sums: dict) -> dict[str ) or {} for part in PARTS: out[part] = None if row.get("blank") else num(row.get(part)) # 빈 줄 대가 = 빈칸 + elif kind == "fixed": + out = _fixed(line, books) else: # blank — 못 펼친 줄 out = dict.fromkeys(PARTS, None) return out +def _fixed(line: dict[str, Any], books: dict[str, Any]) -> dict[str, float | None]: + """고정 줄 단가(705-1) — 줄 단가 위에 이은 비목만 단가 책 값.""" + own, link = line.get("own") or {}, line.get("link") or {} + out = {part: num(own.get(part)) or 0.0 for part in PARTS} + labor = books["labor"].get(link.get("labor")) if link.get("labor") else None + if labor and num(labor.get("price")) is not None: + out["labor"] = num(labor["price"]) + row = books["material"].get(link.get("material")) if link.get("material") else None + if row and (row.get("supply") in SUPPLY or num(row.get("price")) is not None): + out["material"] = 0.0 if row.get("supply") in SUPPLY else num(row["price"]) + machine = books["machine"].get(link.get("machine")) if link.get("machine") else None + if machine and not machine.get("blank"): + for part in PARTS: + if num(machine.get(part)) is not None: + out[part] = num(machine[part]) + return out + + def _base(spec: str, lines: list[dict[str, Any]]) -> float | None: """% 줄 밑수 — `part:비목` 그 비목 줄 금액 합 · `lines:labor` 노무 줄 금액 합 · `line:이름` 그 줄.""" diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.ts b/B09_Estimation/B09_Estimation_Calc_Labor.ts index 50651819c..722b6979a 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.ts +++ b/B09_Estimation/B09_Estimation_Calc_Labor.ts @@ -8,6 +8,8 @@ * * 노무 줄 = 노무비목록표 · 재료 줄 = 재료비목록표 · 중기 줄 = 중기목록표 · 대가 줄 = 부른 블록 합계 · * 산근 줄 = 145-1 산근 단가 · % 줄 = 밑수(노무 줄 금액 합) × % / 100 + * 고정 줄(705-1) = 줄 단가(own) · 이음(link) 있으면 그 비목만 단가 책 값(노임 = 노무 · 자재 = 재료 + * (관급 · 별도 0원) · 기계 = 중기 세 비목) · 책에 없으면 줄 단가 그대로 · 빈칸 안 됨 * 비고 = 대가 줄 「대가n」 · 산근 줄 「산근 n호표」(산근 목록 번호) * 줄 금액 ROUNDDOWN(단가 × 수량, 1) · 블록 비목 합 ROUNDDOWN(Σ, 0) · 합계 = 세 비목 합 * 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락에서 셈) @@ -59,7 +61,25 @@ function linePrices(line: Row, books: Row, sums: Record): Parts { const src: Row = ({ machine: books.machine, daega: sums, sangeun: books.sangeun } as Row)[kind]; const row: Row = src[key] || {}; for (const part of PARTS) out[part] = row.blank ? null : num(row[part]); // 빈 줄 대가 = 빈칸 - } else return blanks(); // blank — 못 펼친 줄 + } else if (kind === "fixed") return fixedPrices(line, books); + else return blanks(); // blank — 못 펼친 줄 + return out; +} + +/** 고정 줄 단가(705-1) — 줄 단가 위에 이은 비목만 단가 책 값(짝 `_fixed`) */ +function fixedPrices(line: Row, books: Row): Parts { + const own: Row = line.own || {}; + const link: Row = line.link || {}; + const out = zeros(); + for (const part of PARTS) out[part] = num(own[part]) ?? 0; + const labor: Row | undefined = link.labor ? books.labor[link.labor] : undefined; + if (labor && num(labor.price) !== null) out.labor = num(labor.price); + const row: Row | undefined = link.material ? books.material[link.material] : undefined; + const supplied = !!row && (SUPPLY as readonly string[]).includes(row.supply); + if (row && (supplied || num(row.price) !== null)) out.material = supplied ? 0 : num(row.price); + const machine: Row | undefined = link.machine ? books.machine[link.machine] : undefined; + if (machine && !machine.blank) + for (const part of PARTS) if (num(machine[part]) !== null) out[part] = num(machine[part]); return out; } diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index c12fbdc2b..6906626af 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -10,6 +10,7 @@ — 조합이 수량만큼 부름 · 기계 = 기계경비 로직 줄 · 다른 돈 로직 줄 = 하위 산근 산근 단가산출 줄(일위대가화 아닌 로직 줄) = 산근 호표 참조(키 = 145-1 `sangeun_key`) (155-3 · 사용자 뜻 = 일위대가화 줄 = 대가 · 단가산출 줄 = 산근 · 145 정함 뒤집음) + 고정 고정 줄(705-1) = `fixed` 줄 하나 · `own` = 줄 단가 · `link` = 이음 키(셈 짝이 단가 책 값) 같은 블록 = 같은 꼴 → 한 호표(id = 내용 해시 · 같은 원본 + 같은 셈 단가 = 하나) · 덮개 열쇠(702-2 · 내용 해시 밖 · 수량 · 단가가 바뀌어도 그대로) = 블록 `cover` + 줄 `cid` — @@ -34,6 +35,7 @@ from B09_Estimation import B09_Estimation_Sangeun as sg from B09_Estimation import B09_Estimation_Sangeun_Text as st from M02_MasterTemplete import M02_Item_Prices as item_prices from M02_MasterTemplete import M02_Labor_Doc as labor_doc +from M02_MasterTemplete import M02_Labor_Fixed as fixed from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete.M02_Item_Snapshot import ( MANIFEST_NAME, @@ -194,6 +196,8 @@ class Builder: q = _f(qty) t = _f(times) or 1.0 each = None if q is None else q / t + if fixed.is_fixed(one): + return fixed_line(one[fixed.FIXED], q) ref = one.get("참조") if isinstance(ref, dict): doc = copies.get(refs.ref_id(ref)) @@ -324,6 +328,22 @@ class Builder: return self.precombo(whole, ref, doc, copies, 1) +def fixed_line(fx: dict, qty: Any) -> dict[str, Any]: + """고정 줄(705-1) → 블록 줄 `fixed` — 수량 그대로(배 1) · 이음 없는 비목 = 줄 단가.""" + link = fx.get("이음") or {} + return { + "kind": "fixed", + **{k: fx.get(v) or "" for k, v in (("name", "이름"), ("spec", "규격"), ("unit", "단위"))}, + "qty": _f(qty), + "own": {PART[k]: _f(v) for k, v in (fx.get("단가") or {}).items() if k in PART}, + "link": { + "labor": link.get("노임"), + "material": link.get("자재"), + "machine": link.get("기계"), + }, + } + + def _material(whole: Any, mat: dict, qty: Any) -> dict[str, Any]: """자재 줄 — 조합 사용자 자재 · 사전조합 자재(175-2) 한 꼴 · 이름 · 규격 · 단위 = 사본 단가.""" row = (whole.get(mat.get("코드")) if mat.get("코드") else None) or {} diff --git a/B09_Estimation/B09_Estimation_Linked.py b/B09_Estimation/B09_Estimation_Linked.py index f1776fdaf..2ea2e6a7f 100644 --- a/B09_Estimation/B09_Estimation_Linked.py +++ b/B09_Estimation/B09_Estimation_Linked.py @@ -4,7 +4,7 @@ 사본마다 `일위대가.json` 담은 로직을 그 사본 단가 묶음(`단가.json`) · 입력값 · 고른자재로 돌리며 실제로 부른 자원 줄(인력 LB · 기계 EQ · 자재 MT · 유가 MO)만 모음 — 단가 묶음 통째 (고르기 후보 전부)는 안 씀 · 값은 쓰지 않음(단가는 원가 한 벌 `price_book.json` 이 맡음) · -사본 일위대가 자재 · 굳힌 사전조합 자재 코드도(183-1 · 로직을 안 돌아 `get` 에 안 걸림). +사본 일위대가 자재 · 굳힌 사전조합 자재 코드 · 고정 줄 이음 키도(183-1 · 705-1 · 로직을 안 돌아 `get` 에 안 걸림). 고르기가 훑은 후보 줄도 걸림 → 원가 길이 두 번 셈해 쓰는 줄만 남김(`Summary.used_materials`). 구조물 조합 사본 없으면 그 구조물은 없음 · 일위대가 없는 사본은 자원 없음. """ @@ -16,6 +16,7 @@ from typing import Any from M02_MasterTemplete import M02_Item_Prices as item_prices from M02_MasterTemplete import M02_Labor_Doc as labor_doc +from M02_MasterTemplete import M02_Labor_Fixed as fixed from M02_MasterTemplete.M02_Item_Snapshot import ( PICKS_FILE, VALUES_FILE, @@ -59,8 +60,10 @@ def used_keys(folder: Path) -> list[str]: labor_doc.run_line( snapshot, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름") ) - for mat in [*(doc.get("자재") or []), *(m for d in inner for m in d.get("자재") or [])]: - code = str(mat.get("코드") or "") if isinstance(mat, dict) else "" + mats = [*(doc.get("자재") or []), *(m for d in inner for m in d.get("자재") or [])] + codes = [str(mat.get("코드") or "") if isinstance(mat, dict) else "" for mat in mats] + codes += [k for d in [doc, *inner] for k in fixed.link_keys(d)] # 705-1 고정 줄 이음 + for code in codes: if code[:2] in RESOURCE_TABLES and code not in seen: seen.append(code) return seen diff --git a/B09_Estimation/B09_Estimation_Summary.py b/B09_Estimation/B09_Estimation_Summary.py index 9a660b718..965c4ea12 100644 --- a/B09_Estimation/B09_Estimation_Summary.py +++ b/B09_Estimation/B09_Estimation_Summary.py @@ -9,6 +9,8 @@ 4 자재대 · 관급자재대 자재 줄 = 그 자재 그대로(호표 없음) 연결 없는 항목(수량 · 호표 없음 · 호표 구성 없음 — 조합 밖 토공 · 운반 산근 등) = 전개 안 함 · 「연결 없음 n」(`unlinked`) + 고정 줄(705-1) = 이은 노임 · 자재 · 기계 키 자원으로 셈 · 이음 없는 줄 = 집계에서 빠짐 · + 「고정 줄 n」(`fixed` · 빠진 줄 수) 끝수 = 엑셀 자리 그대로 — 총수량 ROUND 2 · 금액 = ROUND(수량 × 단가, 0)(여기만 반올림) · 중기 = 노무 · 재료 · 경비 각 ROUND(시간 × 시간당, 0) · 합계 = 셋 합. 단가 = 목록표(노무비 · 재료비 · 경비 · 중기목록표) — 없으면 금액 빈칸(수량은 둠). @@ -39,7 +41,7 @@ def expand( kind = line.get("kind") if kind == "hopyo": expand(lines_of, str(line.get("key")), qty * q, out, depth + 1) - elif kind in KINDS: + elif kind in (*KINDS, "fixed"): bucket = out.setdefault(kind, {}) bucket[str(line.get("key"))] = bucket.get(str(line.get("key")), 0.0) + qty * q @@ -123,6 +125,7 @@ def compute_summary(sheets: dict[str, Any], lines_of: dict[str, Any]) -> dict[st used, unlinked = totals(sheets, lines_of) return { "unlinked": unlinked, + "fixed": len(used.get("fixed", {})), "sum_material": _resource_sheet(used.get("material", {}), _list(sheets["material"]), ""), "sum_labor": _resource_sheet(used.get("labor", {}), _list(sheets["labor"]), "인"), "sum_expense": _resource_sheet(used.get("expense", {}), _list(sheets["expense"]), ""), @@ -147,6 +150,9 @@ def block_lines(blocks: list[dict[str, Any]] | None) -> dict[str, Any]: for block in blocks or []: lines = [] for line in block.get("lines") or []: + if line.get("kind") == "fixed" and num(line.get("qty")) is not None: + lines += _fixed_lines(block, line) + continue kind = LINE_KIND.get(line.get("kind")) key = (line.get("ref") or {}).get("key") if kind and key and num(line.get("qty")) is not None: @@ -155,6 +161,16 @@ def block_lines(blocks: list[dict[str, Any]] | None) -> dict[str, Any]: return out +def _fixed_lines(block: dict[str, Any], line: dict[str, Any]) -> list[dict[str, Any]]: + """고정 줄(705-1) → 이은 자원 줄(기계 = 중기 하나) · 이음 없음 = `fixed` 줄(빠짐 셈).""" + link = line.get("link") or {} + pairs = [("machine", link.get("machine"))] if link.get("machine") else [] + pairs = pairs or [(k, link.get(k)) for k in ("labor", "material") if link.get(k)] + if not pairs: + pairs = [("fixed", f"{block['key']}|{line.get('cid') or line.get('name')}")] + return [{"kind": k, "key": str(v), "qty": line["qty"]} for k, v in pairs] + + def used_materials(sheets: dict[str, Any], inputs: dict[str, Any], user: dict[str, Any]) -> set: """재료비목록표에 남길 자재 키(183-1) — 대가 · 산근 · 중기 블록 재료 줄(수량 빈칸 · 0 도 = 집계표 역전개와 같은 줄) · B08 총괄 줄(수량 있는 입력) · 관급 · 별도 표시 줄. @@ -167,6 +183,12 @@ def used_materials(sheets: dict[str, Any], inputs: dict[str, Any], user: dict[st for line in block.get("lines") or [] if line.get("kind") == "material" and (line.get("ref") or {}).get("key") } + keep |= { # 705-1 고정 줄이 이은 자재 + str(line["link"]["material"]) + for block in blocks + for line in block.get("lines") or [] + if line.get("kind") == "fixed" and (line.get("link") or {}).get("material") + } marks = user.get("materials") or {} for m in inputs.get("materials") or []: if "qty" in m or m.get("supply") or (marks.get(m["key"]) or {}).get("supply"): diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts index 73f6e7a43..074f3bc64 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts @@ -100,7 +100,7 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => { prices: { sum: ln.price, ...ln.prices }, amounts: { sum: ln.amount, ...ln.amounts }, note: ln.ref_no || ln.why || "", - tag: HOPYO_TAG.pum, + tag: ln.kind === "fixed" ? HOPYO_TAG.fix : HOPYO_TAG.pum, // 705-1 고정 줄 })), })), ); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts index 6bbc83213..489268092 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts @@ -4,6 +4,7 @@ * * 값 = 서버 단독 역전개(`B09_Estimation_Summary` · GET 응답 `summary`) — 조작 중엔 안 바뀜 · [저장] 뒤 정본 * 끝수 = 총수량 ROUND 2 · 금액 ROUND(수량 × 단가, 0) · 줄 누름 = 단가를 낸 목록표 줄 + * 고정 줄(705-1) 이음 없는 줄 = 집계에서 빠짐 — 안내 「고정 줄 n」(서버 `summary.fixed`) * 경비 집계표 빈 까닭(166-5) = 산근 · 대가 어느 길도 경비 자원 줄을 안 냄(기계 → 중기 · 구역화물 → 자재) * ========================================================================== */ @@ -22,9 +23,14 @@ import { const HINT = "줄을 누르면 목록표로 이동"; /** 안내 + 「빈 줄 n」(설계내역서와 같은 수 · 701-2 — 금액 모르는 줄은 집계에서 빠짐) */ -const hint = (sheets: Row, extra = ""): string => { +const hint = (sheets: Row, payload: Row): string => { const n = blankRows(sheets).length; - return `${HINT}${extra}${n ? ` · 빈 줄 ${n} — 집계에서 빠짐(좌측 데이터 점검)` : ""}`; + const fixed = Number((payload.summary || {}).fixed) || 0; + return [ + HINT, + ...(n ? [`빈 줄 ${n} — 집계에서 빠짐(좌측 데이터 점검)`] : []), + ...(fixed ? [`고정 줄 ${fixed} — 이은 단가 없어 집계에서 빠짐`] : []), + ].join(" · "); }; /** 줄이 없을 때 표 위 맨 앞 안내 */ @@ -48,7 +54,7 @@ function resourceTab(id: string, list: TabId, unitPrice: string, none = ""): Tab rows, total: rows.length ? { name: "계", amount: sum } : null, frozen: 2, - hint: !rows.length && none ? `${none} · ${hint(sheets)}` : hint(sheets), + hint: !rows.length && none ? `${none} · ${hint(sheets, payload)}` : hint(sheets, payload), link: (i) => (rows[i] ? { tab: list, key: String(rows[i].key) } : null), }; }; @@ -86,7 +92,7 @@ export const buildSumMachine: TabBuild = ({ payload, sheets }): TabTable => { } : null, frozen: 2, - hint: hint(sheets), + hint: hint(sheets, payload), link: (i) => (rows[i] ? { tab: "machine", key: String(rows[i].key) } : null), }; }; diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py index 597cb086c..985a1cb11 100644 --- a/M02_MasterTemplete/M02_Item_Source.py +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -159,6 +159,7 @@ def price_bundle(labor: dict[str, Any] | None, root: Path) -> dict[str, Any]: """사본 단가 묶음(`단가.json`) — 담은 로직 · 굳힌 참조 로직 · 자재 · 고른 자재 · 사전조합 자재 · 개인 자재(`root` = 원본 층 · B09 설계내역서 서고 사본도 이 길 · 204-2).""" from M02_MasterTemplete import M02_Item_Prices as prices + from M02_MasterTemplete import M02_Labor_Fixed as fixed from M02_MasterTemplete import M02_Labor_Ref as refs logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])] @@ -169,6 +170,7 @@ def price_bundle(labor: dict[str, Any] | None, root: Path) -> dict[str, Any]: str(key) for one in doc.get("담은로직") or [] for key in (one.get("고름") or {}).values() ] materials += refs.frozen_materials((labor or {}).get("참조사본") or {}) # 175-2 사전조합 자재 + materials += fixed.link_keys(doc) # 705-1 고정 줄이 잇는 노임 · 자재 · 기계 wanted = logic_keys + [code for code in materials if code] bundle = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {} if mine := own.bundle( diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 655ec4775..d838ceb5c 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -25,6 +25,7 @@ 빈 칸 = 읽을 때 `MARK_DEFAULT`(`mark_of` · 저장은 비움 · 옛 「일반」 = 버림 → 사급 · 162-1). 별도계상(222-1) = 그 자재 금액은 기준 · 합에서 빠지고 로직 `줄` 에서 지움(`drop_apart` · `settle` · 짝 `Labor_Calc.ts`) · `재료` · `자재`(자재목록 · B08 수량)엔 남음. +고정 줄(705-1) = `{고정: {이름 · 규격 · 단위 · 단가 · 이음 · 출처}}` — 숫자 그대로 · 셈 `M02_Labor_Fixed`. % 덧줄(149-4) = 식 「X * Y / 100」 덧줄에 덧칸 `율`(Y) · `규격`(「노무비의 %」 등 · `pct_extras`) — 표기만 · 단위 · 수량 · 금액 그대로(「식 · 1」) · 화면이 율 · % 로 그림 · M01 시험계산 표도 이 함수. """ @@ -38,6 +39,7 @@ 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_Labor_Fixed as fixed from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -101,6 +103,15 @@ def clean(doc: Any) -> dict[str, Any]: where = f"단가산출 로직 {at}째" if not isinstance(one, dict): raise _bad(f"{where} · 묶음 아님") + if fixed.is_fixed(one) and not one.get("로직"): + fx = fixed.clean(one[fixed.FIXED], where, whole) + if fixed.fixed_id(fx) in seen: + raise _bad(f"{where} · 같은 고정 줄을 두 번 담음 「{fx['이름']}」") + seen.add(fixed.fixed_id(fx)) + memo, alias = one.get("메모"), _alias(one.get("별칭"), where) + line = {"고정": fx, "별칭": alias, "메모": str(memo) if memo else None} + lines.append({**line, "수량": _qty(one.get("수량"), where)}) + continue if one.get("참조") is not None and not one.get("로직"): here = here or refs.here() ref = refs.check_ref(one["참조"], where, here) @@ -407,7 +418,9 @@ def _is_ref(one: Any) -> bool: def line_id(one: dict) -> Any: - """줄 가름 — 로직 키 · 참조 줄은 `ref_id`(짝 `Labor_Calc.ts` `lineId`).""" + """줄 가름 — 로직 키 · 참조 줄은 `ref_id` · 고정 줄은 `fixed_id`(짝 `Labor_Calc.ts` `lineId`).""" + if fixed.is_fixed(one): + return fixed.fixed_id(one[fixed.FIXED]) return refs.ref_id(one["참조"]) if _is_ref(one) else one.get("로직") @@ -493,6 +506,9 @@ def calc( if _is_ref(one): lines.append(ref_line(one["참조"], expand(one["참조"]))) continue + if fixed.is_fixed(one): + lines.append(fixed.line(whole, one[fixed.FIXED])) + continue code = one["로직"] pill = by_logic.get(code) or {} line = { diff --git a/M02_MasterTemplete/M02_Labor_Fixed.py b/M02_MasterTemplete/M02_Labor_Fixed.py new file mode 100644 index 000000000..32f76da8d --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Fixed.py @@ -0,0 +1,123 @@ +"""M02 일위대가 고정 줄(PLAN 705-1 · 요구서 1장 · 4장 1 · 2 · 3) — 품 · 수량 · 단가 숫자를 그대로 적어 둔 줄. + +줄 = `{고정: {이름, 규격, 단위, 단가: {노무비, 재료비, 경비}, 이음: {노임, 자재, 기계}, 출처?}, +별칭, 메모, 수량}` — 담은로직 안 로직 · 참조 줄과 섞임(줄 단위 꼬리표 · 기본 = 품셈). +단가 = 그 줄 단위 1 개 값 · 빈 비목 = 0 · 합계만 있는 줄 = 고른 비목 하나(기본 재료비 · 붙이는 쪽 몫). +이음 = 이 공사 노임(LB) · 자재(MT) · 기계(EQ) 키 · 못 이음 = null(「손 단가」) — 이은 비목만 단가 책 값 +(노임 새 판 때 따라감) · 품(수량)은 그대로. +M02 셈(층 · 사본) = 노임 「값」 · 자재 기준단가로 이음 · 기계 = 줄 단가(기계 시간 단가 = B09 단가 책 몫). +출처 = `{파일, 호표, 줄, 판}`(글 · 수만) · 줄 가름 = 「고정|이름|규격|단위」(짝 `Labor_Calc.ts` `lineId`). +B09 대가 갈래 `fixed` = `B09_Estimation_Labor_Source.fixed_line` · 셈 `B09_Estimation_Calc_Labor`. +""" + +from __future__ import annotations + +from decimal import Decimal, InvalidOperation +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 + +mf = m01.cm.mf +mm = mf.mp.mm +COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비 +FIXED = "고정" +LINKS = {"노임": "LB", "자재": "MT", "기계": "EQ"} +SOURCE_KEYS = ("파일", "호표", "줄", "판") + + +def _bad(text: str) -> Exception: + from M02_MasterTemplete import M02_MasterTemplete_Store as store + + return store.StoreError(400, text) + + +def _num(raw: Any) -> 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 _plain(value: Decimal) -> int | float: + return int(value) if value == value.to_integral_value() else float(value) + + +def is_fixed(one: Any) -> bool: + return isinstance(one, dict) and isinstance(one.get(FIXED), dict) + + +def fixed_id(fx: dict) -> str: + return f"고정|{fx.get('이름') or ''}|{fx.get('규격') or ''}|{fx.get('단위') or ''}" + + +def clean(fx: Any, where: str, whole: Any) -> dict[str, Any]: + """고정 칸 정리 — 이름 없음 · 단가가 수 아님 · 이음 키 머리 틀림 · M01 에 없는 이음 = 400.""" + if not isinstance(fx, dict): + raise _bad(f"{where} · 고정 줄이 묶음 아님") + name = str(fx.get("이름") or "").strip() + if not name: + raise _bad(f"{where} · 고정 줄 이름 없음") + raw = fx.get("단가") or {} + if not isinstance(raw, dict): + raise _bad(f"{where} · 고정 줄 단가는 {{노무비, 재료비, 경비}}") + price = {} + for k in COSTS: + got = _num(raw.get(k)) + if got is None and raw.get(k) not in (None, ""): + raise _bad(f"{where} · 고정 줄 {k} 단가가 수 아님") + price[k] = _plain(got) if got is not None else 0 + given = fx.get("이음") or {} + link = {} + for k, head in LINKS.items(): + code = str(given.get(k) or "") if isinstance(given, dict) else "" + if code and (code[:2] != head or not _has(whole, code)): + raise _bad(f"{where} · 고정 줄 {k} 이음 「{code}」 M01 에 없음") + link[k] = code or None + src = fx.get("출처") if isinstance(fx.get("출처"), dict) else {} + kept = {k: v for k in SOURCE_KEYS if isinstance(v := src.get(k), (str, int, float))} + out = {"이름": name, "규격": str(fx.get("규격") or ""), "단위": str(fx.get("단위") or "")} + return {**out, "단가": price, "이음": link, **({"출처": kept} if kept else {})} + + +def _has(whole: Any, code: str) -> bool: + try: + return bool(whole.get(code)) + except mf.FormulaError: + return False + + +def link_keys(doc: Any) -> list[str]: + """문서 고정 줄이 잇는 키 — 사본 `단가.json` · B09 단가 책 수요(705-1).""" + out: list[str] = [] + for one in (doc or {}).get("담은로직") or []: + if is_fixed(one): + out += [v for v in (one[FIXED].get("이음") or {}).values() if v and v not in out] + return out + + +def line(whole: Any, fx: dict) -> dict[str, Any]: + """고정 줄 계산 답 — 기준 = 줄 단가 · 이은 노임 · 자재는 `whole` 값(없으면 줄 단가 그대로) · + 분모 = 줄 단위 · 배 1 · `이은` = 단가 책 값을 쓴 비목.""" + base = {k: _num((fx.get("단가") or {}).get(k)) or Decimal(0) for k in COSTS} + link, used = fx.get("이음") or {}, [] + if code := link.get("노임"): + try: + got = _num((whole.get(code) or {}).get("값")) + except mf.FormulaError: + got = None + if got is not None: + base["노무비"], used = got, [*used, "노무비"] + if code := link.get("자재"): + try: + got = _num(mm.element(whole, code, {}, fx.get("단위") or None)[0]) + except mf.FormulaError: + got = None + if got is not None: + base["재료비"], used = got, [*used, "재료비"] + # ponytail: 기계 이음은 M02 셈에서 줄 단가 그대로 — 기계 시간 단가 셈은 B09 중기목록표 몫 + money = {**base, "계": sum(base.values())} + head = {FIXED: fx, "이름": fx.get("이름"), "알약": None, "분모": fx.get("단위"), "배": 1} + return {**head, "기준": money, "이은": used, "까닭": None} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts index cea35ce81..c678a808a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -5,7 +5,8 @@ * 기준(결과단위 그대로)은 서버 calc 길이 셈 · 여기는 받은 기준에 * 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만. * 별도계상 자재(222-1 · 빈 칸 = 기본 별도계상) = 반영은 보이되 합에 안 더함(짝 `apart`). - * 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`). */ + * 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`) · + * 고정 줄(705-1)은 「고정|이름|규격|단위」(짝 `M02_Labor_Fixed.fixed_id`) · 기준 = 서버가 낸 줄 단가. */ import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import type { LogicMat } from "./M02_Labor_LogicMats_Model"; @@ -23,10 +24,23 @@ export interface LaborRef { 키: string; } +/** 고정 줄(705-1) — 숫자 그대로 · 이음 = 이 공사 노임 · 자재 · 기계 키(못 이음 = null) */ +export interface LaborFixed { + 이름: string; + 규격?: string; + 단위?: string; + 단가: Record<"노무비" | "재료비" | "경비", number>; + 이음?: Record<"노임" | "자재" | "기계", string | null>; + 출처?: Record; +} + export interface CalcLine { /** 참조 줄은 서버 답에 없음(빈 글로 다룸) */ 로직: string; 참조?: LaborRef; + 고정?: LaborFixed; + /** 고정 줄 — 단가 책 값을 쓴 비목 */ + 이은?: string[]; 알약?: string | null; 분모?: string | null; 배: number; @@ -79,7 +93,7 @@ export interface LaborCalc { /** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 */ export interface LaborQtyDoc { - 담은로직?: { 로직?: string; 참조?: LaborRef; 수량?: Qty }[]; + 담은로직?: { 로직?: string; 참조?: LaborRef; 고정?: LaborFixed; 수량?: Qty }[]; 자재?: { 코드: string; 수량?: Qty; 별도계상?: string }[]; } @@ -89,8 +103,15 @@ function num(raw: unknown): number | null { return String(raw).trim() !== "" && Number.isFinite(n) ? n : null; } -/** 줄 가름 — 로직 키 · 참조 줄은 「참조|층|주인|키」(짝 `M02_Labor_Doc.line_id`) */ -export function lineId(one: { 로직?: string; 참조?: LaborRef }): string | undefined { +/** 줄 가름 — 로직 키 · 참조 줄은 「참조|층|주인|키」 · 고정 줄은 「고정|이름|규격|단위」 + * (짝 `M02_Labor_Doc.line_id`) */ +export function lineId(one: { + 로직?: string; + 참조?: LaborRef; + 고정?: LaborFixed; +}): string | undefined { + const fx = one.고정; + if (fx) return `고정|${fx.이름 ?? ""}|${fx.규격 ?? ""}|${fx.단위 ?? ""}`; if (one.참조) return `참조|${one.참조.층}|${one.참조.주인 ?? ""}|${one.참조.키}`; return one.로직; } diff --git a/resources/tester/test_145_5_b09_summary.py b/resources/tester/test_145_5_b09_summary.py index 3bde701e8..63ea7ae64 100644 --- a/resources/tester/test_145_5_b09_summary.py +++ b/resources/tester/test_145_5_b09_summary.py @@ -95,7 +95,7 @@ def test_GET_응답에_집계표(tmp_path, monkeypatch): monkeypatch.setattr(router_mod, "material_sheets", fake_materials) body = asyncio.run(router_mod.payload("p", tmp_path)) assert set(body["summary"]) == { - "sum_material", "sum_labor", "sum_expense", "sum_machine", "unlinked", + "sum_material", "sum_labor", "sum_expense", "sum_machine", "unlinked", "fixed", # 705-1 고정 줄 빠짐 수 } # fmt: skip assert body["summary"]["sum_material"] == [] # 「별도」 · 「관급」 표시 전엔 내역 자재 줄 없음 diff --git a/resources/tester/test_705_1_fixed_line.py b/resources/tester/test_705_1_fixed_line.py new file mode 100644 index 000000000..2748949b1 --- /dev/null +++ b/resources/tester/test_705_1_fixed_line.py @@ -0,0 +1,282 @@ +"""705-1 고정 줄 갈래 — 품 · 수량 · 단가 숫자를 그대로 적어 둔 일위대가 줄(요구서 1장 · 4장 1 · 2 · 3). + +- M02 일위대가 줄 `{고정: {이름 · 규격 · 단위 · 단가 · 이음 · 출처}}` — 정리 · 셈 · 거울(`settle` 파이썬 ↔ TS) +- B09 대가 줄 `fixed` — 이음 있으면 이 공사 단가 책 값 · 없으면 줄 단가 · 덮개 · 거울(Calc_Labor 파이썬 ↔ TS) +- 노임 새 판 = 이은 비목만 따라감 · 품(수량) 그대로 · 원가계산서 빈 줄 0 · 집계표 이음 · 빠짐 수 +견본 = `test_145_2_b09_labor_calc`(엑셀 바탕 표 · 사본 꼴) · `test_m02_labor_calc_mirror`(TS 헬퍼). +""" + +import copy +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor, daega_price # noqa: E402 +from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402 +from B09_Estimation.B09_Estimation_Linked import linked_resource_keys # noqa: E402 +from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines # noqa: E402 +from M02_MasterTemplete import M02_Item_Prices as item_prices # noqa: E402 +from M02_MasterTemplete import M02_Labor_Doc as labor # noqa: E402 +from M02_MasterTemplete import M02_Labor_Fixed as fixed # noqa: E402 +from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402 +from M02_MasterTemplete import M02_Item_Snapshot as snapshots # noqa: E402 + + +def _load(name): + spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(f"{name}.py")) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +b145 = _load("test_145_2_b09_labor_calc") +m02 = _load("test_m02_labor_calc_mirror") + +LB = "LB000033" # 메쌓기 인력 줄 직종(견본 사본 · M01 에 있음) + + +def _fx(name, price, link=None, unit="인"): + return {"이름": name, "규격": "", "단위": unit, "단가": price, "이음": link or {}} + + +# ── M02 일위대가 줄 ──────────────────────────────────────────────────────── +def test_M02_정리_꼴과_막힘(): + doc = labor.clean( + { + "담은로직": [ + {"로직": m02.TEN, "수량": None}, + { + "고정": { + **_fx("보통인부", {"노무비": "1,000"}, {"노임": LB}), + "출처": {"파일": "a.xlsx", "호표": 3, "몰래": 1}, + }, + "수량": {"손": 0.5}, + }, + ] + } + ) + line = doc["담은로직"][1] + assert line["고정"] == { + "이름": "보통인부", "규격": "", "단위": "인", + "단가": {"노무비": 1000, "재료비": 0, "경비": 0}, + "이음": {"노임": LB, "자재": None, "기계": None}, + "출처": {"파일": "a.xlsx", "호표": 3}, + } # fmt: skip + assert line["수량"] == {"손": 0.5} and labor.line_id(line) == "고정|보통인부||인" + bad = [ + [{"고정": _fx("", {})}], # 이름 없음 + [{"고정": _fx("a", {"노무비": "글"})}], # 단가가 수 아님 + [{"고정": _fx("a", {}, {"노임": "MT000008"})}], # 이음 머리 틀림 + [{"고정": _fx("a", {}, {"자재": "MT999999"})}], # M01 에 없음 + [{"고정": _fx("a", {})}, {"고정": _fx("a", {"재료비": 1})}], # 같은 줄 두 번 + ] + for lines in bad: + with pytest.raises(store.StoreError) as e: + labor.clean({"담은로직": lines}) + assert e.value.status == 400 + + +def _mixed_doc(): + """섞인 호표 — 로직 줄 셋 + 고정 줄(이은 노임 · 이은 자재 · 손 단가) + 자재.""" + return labor.clean( + { + "열": "rv", + "담은로직": [ + {"로직": m02.TEN, "수량": None}, + {"고정": _fx("석공", {"노무비": 1}, {"노임": LB}), "수량": {"손": 0.3}}, + {"로직": m02.THOUSAND, "수량": {"손": 4}}, + { + "고정": _fx("자갈", {"재료비": 1}, {"자재": m02.MAT}, unit=""), + "수량": {"손": 2}, + }, + {"고정": _fx("잡재료", {"재료비": 1234.5}, unit="식"), "수량": None}, + {"로직": m02.PLAIN, "수량": None}, + ], + "자재": [{"코드": m02.MAT, "수량": None}], + } + ) + + +@pytest.mark.parametrize( + "values", + [{}, {"사석 수상고르기|㎡": 2.5, f"자재|{m02.MAT}": "3"}, {"나무식재|본": "1,200"}], +) +def test_M02_섞인_호표_파이썬과_TS_가_같은_답(values): + doc = _mixed_doc() + answer = labor.calc(doc, {}) + py = labor.settle(answer, doc, values) + m02._same(py, m02._ts(answer, doc, values)) + assert py == labor.calc(doc, values) + stone, gravel, misc = (ln for ln in py["줄"] if "고정" in ln) + whole = labor.m01.loaded()[1] + assert stone["기준"]["노무비"] == whole.get(LB)["값"] and stone["이은"] == ["노무비"] + assert stone["반영"]["노무비"] == pytest.approx(stone["기준"]["노무비"] * 0.3) + assert gravel["이은"] == ["재료비"] and gravel["기준"]["재료비"] != 1 + assert misc["기준"] == {"노무비": 0, "재료비": 1234.5, "경비": 0, "계": 1234.5} + assert ( + misc["이은"] == [] and misc["수량"] is None and misc["반영"] is None + ) # 손 단가 · 수량 없음 + + +def test_M02_사본_셈_이음_단가_묶음(tmp_path): + """사본 `단가.json` 에 이은 키가 실림(price_bundle) · 사본 셈 = 층 셈.""" + from M02_MasterTemplete.M02_Item_Source import price_bundle + + doc = _mixed_doc() + bundle = price_bundle({"로직": [], "줄": doc}, tmp_path) + got = item_prices.load_prices(bundle) + assert got.get(LB)["값"] == labor.m01.loaded()[1].get(LB)["값"] + snap = item_prices.calc({"줄": doc}, bundle, {}, {}) + fixed_lines = [ln for ln in snap["줄"] if "고정" in ln] + assert [ln["이은"] for ln in fixed_lines] == [["노무비"], ["재료비"], []] + + +# ── B09 대가 셈(바탕 표 = 엑셀 견본) ────────────────────────────────────────── +def _fixed_blocks(): + """조합 블록 — 노무 줄 + 고정 줄 넷(이은 노임 · 이은 관급 자재 · 이은 중기 · 손 단가).""" + own = {"labor": 100.0, "material": 200.0, "expense": 300.0} + lines = [ + {"kind": "labor", "key": "L00017", "name": "보통인부", "qty": 0.1}, + {"kind": "fixed", "name": "석공", "qty": 0.2, "own": own, + "link": {"labor": "L00005", "material": None, "machine": None}}, + {"kind": "fixed", "name": "파형강관", "qty": 1, "own": own, + "link": {"labor": None, "material": "M00222", "machine": None}}, + {"kind": "fixed", "name": "굴착기", "qty": 0.5, "own": own, + "link": {"labor": None, "material": None, "machine": "X00044"}}, + {"kind": "fixed", "name": "잡재료", "qty": 2, "own": {"material": 1234.5}, + "link": {"labor": "L99999", "material": None, "machine": None}}, # 책에 없음 = 손 단가 + ] # fmt: skip + for n, ln in enumerate(lines): + ln["cid"] = f"{ln['kind']}:{ln.get('key') or ln['name']}#0" + ln["n"] = n + block = {"id": "DGF", "cover": "S:f", "kind": "조합", "name": "섞인", "spec": "", "unit": "m", + "cite": "", "origin": {}, "lines": lines} # fmt: skip + return {"blocks": [block], "owners": {"pipe@100.0": "DGF"}} + + +def _line(out, name): + return next(ln for ln in out["daega_blocks"][0]["lines"] if ln["name"] == name) + + +def test_B09_고정_줄_단가_이음과_손_단가(): + out = compute_labor(_fixed_blocks(), b145.SHEETS, {}) + assert _line(out, "석공")["prices"] == {"labor": 258935, "material": 200, "expense": 300} + assert _line(out, "파형강관")["prices"]["material"] == 0 # 관급 = 대가 안 0원 + assert _line(out, "굴착기")["prices"] == {"labor": 47231, "material": 7784, "expense": 9994} + misc = _line(out, "잡재료") + assert misc["prices"] == {"labor": 0, "material": 1234.5, "expense": 0} + assert misc["amounts"]["material"] == 2469.0 + block = out["daega_blocks"][0] + assert block["blank"] == 0 and out["gaps"] == [] # 빈칸 규칙에 안 걸림 + assert daega_price(out, "pipe@100.0")["sum"] == block["sum"] is not None + + +def test_B09_노임_새_판_이은_단가만_따라감(): + old = compute_labor(_fixed_blocks(), b145.SHEETS, {}) + sheets = copy.deepcopy(b145.SHEETS) + for row in sheets["labor"]: + row["price"] = round(row["price"] * 1.05) + new = compute_labor(_fixed_blocks(), sheets, {}) + assert _line(new, "석공")["prices"]["labor"] == round(258935 * 1.05) + assert _line(new, "석공")["qty"] == _line(old, "석공")["qty"] == 0.2 # 품 그대로 + for name in ("파형강관", "굴착기", "잡재료"): + assert _line(new, name)["prices"] == _line(old, name)["prices"] + + +def test_B09_덮개도_고정_줄에_먹음(): + cover = {"S:f": {"fixed:잡재료#0": {"qty": 3, "material": 1000}}} + out = compute_labor(_fixed_blocks(), b145.SHEETS, {}, cover) + misc = _line(out, "잡재료") + assert misc["qty"] == 3 and misc["prices"]["material"] == 1000 + assert misc["base"] == {"qty": 2, "labor": 0, "material": 1234.5, "expense": 0} + assert out["daega_orphans"] == [] + + +@pytest.mark.parametrize("cover", [None, {"S:f": {"fixed:석공#0": {"labor": 1}}}]) +def test_B09_거울_파이썬_TS(cover): + daega = _fixed_blocks() + py = json.loads(json.dumps(compute_labor(daega, b145.SHEETS, {}, cover), ensure_ascii=False)) + ts = b145._ts(daega, b145.SHEETS, {}, cover) + assert b145._same(py, ts["out"]) + assert b145._same(daega_price(py, "pipe@100.0"), ts["price"]) + + +# ── 사본 → 대가 블록 → 내역 · 원가 · 집계표 한 바퀴 ───────────────────────────── +def _snap(root, owner, key, lines): + folder = snapshots.structures_dir(root) / snapshots.folder_name(owner) + folder.mkdir(parents=True) + + def write(name, data): + (folder / name).write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + + doc = labor.clean({"단위": "m", "담은로직": lines}) + codes = [one["로직"] for one in lines if "로직" in one] + write(snapshots.MANIFEST_NAME, {"원본": {**b145._item(key), "경로": ["기슭막이", key]}}) + write("단가.json", item_prices.to_json(item_prices.collect(codes, fixed.link_keys(doc)))) + write("일위대가.json", {"줄": doc}) + write("입력값.json", b145._values(b145.STONE)) + + +def test_한_바퀴_사본_고정_줄_원가_빈칸_0_집계표(tmp_path): + from B09_Estimation.B09_Estimation_Router import compute_sheets + + root = b145._project(tmp_path / "loop", [(100, "SS000901")]) + lines = [ + {"로직": "GF000219", "승격": True, "수량": {"손": 1}}, # 품셈 줄(메쌓기) + {"고정": _fx("석공", {"노무비": 1}, {"노임": LB}), "수량": {"손": 0.5}}, # 이은 노임 + {"고정": _fx("잡재료", {"재료비": 1000}, unit="식"), "수량": {"손": 1}}, # 손 단가 + ] + _snap(root, "pipe@100.0", "SS000901", lines) + keys = linked_resource_keys(root) + assert LB in keys # 단가 책 수요에 이음 키 + inputs = prices.build_inputs(None, keys) + inputs["daega"] = daega_inputs(root) + inputs["hopyo"] = {} + inputs["bill"] = { + "items": [ + {"key": "st:revet", "section": "2.1", "name": "돌기슭막이", "spec": "", "unit": "m", + "qty": 10, "ref": {"tab": "dg_list", "key": "revet"}, "note": "", + "owners": {"pipe@100.0": 10}}, + ] + } # fmt: skip + sheets = compute_sheets(root, inputs, {}) + combo = next(b for b in sheets["daega_blocks"] if b["kind"] == "조합") + kinds = [ln["kind"] for ln in combo["lines"]] + assert kinds == ["daega", "fixed", "fixed"] and combo["blank"] == 0 + stone = combo["lines"][1] + book = {r["key"]: r for r in sheets["labor"]} + assert stone["prices"]["labor"] == book[LB]["price"] is not None + assert sheets["bill"]["totals"]["direct"]["blank"] == 0 # 원가계산서 빈 줄 0 + assert sheets["bill"]["sums"]["material"] > 0 + summary = compute_summary(sheets, hopyo_lines(sheets)) + labor_qty = {r["key"]: r["qty"] for r in summary["sum_labor"]} + assert labor_qty[LB] >= 10 * 0.5 # 이은 고정 줄 = 노임 자원으로 셈 + assert summary["fixed"] == 1 # 손 단가 줄 = 집계에서 빠짐 1 + + +def test_사본_재적용_업데이트_고정_줄_그대로(tmp_path): + """[업데이트](703-3) = 단가 책만 새로 — 고정 줄 품 · 손 단가 그대로 · 이은 단가만 새 판.""" + root = b145._project(tmp_path / "upd", [(100, "SS000901")]) + lines = [ + {"고정": _fx("석공", {"노무비": 1}, {"노임": LB}), "수량": {"손": 0.5}}, + {"고정": _fx("잡재료", {"재료비": 1000}, unit="식"), "수량": {"손": 1}}, + ] + _snap(root, "pipe@100.0", "SS000901", lines) + daega = daega_inputs(root) + book = prices.build_inputs(None, linked_resource_keys(root)) + rows = [{**r, "price": 100} if r["key"] == LB else r for r in book["labor"]] + first = compute_labor(daega, {**b145.SHEETS, "labor": rows}, {}) + again = daega_inputs(root) # 사본 다시 읽음(재적용 뒤 꼴 = 같은 원본) + second = compute_labor(again, {**b145.SHEETS, "labor": [{**r, "price": 200} for r in rows]}, {}) + a, b = first["daega_blocks"][0]["lines"], second["daega_blocks"][0]["lines"] + assert [ln["qty"] for ln in a] == [ln["qty"] for ln in b] == [0.5, 1] + assert (a[0]["prices"]["labor"], b[0]["prices"]["labor"]) == (100, 200) + assert a[1]["prices"] == b[1]["prices"] == {"labor": 0, "material": 1000, "expense": 0} From 59a694a7ed34c7055c0d86828c25d40c8a3caeab Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 14:11:44 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(B09):=20=EB=8B=A8=EA=B0=80=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EC=A6=89=EC=8B=9C=20=EA=B8=88=EC=95=A1(=EB=92=A4?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=84=9C=EB=B2=84=20=EC=85=88)=20=C2=B7?= =?UTF-8?q?=20[=EC=A0=80=EC=9E=A5]=20=EB=8B=A8=EC=B6=94=20=C2=B7=20?= =?UTF-8?q?=EC=98=AE=EA=B8=B8=20=EB=95=8C=20=EB=AC=BC=EC=9D=8C(PLAN=20702-?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 산근(로직 연결 · 입력값) = 연결 즉시 뒤에서 POST …/estimation/calc(화면 캐시로 셈 · 저장 안 함) · 차례 번호 · 옛 요청 끊기 · 오는 동안 그 줄 금액 칸만 「셈 중」 · 잠금 없음 (새 `B09_Estimation_UI_Live.ts`) · 기준 = payload.user(서버가 그 셈에 쓴 값) - 조작 중 셈 = 마지막 GET · [저장] 의 B08 원천 다시 씀(120 s · 0.6 → 0.15 s) · [저장] · GET 은 늘 새로 읽음 - [저장] 단추 = 왼쪽 아래 [초기화] 옆(B08 과 같은 부품 · 꼴) · 고침 없으면 알림만 - 단계 이동 · [>] = 저장 안 한 고침 있으면 공용 떠남 물음(취소 · 폐기 · 저장) — 옛 자동 저장 없앰 - 서고 사본 payload 의 user 받음(서버 기준) — 화면 캐시와 다른 로직 줄은 뒤에서 다시 셈 - 낡은 시험 고침: 481-2 · 298-2(자동 저장 → 물음 · [저장] 단추) · 184-3 · 304(단추 줄) · 204-2(user 받음) · 200-2(701-3 띠 · 702-1 펼침 뒤 셸 꼴) - 확인 = test_702_3 3 · B09 관련 312 통과 · typecheck 0 · 검증_sub5 T6 11 누름 · 연결 뒤 0.32~0.49 s 금액 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Calc_Bill.py | 4 +- B09_Estimation/B09_Estimation_Calc_Bill.ts | 4 +- B09_Estimation/B09_Estimation_Router.py | 37 +++++- B09_Estimation/B09_Estimation_UI_Live.ts | 106 ++++++++++++++++++ B09_Estimation/B09_Estimation_UI_Shell.ts | 67 +++++++---- .../B09_Estimation_UI_Tabs_Bill_Link.ts | 11 +- .../tester/helper_298_2_b08_b09_buttons.cjs | 20 +++- resources/tester/helper_481_2_save_move.cjs | 24 +++- resources/tester/helper_702_3_live.cjs | 102 +++++++++++++++++ resources/tester/test_184_3_b09_bill_menu.py | 2 +- .../tester/test_200_2_b09_bill_level_bar.py | 3 +- .../tester/test_204_2_b09_bill_library.py | 5 +- .../tester/test_298_2_b08_b09_buttons.py | 11 +- resources/tester/test_304_b09_view_setting.py | 2 +- resources/tester/test_481_2_save_move.py | 12 +- .../tester/test_702_3_b09_estimation_live.py | 37 ++++++ 16 files changed, 399 insertions(+), 48 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UI_Live.ts create mode 100644 resources/tester/helper_702_3_live.cjs diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.py b/B09_Estimation/B09_Estimation_Calc_Bill.py index 50a783f96..5c7c2a8ce 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.py +++ b/B09_Estimation/B09_Estimation_Calc_Bill.py @@ -14,7 +14,7 @@ 제목 줄 = 아래 항목 금액 합(엑셀 SUMIF — 빈 줄은 0 이 아니라 「빈 줄 n」 으로 셈) 빈칸 = 수량이나 단가를 낸 원천이 없으면 그 줄 금액 None + 「연결 없음」 목록. 단가 = `ref_price(ref)` 하나 — dg = 일위대가목록표 · sg = 단가산출근거목록표 · - logic = M01 로직 새 산근(서버가 `sg_hand{줄 키: 산근 키}` 로 줌 · 그전엔 빈칸 「[저장] 뒤」) · + logic = M01 로직 새 산근(서버가 `sg_hand{줄 키: 산근 키}` 로 줌 · 그전엔 빈칸 · 화면은 연결 즉시 뒤에서 서버 셈 702-3) · 그 밖 = `inputs.hopyo` · 비고 = 「대가n」 · 「산근 n호표」(155-3 꼴). 자유 편집(184) = 기본 로직 표 = 초기값(저장 안 함) · 사용자 값 `user.bill` v2 = 덮개(계약 — 184-3 화면): @@ -149,7 +149,7 @@ def _material_items(inputs: dict[str, Any], compare: list[dict[str, Any]]) -> li PriceOf = Callable[[str], dict[str, Any] | None] WHY_HAND = "수기 줄 — 연결 없음(우클릭 [연결])" -WHY_LOGIC = "새 산근 — [저장] 뒤 단가(서버 셈)" +WHY_LOGIC = "새 산근 — 서버 셈 뒤 단가(702-3 뒤에서 셈)" def ref_price( diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.ts b/B09_Estimation/B09_Estimation_Calc_Bill.ts index 3edec67e9..631c91f25 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.ts +++ b/B09_Estimation/B09_Estimation_Calc_Bill.ts @@ -10,7 +10,7 @@ * 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료 * 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 조달수수료 = ROUNDDOWN(순자재대 × 율 ÷ 100, 0) * 묶음 금액 = 자손 항목 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(아는 금액까지 + blank · 701-3 · 관급만 빈 줄 있으면 null) - * 단가 = `refPrice(ref)` 하나(dg 대가 목록 · sg 산근 목록 · logic = 서버 sg_hand 산근 · 그 밖 hopyo) + * 단가 = `refPrice(ref)` 하나(dg 대가 목록 · sg 산근 목록 · logic = 서버 sg_hand 산근(702-3 연결 즉시 뒤에서 셈) · 그 밖 hopyo) * 자유 편집(184) = `user.bill` v2 덮개 {v: 2, rows: [표 차례 · 고친 칸 · 새 줄], gone: [지운 자동 줄]} · * 옛 꼴(qty · 손 줄 after · parent) 그대로 읽음 · 저장 꼴 · 출력 줄 칸(계약) = 짝 .py 머리 * 출력 줄 칸 = kind · key · name · parent · level · no · cost · auto | hand · auto_{칸} · section @@ -112,7 +112,7 @@ function materialItems(inputs: Row, compare: Row[]): Row[] { /** 사본 주인 id → 대가 1 단위 단가(145-2 `daegaPrice`) */ export type PriceOf = (owner: string) => Row | null; const WHY_HAND = "수기 줄 — 연결 없음(우클릭 [연결])"; -const WHY_LOGIC = "새 산근 — [저장] 뒤 단가(서버 셈)"; +const WHY_LOGIC = "새 산근 — 서버 셈 뒤 단가(702-3 뒤에서 셈)"; /** 연결 → 1 단위 단가 {no, key, labor, material, expense, sum} — 없으면 null */ export function refPrice(ref: Row | null, books: Row, hopyo: Row, rowKey = ""): Row | null { diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index bf856eb6e..55fecd703 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -13,6 +13,8 @@ POST /api/projects/{id}/estimation/prices {user?} [단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 · user = 화면 캐시(296 · 지운 줄 · 그 사본은 수요에서 뺌 · 저장은 안 함) · M01 원본이 지워진 줄 = 굳힌 값 그대로 + POST /api/projects/{id}/estimation/calc {user} + 조작 중 서버 셈(702-3 · 단가연결 즉시 금액) — 화면 캐시로 GET 꼴 셈 · 저장 안 함 POST /api/projects/{id}/estimation/logic-preview {code, given, rate, picks} 「운반 단가」 모달 계산 절차(253-3) — 그 로직 한 산근을 프로젝트 단가로 풀어 글 줄 · 단가 · 저장 안 함(`render_sangeun_text` 를 `Sangeun.Solver` 로) · `blocks` = 칸 나눔(277-2) @@ -38,6 +40,7 @@ import asyncio import copy import importlib import logging +import time from pathlib import Path from typing import Any from uuid import UUID @@ -80,6 +83,25 @@ except ImportError: # pragma: no cover router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) +# 702-3 조작 중 셈 = 마지막 GET · [저장] 이 읽은 B08 원천 다시 씀(B08 은 B09 조작으로 안 바뀜) +# ponytail: 시간 창만 봄 — 창 안에 남이 B08 을 고치면 미리보기만 옛 수량 · [저장] 은 늘 새로 읽음 +B08_REUSE_S = 120.0 +_b08_seen: dict[UUID, tuple[float, dict[str, Any], list[str]]] = {} + + +async def _b08_for(project_id: UUID, notes: list[str], reuse: bool) -> dict[str, Any]: + """B08 원천 — reuse = 시간 창 안 마지막 값(사본) · 아니면 새로 읽고 남김.""" + seen = _b08_seen.get(project_id) + if reuse and seen and time.monotonic() - seen[0] < B08_REUSE_S: + notes.extend(seen[2]) + return copy.deepcopy(seen[1]) + mine: list[str] = [] + out = await _b08(project_id, mine) + _b08_seen[project_id] = (time.monotonic(), copy.deepcopy(out), mine) + notes.extend(mine) + return out + + async def _b08(project_id: UUID, notes: list[str]) -> dict[str, Any]: """B08 수량 원천 — 못 읽으면 빈칸 + 알림(B09 는 B08 를 고치지 않음).""" out: dict[str, Any] = {} @@ -170,10 +192,10 @@ async def _demand(root: Path, draft: dict[str, Any] | None = None) -> list[str]: async def payload( - project_id: UUID, root: Path, draft: dict[str, Any] | None = None + project_id: UUID, root: Path, draft: dict[str, Any] | None = None, reuse_b08: bool = False ) -> dict[str, Any]: notes: list[str] = [] - b08 = await _b08(project_id, notes) + b08 = await _b08_for(project_id, notes, reuse_b08) keys = await _demand(root, draft) if not keys: notes.append( @@ -182,7 +204,8 @@ async def payload( book = await asyncio.to_thread(store.read_book, root) inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"]) hauls = await asyncio.to_thread(haul_routes, root) - user = await asyncio.to_thread(store.read_user, root) + # 702-3 draft = 화면 캐시로 셈(저장 안 함) · 없으면 저장 값 + user = await asyncio.to_thread(store.read_user, root) if draft is None else draft calc = await asyncio.to_thread(library.resolve_hauls, root, {**user, **haul_user(hauls)}) inputs["bill"] = bill_items({**b08, **calc, "haul_routes": hauls}) inputs["hopyo"] = hopyo_prices(root) @@ -276,6 +299,14 @@ async def refresh_prices( return await payload(project_id, root, draft) +@router.post("/{project_id}/estimation/calc") +async def calc_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> dict[str, Any]: + """702-3 조작 중 서버 셈 — 화면 캐시(user)로 GET 꼴 셈 · 저장 안 함 · 단가 = 굳힌 책 + (없는 줄 = 지금 M01 · [저장] 때 책에 굳힘 → 같은 값).""" + root = await _project_root(project_id) + return await payload(project_id, root, store.clean_user(body.get("user")), reuse_b08=True) + + class PreviewBody(BaseModel): code: str = Field(min_length=1, max_length=40) given: dict[str, Any] = Field(default_factory=dict) diff --git a/B09_Estimation/B09_Estimation_UI_Live.ts b/B09_Estimation/B09_Estimation_UI_Live.ts new file mode 100644 index 000000000..8c3bcccdd --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Live.ts @@ -0,0 +1,106 @@ +/* ============================================================================= + * B09_Estimation_UI_Live.ts + * B09 조작 중 서버 셈(PLAN 702-3) — 새 산근(단가연결 · 로직 입력값)은 서버 M01 엔진 몫 → + * 바뀌면 그 자리에서 뒤로 POST …/estimation/calc {user}(저장 안 함 · GET 꼴 답) · + * 차례 번호 · 옛 요청 끊기(`design_pipeline` 꼴) · 오는 동안 그 줄 금액 칸만 「셈 중」 · 화면 잠금 없음 + * + * 기준 = payload.user(서버가 그 셈에 쓴 사용자 값) — 로직 연결 지문이 다른 줄 = 기다림 · + * 기다리는 줄은 옛 산근 값을 안 씀(sg_hand 에서 뺌) · 못 받으면 알림 뒤 빈칸(같은 지문 다시 안 보냄) + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { showToast } from "@ui/ui_template_elements"; +import type { Row } from "./B09_Estimation_Calc_Base"; + +export const WAITING = "셈 중"; + +/** 줄 키 → 로직 연결 지문(코드 · 입력값 · 켜쌓기율 · 고름) */ +function logicSig(user: Row | null | undefined): Map { + const out = new Map(); + for (const r of user?.bill?.rows || []) { + const ref = r.ref; + if (ref?.tab === "logic") + out.set( + r.key, + JSON.stringify([ref.code, ref.given ?? {}, ref.rate ?? null, ref.picks ?? null]), + ); + } + return out; +} + +/** 서버 셈을 기다리는 줄 — 로직 연결이 서버 기준(payload.user)과 다름 */ +export function waitingKeys(payload: Row, user: Row): Set { + const known = logicSig(payload.user); + const out = new Set(); + for (const [key, sig] of logicSig(user)) if (known.get(key) !== sig) out.add(key); + return out; +} + +/** 셈 입력 — 기다리는 줄은 옛 산근 단가를 안 씀 */ +export function withoutWaiting(payload: Row, wait: Set): Row { + if (!wait.size) return payload; + const hand = { ...(payload.sheets?.sg_hand || {}) }; + for (const key of wait) delete hand[key]; + return { ...payload, sheets: { ...payload.sheets, sg_hand: hand } }; +} + +/** 기다리는 줄 금액 칸 = 「셈 중」(셈이 끝난 뒤 · 계에는 안 듦) */ +export function markWaiting(sheets: Row, wait: Set): void { + for (const r of sheets.bill?.rows || []) if (wait.has(r.key) && r.sum == null) r.sum = WAITING; +} + +/** settle(next, sent) — next = 서버 답(못 받음 = null) · sent = 보낸 사용자 값 */ +export function createLiveCalc( + projectId: string, + settle: (next: Row | null, sent: Row) => void, +): { busy: () => boolean; kick: (wait: Set, user: Row) => void } { + let seq = 0; + let ctrl: AbortController | null = null; + let sentSig = ""; + let failedSig = ""; + return { + busy: () => ctrl !== null, + /** 기다리는 줄 있으면 서버 셈(같은 것이 가는 중 · 못 받은 것이면 그대로) · 없으면 가던 것 끊음 */ + kick(wait, user) { + const sig = JSON.stringify([...logicSig(user)]); + if (wait.size && (sig === failedSig || (ctrl && sig === sentSig))) return; + ctrl?.abort(); + ctrl = null; + seq += 1; + if (!wait.size) return; + const mine = seq; + const me = new AbortController(); + ctrl = me; + sentSig = sig; + fetch(`${API_BASE_URL}/projects/${projectId}/estimation/calc`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user }), + signal: me.signal, + }) + .then(async (response) => { + const body = (await response.json().catch(() => null)) as Row | null; + if (!response.ok || !body) { + const detail = body?.detail; + throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); + } + return body; + }) + .then( + (next) => { + if (mine !== seq) return; + ctrl = null; + settle(next, user); + }, + (error: unknown) => { + if (mine !== seq || me.signal.aborted) return; + ctrl = null; + failedSig = sig; + showToast(`새 산근 단가를 못 받음 — [저장] 때 다시 셈 · ${String(error)}`, "warning"); + settle(null, user); + }, + ); + }, + }; +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 71fd0d8f1..b1550bc15 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -4,8 +4,9 @@ * * 원본 = 기번3 공통 원가산출 엑셀 · 탭 하나 = 화면 표 하나(`…_UI_Tabs` 차례) · 옛 원가 코드 안 봄 * 자료 = GET …/estimation(입력 · 사용자 값 · 서버가 셈한 표) · 조작 = 캐시(`b09-estimation`) + - * 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · 저장(PUT → 서버 정본) = 단계 이동 · [>] 때(481-2 · [저장] 단추 없음 · 요구 301) - * [>] = 고친 값 저장 → POST …/estimation/complete(단계 6 완료) → `DRAFT.next`(인쇄 미리보기 · 초안) · 실패면 머묾 + * 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · 새 산근(로직 연결) = 뒤에서 서버 셈(702-3 · `…_UI_Live`) · + * [저장](702-3 · 왼쪽 아래 [초기화] 옆) = PUT → 서버 정본으로 다시 셈 · 저장 안 하고 단계 옮김 · [>] = 물음(저장 · 버림 · 머묾) + * [>] = 물음 → POST …/estimation/complete(단계 6 완료) → `DRAFT.next`(인쇄 미리보기 · 초안) · 실패면 머묾 * [초기화](요구 298-2) = 확인 창 → POST …/estimation/reset(사용자 값 · 단가 책 지움 · 견적서 남김) → 셈 값 · [업데이트] = POST …/prices * 호표 칸 누름 = 그 탭 그 줄로 이동 · 자동 저장 없음 * ========================================================================== */ @@ -36,6 +37,7 @@ import { computeLabor, daegaPrice } from "./B09_Estimation_Calc_Labor"; import { computeTotal } from "./B09_Estimation_Calc_Total"; import { createTabBars } from "./B09_Estimation_UI_TabBar"; import { createCheckPanel } from "./B09_Estimation_UI_Check"; +import { createLiveCalc, markWaiting, waitingKeys, withoutWaiting } from "./B09_Estimation_UI_Live"; import { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders"; import { createDock, linkMode } from "./B09_Estimation_UI_Dock"; import { closePopups, openRowPopup } from "./B09_Estimation_UI_Popup"; @@ -56,7 +58,11 @@ import { renderGroupPanel, unpickOnRelease, } from "./B09_Estimation_UI_Tabs_Groups"; -import { currentProjectId, guardProjectLeave } from "../A00_Common/b_project_switch"; +import { + askProjectLeave, + currentProjectId, + guardProjectLeave, +} from "../A00_Common/b_project_switch"; // 252 초안 탭(공기 등) — draft 폴더가 있으면 등록 · 지우면 빈 목록(오류 없음) import.meta.glob("./draft/*_UI.ts", { eager: true }); @@ -139,22 +145,37 @@ export function renderB09Estimation(root: HTMLElement): void { body.className = "b09-estimation"; body.append(bars.top, bars.sub, panel); + // 702-3 새 산근 = 뒤에서 서버 셈 → 받은 답을 같은 자료 묶음에 끼움(열린 창이 쥔 payload 그대로) + const live = projectId + ? createLiveCalc(projectId, (next, sent) => { + if (next && payload) Object.assign(payload, next, { user: sent }); + redraw(); + }) + : null; + const recompute = (): void => { if (!payload) return; - sheets = computeSheets(payload, user); + const wait = waitingKeys(payload, user); + live?.kick(wait, user); + sheets = computeSheets(withoutWaiting(payload, wait), user); + if (live?.busy()) markWaiting(sheets, wait); check.draw(payload, sheets); // 203-1 좌측 연결 점검 · [업데이트] 배지 }; const setUser = (next: Row): void => { user = next; if (projectId) writeState("b09-estimation", user, projectId); + redraw(); + }; + + function redraw(): void { recompute(); // 고친 뒤 다시 그려도 보던 자리 지킴(166-2 내역서 수량 · 손 줄) const top = panel.querySelector(".ui-data-table")?.scrollTop ?? 0; show(current); const box = panel.querySelector(".ui-data-table"); if (box && top) box.scrollTop = top; - }; + } /** 탭 하나 그림 · focus = 이동해 온 줄 키 */ const show = (tab: TabId, focus?: string): void => { @@ -342,11 +363,22 @@ export function renderB09Estimation(root: HTMLElement): void { }), }); reset.dataset.b09Reset = ""; + // 702-3 [저장] — 화면 캐시 → 서버 정본으로 다시 셈(고침 없으면 요청 없이 알림) + const save = createButton({ + label: L("Common_Btn_Save"), + variant: "filled", + onClick: () => { + if (!projectId || readState("b09-estimation", projectId) === null) + return void showToast("저장할 고침이 없습니다.", "info"); + void saveChanged(); + }, + }); + save.dataset.b09Save = ""; // 카드 = 좌측 패널 공용 카드(269 · 「구조물 배치」 양식) const section = createPanelCard("데이터 점검", true); // 311 기본 접힘 section.append(refresh, check.list); // 303-1 · 304-1 하단 고정 줄 — [박스보기|호표보기] · [하이퍼링크|팝업] · 초안 단추 · [?][<][>][초기화] - const actions = createDock([reset], () => isGroupTab(current) && show(current)); + const actions = createDock([save, reset], () => isGroupTab(current) && show(current)); const left = document.createElement("div"); left.className = "ui-sidebar-panel b09-options"; left.append(section, ...DRAFT.panels.map((make) => make(() => user, setUser)), actions); @@ -359,15 +391,15 @@ export function renderB09Estimation(root: HTMLElement): void { mainContent: body, leftPanel: left, routes: WORKFLOW_STEP_ROUTES, - // 481-2 단계 이동 = 고친 값 저장 성공 뒤 · 실패면 머묾 · 불러오기 전 · 고침 없음 = 요청 없이 + // 702-3 단계 이동 = 저장 안 한 고침 있으면 물음(저장 · 버림 · 머묾) 뒤 · 고침 없음 = 바로 onStepClick: (stepIndex: number) => { if (!projectId) return; - void moveStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex], [ - { name: "원가 저장", run: saveChanged }, - ]); + void askProjectLeave().then((go) => { + if (go) void moveStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex], []); + }); }, }); - // [>] = 다음 단계 없음 → 공용 틀 대신 여기서 — 저장 → 단계 6 완료 → 초안 다음 길 · 초안 없으면 흐림 그대로 + // [>] = 다음 단계 없음 → 공용 틀 대신 여기서 — 물음(702-3) → 단계 6 완료 → 초안 다음 길 · 초안 없으면 흐림 그대로 const next = left.querySelector('[data-step-nav="1"]'); const goNext = DRAFT.next; if (next && goNext) next.disabled = false; @@ -376,14 +408,11 @@ export function renderB09Estimation(root: HTMLElement): void { event.stopPropagation(); const why = DRAFT.blocked?.(); if (why) return void showToast(why, "warning"); // 701-3 확정 = 빈 줄 0 일 때만 - void moveStage(projectId, goNext, [ - { name: "원가 저장", run: saveChanged }, - { - name: "원가 계산 단계 완료", - run: async () => - void (await request(`/projects/${projectId}/estimation/complete`, { method: "POST" })), - }, - ]); + const complete = async (): Promise => + void (await request(`/projects/${projectId}/estimation/complete`, { method: "POST" })); + void askProjectLeave().then((go) => { + if (go) void moveStage(projectId, goNext, [{ name: "원가 계산 단계 완료", run: complete }]); + }); }); root.append(layout.root); show(current); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts index 31a4dbd21..4f5451c58 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts @@ -2,7 +2,7 @@ * B09_Estimation_UI_Tabs_Bill_Link.ts * B09 설계내역서 연결 고르기(166-2 · 184-3) — 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) · * 서고(마스터 · 회사 · 개인) 구조물조합 · 일위대가 사전조합(204-2 — 서버가 프로젝트 사본으로 들여와 새 - * 대가 호표 · 같은 것 = 있는 사본) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) · 로직 입력값 고치기 · + * 대가 호표 · 같은 것 = 있는 사본) · M01 로직(새 산근 = 서버 셈 · 연결 즉시 뒤에서 · 702-3) · 로직 입력값 고치기 · * 연결 풀기. * 251 단가연결 = 한 모달(`openPriceLink` · 종류 고르기 + 검색) — B09 설계내역서 · B08 거리이정표 같이 씀 · * 단가산출 = 옵션 값 창(`askInputs` · 160) @@ -35,7 +35,7 @@ const LAYER_TEXT: Record = { personal: "개인", }; -/** M01 로직 → 입력값 창 → 연결(새 산근 · 단가는 [저장] 뒤 서버 셈) */ +/** M01 로직 → 입력값 창 → 연결(새 산근 · 단가 = 화면이 뒤에서 서버 셈 · 702-3 `…_UI_Live`) */ export function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void { fetchLogicInputs([code]).then( (got) => { @@ -48,14 +48,14 @@ export function askInputs(apply: ApplyRef, code: string, name: string, old?: Row rate: old?.rate ?? null, readOnly: false, okLabel: "연결", - notice: "새 산근 = 단가산출근거 뒤 번호 · 단가는 [저장] 뒤 서버가 셈", + notice: "새 산근 = 단가산출근거 뒤 번호 · 단가는 연결 뒤 서버가 바로 셈", done: (next) => { const unit = String(logic?.결과단위 || "") .replace(/^원\//, "") .replace(/^[\d.]+/, ""); const ref = { tab: "logic", code, name, given: next.입력값, rate: next.켜쌓기율 }; apply({ ...ref, picks: old?.picks ?? null }, { name, unit }); - showToast("연결함 — 새 산근 단가는 [저장] 뒤에 채워짐", "success"); + showToast("연결함 — 새 산근 단가 셈 중", "success"); }, }); }, @@ -130,7 +130,8 @@ export async function importLib( try { const got = await postLib("library", pick); if (!got.key) throw new Error("사본은 들여왔으나 호표를 못 만듦"); - const { user: _user, ...rest } = got.payload as Row; + // 702-3 user = 서버가 이 셈에 쓴 값(저장 값) — 화면 캐시와 다른 로직 줄은 뒤에서 다시 셈 + const rest = got.payload as Row; o.onPayload?.(rest); const from = ((rest.sheets?.daega as Row[]) || []).find((r) => r.key === got.key); const hand = lib.haul ? { given: lib.haul.given, own: lib.haul.own } : {}; diff --git a/resources/tester/helper_298_2_b08_b09_buttons.cjs b/resources/tester/helper_298_2_b08_b09_buttons.cjs index 35a9518d4..299de7990 100644 --- a/resources/tester/helper_298_2_b08_b09_buttons.cjs +++ b/resources/tester/helper_298_2_b08_b09_buttons.cjs @@ -86,6 +86,8 @@ const buttons = []; // 만든 단추 — { variant, node } let layoutOpts = null; let confirmAnswer = false; const store = new Map(); // 가짜 세션 캐시 +let leaveGuard = null; +let leaveAnswer = "save"; // save | discard | stay const common = { "../A00_Common/b_page_tabs": { // 탭 줄 부품 흉내 — 칸마다 dataset[data] = 열쇠 · 지금 칸 누름은 안 부름 @@ -140,8 +142,18 @@ const common = { return true; }, }, - // 572-2 지금 프로젝트 = 탭마다 · 떠남 가드는 test_560_4 - "../A00_Common/b_project_switch": { currentProjectId: () => "P1", guardProjectLeave() {} }, + // 572-2 지금 프로젝트 = 탭마다 · 떠남 가드는 test_560_4 · 702-3 B09 단계 이동 물음 = 가드 흉내(leaveAnswer) + "../A00_Common/b_project_switch": { + currentProjectId: () => "P1", + guardProjectLeave: (_root, g) => (leaveGuard = g), + askProjectLeave: async () => { + if (!leaveGuard || !leaveGuard.dirty()) return true; + calls.push("ask"); + if (leaveAnswer === "stay") return false; + if (leaveAnswer === "discard") return (await leaveGuard.discard?.(), true); + return (await leaveGuard.save()) !== false; + }, + }, "../A00_Common/b_page_state": { readState: (name) => store.get(name) ?? null, writeState: (name, v) => store.set(name, v), @@ -295,8 +307,8 @@ async function b09(withNext) { unfinished = "빈 줄 2 · 미완성"; out.blocked = await nav(1); unfinished = null; - // [초기화] - const reset = dock.children[0]; + // [초기화](702-3 [저장] 다음) + const reset = dock.children[1]; const press = async () => { take(); reset.onClick(); diff --git a/resources/tester/helper_481_2_save_move.cjs b/resources/tester/helper_481_2_save_move.cjs index bfd031bb2..f37215ba8 100644 --- a/resources/tester/helper_481_2_save_move.cjs +++ b/resources/tester/helper_481_2_save_move.cjs @@ -80,6 +80,8 @@ const flush = () => new Promise((r) => setTimeout(r, 0)); const calls = []; let layoutOpts = null; const store = new Map(); // 가짜 세션 캐시 — 이름 → 값 +let leaveGuard = null; +let leaveAnswer = "save"; // save | discard | stay const common = { "../A00_Common/b_page_tabs": { // 탭 줄 부품 흉내 — 칸마다 dataset[data] = 열쇠 · 지금 칸 누름은 안 부름 @@ -129,7 +131,18 @@ const common = { }, }, // 572-2 지금 프로젝트 = 탭마다 · 떠남 가드는 test_560_4 - "../A00_Common/b_project_switch": { currentProjectId: () => "P1", guardProjectLeave() {} }, + // 572-2 지금 프로젝트 = 탭마다 · 떠남 가드는 test_560_4 · 702-3 B09 단계 이동 물음 = 가드 흉내(leaveAnswer) + "../A00_Common/b_project_switch": { + currentProjectId: () => "P1", + guardProjectLeave: (_root, g) => (leaveGuard = g), + askProjectLeave: async () => { + if (!leaveGuard || !leaveGuard.dirty()) return true; + calls.push("ask"); + if (leaveAnswer === "stay") return false; + if (leaveAnswer === "discard") return (await leaveGuard.discard?.(), true); + return (await leaveGuard.save()) !== false; + }, + }, "../A00_Common/b_page_state": { readState: (name) => store.get(name) ?? null, writeState: (name, v) => store.set(name, v), @@ -223,6 +236,15 @@ async function b09() { out.fail = await step(5); fail = false; out.ok = await step(5); + // 702-3 물음 [버림] = 캐시 지우고 이동 · [머묾] = 요청 없이 머묾 + store.set("b09-estimation", { x: 1 }); + leaveAnswer = "discard"; + out.discard = [await step(5), store.has("b09-estimation")]; + store.set("b09-estimation", { x: 1 }); + leaveAnswer = "stay"; + out.stay = [await step(5), store.has("b09-estimation")]; + leaveAnswer = "save"; + store.delete("b09-estimation"); return out; } diff --git a/resources/tester/helper_702_3_live.cjs b/resources/tester/helper_702_3_live.cjs new file mode 100644 index 000000000..e23712afa --- /dev/null +++ b/resources/tester/helper_702_3_live.cjs @@ -0,0 +1,102 @@ +/* 702-3 B09 조작 중 서버 셈 — 진짜 `B09_Estimation_UI_Live.ts` 를 트랜스파일해 가짜 fetch 위에서 돌림 · + * 차례 번호 · 옛 요청 끊기 · 같은 지문 다시 안 보냄 · 기다리는 줄(옛 산근 안 씀 · 「셈 중」) · 못 받음 = 한 번만. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const ROOT = path.join(__dirname, "..", ".."); + +const toasts = []; +function load(rel, stubs) { + const src = fs.readFileSync(path.join(ROOT, rel), "utf8"); + const js = ts.transpileModule(src, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const mod = { exports: {} }; + new Function("exports", "module", "require", js)(mod.exports, mod, (id) => stubs[id] ?? {}); + return mod.exports; +} +const live = load("B09_Estimation/B09_Estimation_UI_Live.ts", { + "@config/config_frontend": { API_BASE_URL: "/api" }, + "@ui/ui_template_elements": { showToast: (text, kind) => toasts.push(kind) }, +}); + +// 가짜 fetch — 보낸 차례대로 쥐고 있다가 손으로 답함 · 끊김 = AbortError +const pending = []; +global.fetch = (url, init) => + new Promise((resolve, reject) => { + const one = { url, body: JSON.parse(init.body), resolve, reject, aborted: false }; + init.signal.addEventListener("abort", () => { + one.aborted = true; + reject(Object.assign(new Error("aborted"), { name: "AbortError" })); + }); + pending.push(one); + }); +const answer = (one, ok = true) => + one.resolve({ + ok, + status: ok ? 200 : 500, + json: async () => (ok ? { tag: one.body.user.tag } : {}), + }); +const flush = async () => { + for (let k = 0; k < 8; k++) await new Promise((r) => setTimeout(r, 0)); +}; + +const logic = (given) => ({ + bill: { rows: [{ key: "hand:a", ref: { tab: "logic", code: "GF1", given } }, { key: "b" }] }, +}); + +(async () => { + const out = {}; + const settled = []; + const calc = live.createLiveCalc("P1", (next, sent) => + settled.push([next && next.tag, sent.tag]), + ); + const base = { user: logic({ 높이: 3 }), sheets: { sg_hand: { "hand:a": "SG:old" } } }; + // 기다리는 줄 = 서버 기준과 다른 로직 연결만 + const u1 = { ...logic({ 높이: 4 }), tag: 1 }; + const wait = live.waitingKeys(base, u1); + out.wait = [...wait]; + out.same = [...live.waitingKeys(base, base.user)]; + out.strip = live.withoutWaiting(base, wait).sheets.sg_hand; + const sheets = { + bill: { + rows: [ + { key: "hand:a", sum: null }, + { key: "b", sum: null }, + ], + }, + }; + live.markWaiting(sheets, wait); + out.mark = sheets.bill.rows.map((r) => r.sum); + // 차례 — 첫 요청 가는 중 같은 지문 = 안 보냄 · 바뀐 지문 = 옛 것 끊고 새로 + calc.kick(wait, u1); + calc.kick(wait, u1); + out.sentAfterSame = pending.length; + const u2 = { ...logic({ 높이: 5 }), tag: 2 }; + calc.kick(live.waitingKeys(base, u2), u2); + await flush(); + out.firstAborted = pending[0].aborted; + out.url = pending[1].url; + answer(pending[1]); + await flush(); + out.settled = settled.slice(); + out.busyAfter = calc.busy(); + // 기다릴 줄 없음 = 가던 것 끊음 + const u3 = { ...logic({ 높이: 6 }), tag: 3 }; + calc.kick(live.waitingKeys(base, u3), u3); + calc.kick(new Set(), base.user); + await flush(); + out.cancelled = [pending[2].aborted, calc.busy()]; + // 못 받음 = 알림 한 번 · 같은 지문 다시 안 보냄 + const u4 = { ...logic({ 높이: 7 }), tag: 4 }; + calc.kick(live.waitingKeys(base, u4), u4); + answer(pending[3], false); + await flush(); + calc.kick(live.waitingKeys(base, u4), u4); + out.failed = [toasts, settled[settled.length - 1], pending.length]; + console.log(JSON.stringify(out)); +})().catch((e) => { + console.error(e && e.stack ? e.stack : e); + process.exit(1); +}); diff --git a/resources/tester/test_184_3_b09_bill_menu.py b/resources/tester/test_184_3_b09_bill_menu.py index 12591151d..6c01f5658 100644 --- a/resources/tester/test_184_3_b09_bill_menu.py +++ b/resources/tester/test_184_3_b09_bill_menu.py @@ -286,4 +286,4 @@ def test_초기화_배선(): assert 'variant: "danger-ghost"' in block and "showConfirmDialog(" in block assert 'if (ok) void run("/estimation/reset", "POST"' in block assert "내역 초기화" not in src and "b09BillReset" not in src - assert "createDock([reset]," in src # 303-1 하단 고정 줄 맨 아래 줄 · [저장] 없음(요구 301) + assert "createDock([save, reset]," in src # 303-1 하단 고정 줄 맨 아래 줄 · 702-3 [저장] 생김 diff --git a/resources/tester/test_200_2_b09_bill_level_bar.py b/resources/tester/test_200_2_b09_bill_level_bar.py index 48a6f0f3c..c3ccb5dd5 100644 --- a/resources/tester/test_200_2_b09_bill_level_bar.py +++ b/resources/tester/test_200_2_b09_bill_level_bar.py @@ -202,7 +202,8 @@ def test_셸_배선(): ) # 239-1 · 242-1 이동 줄 빨강 곁(고름과 한때 하나) assert "if (level) bars.setTools(level.tools);" in src # 229-1 단추 = 보조 탭 줄 assert ( - "panel.replaceChildren(table.root, ...(foot ? [foot] : []))" in src + "panel.replaceChildren(...banner, table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : []))" + in src ) # 203 레벨 안내 + 탭 안내 = 한 영역 lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8") assert "table.menu(index)" in lvl # 단추 · 키 = 우클릭 메뉴 같은 함수 diff --git a/resources/tester/test_204_2_b09_bill_library.py b/resources/tester/test_204_2_b09_bill_library.py index 30d551580..bcba88818 100644 --- a/resources/tester/test_204_2_b09_bill_library.py +++ b/resources/tester/test_204_2_b09_bill_library.py @@ -238,6 +238,7 @@ def test_화면_출처_다섯_사전조합_창_들여오기_줄_연결_TS(): "key": "SS000011"} # fmt: skip ref = {"tab": "dg", "key": "DG:new", "lib": "precombo@company@7@SC-000002"} assert out["applied"][0] == [ref, "회사 조합"] - # 원가 자료만 바꿔 끼움 — 사용자 값(서버 user)은 안 받음 - assert "user" not in out["payload"] and out["payload"]["inputs"] == {"daega": {"libs": {}}} + # 원가 자료 바꿔 끼움 — 702-3 user = 서버가 그 셈에 쓴 기준(화면 캐시는 그대로 · 다른 로직 줄 = 뒤 셈) + assert out["payload"]["user"] == {"bill": "서버 것"} + assert out["payload"]["inputs"] == {"daega": {"libs": {}}} assert out["toasts"][-1][0] == "success" diff --git a/resources/tester/test_298_2_b08_b09_buttons.py b/resources/tester/test_298_2_b08_b09_buttons.py index 6d498e0e5..84bf706b4 100644 --- a/resources/tester/test_298_2_b08_b09_buttons.py +++ b/resources/tester/test_298_2_b08_b09_buttons.py @@ -63,8 +63,8 @@ def test_B08_다음_단계_완료_뒤_이동_실패면_머묾(r): assert p["fail"] == ["req:POST:/projects/P1/quantity/complete", "toast:error"] -def test_B09_단추_줄_초기화_하나_저장_없음(r): - assert r["b09"]["dock"] == ["danger-ghost"] +def test_B09_단추_줄_저장_초기화(r): + assert r["b09"]["dock"] == ["filled", "danger-ghost"] # 702-3 [저장] = B08 과 같은 꼴 · 자리 assert r["b09"]["nextEnabled"] is True @@ -74,6 +74,7 @@ def test_B09_다음_저장_완료_인쇄(r): assert b["changed"] == [ True, [ + "ask", # 702-3 저장 안 한 고침 = 물음([저장] 고름) "lock", "fetch:PUT:/api/projects/P1/estimation", "unlock", @@ -101,7 +102,11 @@ def test_B09_초기화(r): def test_B09_초안_없으면_다음_흐림(r): - assert r["b09NoDraft"] == {"dock": ["danger-ghost"], "nextEnabled": False, "nav": [False, []]} + assert r["b09NoDraft"] == { + "dock": ["filled", "danger-ghost"], + "nextEnabled": False, + "nav": [False, []], + } def test_초안_다음_길_등록(): diff --git a/resources/tester/test_304_b09_view_setting.py b/resources/tester/test_304_b09_view_setting.py index 53266f234..82d2028aa 100644 --- a/resources/tester/test_304_b09_view_setting.py +++ b/resources/tester/test_304_b09_view_setting.py @@ -98,6 +98,6 @@ def test_잇기(): groups = (ROOT / GROUPS).read_text("utf-8") assert "toggle" not in groups and "redraw" not in groups and "createButton" not in groups shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") - assert "createDock([reset], () => isGroupTab(current) && show(current))" in shell + assert "createDock([save, reset], () => isGroupTab(current) && show(current))" in shell state = (ROOT / "A00_Common/b_page_state.ts").read_text("utf-8") assert '"b09-group-view": { bucket: "pref", scope: "global" },' in state diff --git a/resources/tester/test_481_2_save_move.py b/resources/tester/test_481_2_save_move.py index 6ba5d26c1..4b8c87582 100644 --- a/resources/tester/test_481_2_save_move.py +++ b/resources/tester/test_481_2_save_move.py @@ -59,12 +59,16 @@ def test_B08_좌측_옵션_save(r): assert o["keptOnFail"] is True -def test_B09_고친_값_저장_성공_뒤_이동(r): +def test_B09_고친_값_있으면_물음_뒤_이동(r): + # 702-3 B09 [저장] 단추 생김 — 단계 이동 = 저장 안 한 고침 있으면 물음(저장 · 버림 · 머묾) b = r["b09"] - assert b["beforeLoad"] == ["go:b08"] # 불러오기 전 = 저장 없이(캐시 남음) + assert b["beforeLoad"] == ["go:b08"] # 불러오기 전 = 물음 · 저장 없이(캐시 남음) assert b["clean"] == ["go:b08"] # 고침 없음 = 요청 없이 - assert b["fail"] == ["fetch:PUT:/api/projects/P1/estimation"] # 실패 = 머묾 - assert b["ok"] == ["fetch:PUT:/api/projects/P1/estimation", "go:b08"] + put = "fetch:PUT:/api/projects/P1/estimation" + assert b["fail"] == ["ask", put] # [저장] 실패 = 머묾 + assert b["ok"] == ["ask", put, "go:b08"] + assert b["discard"] == [["ask", "go:b08"], False] # [버림] = 캐시 지움 · 저장 없이 + assert b["stay"] == [["ask"], True] def test_자재_수리_탭_저장_등록(): diff --git a/resources/tester/test_702_3_b09_estimation_live.py b/resources/tester/test_702_3_b09_estimation_live.py index 26e235fcc..0e54c70ec 100644 --- a/resources/tester/test_702_3_b09_estimation_live.py +++ b/resources/tester/test_702_3_b09_estimation_live.py @@ -7,6 +7,8 @@ 임시 층(견본 마스터 · 임시 storage) · 실제 마스터 · 프로젝트는 안 씀. """ +import json +import subprocess import sys from pathlib import Path @@ -43,3 +45,38 @@ def test_서고_사본_산근_목록에_듦_대가_단가_채움(world): labor = compute_labor(daega, {**base, "gaps": []}, by_key) row = next(r for r in labor["daega"] if r["key"] == block["id"]) assert row["sum"] and not row.get("blank") + + +def test_화면_연결_뒤_셈_저장_단추_옮김_물음_TS(): + shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + live = (B09 / "B09_Estimation_UI_Live.ts").read_text("utf-8") + router = (B09 / "B09_Estimation_Router.py").read_text("utf-8") + assert '"/{project_id}/estimation/calc"' in router + assert "AbortController" in live and "seq" in live # 차례 번호 · 옛 요청 끊기 + assert "Common_Btn_Save" in shell and "b09Save" in shell + assert "askProjectLeave" in shell # 단계 이동 = 물음(저장 · 폐기 · 취소) + + +def test_뒤_셈_차례_번호_옛_요청_끊기_셈_중(): + proc = subprocess.run( + ["node", str(Path(__file__).parent / "helper_702_3_live.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + o = json.loads(proc.stdout) + assert o["wait"] == ["hand:a"] and o["same"] == [] # 로직 연결이 서버 기준과 다른 줄만 + assert o["strip"] == {} # 기다리는 줄 = 옛 산근 단가 안 씀 + assert o["mark"] == ["셈 중", None] # 그 줄 금액 칸만 + assert o["sentAfterSame"] == 1 # 가는 중 같은 지문 = 다시 안 보냄 + assert o["firstAborted"] is True # 바뀐 지문 = 옛 요청 끊음 + assert o["url"] == "/api/projects/P1/estimation/calc" + assert o["settled"] == [[2, 2]] and o["busyAfter"] is False # 마지막 것만 받음 + assert o["cancelled"] == [True, False] # 기다릴 줄 없어짐 = 가던 것 끊음 + assert o["failed"] == [ + ["warning"], + [None, 4], + 4, + ] # 못 받음 = 알림 한 번 · 같은 지문 다시 안 보냄