From 2789afb102727ab62bf007355e205b293d2a210d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 15:10:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02=20=EC=8B=A4=EB=AC=B4=EC=9E=90=EB=A3=8C?= =?UTF-8?q?=20=EB=B9=A0=EC=A7=90=200):=20=ED=99=95=EC=9D=B8=20=EC=95=88=20?= =?UTF-8?q?=ED=95=9C=20=EC=A4=84=20=C2=B7=20=EB=85=B8=EC=9E=84=20=C2=B7=20?= =?UTF-8?q?%=20=3D=20=EA=B3=A0=EC=A0=95=20=EC=A4=84=20=C2=B7=20[=EB=AA=A8?= =?UTF-8?q?=EB=91=90=20=EA=B3=A0=EC=A0=95=EC=9C=BC=EB=A1=9C=20=EB=93=A4?= =?UTF-8?q?=EC=97=AC=EC=98=A4=EA=B8=B0]=20=C2=B7=20[=ED=92=88=EC=85=88?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B0=94=EA=BE=B8=EA=B8=B0]=20(PLAN=20705?= =?UTF-8?q?-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 만들기 — 체크 안 한 줄 · 노임 · 「우리에 없음」 = 고정 줄(엑셀 숫자 그대로 · 서버가 파일에서) · % 줄 = 비율 고정 줄(밑수 = 다른 줄 합일 때 · 아니면 금액 그대로) · 합계만 = 재료비 · 같은 이름 = 차례 가름 · 하위 호표 = 확인된 사전조합 참조 - 이음 = 이름 · 규격 · 단위 · 단가 다 같은 노임 · 자재만 저절로 · 다른 단가 = 이음 후보 수 - 시트 통째 — /hopyo-build-all(하위 먼저 · 확인된 호표 그대로) · 알림 「호표 · 줄 · 품셈 줄 · 고정 줄 · 빠짐 · 엑셀 합계 = 우리 합계」 · 비교 = 엑셀 끝수 규칙(EST 내림 · STmate 반올림) - 비율 고정 줄 셈 — M02 settle 짝(파이썬 · TS ratioLines) · 사전조합 셈 · B09 pct 줄 - 확인 창 — 만든 사전조합 품셈 줄 = 첫 체크 · [품셈으로 바꾸기] · [모두 고정으로] · 생성 물음에 전후 금액 · 품 다른 줄 수 - 낡은 시험 고침 — 빈 몸 400 · 빠진 줄(확인 안 함 · %) 단정 = 고정 줄로 바뀐 규칙 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Labor_Source.py | 7 +- M02_MasterTemplete/M02_Hopyo_Build.py | 158 ++++++++++-- M02_MasterTemplete/M02_Hopyo_Fixed.py | 143 +++++++++++ M02_MasterTemplete/M02_Hopyo_Line.py | 13 +- M02_MasterTemplete/M02_Labor_Doc.py | 1 + M02_MasterTemplete/M02_Labor_Fixed.py | 37 ++- M02_MasterTemplete/M02_Labor_Ref.py | 10 +- M02_MasterTemplete/M02_Labor_Router.py | 23 ++ .../M02_MasterTemplete_Hopyo_Api.ts | 58 ++++- .../M02_MasterTemplete_Hopyo_Check.ts | 79 +++++- .../M02_MasterTemplete_Hopyo_Sheet.ts | 65 ++++- .../M02_MasterTemplete_Labor_Calc.ts | 26 +- resources/tester/test_705_3_hopyo_fixed.py | 226 ++++++++++++++++++ resources/tester/test_m02_hopyo_build.py | 25 +- resources/tester/test_m02_hopyo_flow.py | 5 +- 15 files changed, 825 insertions(+), 51 deletions(-) create mode 100644 M02_MasterTemplete/M02_Hopyo_Fixed.py create mode 100644 resources/tester/test_705_3_hopyo_fixed.py diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index 6906626af..ba3d269db 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -329,7 +329,12 @@ class Builder: def fixed_line(fx: dict, qty: Any) -> dict[str, Any]: - """고정 줄(705-1) → 블록 줄 `fixed` — 수량 그대로(배 1) · 이음 없는 비목 = 줄 단가.""" + """고정 줄(705-1) → 블록 줄 `fixed` — 수량 그대로(배 1) · 이음 없는 비목 = 줄 단가 · + 비율 줄(705-3) = `pct`(밑 = 블록 그 비목 합 · 수량 = 율).""" + if rate := fx.get("비율"): + name = {"name": fx.get("이름") or "", "spec": fx.get("규격") or "", "unit": "%"} + return {**name, "kind": "pct", "part": PART[rate["비목"]], + "base": f"part:{PART[rate['기준']]}", "qty": _f(qty)} # fmt: skip link = fx.get("이음") or {} return { "kind": "fixed", diff --git a/M02_MasterTemplete/M02_Hopyo_Build.py b/M02_MasterTemplete/M02_Hopyo_Build.py index 9546c75c1..9f214d653 100644 --- a/M02_MasterTemplete/M02_Hopyo_Build.py +++ b/M02_MasterTemplete/M02_Hopyo_Build.py @@ -2,7 +2,7 @@ [확인하고 사전조합 만들기] 몸 = 사용자가 체크한 줄만 — `줄 [{차례, 대상 [{로직|자재|호표, 입력값, 배, 몫}] | 뺌}]` · 직접 줄 호표 = `통째 {대상}`. -몸이 비면 400(확인 없이 들어가는 길 없음). +몸이 비면 = 모두 고정(705-3 · [모두 고정으로 들여오기]). 서버 재검사(브라우저 값을 받아 적지 않음 · 금액 · 품 칸 없음) — 차례 = 그 호표 줄 · 대상 하나 = 돈 로직 · M01 자재품목 · 확인된 호표 중 하나 · 입력 이름 = 그 로직 입력 · 배 0 넘음 · 몫 (0, 1] · 호표 = 만들 층에 확인된 사전조합(없으면 409 「제N호표 먼저 확인」). @@ -20,19 +20,24 @@ 지역은 프로젝트에서) · 장부엔 안 남김. 같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 확인한 줄은 별칭 장부에 남음 (`M02_Hopyo_Ledger` · 호표 참조 줄 뺌). -빠진 줄(확인 안 함 · 뺌 · %) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가(참고). +빠짐 0(705-3) — 확인 안 한 줄 · 노임 · % · 「우리에 없음」 줄 = 고정 줄(`M02_Hopyo_Fixed` · 엑셀 숫자 +그대로) · 확인 안 한 호표 줄 = 확인된 하위 사전조합 참조(단위 같음 · 아니면 고정) · 빠진 줄 = 「안 씀」 · +통째 대신 · 로직 자재 겹침 · 전국만. `본뜬.품셈` = 품셈으로 넣은 엑셀 줄 차례([품셈으로 바꾸기] 첫 체크) · +`줄수 {품셈, 고정, 호표, 빠짐, 이음, 이음후보}` · 만든 값 ↔ 엑셀 호표 단가(`비교` · 같음 = 비목마다 1 원 안). 엔진 · 일위대가 계산은 그대로(`M02_Labor_Ref.Expand`). """ from __future__ import annotations import json -from decimal import Decimal +from decimal import ROUND_FLOOR, ROUND_HALF_UP, Decimal from pathlib import Path from typing import Any from common_util.common_util_time import utc_now_text from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Hopyo_Finger as finger +from M02_MasterTemplete import M02_Hopyo_Fixed as hfix from M02_MasterTemplete import M02_Hopyo_Ledger as ledger from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete import M02_Hopyo_Material as hmat @@ -177,8 +182,8 @@ class Builder: """몸 재검사 → ({차례: 확인 줄}, 통째 대상) — 틀리면 400 · 하위 호표 409.""" if whole is not None and lines: raise _bad("통째 · 줄 중 하나만") - if not lines and not (whole or {}).get("대상"): - raise _bad("확인한 줄이 없음") + if whole is not None and not whole.get("대상"): + raise _bad("통째 대상이 없음") if len(lines) > LINES_MAX: raise _bad(f"줄은 {LINES_MAX} 개 안") have = self.hopyo.get("줄") or [] @@ -221,6 +226,9 @@ class Builder: hopyo, file = self.hopyo, self.doc.get("파일") or "" lines, mats, missing, used, mat_lines = [], {}, [], set(), {} nation: dict[tuple, dict[str, Any]] = {} + taken, linker, poomsem, kids, kid_n = set(), hfix.Linker(), [], set(), 0 + where = {"파일": file, "호표": self.no} + rows = hopyo.get("줄") or [] def put(line: dict[str, Any], one: dict[str, Any], alias: str | None) -> None: qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(one["배"])) @@ -252,6 +260,7 @@ class Builder: mat_lines[one["자재"]] = line return if "호표" in one: + kids.add(one["호표"]) ref = { "층": refs.here()[0], "주인": refs.here()[1], @@ -282,18 +291,23 @@ class Builder: for one in whole: put({"수량": 1, "단위": hopyo.get("단위"), "이름": hopyo.get("이름")}, one, None) book[ledger.hopyo_key(hopyo)] = self._row(hopyo, "짝", whole) - for at, line in enumerate(hopyo.get("줄") or []): + for at, line in enumerate(rows): got = checked.get(at) - if line.get("갈래") == "%": - missing.append(_missing(line, PERCENT)) + if whole: + missing.append(_missing(line, WHOLE)) continue - if whole or got is None: - missing.append(_missing(line, WHOLE if whole else UNCHECKED)) - continue - if got.get("뺌"): - missing.append(_missing(line, f"뺌 — {got['뺌']}")) + if got and got.get("뺌"): book[ledger.key(line)] = self._row(line, got["뺌"], []) + if got and got.get("뺌") == "안 씀": + missing.append(_missing(line, f"뺌 — {got['뺌']}")) continue + if not got or got.get("뺌"): # 빠짐 0(705-3) — 하위 호표 참조 · 아니면 고정 줄 + kid = self._kid(line, kids) + kid_n += bool(kid) + others = [x for x in rows if x is not line] + lines.append(kid or hfix.line_of(line, others, where, taken, linker)) + continue + poomsem.append(at) alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None for one in got["대상"]: put(line, one, alias) @@ -314,9 +328,32 @@ class Builder: } if nation: doc["본뜬"]["전국자재"] = list(nation.values()) + doc["본뜬"]["품셈"] = poomsem ref, again = _save(doc) wrote = ledger.write(self.folder, book, self.who, file or None) - return {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}} + fixed_n = sum(1 for x in lines if "고정" in x) + links = sum(1 for x in lines if any(((x.get("고정") or {}).get("이음") or {}).values())) + count = {"품셈": len(poomsem), "고정": fixed_n, "호표": kid_n} + count |= {"빠짐": sum(1 for x in missing if x["까닭"] != TWICE), "이음": links} + out = {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}} + return {**out, "줄수": {**count, "이음후보": linker.near}} + + def _kid(self, line: dict[str, Any], kids: set[int]) -> dict[str, Any] | None: + """확인 안 한 호표 줄 → 확인된 하위 사전조합 참조(단위 같음 · 같은 하위 한 번) · 아니면 None.""" + ref = line.get("참조") or {} + if finger.kind_of(line) != "호표" and ref.get("종류") != "일위": + return None + no = int(ref.get("번") or 0) + made = self.done.get(mark(self.key, no)) + unit = next((h.get("단위") for h in self.doc["호표"] if int(h["번"]) == no), None) + times = hopyo_match.factor(line.get("단위"), unit, line.get("규격"))["배"] + if not made or no in kids or no == self.no or times != 1: + return None + kids.add(no) + qty = Decimal(str(line.get("수량") or 0)) + head = {"층": refs.here()[0], "주인": refs.here()[1], "키": made} + return {"참조": head, "별칭": str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None, + "메모": f"실무 {qty}{line.get('단위') or ''} · 제{no}호표", "수량": {"손": _num(qty)}} # fmt: skip def _inside(self, lines: list[dict[str, Any]], mats: dict[str, dict[str, Any]]) -> list[str]: """로직 자재와 같은 품목(Labor_Doc `no_twice` 400 막음) — 사용자 자재에서 빼고 @@ -346,14 +383,43 @@ class Builder: } +# 엑셀 끝수 꼴(705-3) — (줄 금액 0.1 · 합 원 자름, 계 = 줄 계 합에서) · EST = 비목 합 내림 · +# STmate = 줄 반올림 · 계 = 줄 계 합 내림(비목 머리 값과 따로) +CUTS = ((ROUND_FLOOR, False), (ROUND_HALF_UP, True), (ROUND_HALF_UP, False)) + + +def _excel_round(got: dict[str, Any], how: str, by_line: bool) -> Decimal | None: + """엑셀 끝수(705-3) — 줄 금액 비목마다 0.1(`how`) · 하위 호표 단가 = 원 · 비율 줄 = 기준 × 율 ÷ 100 · + 합 = 비목마다 원(`how`) 더함 · `by_line` = 줄 계 합 원 내림 · 못 셈 줄 있으면 None.""" + parts = dict.fromkeys(labor.COSTS, Decimal(0)) + for one in got.get("안") or []: + if one.get("기준") is None: + return None + cut = 100 if (one.get("고정") or {}).get("비율") else 1 + for k in labor.COSTS: + unit = Decimal(one["기준"][k]) + unit = unit.to_integral_value(how) if "참조" in one else unit + parts[k] += (unit * one["수량"] / cut).quantize(Decimal("0.1"), how) + if by_line: + return sum(parts.values()).to_integral_value(ROUND_FLOOR) + return sum(v.to_integral_value(how) for v in parts.values()) + + def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]: - """만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가(참고 · 판정 아님).""" + """만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가(참고 · 판정 아님) — 우리 = 엑셀 끝수 규칙(내림 · + 반올림 중 엑셀과 같은 쪽 · `_excel_round`).""" got = refs.Expand(m01.loaded()[1])({k: ref[k] for k in ("층", "주인", "키")}) base = got.get("기준") or {} - ours = float(base["계"]) if "계" in base else None excel = (hopyo.get("금액") or {}).get("계") + cuts = [_excel_round(got, *one) for one in CUTS] if base else [] + cuts = [c for c in cuts if c is not None] + cut = next( + (c for c in cuts if abs(c - Decimal(str(excel or 0))) < 1), cuts[0] if cuts else None + ) + ours = float(cut if cut is not None else base["계"]) if "계" in base else None gap = round(ours - excel, 1) if ours is not None and excel is not None else None - return {"엑셀": excel, "우리": ours, "차이": gap, "까닭": got.get("까닭")} + same = ours is not None and abs(ours - (excel or 0)) < 1 # 705-3 — 엑셀 금액 없음 = 0 + return {"엑셀": excel, "우리": ours, "차이": gap, "같음": same, "까닭": got.get("까닭")} def build( @@ -371,3 +437,61 @@ def build( checked, pairs = maker.check(list(lines or []), whole) made = maker.build(checked, pairs) return {**made, "이름": maker.hopyo.get("이름"), "비교": compare(made["참조"], maker.hopyo)} + + +def _order(units: dict[int, dict[str, Any]]) -> list[int]: + """하위 호표 먼저(호표 줄 참조) · 고리는 끊음.""" + out: list[int] = [] + seen: set[int] = set() + + def visit(no: int) -> None: + if no in seen or no not in units: + return + seen.add(no) + for line in units[no].get("줄") or []: + ref = line.get("참조") or {} + if finger.kind_of(line) == "호표" or ref.get("종류") == "일위": + visit(int(ref.get("번") or 0)) + out.append(no) + + for no in sorted(units): + visit(no) + return out + + +def build_all(folder: Path, source: Path, key: str, who: Any = None) -> dict[str, Any]: + """[모두 고정으로 들여오기](705-3) — 시트 호표 모두 · 하위 먼저 · 줄 = 고정(하위 = 참조) · + 이미 확인된 호표는 덮지 않음(`그대로`) → `{호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보, + 같음, 그대로, 다름 [{번, 이름, 엑셀, 우리, 차이}], 실패 [{번, 까닭}]}`.""" + units = {int(h["번"]): h for h in hopyo_store.read(source, key).get("호표") or []} + sums = dict.fromkeys(("품셈", "고정", "호표", "빠짐", "이음", "이음후보"), 0) + out: dict[str, Any] = { + "호표": len(units), + "줄": 0, + "같음": 0, + "그대로": 0, + "다름": [], + "실패": [], + } + for no in _order(units): + out["줄"] += len(units[no].get("줄") or []) + try: + maker = Builder(folder, source, key, no, who) + have = maker.done.get(mark(key, no)) + if have: + out["그대로"] += 1 + ref = {"층": refs.here()[0], "주인": refs.here()[1], "키": have} + else: + made = maker.build({}, []) + ref = made["참조"] + for k in sums: + sums[k] += made["줄수"][k] + got = compare(ref, maker.hopyo) + except store.StoreError as e: + out["실패"].append({"번": no, "까닭": str(e.detail)}) + continue + if got["같음"]: + out["같음"] += 1 + else: + out["다름"].append({"번": no, "이름": units[no].get("이름"), **got}) + return {**out, **{("호표참조" if k == "호표" else k): v for k, v in sums.items()}} diff --git a/M02_MasterTemplete/M02_Hopyo_Fixed.py b/M02_MasterTemplete/M02_Hopyo_Fixed.py new file mode 100644 index 000000000..262697496 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Fixed.py @@ -0,0 +1,143 @@ +"""M02 실무자료 줄 → 고정 줄(PLAN 705-3 · 요구서 1장 · 4장 2 · 3 · 7) — 엑셀 숫자 그대로 · 빠짐 0. + +맞춤 확인 안 한 줄 · 노임 · % · 「우리에 없음」 줄 = 빠진 줄 대신 고정 줄(705-1 그릇 `M02_Labor_Fixed`). +숫자는 서버가 실무자료 파일에서 읽음(브라우저 값 안 받음). + 단가 = 엑셀 단가 노무비 · 재료비 · 경비(빈 칸 0) · 합계만 있는 줄 = 재료비(4장 3) · + 수량 없음 = 수량 1 · 단가 = 엑셀 금액(「금액만」). + 이음 = 이름 · 규격 · 단위 · 단가가 다 같은 노임(LB) · 자재(MT)만 저절로 → 엑셀 합계 = 우리 합계 · + 단가가 다른 짝 = `후보` 수만(바꾸기는 705-2 「이 공사 단가로 다시 잇기」). + % 줄 = 비율 줄 `비율 {기준, 비목}` — 엑셀 밑수(단가 계) = 다른 줄 금액[기준] 합일 때만 · + 아니면(주재료비 등) 수량 1 · 「식」 · 단가 = 엑셀 금액. + 같은 이름 · 규격 · 단위 둘째부터 = 이름 끝 「 (2)」(줄 가름 · 705-1 400 피함). +출처 = `{파일, 호표, 줄}`(줄 = 엑셀 줄 차례 0부터 · [품셈으로 바꾸기] 짝). +""" + +from __future__ import annotations + +import re +from decimal import Decimal +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Hopyo_Finger as finger +from M02_MasterTemplete import M02_Hopyo_Match as hm +from M02_MasterTemplete import M02_Hopyo_Material as hmat +from M02_MasterTemplete import M02_Hopyo_Shares as shares +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Fixed as fixed + +COSTS = ("노무비", "재료비", "경비") +_BASE = re.compile(r"(? dict[str, Decimal]: + """엑셀 단가 · 금액 → 세 비목(빈 칸 0) · 합계만 있으면 재료비(4장 3).""" + money = money if isinstance(money, dict) else {} + out = {k: _d(money.get(k)) or Decimal(0) for k in COSTS} + total = _d(money.get("계")) + if total and not any(_d(money.get(k)) is not None for k in COSTS): + out["재료비"] = total + return out + + +def _ratio(line: dict[str, Any], others: list[dict[str, Any]]) -> dict[str, str] | None: + """% 줄 → `{기준, 비목}` — 밑수 = 다른 줄 엑셀 금액[기준] 합(끝수 1 원 안) · 금액 비목 하나.""" + hit = _BASE.search(f"{line.get('규격') or ''} {line.get('이름') or ''}") + under = _d((line.get("단가") or {}).get("계")) + paid = [k for k, v in _parts(line.get("금액")).items() if v] + if not hit or under is None or len(paid) != 1: + return None + base = "노무비" if hit.group(1) == "인건비" else hit.group(1) + total = sum((_parts(x.get("금액"))[base] for x in others), Decimal(0)) + return {"기준": base, "비목": paid[0]} if abs(total - under) <= GAP else None + + +class Linker: + """이음 — 노임(LB 이름 같음) · 자재(이름 · 규격 같은 한 품목 · 단위 같음)가 단가까지 같을 때만.""" + + def __init__(self) -> None: + self.whole = m01.loaded()[1] + self.got = hm.targets() + self.labor: dict[str, list[tuple[str, Decimal | None]]] = {} + for row in self.whole.numbers.get("LB", {}).values(): + pair = (str(row.get("키")), _d(row.get("값"))) + self.labor.setdefault(str(row.get("이름") or ""), []).append(pair) + self.near = 0 # 단가 다른 이음 후보 수 + + def link(self, line: dict[str, Any], price: dict[str, Decimal]) -> dict[str, str | None]: + out: dict[str, str | None] = {"노임": None, "자재": None, "기계": None} + kind = finger.kind_of(line) + if kind == "노임": + pairs = self.labor.get(shares.trade(line.get("이름")) or "", []) + out["노임"] = self._same(pairs, price["노무비"]) + elif kind in hm.MATERIAL_KINDS and price["재료비"]: + out["자재"] = self._same(self._mats(line), price["재료비"]) + # ponytail: 기계 이음 없음 — 기계 시간 단가는 B09 중기목록표 셈 · 같은 값 판정 못 함 + return out + + def _same(self, pairs: list[tuple[str, Decimal | None]], want: Decimal) -> str | None: + same = [code for code, value in pairs if value is not None and abs(value - want) < GAP] + if len(same) == 1: + return same[0] + self.near += bool(pairs) + return None + + def _mats(self, line: dict[str, Any]) -> list[tuple[str, Decimal | None]]: + me, _ = hmat.clean(line) + found = hm._exact(me, "자재", self.got) + if len(found) != 1: + found = [r["one"] for r in hmat.sure(me)[0]] + out = [] + for one in found: + code = one.get("자재") + if not code or hm.factor(line.get("단위"), one.get("단위"))["배"] != 1: + continue + try: + price = fixed.mm.element(self.whole, code, {}, line.get("단위") or None)[0] + except fixed.mf.FormulaError: + price = None + out.append((code, _d(price))) + return out + + +def line_of( + line: dict[str, Any], + others: list[dict[str, Any]], + where: dict[str, Any], + taken: set[str], + linker: Linker, +) -> dict[str, Any]: + """엑셀 줄 하나 → 일위대가 고정 줄 `{고정, 별칭, 메모, 수량}` — `taken` = 이 호표 줄 가름(채워 감).""" + qty = _d(line.get("수량")) + spec, unit = str(line.get("규격") or "").strip(), str(line.get("단위") or "").strip() + rate = _ratio(line, others) if finger.kind_of(line) == "%" else None + memo = f"실무 {qty if qty is not None else ''}{unit} 그대로" + if rate: + price = dict.fromkeys(COSTS, Decimal(0)) + elif qty is None or finger.kind_of(line) == "%": # 금액만 · 밑수 다른 % 줄 + price, qty, memo = _parts(line.get("금액")), Decimal(1), f"{memo} · 금액만" + unit = "식" if finger.kind_of(line) == "%" else unit + else: + price = _parts(line.get("단가")) + name = str(line.get("이름") or "").strip() or "이름 없음" + head, n = name, 1 + while fixed.fixed_id({"이름": name, "규격": spec, "단위": unit}) in taken: + n += 1 + name = f"{head} ({n})" + fx = { + "이름": name, + "규격": spec, + "단위": unit, + "단가": {k: _plain(v) for k, v in price.items()}, + } + fx["이음"] = dict.fromkeys(fixed.LINKS) if rate else linker.link(line, price) + fx["출처"] = {**where, "줄": int(line.get("차례", 0))} + if rate: + fx["비율"] = rate + taken.add(fixed.fixed_id(fx)) + alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None + return {"고정": fx, "별칭": alias, "메모": memo, "수량": {"손": _plain(qty)}} diff --git a/M02_MasterTemplete/M02_Hopyo_Line.py b/M02_MasterTemplete/M02_Hopyo_Line.py index b53ae639e..3cb722f25 100644 --- a/M02_MasterTemplete/M02_Hopyo_Line.py +++ b/M02_MasterTemplete/M02_Hopyo_Line.py @@ -21,6 +21,7 @@ from __future__ import annotations +import json from pathlib import Path from typing import Any @@ -382,10 +383,20 @@ def hopyo_view(hopyo: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, if full: made = ctx.done.get(hopyo_build.mark(ctx.key, int(hopyo["번"]))) out.update(통째=whole(hopyo, ctx), 셈=ctx.calc) - out["만든"] = {"키": made, "확인": True} if made else None + out["만든"] = {"키": made, "확인": True, **_made(ctx.to, made)} if made else None return out +def _made(folder: Path, key: str) -> dict[str, Any]: + """만든 사전조합의 줄 갈래(705-3) — `품셈` = 품셈으로 넣은 엑셀 줄 차례(옛 것 = None) · `고정` 줄 수.""" + try: + doc = json.loads((Path(folder) / "precombo" / f"{key}.json").read_bytes()) + except (OSError, ValueError): + return {"품셈": None, "고정": 0} + fixed = sum(1 for x in doc.get("담은로직") or [] if isinstance(x, dict) and "고정" in x) + return {"품셈": (doc.get("본뜬") or {}).get("품셈"), "고정": fixed} + + def guess( folder: Path, key: str, no: int, at: int, target: Any, to: Path | None = None ) -> dict[str, Any]: diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index d838ceb5c..1ec4226dc 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -449,6 +449,7 @@ def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]: continue bases.append({**zero, "재료비": price, "계": price} if price is not None else None) reflects.append({**zero, "재료비": cost, "계": cost} if cost is not None else None) + fixed.ratios(lines, reflects, bases) # 비율 고정 줄(705-3) — 다른 줄 반영합 × 율 return _json({"줄": lines, "자재": mats, "기준합": _sum(bases), "반영합": _sum(reflects)}) diff --git a/M02_MasterTemplete/M02_Labor_Fixed.py b/M02_MasterTemplete/M02_Labor_Fixed.py index 32f76da8d..3f87a65e3 100644 --- a/M02_MasterTemplete/M02_Labor_Fixed.py +++ b/M02_MasterTemplete/M02_Labor_Fixed.py @@ -7,6 +7,8 @@ (노임 새 판 때 따라감) · 품(수량)은 그대로. M02 셈(층 · 사본) = 노임 「값」 · 자재 기준단가로 이음 · 기계 = 줄 단가(기계 시간 단가 = B09 단가 책 몫). 출처 = `{파일, 호표, 줄, 판}`(글 · 수만) · 줄 가름 = 「고정|이름|규격|단위」(짝 `Labor_Calc.ts` `lineId`). +비율 줄(705-3 · 엑셀 % 줄) = `비율 {기준, 비목}` · 수량 = 율(%) — 반영[비목] = 다른 줄 반영합[기준] × 율 ÷ 100 +(`ratios` · 짝 `Labor_Calc.ts` `ratioLines` · B09 = `pct` 줄). B09 대가 갈래 `fixed` = `B09_Estimation_Labor_Source.fixed_line` · 셈 `B09_Estimation_Calc_Labor`. """ @@ -76,10 +78,16 @@ def clean(fx: Any, where: str, whole: Any) -> dict[str, Any]: if code and (code[:2] != head or not _has(whole, code)): raise _bad(f"{where} · 고정 줄 {k} 이음 「{code}」 M01 에 없음") link[k] = code or None + rate = fx.get("비율") + if rate is not None and not ( + isinstance(rate, dict) and rate.get("기준") in COSTS and rate.get("비목") in COSTS + ): + raise _bad(f"{where} · 고정 줄 비율은 {{기준, 비목}} — 노무비 · 재료비 · 경비") 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 {})} + out = {**out, "단가": price, "이음": link, **({"출처": kept} if kept else {})} + return {**out, "비율": {k: rate[k] for k in ("기준", "비목")}} if rate else out def _has(whole: Any, code: str) -> bool: @@ -102,6 +110,9 @@ 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} + if fx.get("비율"): # 기준 · 반영 = `ratios`(settle 끝) + return {FIXED: fx, "이름": fx.get("이름"), "알약": None, "분모": "%", "배": 100, + "기준": {**base, "계": Decimal(0)}, "이은": [], "까닭": None} # fmt: skip link, used = fx.get("이음") or {}, [] if code := link.get("노임"): try: @@ -121,3 +132,27 @@ def line(whole: Any, fx: dict) -> dict[str, Any]: money = {**base, "계": sum(base.values())} head = {FIXED: fx, "이름": fx.get("이름"), "알약": None, "분모": fx.get("단위"), "배": 1} return {**head, "기준": money, "이은": used, "까닭": None} + + +def ratio_row(rate: dict, qty: Decimal | None, under: dict) -> tuple[dict, dict | None]: + """비율 줄 (기준, 반영) — 기준[비목] = 밑[기준] · 반영 = 기준 × 율 ÷ 100(수량 없음 = None).""" + base = dict.fromkeys((*COSTS, "계"), Decimal(0)) + base[rate["비목"]] = base["계"] = under[rate["기준"]] + return base, None if qty is None else {k: v * qty / 100 for k, v in base.items()} + + +def ratios(lines: list[dict], reflects: list[dict | None], bases: list[dict | None]) -> None: + """비율 줄(705-3) — `settle` 끝에 · 밑 = 비율 줄 뺀 반영합(줄 · 자재) · 배 100 · 기준합엔 안 셈 · + `lines` · `reflects` · `bases` 앞 줄 = 같은 차례(짝 `Labor_Calc.ts` `ratioLines`).""" + at = {i for i, x in enumerate(lines) if (x.get(FIXED) or {}).get("비율")} + under = { + k: sum((r[k] for i, r in enumerate(reflects) if r and i not in at), Decimal(0)) + for k in COSTS + } + for i in sorted(at): + base, done = ratio_row(lines[i][FIXED]["비율"], lines[i].get("수량"), under) + lines[i], reflects[i], bases[i] = ( + {**lines[i], "기준": base, "배": 100, "반영": done}, + done, + None, + ) diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index 03f62c17c..69cb5600e 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -26,6 +26,7 @@ from decimal import Decimal from pathlib import Path from typing import Any +from M02_MasterTemplete import M02_Labor_Fixed as fixed from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Template_Layers as layers @@ -206,7 +207,7 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]: lines = [] for one in labor.clean({"담은로직": doc.get("담은로직") or []})["담은로직"]: - head = {"참조": one["참조"]} if "참조" in one else {"로직": one["로직"]} + head = {k: one[k] for k in ("참조", "고정") if k in one} or {"로직": one["로직"]} if "로직" in one: head["입력값"] = one["입력값"] head.update({k: one[k] for k in ("켜쌓기율", "고름", "자재표시") if k in one}) @@ -461,6 +462,8 @@ class Expand: head, sub = {"참조": ref}, self(ref, trail) elif one.get("로직"): # 로직 줄(51-11) = 1 단위 값(기준 ÷ 배 · 승격과 같음) head, sub = {"로직": one["로직"]}, self._logic(one, by_logic.get(one["로직"])) + elif fixed.is_fixed(one): # 고정 줄(705-3) = 줄 단가 · 비율 줄 = 끝에(`ratio_row`) + head, sub = {"고정": one["고정"]}, fixed.line(self.whole, one["고정"]) else: continue qty = labor._number((one.get("수량") or {}).get("손")) @@ -494,6 +497,11 @@ class Expand: why = why or f"「{name}」 {got['까닭']}" continue rows.append({k: v * qty for k, v in base.items()}) + under = labor._sum(rows) + for one in inner: # 비율 고정 줄(705-3) — 밑 = 다른 줄 합 + if (one.get("고정") or {}).get("비율"): + one["기준"], row = fixed.ratio_row(one["고정"]["비율"], one["수량"], under) + rows.append(row) return { "이름": doc.get("이름"), "단위": doc.get("단위"), diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 7e278e7c4..76a3429b2 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -382,6 +382,29 @@ def hopyo_make( return _call(folder, hopyo_build.build, *args) +class BuildAllBody(_Strict): + """시트 통째(705-3) — 파일 층 `층` · `주인` · `키` 만(숫자 · 줄 칸 없음 · 서버가 파일에서 읽음).""" + + 층: Layer + 주인: str | None = None + 키: str + + +@router.post("/layers/{layer}/hopyo-build-all") +def hopyo_make_all( + layer: Layer, + body: BuildAllBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """[모두 고정으로 들여오기] — 시트 호표 모두 고정 줄로 · 하위 먼저 · 확인된 호표 그대로 → + `{호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보, 같음, 그대로, 다름, 실패}`.""" + folder = _root(session, layer, owner, write=True) + source = _root(session, body.층, body.주인) + args = (folder, source, body.키, _who(session)) + return _call(folder, hopyo_build.build_all, *args) + + @router.get("/layers/{layer}/hopyo-ledger") def hopyo_ledger_read( layer: Layer, owner: str | None = None, session: Session = Depends(verify_session) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index 3e2ddaa0f..27f12f6bc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -181,7 +181,13 @@ export interface HopyoOne extends HopyoFile { 통째?: { 대상: HopyoPart[]; 글?: string; 체크: boolean } | null; 셈?: HopyoCalc; /** 만들 층에 이 호표로 만든 사전조합 */ - 만든?: { 키: string; 확인: boolean } | null; + 만든?: { + 키: string; + 확인: boolean; + /** 705-3 — 품셈으로 넣은 엑셀 줄 차례(옛 것 = null) · 고정 줄 수 */ + 품셈?: number[] | null; + 고정?: number; + } | null; }; } @@ -226,8 +232,49 @@ export interface HopyoBuilt { 이름: string; 다시씀: boolean; 빠진줄: HopyoMissing[]; - 비교: { 엑셀: number | null; 우리: number | null; 차이: number | null; 까닭: string | null }; + 비교: { + 엑셀: number | null; + 우리: number | null; + 차이: number | null; + /** 705-3 — 엑셀 끝수 규칙으로 같음 */ + 같음?: boolean; + 까닭: string | null; + }; 장부?: { 씀: number }; + /** 705-3 — 엑셀 줄 갈래 수 */ + 줄수?: HopyoLineCount; +} + +/** 705-3 줄 갈래 수 — 품셈 · 고정 · 호표(하위 참조) · 빠짐 · 이음(저절로) · 이음후보(단가 다름) */ +export interface HopyoLineCount { + 품셈: number; + 고정: number; + 호표: number; + 빠짐: number; + 이음: number; + 이음후보: number; +} + +/** [모두 고정으로 들여오기] 결과(705-3) */ +export interface HopyoBuiltAll { + 호표: number; + 줄: number; + 품셈: number; + 고정: number; + 호표참조: number; + 빠짐: number; + 이음: number; + 이음후보: number; + 같음: number; + 그대로: number; + 다름: { + 번: number; + 이름: string; + 엑셀: number | null; + 우리: number | null; + 차이: number | null; + }[]; + 실패: { 번: number; 까닭: string }[]; } /** [로직 추가] 「실무 호표」 줄 하나 — 확인 = 문서 층에 확인된 사전조합 */ @@ -340,6 +387,13 @@ export const buildHopyo = ( body: JSON.stringify(통째 ? { ...from, 통째 } : { ...from, 줄 }), }); +/** [모두 고정으로 들여오기](705-3) — 시트 호표 모두 · 숫자는 서버가 파일에서 */ +export const buildHopyoAll = ( + target: HopyoScope, + from: { 층: Layer; 주인: string | null; 키: string }, +): Promise => + call(at(target, "/hopyo-build-all"), { method: "POST", body: JSON.stringify(from) }); + export const listHopyoPicks = async (scope: HopyoScope): Promise => (await call<{ 줄: HopyoPickRow[] }>(at(scope, "/hopyo-picks"))).줄; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts index c6cc53813..92867ef87 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts @@ -9,6 +9,9 @@ * · 대상 · 옵션을 바꾸면 서버 `guess`(저장 안 함 · 줄마다 최신 응답만)가 우리 품을 다시 셈 * · 캐시 = sessionStorage `m02-hopyo-check:{층}:{주인}:{키}:{번}`(조작 즉시 · 만들기 성공하면 지움) · 자동 저장 없음 * · 화면 잠금은 [사전조합 생성] 때만 · 쓰기 단추 = 만들 층 쓰기 권한만. + * · 705-3 — 체크 안 한 줄 = 고정 줄(엑셀 숫자 그대로) · 만든 사전조합이 있으면 첫 체크 = 그 품셈 줄(`만든.품셈`) · + * [품셈으로 바꾸기 (n)] = 맞음 · 별칭 줄 체크 + 생성 · [모두 고정으로] = 체크 모두 끔 + 생성(되돌리기) · + * 생성 물음에 전후 금액(품셈 = 우리 미리 셈 · 고정 = 엑셀) · 품 다른 줄 수. * 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build` · `/labor/{열}/calc`) — 화면은 보이기 · 체크 · 고름만. * ========================================================================== */ @@ -240,6 +243,8 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { const one = await readHopyoOne(opt.scope, opt.key, opt.no, opt.target); if (my !== seq) return; got = one; + const made = one.호표.만든?.품셈; // 705-3 — 만든 사전조합의 품셈 줄 = 첫 체크 + if (made && !hasDraft(k)) for (const l of one.호표.줄) d.체크[l.차례] = made.includes(l.차례); draw(); } catch (error) { if (my === seq && !quiet) root.replaceChildren(loadingError(why(error), () => void load())); @@ -391,21 +396,38 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { 단산행: 행, }); + /** 1 단위 금액(705-3) — 품셈 줄 = 우리 미리 셈 · 고정 줄 = 엑셀 · 셈 중 = null */ + function sumOf(lines: HopyoLine[], poom: (l: HopyoLine) => boolean): number | null { + let total = 0; + for (const l of lines) { + const p = poom(l) ? price(l) : null; + if (p && !p.calc && !p.fail) return null; + total += p?.calc?.반영합?.계 ?? l.금액.계 ?? 0; + } + return total; + } + async function make(h: HopyoOne["호표"]): Promise { const whole = !!h.통째 && (d.통째 ?? h.통째.체크); const lines = whole ? [] : bodyOf(h.줄, d); - const out = leftOut(h.줄, d); - const put = lines.filter((x) => x.대상).length; - const outs = lines.length - put; + const poom = (l: HopyoLine): boolean => isChecked(l, d) && checkable(l, d) && !d.뺌[l.차례]; + const now = (l: HopyoLine): boolean => (h.만든?.품셈 ?? []).includes(l.차례); + const outs = h.줄.filter((l) => isChecked(l, d) && d.뺌[l.차례] === "안 씀").length; + const put = h.줄.filter(poom); + const diff = put.filter((l) => { + const q = d.품[l.차례] ?? l.맞춤.품; + return !!q && Object.values(q.같음).some((v) => !v); + }).length; + const after = sumOf(h.줄, poom); + const known = !!h.만든?.품셈; // 옛 사전조합(품셈 줄 기록 없음) = 지금 금액 모름 + const before = known ? sumOf(h.줄, now) : null; + const money = + `1 단위 ${known ? `지금 ${before === null ? "셈 중" : won(Math.round(before))} → ` : ""}` + + `바뀐 뒤 ${after === null ? "셈 중" : won(Math.round(after))} · 엑셀 ${won(h.금액.계)}`; const ask = whole ? `호표 통째로 만듦 — ${h.통째!.대상.map(targetText).join(" · ")}` - : `넣을 줄 ${put} · 뺌 ${outs} · 빠질 줄 ${Object.values(out).reduce((a, b) => a + b, 0)}` + - (Object.keys(out).length - ? `(${Object.entries(out) - .map(([a, b]) => `${a} ${b}`) - .join(" · ")})` - : "") + - " — 사전조합 생성"; + : `품셈 줄 ${put.length}${diff ? `(품 다름 ${diff})` : ""} · 고정 줄 ${h.줄.length - put.length - outs}` + + ` · 뺌 ${outs} — ${money}(미리 셈 · 끝수 차 있음) — 사전조합 생성`; if (!(await showConfirmDialog(ask, "생성"))) return; const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: opt.no }; showLoadingOverlay(); @@ -498,10 +520,32 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { }); const whole = !!h.통째 && (d.통째 ?? h.통째.체크); const go = createButton({ label: "사전조합 생성", variant: "filled", size: "sm" }); - go.disabled = !whole && checked < 1; go.setAttribute("data-hopyo-make", ""); go.addEventListener("click", () => void make(h)); - tools.push(all, go); + tools.push(all); + if (h.만든 && !whole) { + // 705-3 — 고정 ↔ 품셈 바꾸기(줄 하나 = 체크 하나 + [사전조합 생성]) + const poom = createButton({ label: `품셈으로 바꾸기 (${sure.length})`, size: "sm" }); + poom.setAttribute("data-hopyo-to-poom", ""); + poom.disabled = !sure.length; + poom.addEventListener("click", () => { + for (const l of sure) d.체크[l.차례] = true; + keep(); + draw(); + void make(h); + }); + const back = createButton({ label: "모두 고정으로", size: "sm" }); + back.setAttribute("data-hopyo-to-fixed", ""); + back.disabled = !h.만든.품셈?.length; + back.addEventListener("click", () => { + for (const l of h.줄) d.체크[l.차례] = false; + keep(); + draw(); + void make(h); + }); + tools.push(poom, back); + } + tools.push(go); } const head = el("div", { className: "ui-toolbar m02-hopyo__bar", @@ -515,7 +559,16 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { ...(calc && !calc.끝 ? [note(`숫자 판정 셈 중 ${calc.한}/${calc.모두}${calc.까닭 ? ` · ${calc.까닭}` : ""}`)] : []), - ...(h.만든 ? [note(`만든 사전조합 ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}`)] : []), + ...(h.만든 + ? [ + note( + `만든 사전조합 ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}` + + (h.만든.품셈 + ? ` · 품셈 줄 ${h.만든.품셈.length} · 고정 줄 ${h.만든.고정 ?? 0}` + : ""), + ), + ] + : []), createButton({ label: "엑셀 보기", size: "sm", diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index 9711005cd..95e4c47d0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -9,6 +9,8 @@ * · [저장] = 체크한 호표마다 체크 줄(확인 창 캐시 · 없으면 서버 제안)로 하위 호표부터 차례로 `/hopyo-build` — * 물음 한 번(확인 창 안 연 호표 수) · 체크 줄 없는 호표 건너뜀 · 하위 확인 전(409)은 안내 · * 결과 표(사전조합 키 · 빠진 줄 · 값 차이) · `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄) + * · [모두 고정으로 들여오기](705-3) = 물음 한 번 → 서버 `/hopyo-build-all`(호표 모두 · 하위 먼저 · 엑셀 숫자 그대로 · + * 이미 만든 호표 그대로) → 알림 「호표 n · 줄 m · 품셈 줄 a · 고정 줄 b · 빠짐 0 · 엑셀 합계 = 우리 합계 n/n」 + 다음 걸음 * · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 저장은 됨 * ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게). * ========================================================================== */ @@ -27,12 +29,14 @@ import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; import { StaleError } from "./M02_MasterTemplete_Api_Fetch"; import { buildHopyo, + buildHopyoAll, deleteHopyo, readHopyo, readHopyoGrid, readHopyoOne, type HopyoBrief, type HopyoBuilt, + type HopyoBuiltAll, type HopyoGrid, type HopyoOne, type HopyoScope, @@ -167,6 +171,9 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { const go = createButton({ label: "저장", variant: "filled", size: "sm" }); go.disabled = true; go.setAttribute("data-hopyo-save", ""); + const fixAll = createButton({ label: "모두 고정으로 들여오기", size: "sm" }); + fixAll.setAttribute("data-hopyo-all-fixed", ""); + fixAll.disabled = true; // 엑셀을 다 읽은 뒤 켬 const result = el("div", { className: "m02-hopyo-sheet__result" }); const right = el("div", { className: "m02-hopyo-sheet__side", @@ -174,7 +181,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { search, el("div", { className: "ui-toolbar m02-hopyo__bar", - children: [count, ...(opt.canWrite ? [go] : [])], + children: [count, ...(opt.canWrite ? [fixAll, go] : [])], }), list, result, @@ -372,6 +379,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { ]); if (closed) return; briefs = file.호표; + fixAll.disabled = !briefs.length; spans = grid instanceof Error ? [] : grid.호표; paintHead(file); paintList(); @@ -508,5 +516,60 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { } go.addEventListener("click", () => void save()); + /** 들여오기 알림 글(705-3) */ + const allText = (got: HopyoBuiltAll): string => + `호표 ${got.호표} · 줄 ${got.줄} · 품셈 줄 ${got.품셈} · 고정 줄 ${got.고정} · ` + + `하위 호표 참조 ${got.호표참조} · 빠짐 ${got.빠짐} · 엑셀 합계 = 우리 합계 ${got.같음}/${got.호표}` + + (got.그대로 ? ` · 이미 만든 호표 ${got.그대로} 그대로` : ""); + + /** [모두 고정으로 들여오기](705-3) — 물음 한 번 · 서버가 호표 모두를 하위부터(숫자 = 서버가 파일에서) */ + async function importAll(): Promise { + const ask = + `호표 ${briefs.length}개를 엑셀 숫자 그대로(고정 줄) 사전조합으로 들여옴 — ` + + "이미 만든 호표는 그대로 · 품셈 맞춤은 뒤에 호표 [수정]에서 [품셈으로 바꾸기]"; + if (!(await showConfirmDialog(ask, "들여오기"))) return; + fixAll.disabled = true; + showLoadingOverlay(); + try { + const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key }; + const got = await buildHopyoAll(target, from); + if (got.호표 > got.그대로) document.dispatchEvent(new Event(HOPYO_BUILT)); + const next = + "다음 — 호표 [수정]에서 [품셈으로 바꾸기]" + + (got.이음후보 + ? ` · 이음 후보 ${got.이음후보} 줄(이름은 같고 단가가 다름)은 엑셀 단가 그대로 — 「이 공사 단가로 다시 잇기」에서 바꿈` + : ""); + const rows = [ + ...got.다름.map((x) => + el("tr", { + children: [ + el("td", { text: `제${x.번}호표 ${x.이름}` }), + el("td", { + text: `엑셀 ${won(x.엑셀)} · 우리 ${won(x.우리)}`, + className: "m02-hopyo__num", + }), + el("td", { text: won(x.차이), className: "m02-hopyo__num" }), + ], + }), + ), + ...got.실패.map((x) => + el("tr", { children: [el("td", { text: `제${x.번}호표` }), el("td", { text: x.까닭 })] }), + ), + ]; + result.replaceChildren( + note(allText(got)), + note(next), + ...(rows.length ? [table(["호표", "합계 다름 · 실패", "차이"], rows)] : []), + ); + showToast(allText(got), got.빠짐 || got.실패.length ? "warning" : "success"); + } catch (error) { + showToast(`실패 — ${why(error)}`, "error"); + } finally { + hideLoadingOverlay(); + fixAll.disabled = false; + } + } + fixAll.addEventListener("click", () => void importAll()); + void load(); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts index c678a808a..6c19bee22 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -32,7 +32,10 @@ export interface LaborFixed { 단가: Record<"노무비" | "재료비" | "경비", number>; 이음?: Record<"노임" | "자재" | "기계", string | null>; 출처?: Record; + /** 비율 줄(705-3 · 엑셀 % 줄) — 수량 = 율 · 반영[비목] = 다른 줄 반영합[기준] × 율 ÷ 100 */ + 비율?: { 기준: CostKey; 비목: CostKey }; } +type CostKey = "노무비" | "재료비" | "경비"; export interface CalcLine { /** 참조 줄은 서버 답에 없음(빈 글로 다룸) */ @@ -176,5 +179,26 @@ export function settle( if (cost !== null && !aside.has(one.코드)) add(done, { ...zero(), 재료비: cost, 계: cost }); return { ...one, 수량: qty, 수량출처: source, 반영: cost }; }); - return { 줄, 자재, 기준합: base, 반영합: done }; + return { 줄: ratioLines(줄, done, base), 자재, 기준합: base, 반영합: done }; +} + +/** 비율 고정 줄(705-3 · 짝 `M02_Labor_Fixed.ratios`) — 밑 = 비율 줄 뺀 반영합 · 기준[비목] = 밑[기준] · + * 배 100 · 반영 = 기준 × 율 ÷ 100 · `done` 에 더함 · 기준합(`base`)엔 안 셈 */ +function ratioLines(lines: CalcLine[], done: Money, base: Money): CalcLine[] { + for (const l of lines.filter((x) => x.고정?.비율)) { + for (const k of MONEY_KEYS) { + done[k] -= l.반영?.[k] ?? 0; + base[k] -= l.기준?.[k] ?? 0; + } + } + const under = { ...done }; + return lines.map((line) => { + const rate = line.고정?.비율; + if (!rate) return line; + const own = zero(); + own[rate.비목] = own.계 = under[rate.기준]; + const got = reflect(own, line.수량 ?? null, 100); + if (got) for (const k of MONEY_KEYS) done[k] += got[k]; + return { ...line, 기준: own, 배: 100, 반영: got }; + }); } diff --git a/resources/tester/test_705_3_hopyo_fixed.py b/resources/tester/test_705_3_hopyo_fixed.py new file mode 100644 index 000000000..ed3822245 --- /dev/null +++ b/resources/tester/test_705_3_hopyo_fixed.py @@ -0,0 +1,226 @@ +"""705-3 실무자료 불러오기 빠짐 0 · 시트 통째 한 번 · [품셈으로 바꾸기](요구서 1장 · 4장 2 · 3 · 7). + +- 확인 안 한 줄 · 노임 · % · 「우리에 없음」 = 고정 줄(엑셀 숫자 그대로) · 체크한 줄만 품셈 · 하위 호표 = 참조 +- % 줄 = 비율 고정 줄 — M02 settle 거울(파이썬 ↔ TS) · 사전조합 셈 · B09 `pct` 줄 +- [모두 고정으로 들여오기] — 북부청 · 실무 xlsx 넷: 빠짐 0 · 엑셀 합계 = 우리 합계(호표마다) · 줄 꼬리표 수 · + 확인된 호표 그대로 · 이음 = 단가까지 같은 줄만 · 같은 이름 줄 차례 가름 · 몸에 숫자 칸 없음(422) +- [품셈으로 바꾸기] 전후 — 체크 한 줄 = 품셈 · 나머지 고정 · 되돌리기(빈 몸) = 모두 고정 +층은 임시 폴더(공용 storage 안 씀). +""" + +import importlib.util +import json +from pathlib import Path + +import pytest + +from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor +from B09_Estimation.B09_Estimation_Labor_Source import fixed_line +from M02_MasterTemplete import M02_Hopyo_Fixed as hfix +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Template_Layers as layers + +# 올리기 길 시험의 `client` 마당을 같이 씀 +# ruff: noqa: F811 +from resources.tester.test_m02_hopyo_excel import NORTH, _post, client, master_hash # noqa: F401 + + +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 + + +m02 = _load("test_m02_labor_calc_mirror") +ALL = "/api/m02/layers/{}/hopyo-build-all" +BUILD = "/api/m02/layers/{}/hopyo-build" +DOCS = Path(__file__).resolve().parents[2] / "resources" / "knowledge" / "original" / "실무문서" +FILES = { # 실무 xlsx 넷 — EST 꼴 거창 · STmate 꼴 영월 · 울진 대흥 · 울진 소광 + "거창": "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx", + "영월": "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx", + "대흥": "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/" + "2024년 간선임도사업(기번3).xlsx", + "소광": "25년 산불진화임도(기번8)(울진소광)/" + "2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx", +} +# (호표, 줄, 고정, 하위 참조, 이음, 엑셀 = 우리) — 영월 제22호표 = STmate 하위 호표 비목 반올림 1 원 끝수 +EXPECT = { + "거창": (14, 68, 56, 12, 11, 14), + "영월": (23, 138, 135, 3, 0, 22), + "대흥": (34, 171, 143, 28, 0, 34), + "소광": (25, 117, 98, 19, 0, 25), +} + + +def _fx(name, price, rate=None, unit="식"): + out = {"이름": name, "규격": "", "단위": unit, "단가": price, "이음": {}} + return {**out, "비율": rate} if rate else out + + +def _all(client, key="HP-000001"): + res = client.post(ALL.format("personal"), json={"층": "personal", "키": key}) + assert res.status_code == 200, res.text + return res.json() + + +def _docs(): + folder = layers.personal_dir(7, 42) / "precombo" + return [json.loads(p.read_bytes()) for p in sorted(folder.glob("SC-*.json"))] + + +# ── 비율 고정 줄 셈 ────────────────────────────────────────────────────────── +def _ratio_doc(): + return labor.clean( + { + "열": "rv", + "담은로직": [ + {"고정": _fx("고화매트", {"재료비": 37000}, unit="㎡"), "수량": {"손": 1}}, + {"고정": _fx("고정핀", {"재료비": 200}, unit="EA"), "수량": {"손": 4.95}}, + {"고정": _fx("인부", {"노무비": 1000}, unit="인"), "수량": None}, + {"로직": m02.PLAIN, "수량": None}, + { + "고정": _fx("잡재료비", {}, {"기준": "재료비", "비목": "재료비"}), + "수량": {"손": 3}, + }, + { + "고정": _fx("공구손료", {}, {"기준": "노무비", "비목": "경비"}), + "수량": {"손": 2}, + }, + ], + "자재": [{"코드": m02.MAT, "수량": None}], + } + ) + + +@pytest.mark.parametrize("values", [{}, {f"자재|{m02.MAT}": "3"}, {"사석 수상고르기|㎡": 2}]) +def test_비율_고정_줄_파이썬과_TS_가_같은_답(values): + doc = _ratio_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) + misc, tool = [ln for ln in py["줄"] if (ln.get("고정") or {}).get("비율")] + others = [ln for ln in py["줄"] if ln not in (misc, tool)] + under = sum((ln["반영"] or {}).get("재료비", 0) for ln in others) # 자재 = 별도계상(합 밖) + assert misc["반영"]["재료비"] == pytest.approx(under * 3 / 100) and misc["배"] == 100 + assert misc["반영"]["노무비"] == misc["반영"]["경비"] == 0 + assert tool["반영"]["경비"] == 0 # 인부 수량 없음 = 노무비 밑 0 + assert py["반영합"]["계"] == pytest.approx( + sum((ln["반영"] or {}).get("계", 0) for ln in py["줄"]) + ) + + +def test_비율_정리_막힘(): + bad = _fx("잡", {}, {"기준": "합계", "비목": "재료비"}) + with pytest.raises(Exception, match="비율"): + labor.clean({"담은로직": [{"고정": bad, "수량": {"손": 1}}]}) + + +def test_B09_비율_고정_줄_pct(): + fx = _fx("잡재료비", {}, {"기준": "재료비", "비목": "재료비"}) + one = fixed_line(fx, 3) + assert {k: one[k] for k in ("kind", "part", "base", "qty", "unit")} == { + "kind": "pct", "part": "material", "base": "part:material", "qty": 3.0, "unit": "%" + } # fmt: skip + lines = [ + {"kind": "fixed", "name": "고화매트", "qty": 1, "own": {"material": 37000.0}, + "link": {"labor": None, "material": None, "machine": None}}, + {"kind": "fixed", "name": "고정핀", "qty": 4.95, "own": {"material": 200.0}, + "link": {"labor": None, "material": None, "machine": None}}, + one, + ] # fmt: skip + for n, ln in enumerate(lines): + ln["cid"], ln["n"] = f"{ln['kind']}:{ln['name']}#0", n + block = {"id": "DGF", "cover": "S:f", "kind": "조합", "name": "고화매트재료비", "spec": "", + "unit": "㎡", "cite": "", "origin": {}, "lines": lines} # fmt: skip + out = compute_labor({"blocks": [block], "owners": {}}, {}, {}) + got = out["daega_blocks"][0] + pct = next(ln for ln in got["lines"] if ln["kind"] == "pct") + assert pct["amounts"]["material"] == 1139.7 # (37000 + 990) × 3 % + assert got["blank"] == 0 and out["gaps"] == [] + + +# ── 줄 하나 → 고정 줄 ──────────────────────────────────────────────────────── +def test_같은_이름_차례_가름_합계만_재료비_금액만(): + linker, taken = hfix.Linker(), set() + line = {"차례": 0, "이름": "잡석", "규격": "", "단위": "㎥", "수량": 2, "갈래": "자재", + "단가": {"계": 5000, "노무비": None, "재료비": None, "경비": None}} # fmt: skip + a = hfix.line_of(line, [], {"파일": "x", "호표": 1}, taken, linker) + b = hfix.line_of({**line, "차례": 1}, [], {"파일": "x", "호표": 1}, taken, linker) + assert (a["고정"]["이름"], b["고정"]["이름"]) == ("잡석", "잡석 (2)") + assert a["고정"]["단가"] == {"노무비": 0, "재료비": 5000, "경비": 0} # 합계만 = 재료비 + assert b["고정"]["출처"] == {"파일": "x", "호표": 1, "줄": 1} and a["수량"] == {"손": 2} + doc = labor.clean({"담은로직": [a, b]}) # 같은 줄 가름 400 안 남 + assert len(doc["담은로직"]) == 2 + only = {**line, "차례": 2, "이름": "운반", "수량": None, + "금액": {"계": 700, "노무비": 700, "재료비": None, "경비": None}} # fmt: skip + c = hfix.line_of(only, [], {}, taken, linker) + assert c["수량"] == {"손": 1} and c["고정"]["단가"]["노무비"] == 700 # 금액만 + + +def test_퍼센트_밑수_다르면_금액_그대로(): + pct = {"차례": 2, "이름": "소모자재", "규격": "주재료비의", "단위": "%", "수량": 11, "갈래": "%", + "단가": {"계": 7937.2}, "금액": {"계": 873, "노무비": 0, "재료비": 873, "경비": 0}} # fmt: skip + got = hfix.line_of(pct, [], {}, set(), hfix.Linker()) + assert "비율" not in got["고정"] and got["고정"]["단위"] == "식" + assert got["수량"] == {"손": 1} and got["고정"]["단가"]["재료비"] == 873 + + +# ── 시트 통째 ──────────────────────────────────────────────────────────────── +def test_북부청_모두_고정_빠짐_0_합계_같음_그대로(client): + before = master_hash() + assert _post(client).status_code == 200 + got = _all(client) + assert {k: got[k] for k in ("호표", "줄", "품셈", "고정", "호표참조", "빠짐", "같음")} == { + "호표": 68, "줄": 415, "품셈": 0, "고정": 414, "호표참조": 1, "빠짐": 0, "같음": 68 + } # fmt: skip + assert got["실패"] == [] and got["다름"] == [] and got["그대로"] == 0 + assert got["이음"] == 13 and got["이음후보"] == 31 + docs = [d for d in _docs() if d["본뜬"]["키"].count(" ") == 1] # 호표 문서(감싼 줄 빼고) + lines = [x for d in docs for x in d["담은로직"]] + assert len(docs) == 68 and all(d["본뜬"]["품셈"] == [] for d in docs) + assert sum("고정" in x for x in lines) == 414 and sum("참조" in x for x in lines) == 1 + assert sum(bool(x.get("고정", {}).get("비율")) for x in lines) == 1 # 잡재료비 3 % + again = _all(client) # 다시 = 확인된 호표 덮지 않음 + assert again["그대로"] == 68 and again["고정"] == 0 and again["같음"] == 68 + assert master_hash() == before + + +@pytest.mark.parametrize("tag", list(FILES)) +def test_실무_xlsx_넷_모두_고정(client, tag): + path = DOCS / FILES[tag] + res = _post(client, data=path.read_bytes(), name=path.name) + assert res.status_code == 200, res.text + got = _all(client) + no, rows, fixed_n, kids, links, same = EXPECT[tag] + assert (got["호표"], got["줄"], got["고정"], got["호표참조"]) == (no, rows, fixed_n, kids) + assert got["빠짐"] == 0 and got["품셈"] == 0 and got["실패"] == [] + assert got["고정"] + got["호표참조"] == got["줄"] # 줄 하나도 안 빠짐 + assert got["이음"] == links and got["같음"] == same + assert all(abs(x["차이"]) <= 1 for x in got["다름"]) + + +def test_몸에_숫자_칸_없음(client): + assert _post(client).status_code == 200 + bad = {"층": "personal", "키": "HP-000001", "금액": 1} + assert client.post(ALL.format("personal"), json=bad).status_code == 422 + + +# ── [품셈으로 바꾸기] 전후 ──────────────────────────────────────────────────── +def test_품셈으로_바꾸기_되돌리기(client): + assert _post(client).status_code == 200 + _all(client) + body = {"층": "personal", "키": "HP-000001", "번": 3} + one = {"차례": 1, "대상": [{"로직": "GF000101", "입력값": {"지역": "서울"}, "배": 1}]} + res = client.post(BUILD.format("personal"), json={**body, "줄": [one]}) + assert res.status_code == 200, res.text + after = res.json() + assert after["다시씀"] is True and after["줄수"]["품셈"] == 1 and after["줄수"]["고정"] == 3 + doc = next(d for d in _docs() if d["본뜬"]["키"] == "HP-000001 제3호표") + assert doc["본뜬"]["품셈"] == [1] and doc["담은로직"][1]["로직"] == "GF000101" + assert after["비교"]["같음"] is False # 품셈 줄 = 우리 값(엑셀과 다름) + view = client.get("/api/m02/layers/personal/hopyo/HP-000001/3").json() + assert view["호표"]["만든"]["품셈"] == [1] and view["호표"]["만든"]["고정"] == 3 + back = client.post(BUILD.format("personal"), json={**body, "줄": []}).json() # 되돌리기 + assert back["줄수"]["품셈"] == 0 and back["비교"]["같음"] is True diff --git a/resources/tester/test_m02_hopyo_build.py b/resources/tester/test_m02_hopyo_build.py index 16e3ae284..881a5fb7e 100644 --- a/resources/tester/test_m02_hopyo_build.py +++ b/resources/tester/test_m02_hopyo_build.py @@ -59,7 +59,12 @@ def test_확인한_줄만_별칭_수량_빠진_줄_장부(client): ["personal", "7/42"], ) assert origin["확인"]["누가"] == {"id": 42, "이름": "갑"} and origin["확인"]["때"] - assert doc["담은로직"] == [ + stone, *made, soil = doc["담은로직"] # 705-3 — 확인 안 한 줄 · 「우리에 없음」 = 고정 줄 + assert stone["고정"]["단가"] == {"노무비": 0, "재료비": 1400000, "경비": 0} + assert stone["고정"]["출처"]["줄"] == 0 and stone["수량"] == {"손": 1} + assert soil["고정"]["이름"] == "잔토처리(인력100%)" and soil["수량"] == {"손": 0.09} + assert origin["품셈"] == [1, 2] and got["줄수"]["고정"] == 2 and got["빠진줄"] == [] + assert made == [ { "로직": "GF000101", "입력값": {"지역": "서울"}, @@ -75,11 +80,7 @@ def test_확인한_줄만_별칭_수량_빠진_줄_장부(client): "메모": "실무 0.05㎥ × 2", }, ] - assert doc["자재"] == [] # 표지석 = 몸에 없음 → 안 들어감 - assert [(x["이름"], x["금액"], x["까닭"]) for x in got["빠진줄"]] == [ - ("임도표지석(받침,글새김포함)", 1400000, "확인 안 함"), - ("잔토처리(인력100%)", 3097.1, "뺌 — 우리에 없음"), - ] + assert doc["자재"] == [] # 표지석 = 몸에 없음 → 고정 줄 assert got["비교"]["엑셀"] == 1415169 and got["비교"]["우리"] > 0 assert got["다시씀"] is False and got["장부"] == {"씀": 3} book = _book(mine) @@ -104,7 +105,8 @@ def test_로직_여럿_같은_별칭_감쌈_자재_몫_덮음(client): got = _build(client, 3, lines) mine = layers.personal_dir(7, 42) doc = _doc(mine, got["참조"]["키"]) - first, second, wrapped = doc["담은로직"] + first, second, fx, wrapped = doc["담은로직"] + assert fx["고정"]["출처"]["줄"] == 2 # 확인 안 한 줄 = 고정(705-3) assert first["별칭"] == second["별칭"] == "구조물터파기(인력100%)" # 한 줄 로직 둘 = 같은 별칭 assert second["수량"] == {"손": 0.014} and second["메모"] == "실무 0.14㎥ × 0.1(몫 0.1) · 토사" assert wrapped["별칭"] == "잔토처리(인력100%)" and wrapped["수량"] == {"손": 0.09} @@ -119,7 +121,7 @@ def test_로직_여럿_같은_별칭_감쌈_자재_몫_덮음(client): files = sorted(p.name for p in (mine / "precombo").glob("*.json")) again = _build(client, 3, lines[:1]) # 다시 확인 = 같은 본뜬 덮음 assert again["다시씀"] is True and again["참조"] == got["참조"] - assert _doc(mine, got["참조"]["키"])["담은로직"] == [] + assert [x["고정"]["출처"]["줄"] for x in _doc(mine, got["참조"]["키"])["담은로직"]] == [1, 2, 3] assert sorted(p.name for p in (mine / "precombo").glob("*.json")) == files @@ -177,8 +179,7 @@ def test_전국_자재_키_없이_본뜬에(client): def test_몸_없음_재검사_400(client): assert _post(client).status_code == 200 one = {"로직": "GF000101", "배": 1} - _build(client, 3, want=400) # 옛 몸(줄 없음) = 확인한 줄이 없음 - _build(client, 3, [], want=400) + _build(client, 3, 통째={"대상": []}, want=400) # 빈 몸 = 모두 고정(705-3 시험) · 빈 통째 = 400 bad = [ [{"차례": 9, "대상": [one]}], # 호표 밖 줄 [{"차례": 1, "대상": [one]}, {"차례": 1, "대상": [one]}], # 겹친 줄 @@ -226,7 +227,9 @@ def test_하위_호표_먼저_확인_퍼센트_빠짐_통째(client): ref = [{"차례": 0, "대상": [{"호표": 46, "배": 1}]}] assert "제46호표 먼저 확인" in _build(client, 45, ref, want=409)["detail"] child = _build(client, 46, [{"차례": 0, "뺌": "안 씀"}]) - assert any(x["까닭"].startswith("% 줄") for x in child["빠진줄"]) + assert [x["까닭"] for x in child["빠진줄"]] == ["뺌 — 안 씀"] # % 줄 = 비율 고정 줄(705-3) + rate = _doc(layers.personal_dir(7, 42), child["참조"]["키"])["담은로직"][-1]["고정"] + assert rate["비율"] == {"기준": "재료비", "비목": "재료비"} got = _build(client, 45, ref) mine = layers.personal_dir(7, 42) line = _doc(mine, got["참조"]["키"])["담은로직"][0] diff --git a/resources/tester/test_m02_hopyo_flow.py b/resources/tester/test_m02_hopyo_flow.py index f05e2b9f2..c51d83430 100644 --- a/resources/tester/test_m02_hopyo_flow.py +++ b/resources/tester/test_m02_hopyo_flow.py @@ -113,7 +113,8 @@ def test_끝까지_확인한_줄만_별칭(client): k: "별칭" for k in again["철근가공조립(현장)"]["대상"][0]["입력값"] } first = _one(client, "HP-000001", no) - assert first["만든"] == {"키": made["참조"]["키"], "확인": True} + assert {k: first["만든"][k] for k in ("키", "확인")} == {"키": made["참조"]["키"], "확인": True} + assert first["만든"]["품셈"] == doc["본뜬"]["품셈"] # 705-3 — 품셈 줄 차례(첫 체크) assert master_hash() == before @@ -137,7 +138,7 @@ def test_막는_길(client): "/api/m02/layers/personal/hopyo-build", json={"층": "personal", "키": "HP-000001", "번": 1, "줄": []}, ) - assert empty.status_code == 400 and "확인한 줄이 없음" in empty.text + assert empty.status_code == 200 and empty.json()["줄수"]["품셈"] == 0 # 빈 몸 = 모두 고정(705-3) money = client.post( "/api/m02/layers/personal/hopyo-build", json={"층": "personal", "키": "HP-000001", "번": 1, "줄": [{"차례": 1, "금액": 1}]},