diff --git a/M02_MasterTemplete/M02_Hopyo_Build.py b/M02_MasterTemplete/M02_Hopyo_Build.py index a34230d62..7da2f5d4d 100644 --- a/M02_MasterTemplete/M02_Hopyo_Build.py +++ b/M02_MasterTemplete/M02_Hopyo_Build.py @@ -25,6 +25,11 @@ 통째 대신 · 로직 자재 겹침 · 전국만. `본뜬.품셈` = 품셈으로 넣은 엑셀 줄 차례([품셈으로 바꾸기] 첫 체크) · `줄수 {품셈, 고정, 호표, 빠짐, 이음, 이음후보}` · 만든 값 ↔ 엑셀 호표 단가(`비교` · 같음 = 비목마다 1 원 안). 엔진 · 일위대가 계산은 그대로(`M02_Labor_Ref.Expand`). +이음(729-2 · 요구서 내역19 1장 8) = 확인 안 한 단가산출 줄 = 그 블록 SK 참조(이 층 · 같은 원본 · 없으면 +`M02_Hopyo_Sangeun` 으로 그 블록만 만듦 · 고름 대기면 못 이음) · 확인한 로직 줄이 차이 SK 의 바탕이면 그 SK +참조(차이 보존) · 노임 = 이름 · 기계 = EQ(`M02_Hopyo_Fixed.Linker`) · 사용자 이음 대상 `{노임|기계: 키}` · +`{비율: 기준}` = 고정 줄 + 그 이음 · 남은 줄(`Fixed.linked` 아님 · 「우리에 없음」 고른 줄 뺌) 있으면 409 +`{까닭, 남은줄}`(저장 안 함 · `save` 거짓 = 막지 않고 `남은줄` 만) · 미리 보기 = `unlinked`(쓰기 없음). 수량(723-1 · 내역 17-1) = 저장 때 걷음(`refs.no_qty`) · 엑셀 수량은 `본뜬.실무수량 {줄: {차례: 수}, 자재: {코드: 수}}` (출처 기록 · 셈 안 씀 · 엑셀 대조 `compare` 만 — 하위 호표 것까지 `excel_given`). """ @@ -43,6 +48,7 @@ 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 +from M02_MasterTemplete import M02_Hopyo_SkLink as sklink from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs @@ -138,6 +144,10 @@ def _missing(line: dict[str, Any], why: str) -> dict[str, Any]: } +def _is_sk(one: dict[str, Any]) -> bool: + return str((one.get("참조") or {}).get("키") or "").startswith("SK-") + + def _bad(text: str, status: int = 400) -> store.StoreError: return store.StoreError(status, text) @@ -156,9 +166,13 @@ class Builder: self.hopyo = next((h for h in self.doc.get("호표") or [] if int(h["번"]) == self.no), None) if self.hopyo is None: raise _bad(f"없는 호표 「제{no}호표」", 404) + self.src, self.dry, self._sk, self._maker = Path(source), False, None, None + self.strict = True # 남은 줄 있으면 409(붙여넣기 `build_all` = 거짓) # ── 재검사 ─────────────────────────────────────────────────────── def _target(self, raw: dict[str, Any], where: str, line: dict | None = None) -> dict[str, Any]: + if raw.get("노임") or raw.get("기계") or raw.get("비율"): + return self._link(raw, where, line) if raw.get("전국"): return self._national(raw, where, line) if raw.get("호표") is None: @@ -175,6 +189,23 @@ class Builder: times, part = ledger.times_of(raw, where) return {"호표": child, "배": times, "몫": part} + def _link(self, raw: dict[str, Any], where: str, line: dict | None) -> dict[str, Any]: + """사용자 이음 대상 — `{노임: LB}` · `{기계: EQ}` · `{비율: 기준 비목}`(% 줄 · 수량 = 율).""" + keys = [k for k in ("노임", "기계", "비율") if raw.get(k)] + extra = set(raw) - {"노임", "기계", "비율", "배", "몫", "입력값"} + if len(keys) != 1 or extra or raw.get("입력값"): + raise _bad(f"이음 대상은 노임 · 기계 · 비율 중 하나만 「{where}」") + kind, code = keys[0], str(raw[keys[0]]) + if kind == "비율": + if code not in labor.COSTS or line is None or finger.kind_of(line) != "%": + raise _bad(f"비율은 % 줄 · 노무비 · 재료비 · 경비 「{where}」") + if line.get("수량") is None: + raise _bad(f"% 줄 수량(율) 없음 「{where}」") + return {"이음": {"비율": code}} + if code[:2] != hfix.fixed.LINKS[kind] or not hfix.fixed._has(m01.loaded()[1], code): + raise _bad(f"{kind} 이음 「{code}」 M01 에 없음 「{where}」") + return {"이음": {kind: code}} + def _national(self, raw: dict[str, Any], where: str, line: dict | None) -> dict[str, Any]: """「전국」 대상 — 줄에서 서버가 다시 셈(이름 · 규격 · 단위 = 서버 것).""" if set(raw) - {"전국", "배", "몫", "입력값"} or raw.get("입력값"): @@ -236,11 +267,16 @@ 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 + taken, linker, poomsem, kids, kid_n = set(), hfix.Linker(self.strict and save), [], set(), 0 + left: list[dict[str, Any]] = [] where = {"파일": file, "호표": self.no} rows = hopyo.get("줄") or [] def put(line: dict[str, Any], one: dict[str, Any], alias: str | None) -> None: + if "이음" in one: + others = [x for x in rows if x is not line] + lines.append(hfix.line_of(line, others, where, taken, linker, one["이음"])) + return qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(one["배"])) memo = _memo(line, one) if "전국" in one: @@ -279,6 +315,9 @@ class Builder: lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) return row = {"로직": one["로직"], "입력값": one["입력값"], "메모": memo} + if (kept := sklink.kept(self, line, one, qty, alias, memo)) is not None: + lines.append(kept) + return if one["로직"] not in used: used.add(one["로직"]) lines.append({**row, "별칭": alias, "수량": {"손": _num(qty)}}) @@ -293,7 +332,7 @@ class Builder: "자재": [], "본뜬": self._origin(f"{mark(self.key, self.no, at)} {one['로직']}", stamp), } - ref, _ = _save(wrap) + ref = {"키": "SC-미리"} if self.dry else _save(wrap)[0] # 미리 보기 = 쓰기 없음 lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) book: dict[str, dict[str, Any]] = {} @@ -311,11 +350,15 @@ class Builder: if got and got.get("뺌") == "안 씀": missing.append(_missing(line, f"뺌 — {got['뺌']}")) continue - if not got or got.get("뺌"): # 빠짐 0(705-3) — 하위 호표 참조 · 아니면 고정 줄 + if not got or got.get("뺌"): # 빠짐 0(705-3) — 하위 호표 · SK 참조 · 아니면 고정 줄 kid = self._kid(line, kids) kid_n += bool(kid) + sk, why = (None, None) if kid or got else sklink.sk_line(self, line, save) others = [x for x in rows if x is not line] - lines.append(kid or hfix.line_of(line, others, where, taken, linker)) + one = kid or sk or hfix.line_of(line, others, where, taken, linker) + lines.append(one) + if not hfix.linked(one, bool(got)): + left.append(sklink.left(line, why)) continue poomsem.append(at) alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None @@ -339,18 +382,32 @@ class Builder: if nation: doc["본뜬"]["전국자재"] = list(nation.values()) doc["본뜬"]["품셈"] = poomsem + if self.dry: + return {"남은줄": left} + if left and save and self.strict: + raise store.StoreError(409, {"까닭": sklink.LEFT, "남은줄": left}) 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} + count["단가산출"] = sum(1 for x in lines if _is_sk(x)) if not save: ledger.write(self.folder, book, self.who, file or None) - return {"문서": doc, "빠진줄": missing, "줄수": {**count, "이음후보": linker.near}} + head = {"문서": doc, "빠진줄": missing, "남은줄": left} + return {**head, "줄수": {**count, "이음후보": linker.near}} ref, again = _save(doc) wrote = ledger.write(self.folder, book, self.who, file or None) out = {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}} return {**out, "줄수": {**count, "이음후보": linker.near}} + def unlinked(self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]]) -> list: + """미리 보기 — 만들면 남을 줄(쓰기 없음 · SK 는 만들 수 있는지만 셈).""" + self.dry = True + try: + return self.build(checked, whole)["남은줄"] + finally: + self.dry = False + def _kid(self, line: dict[str, Any], kids: set[int]) -> dict[str, Any] | None: """확인 안 한 호표 줄 → 확인된 하위 사전조합 참조(단위 같음 · 같은 하위 한 번) · 아니면 None.""" ref = line.get("참조") or {} @@ -449,6 +506,21 @@ def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]: return {"엑셀": excel, "우리": ours, "차이": gap, "같음": same, "까닭": got.get("까닭")} +def unlinked( + folder: Path, + source: Path, + key: str, + no: int, + lines: list[dict[str, Any]] | None = None, + whole: dict[str, Any] | None = None, +) -> dict[str, Any]: + """안 이은 줄 미리 보기(729-3) — 몸 = 만들기 몸 → `{남은줄, 수}`(저장 없음).""" + maker = Builder(folder, source, key, no, None) + checked, pairs = maker.check(list(lines or []), whole) + left = maker.unlinked(checked, pairs) + return {"남은줄": left, "수": len(left)} + + def build( folder: Path, source: Path, @@ -486,8 +558,12 @@ def _order(units: dict[int, dict[str, Any]]) -> list[int]: return out -def build_all(folder: Path, source: Path, key: str, who: Any = None) -> dict[str, Any]: +def build_all( + folder: Path, source: Path, key: str, who: Any = None, strict: bool = False +) -> dict[str, Any]: """[모두 고정으로 들여오기](705-3) — 시트 호표 모두 · 하위 먼저 · 줄 = 고정(하위 = 참조) · + `strict`(불러오기 길 · 729-2) = 남은 줄 있는 호표는 안 만듦(`실패 {번, 까닭, 남은줄}`) · + 붙여넣기(705-2)는 막지 않음 · 이미 확인된 호표는 덮지 않음(`그대로`) → `{호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보, 같음, 그대로, 다름 [{번, 이름, 엑셀, 우리, 차이}], 실패 [{번, 까닭}], 만듦 [{번, 이름, 키}]}`.""" units = {int(h["번"]): h for h in hopyo_store.read(source, key).get("호표") or []} @@ -505,6 +581,7 @@ def build_all(folder: Path, source: Path, key: str, who: Any = None) -> dict[str out["줄"] += len(units[no].get("줄") or []) try: maker = Builder(folder, source, key, no, who) + maker.strict = strict have = maker.done.get(mark(key, no)) if have: out["그대로"] += 1 @@ -517,7 +594,8 @@ def build_all(folder: Path, source: Path, key: str, who: Any = None) -> dict[str got = compare(ref, maker.hopyo) out["만듦"].append({"번": no, "이름": units[no].get("이름"), "키": ref["키"]}) except store.StoreError as e: - out["실패"].append({"번": no, "까닭": str(e.detail)}) + why = e.detail if isinstance(e.detail, dict) else {"까닭": str(e.detail)} + out["실패"].append({"번": no, **why}) continue if got["같음"]: out["같음"] += 1 diff --git a/M02_MasterTemplete/M02_Hopyo_Fixed.py b/M02_MasterTemplete/M02_Hopyo_Fixed.py index 262697496..f21f81580 100644 --- a/M02_MasterTemplete/M02_Hopyo_Fixed.py +++ b/M02_MasterTemplete/M02_Hopyo_Fixed.py @@ -4,8 +4,9 @@ 숫자는 서버가 실무자료 파일에서 읽음(브라우저 값 안 받음). 단가 = 엑셀 단가 노무비 · 재료비 · 경비(빈 칸 0) · 합계만 있는 줄 = 재료비(4장 3) · 수량 없음 = 수량 1 · 단가 = 엑셀 금액(「금액만」). - 이음 = 이름 · 규격 · 단위 · 단가가 다 같은 노임(LB) · 자재(MT)만 저절로 → 엑셀 합계 = 우리 합계 · - 단가가 다른 짝 = `후보` 수만(바꾸기는 705-2 「이 공사 단가로 다시 잇기」). + 이음 = 노임(LB) · 자재(MT) = 이름 · 규격 · 단위 · 단가 다 같은 것만 · `by_name`(729-2 불러오기 길 · + 요구서 내역19 3장 7) = 노임 = 이름 같은 LB 하나(값 = 지금 단가 책) · 기계 = 이름(갈래) · 규격 수 같은 EQ + 하나 · 못 이은 짝 = `후보` 수만 · 사용자가 고른 이음(`force` {노임|기계|자재: 키} · {비율: 기준}) = 그대로. % 줄 = 비율 줄 `비율 {기준, 비목}` — 엑셀 밑수(단가 계) = 다른 줄 금액[기준] 합일 때만 · 아니면(주재료비 등) 수량 1 · 「식」 · 단가 = 엑셀 금액. 같은 이름 · 규격 · 단위 둘째부터 = 이름 끝 「 (2)」(줄 가름 · 705-1 400 피함). @@ -25,6 +26,7 @@ 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 +from M02_MasterTemplete.M02_Hopyo_Words import mtypes, specnums COSTS = ("노무비", "재료비", "경비") _BASE = re.compile(r"(? dict[str, Decimal]: @@ -56,29 +59,62 @@ def _ratio(line: dict[str, Any], others: list[dict[str, Any]]) -> dict[str, str] return {"기준": base, "비목": paid[0]} if abs(total - under) <= GAP else None -class Linker: - """이음 — 노임(LB 이름 같음) · 자재(이름 · 규격 같은 한 품목 · 단위 같음)가 단가까지 같을 때만.""" +def linked(one: dict[str, Any], decided: bool = False) -> bool: + """일위대가 줄 하나가 M01 · 기초 · SK 에 이어짐 — 로직 · 참조 · 비율 · 이음 있는 고정 줄 · + `decided` = 사용자가 「우리에 없음」으로 고른 줄.""" + fx = one.get("고정") if isinstance(one, dict) else None + if not isinstance(fx, dict): + return True + return decided or bool(fx.get("비율")) or any((fx.get("이음") or {}).values()) - def __init__(self) -> None: + +class Linker: + """이음 — 노임(LB 이름) · 기계(EQ 갈래 · 규격) · 자재(이름 · 규격 같은 한 품목 · 단위 · 단가 같음).""" + + def __init__(self, by_name: bool = False) -> None: + self.by_name = ( + by_name # 729-2 불러오기 길만 — 붙여넣기 · B09 통째 = 단가 같을 때만(엑셀 합계 그대로) + ) 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 # 단가 다른 이음 후보 수 + self.machines = [ + (str(row.get("키")), mtypes(f"{row.get('상세구분')} {row.get('이름')}"), + set(specnums(row.get("규격"))), mm_plain(row.get("이름")), mm_plain(row.get("규격"))) + for row in self.whole.numbers.get("EQ", {}).values() + ] # fmt: skip + 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["노무비"]) + one = [code for code, _ in pairs] + same = one[0] if self.by_name and len(one) == 1 else None + out["노임"] = same or self._same(pairs, price["노무비"]) + elif kind == "기계" and self.by_name: + out["기계"] = self._machine(line) elif kind in hm.MATERIAL_KINDS and price["재료비"]: out["자재"] = self._same(self._mats(line), price["재료비"]) - # ponytail: 기계 이음 없음 — 기계 시간 단가는 B09 중기목록표 셈 · 같은 값 판정 못 함 return out + def _machine(self, line: dict[str, Any]) -> str | None: + """기계 줄 → EQ 하나 — 이름 · 규격 글 같음 › 갈래 겹침 + 규격 수 같음(하나일 때만).""" + name, spec = mm_plain(line.get("이름")), mm_plain(line.get("규격")) + same = [c for c, _, _, n, s in self.machines if n == name and s == spec] + if len(same) != 1: + text = f"{line.get('이름') or ''} {line.get('규격') or ''}" + kinds, nums = mtypes(text), set(specnums(text)) + same = [c for c, t, s, _, _ in self.machines if kinds & t and nums and nums == s] + if len(same) == 1: + return same[0] + self.near += bool(same) + return None + 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: @@ -110,11 +146,16 @@ def line_of( where: dict[str, Any], taken: set[str], linker: Linker, + force: dict[str, str] | None = None, ) -> dict[str, Any]: - """엑셀 줄 하나 → 일위대가 고정 줄 `{고정, 별칭, 메모, 수량}` — `taken` = 이 호표 줄 가름(채워 감).""" + """엑셀 줄 하나 → 일위대가 고정 줄 `{고정, 별칭, 메모, 수량}` — `taken` = 이 호표 줄 가름(채워 감) · + `force` = 사용자가 고른 이음 `{노임|기계|자재: 키}` · 비율 `{비율: 기준 비목}`(수량 = 율 %).""" 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 + if force and force.get("비율"): + paid = [k for k, v in _parts(line.get("금액")).items() if v] + rate = {"기준": force["비율"], "비목": paid[0] if len(paid) == 1 else force["비율"]} memo = f"실무 {qty if qty is not None else ''}{unit} 그대로" if rate: price = dict.fromkeys(COSTS, Decimal(0)) @@ -135,6 +176,8 @@ def line_of( "단가": {k: _plain(v) for k, v in price.items()}, } fx["이음"] = dict.fromkeys(fixed.LINKS) if rate else linker.link(line, price) + if force and not rate: + fx["이음"] = {k: force.get(k) for k in fixed.LINKS} fx["출처"] = {**where, "줄": int(line.get("차례", 0))} if rate: fx["비율"] = rate diff --git a/M02_MasterTemplete/M02_Hopyo_SkLink.py b/M02_MasterTemplete/M02_Hopyo_SkLink.py new file mode 100644 index 000000000..e2f990493 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_SkLink.py @@ -0,0 +1,108 @@ +"""M02 일위대가 만들기 — 단가산출(SK) 이음 · 남은 줄(PLAN 729-2 · 요구서 내역19 1장 8). + +`M02_Hopyo_Build.Builder` 가 부름(`b` = 그 만들기) — +확인 안 한 단가산출 줄 = 그 블록 SK 참조(이 층 · 같은 원본 · 없으면 `M02_Hopyo_Sangeun.Maker` 로 그 블록만 +만듦 · 미리 보기 = 키만 · 맞춤 셈 전 · 고름 대기 · 단위 다름 = 못 이음) · +확인한 로직 줄이 차이 SK 의 바탕 = 그 SK 참조(엑셀 차이 보존 · 별칭 짝 = 기초까지만) · +남은 줄 = 엑셀 줄 · 갈래 · 까닭 · 후보(단가산출 고름 대기). +""" + +from __future__ import annotations + +import json +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_Sangeun as sangeun +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs + +mm_unit = m01.cm.mf.mp.mm.plain_unit +LEFT = "안 이은 줄이 남음 — 줄마다 이을 것을 고름" +WHY = { + "단가산출": "맞는 기초 없음 — 고름 대기", + "자재": "자재 후보 여럿 · 없음", + "별도계상": "자재 후보 여럿 · 없음", + "노임": "같은 이름 노임 없음", + "기계": "같은 기계(EQ) 없음 · 여럿", + "호표": "하위 호표 확인 전 · 단위 다름", + "%": "밑수가 다른 줄 금액 합 아님", +} + + +def _num(value: Decimal) -> int | float: + value = value.normalize() + return int(value) if value == value.to_integral_value() else float(value) + + +def sks(b: Any) -> dict[str, str]: + """이 층 그 HP 의 SK `{본뜬 키: SK 키}`(한 번 읽어 둠).""" + if b._sk is None: + b._sk = sangeun.made(b.folder, b.key, b.source) + return b._sk + + +def sk_doc(b: Any, key: str) -> dict[str, Any]: + try: + got = json.loads((b.folder / "sangeun" / f"{key}.json").read_bytes()) + except (OSError, ValueError): + return {} + return got if isinstance(got, dict) else {} + + +def _ref(key: str, line: dict, alias: Any, memo: str, qty: Decimal) -> dict[str, Any]: + head = {"층": refs.here()[0], "주인": refs.here()[1], "키": key} + return {"참조": head, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}} + + +def sk_line(b: Any, line: dict[str, Any], create: bool) -> tuple[dict | None, dict | None]: + """단가산출 줄 → (그 블록 SK 참조 줄, 못 이은 까닭 {까닭, 후보?}) — `create` = 없으면 만듦.""" + ref = line.get("참조") or {} + if ref.get("종류") != "단산": + return None, None + no = int(ref.get("번") or 0) + key = sks(b).get(sangeun.mark(b.key, no)) + if key is None and create: + if b._maker is None: + b._maker = sangeun.Maker(b.src, b.key, b.who) + if no not in b._maker.first: + return None, None + if not b._maker.done: + return None, {"까닭": "맞춤 셈 전 — 단가산출 못 만듦"} + plan = b._maker.plan(no) + if plan["갈래"] == sangeun.skpatch.WAIT: + return None, {"까닭": plan["까닭"], "후보": plan["후보"]} + key = f"SK-미리{no}" if b.dry else b._maker.write(no, plan, None) + sks(b)[sangeun.mark(b.key, no)] = key + if key is None: + return None, None + unit = sk_doc(b, key).get("단위") or line.get("단위") + if mm_unit(unit) != mm_unit(line.get("단위")): + return None, {"까닭": f"단가산출 단위 「{unit}」 다름"} + qty = Decimal(str(line.get("수량") or 0)) + alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None + return _ref(key, line, alias, f"실무 {qty}{line.get('단위') or ''} · 단산{no}", qty), None + + +def kept(b: Any, line: dict, one: dict, qty: Decimal, alias: Any, memo: str) -> dict | None: + """확인한 로직 줄이 그 블록 차이 SK 의 바탕 = 그 SK 참조 · 아니면 None.""" + ref = line.get("참조") or {} + if ref.get("종류") != "단산" or one.get("몫") is not None: + return None + key = sks(b).get(sangeun.mark(b.key, int(ref.get("번") or 0))) + doc = sk_doc(b, key) if key else {} + base = (doc.get("바탕") or {}).get("로직") + if (doc.get("본뜬") or {}).get("갈래") != sangeun.skpatch.SK or base != one["로직"]: + return None + return _ref(key, line, alias, f"{memo} · 차이 SK", qty) + + +def left(line: dict[str, Any], why: dict | None) -> dict[str, Any]: + """남은 줄 한 줄 — 엑셀 줄 · 갈래 · 까닭 · 후보.""" + kind = finger.kind_of(line) + keep = ("차례", "이름", "규격", "단위", "수량") + out = {**{k: line.get(k) for k in keep}, "갈래": kind} + out["까닭"] = (why or {}).get("까닭") or WHY.get(kind, "이을 것 없음") + return {**out, "후보": (why or {}).get("후보") or []} diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index ced3611f8..ad09e464e 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -314,6 +314,11 @@ def summary(folder: Path, kind: str, key: str, expand: Expand | None = None) -> layer, owner = where(folder) base = expand({"층": layer, "주인": owner, "키": key}).get("기준") or {} out["기준금액"] = float(base["계"]) if "계" in base else None + if kind == "precombo": # 729-2 — 이음 없는 고정 줄 수(로직 · 참조 · 비율 · 이음 = 이어짐) + fixes = [x["고정"] for x in doc.get("담은로직") or [] if isinstance(x.get("고정"), dict)] + out["안이은줄"] = sum( + 1 for x in fixes if not x.get("비율") and not any((x.get("이음") or {}).values()) + ) # 717-2a 목록 열 — 바탕(로직 「키 이름」 | 참조 키 | None) · 고친 줄 수 · 쓰는 곳 수 if kind == "sangeun": top, (layer, owner) = doc.get("바탕") or {}, where(folder) diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 0e8da10b7..a0951097d 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -375,6 +375,9 @@ class BuildTarget(_Strict): 자재: str | None = None 호표: int | None = None 전국: bool | None = None # 「전국」 자재(361-1 · 키 없음 · 서버가 줄에서 다시 셈) + 노임: str | None = None # 729-2 사용자 이음 — LB 키(엑셀 숫자 + 이음) + 기계: str | None = None # EQ 키 + 비율: Literal["노무비", "재료비", "경비"] | None = None # % 줄 밑수 입력값: dict[str, Any] = Field(default_factory=dict) 배: float = 1 몫: float | None = None @@ -422,6 +425,23 @@ def hopyo_make( return _call(folder, hopyo_build.build, *args) +@router.post("/layers/{layer}/hopyo-unlinked") +def hopyo_unlinked( + layer: Layer, + body: BuildBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """안 이은 줄 미리 보기(729-2) — 만들기 몸 그대로 → `{남은줄 [{차례, 이름, 규격, 단위, 갈래, 까닭, + 후보}], 수}`(저장 없음).""" + folder = _root(session, layer, owner, write=True) + source = _root(session, body.층, body.주인) + lines = [one.model_dump(exclude_none=True) for one in body.줄] + whole = body.통째.model_dump(exclude_none=True) if body.통째 else None + args = (folder, source, body.키, body.번, lines, whole) + return _call(folder, hopyo_build.unlinked, *args) + + class BuildAllBody(_Strict): """시트 통째(705-3) — 파일 층 `층` · `주인` · `키` 만(숫자 · 줄 칸 없음 · 서버가 파일에서 읽음).""" @@ -441,7 +461,7 @@ def hopyo_make_all( `{호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보, 같음, 그대로, 다름, 실패}`.""" folder = _root(session, layer, owner, write=True) source = _root(session, body.층, body.주인) - args = (folder, source, body.키, _who(session)) + args = (folder, source, body.키, _who(session), True) # 729-2 — 남은 줄 있는 호표 = 실패 return _call(folder, hopyo_build.build_all, *args) @@ -503,14 +523,25 @@ def _finish(folder: Path, key: str) -> dict[str, Any]: return {"지움": key, "만든": len(made)} +class SangeunBody(_Strict): + """`바탕` = 고름 대기 블록에 사용자가 고른 기초 `{블록 번: {로직, 입력값?}}`(729-1).""" + + 바탕: dict[str, dict[str, Any]] = Field(default_factory=dict) + + @router.post("/layers/{layer}/hopyo/{key}/sangeun") def hopyo_make_sangeun( - layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) + layer: Layer, + key: str, + body: SangeunBody | None = None, + owner: str | None = None, + session: Session = Depends(verify_session), ) -> dict: - """불러오기 결과 「단가산출」(718-1) — 산근 블록마다 바탕 없는 단가산출(파일 층 = 이 층) → - `{블록, 만듦, 그대로}`.""" + """불러오기 결과 「단가산출」(718-1 · 729-1) — 산근 블록마다 매칭 → 비교 → 기초 · SK · 고름 대기 + (파일 층 = 이 층) → `{블록, 만듦, 그대로, 기초, SK, 고름대기, 별칭}` · 맞춤 셈 전 409.""" folder = _root(session, layer, owner, write=True) - return _call(folder, hopyo_sangeun.build, folder, key, _who(session)) + picks = body.바탕 if body else {} + return _call(folder, hopyo_sangeun.build, folder, key, _who(session), picks) class PasteBody(_Strict): diff --git a/resources/tester/test_705_2_hopyo_paste.py b/resources/tester/test_705_2_hopyo_paste.py index c89f9522a..69535a43e 100644 --- a/resources/tester/test_705_2_hopyo_paste.py +++ b/resources/tester/test_705_2_hopyo_paste.py @@ -183,9 +183,13 @@ def test_다시_잇기_후보_바꿈_되돌리기(client): from resources.tester.test_m02_hopyo_excel import _post assert _post(client).status_code == 200 # 북부청 — 이음 13 · 이음 후보 31(705-3) - made = client.post( - "/api/m02/layers/personal/hopyo-build-all", json={"층": "personal", "키": "HP-000001"} - ).json() + from M02_MasterTemplete import M02_Hopyo_Build as hb + from M02_MasterTemplete import M02_MasterTemplete_Store as mstore + from M02_MasterTemplete import M02_Template_Layers as mlayers + + mine = mlayers.personal_dir(7, 42) # 모두 고정(막지 않음) — 길은 남은 줄 호표 실패(729-2) + with mstore.at(mine): + made = hb.build_all(mine, mine, "HP-000001") keys = [x["키"] for x in made["만듦"]] assert len(keys) == 68 rows = client.post(f"{RELINK}/rows", json={"키": keys}).json()["줄"] diff --git a/resources/tester/test_705_3_hopyo_fixed.py b/resources/tester/test_705_3_hopyo_fixed.py index ed3822245..a35f48770 100644 --- a/resources/tester/test_705_3_hopyo_fixed.py +++ b/resources/tester/test_705_3_hopyo_fixed.py @@ -2,9 +2,10 @@ - 확인 안 한 줄 · 노임 · % · 「우리에 없음」 = 고정 줄(엑셀 숫자 그대로) · 체크한 줄만 품셈 · 하위 호표 = 참조 - % 줄 = 비율 고정 줄 — M02 settle 거울(파이썬 ↔ TS) · 사전조합 셈 · B09 `pct` 줄 -- [모두 고정으로 들여오기] — 북부청 · 실무 xlsx 넷: 빠짐 0 · 엑셀 합계 = 우리 합계(호표마다) · 줄 꼬리표 수 · - 확인된 호표 그대로 · 이음 = 단가까지 같은 줄만 · 같은 이름 줄 차례 가름 · 몸에 숫자 칸 없음(422) -- [품셈으로 바꾸기] 전후 — 체크 한 줄 = 품셈 · 나머지 고정 · 되돌리기(빈 몸) = 모두 고정 +- 모두 고정(`build_all` 막지 않음 = 붙여넣기 길) — 북부청 · 실무 xlsx 넷: 빠짐 0 · 엑셀 합계 = 우리 합계 · + 줄 꼬리표 수 · 확인된 호표 그대로 · 이음 = 단가까지 같은 줄만(노임 이름 · 기계 EQ = 불러오기 길만) · 같은 이름 줄 차례 + 가름 · 몸에 숫자 칸 없음(422) · [모두 고정으로 들여오기] 길 = 남은 줄 있는 호표 실패(729-2 · 718-1 시험) +- [품셈으로 바꾸기] 전후 — 체크 한 줄 = 품셈 · 나머지 고정(「우리에 없음」 고름) · 되돌리기 = 모두 고정 층은 임시 폴더(공용 storage 안 씀). """ @@ -16,7 +17,9 @@ 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_Build as hb from M02_MasterTemplete import M02_Hopyo_Fixed as hfix +from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Template_Layers as layers @@ -59,9 +62,19 @@ def _fx(name, price, rate=None, unit="식"): 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() + """모두 고정(막지 않음 = 붙여넣기 길 `build_all`) — 길(729-2)은 남은 줄 있는 호표 = 실패.""" + folder = layers.personal_dir(7, 42) + with store.at(folder): + return hb.build_all(folder, folder, key) + + +def _decided(client, body): + """남은 줄(729-2)을 「우리에 없음」으로 고른 몸.""" + left = client.post(BUILD.format("personal").replace("build", "unlinked"), json=body).json() + return { + **body, + "줄": [*body["줄"], *({"차례": x["차례"], "뺌": "우리에 없음"} for x in left["남은줄"])], + } def _docs(): @@ -213,7 +226,7 @@ def test_품셈으로_바꾸기_되돌리기(client): _all(client) body = {"층": "personal", "키": "HP-000001", "번": 3} one = {"차례": 1, "대상": [{"로직": "GF000101", "입력값": {"지역": "서울"}, "배": 1}]} - res = client.post(BUILD.format("personal"), json={**body, "줄": [one]}) + res = client.post(BUILD.format("personal"), json=_decided(client, {**body, "줄": [one]})) assert res.status_code == 200, res.text after = res.json() assert after["다시씀"] is True and after["줄수"]["품셈"] == 1 and after["줄수"]["고정"] == 3 @@ -222,5 +235,5 @@ def test_품셈으로_바꾸기_되돌리기(client): 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() # 되돌리기 + back = client.post(BUILD.format("personal"), json=_decided(client, {**body, "줄": []})).json() assert back["줄수"]["품셈"] == 0 and back["비교"]["같음"] is True diff --git a/resources/tester/test_718_1_hopyo_import.py b/resources/tester/test_718_1_hopyo_import.py index 72f75bb11..c3a5f6ef3 100644 --- a/resources/tester/test_718_1_hopyo_import.py +++ b/resources/tester/test_718_1_hopyo_import.py @@ -27,7 +27,8 @@ def test_이어서_끝내기_지움_겹침_막음(client): assert again["키"] == "HP-000001" and again["이어서"] is True # 작업 중 같은 엑셀 = 새 키 없음 assert _keys(client) == ["HP-000001"] made = client.post(ALL, json={"층": "personal", "키": "HP-000001"}).json() - assert len(made["만듦"]) == 68 + n = len(made["만듦"]) # 729-2 — 남은 줄 있는 호표 = 실패(남은줄) + assert n and n + len(made["실패"]) == 68 and all(x["남은줄"] for x in made["실패"]) hs.remember(folder, {"장부 줄": {"갈래": "안 씀"}}) # 별칭 장부 = 끝내기 뒤 남음 cache = fit.cache_path(folder, "HP-000001") cache.write_text("{}", encoding="utf-8") @@ -36,7 +37,7 @@ def test_이어서_끝내기_지움_겹침_막음(client): sc_before = sorted((folder / "precombo").glob("SC-*.json")) res = client.delete(URL.format("personal") + "/HP-000001") - assert res.status_code == 200 and res.json() == {"지움": "HP-000001", "만든": 68} + assert res.status_code == 200 and res.json() == {"지움": "HP-000001", "만든": n} assert not list((folder / hs.DIRNAME).glob("HP-*.json")) # 이번 HP 0 assert not list((folder / hs.DIRNAME / hs.GRID).glob("HP-*.json")) assert not cache.exists() and not old_cache.exists() @@ -45,7 +46,7 @@ def test_이어서_끝내기_지움_겹침_막음(client): block = _post(client) # 끝낸 같은 엑셀 = 막음 · 안내 assert block.status_code == 409 and "이미 불러온 엑셀" in block.json()["detail"] - assert "단가산출 68" in block.json()["detail"] and _keys(client) == [] + assert f"단가산출 {n}" in block.json()["detail"] and _keys(client) == [] assert sorted((folder / "precombo").glob("SC-*.json")) == sc_before @@ -67,32 +68,25 @@ def test_옛_HP_목록에서만_숨김(client): assert old.is_file() and _keys(client) == ["HP-000008"] -def test_결과_단가산출_산근_블록_바탕_없음_겹침_없음(client): +def test_결과_단가산출_산근_블록_기초_SK_고름대기_겹침_없음(client): folder = layers.personal_dir(7, 42) assert _post(client).json()["키"] == "HP-000001" url = URL.format("personal") + "/HP-000001/sangeun" + assert client.post(url).status_code == 409 # 729-1 — 맞춤 셈 전 + fit.run_job(folder, "HP-000001") got = client.post(url) assert got.status_code == 200, got.text got = got.json() - assert got["블록"] > 0 and len(got["만듦"]) == got["블록"] and got["그대로"] == 0 + wait = len(got["고름대기"]) + assert got["블록"] > 0 and len(got["만듦"]) + wait == got["블록"] and got["그대로"] == 0 docs = [json.loads(p.read_bytes()) for p in sorted((folder / "sangeun").glob("SK-*.json"))] - assert len(docs) == got["블록"] + assert len(docs) == len(got["만듦"]) == got["기초"] + got["SK"] one = docs[0] - assert one["바탕"] is None and one["본뜬"]["키"].startswith("HP-000001 단산") - assert one["구분"] == "실무자료" and len(one["더한줄"]) == 1 and one["단위"] - hp = hs.read(folder, "HP-000001") - no = int(one["본뜬"]["키"].rsplit("단산", 1)[1]) - line = next( # 그 블록을 부르는 첫 엑셀 줄 = 엑셀 단가 그대로 - x - for h in hp["호표"] - for x in h["줄"] - if (x.get("참조") or {}).get("종류") == "단산" and x["참조"]["번"] == no - ) - price = one["더한줄"][0]["고정"]["단가"] - assert sum(price.values()) == sum((line["단가"] or {}).get(k) or 0 for k in price) + assert one["바탕"]["로직"] and one["본뜬"]["키"].startswith("HP-000001 단산") # 바탕 없는 SK 0 + assert one["구분"] == "실무자료" and one["단위"] and one["본뜬"]["갈래"] in ("기초", "SK") again = client.post(url).json() # 다시 = 그대로(겹침 없음) - assert again["만듦"] == [] and again["그대로"] == got["블록"] + assert again["만듦"] == [] and again["그대로"] == len(docs) res = client.delete(URL.format("personal") + "/HP-000001").json() - assert res["만든"] == got["블록"] # 단가산출만 만들어도 지문 기록 - assert len(list((folder / "sangeun").glob("SK-*.json"))) == got["블록"] # 남음 + assert res["만든"] == len(docs) # 단가산출만 만들어도 지문 기록 + assert len(list((folder / "sangeun").glob("SK-*.json"))) == len(docs) # 남음 assert _post(client).status_code == 409 diff --git a/resources/tester/test_729_hopyo_sk_link.py b/resources/tester/test_729_hopyo_sk_link.py new file mode 100644 index 000000000..ba8a8087d --- /dev/null +++ b/resources/tester/test_729_hopyo_sk_link.py @@ -0,0 +1,154 @@ +"""실무문서 단가산출 → 기초 · SK · 고름 대기 · 일위대가 이음 · 남은 줄 막음(PLAN 729-1 · 729-2 · 요구서 내역19). + +북부청 임도(실물 엑셀)를 임시 층에 올려 맞춤셈 → [단가산출 만들기] → +기초잡석채집 · 운반 · 다짐(엑셀만 덤프트럭) · 철근가공조립 Type-Ⅰ · Type-Ⅱ · 구조물되메우기 = SK · +SK 품 = 엑셀 품(차이는 엑셀 값 그대로) · 같은 단가산출 = 기초(덮개 없음) + 별칭 제안(짝 = 기초까지만) · +고름 대기 = SK 안 만듦 → 고른 기초로 SK · 안 이은 줄 남으면 만들기 409(미리 보기 = 같은 줄) · +사용자가 고르면 만듦 · 노임 = 이름 이음 · 기계 = EQ 이음 · master_data 원본 그대로. 층 = 임시 폴더. +""" + +import json + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Hopyo_Fit as fit +from M02_MasterTemplete import M02_Hopyo_Fixed as hfix +from M02_MasterTemplete import M02_Hopyo_Ledger as ledger +from M02_MasterTemplete import M02_Hopyo_Option as option +from M02_MasterTemplete import M02_Hopyo_Ours as ours +from M02_MasterTemplete import M02_Hopyo_SkPatch as skpatch +from M02_MasterTemplete import M02_Hopyo_Store as hs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_hopyo_excel import URL, _post, client, master_hash # noqa: F401 + +BUILD = "/api/m02/layers/personal/hopyo-build" +LEFT = "/api/m02/layers/personal/hopyo-unlinked" +SAMPLES = { # 블록 번 → (이름 앞, 엑셀 규격 글) + 33: "기초잡석채집,운반,다짐", + 47: "철근가공조립(현장)", # Type-Ⅰ(소량 시공) — 철근공 4.2 · 보통인부 1.4 + 72: "철근가공조립(현장)", # Type-ll — 철근공 2.67 · 보통인부 1.02 + 12: "구조물되메우기(인력100%)", +} + + +def _same(a: float, b: float) -> bool: + return abs(a - b) <= 1e-9 or option.near(a, b, fit.TOL[0], option.ru(a)) + + +def _by_type(rows: list[dict]) -> dict[str, float]: + out: dict[str, float] = {} + for x in rows: + k = "+".join(sorted(x["갈래"])) + out[k] = out.get(k, 0.0) + x["시간"] + return out + + +def _sk_docs(folder) -> dict[int, dict]: + out = {} + for path in sorted((folder / "sangeun").glob("SK-*.json")): + doc = json.loads(path.read_bytes()) + out[int(doc["본뜬"]["키"].rsplit("단산", 1)[1])] = doc + return out + + +def test_실무_단가산출_기초_SK_고름대기_일위대가_막음(client): + before = master_hash() + folder = layers.personal_dir(7, 42) + key = _post(client).json()["키"] + url = f"{URL.format('personal')}/{key}/sangeun" + assert client.post(url).status_code == 409 # 맞춤 셈 전 + fit.run_job(folder, key) + got = client.post(url, json={}) + assert got.status_code == 200, got.text + got = got.json() + assert ( + got["기초"] + got["SK"] + len(got["고름대기"]) + == got["블록"] + == len(got["만듦"]) + len(got["고름대기"]) + ) + assert got["SK"] > 0 and got["기초"] > 0 and got["고름대기"] + docs, whole = _sk_docs(folder), m01.loaded()[1] + assert all(d["바탕"] and d["바탕"]["로직"] for d in docs.values()) # 바탕 없는 SK 0 + assert not set(docs) & {x["번"] for x in got["고름대기"]} + # SK 품 = 엑셀 품 + for no, head in SAMPLES.items(): + doc = docs[no] + assert doc["이름"].startswith(head) and doc["본뜬"]["갈래"] == "SK", (no, doc["본뜬"]) + excel = doc["본뜬"]["엑셀품"] + with store.at(folder): + ours_q = skpatch.measure(doc, whole) + assert set(ours_q["인력"]) == set(excel["인력"]), no + assert all(_same(q, ours_q["인력"][t]) for t, q in excel["인력"].items()), no + theirs, mine = _by_type(excel["장비"]), _by_type(ours_q["장비"]) + assert set(theirs) == set(mine), no + assert all(_same(q, mine[t]) for t, q in theirs.items()), no + dump = [x for x in docs[33]["더한줄"] if x.get("로직") == "GC000268"] + assert any("덤프트럭" in ours.eq_of(_eq(x))[0] for x in dump) # 엑셀만 장비 = 더한줄 + assert docs[12]["덮개"]["labor:LB000002#0"]["qty"] == 0.1 # 엑셀 값 그대로(우리 0.01) + assert docs[47]["더한줄"][0]["로직"] == "GC000206" # 조합 둘째 = 더한줄 기초 + # 같은 단가산출 = 기초 + 별칭 제안(짝 = 기초까지만) + base = next(d for d in docs.values() if d["본뜬"]["갈래"] == "기초") + assert base["덮개"] == {} and base["더한줄"] == [] and base["뺀줄"] == [] + book = ledger.read(folder) + hp = hs.read(folder, key) + lines = { + int(x["참조"]["번"]): x + for h in reversed(hp["호표"]) + for x in reversed(h["줄"]) + if (x.get("참조") or {}).get("종류") == "단산" + } + no = int(base["본뜬"]["키"].rsplit("단산", 1)[1]) + pair = book[ledger.key(lines[no])]["짝"] + assert pair == [{"로직": base["바탕"]["로직"], "입력값": base["입력값"], "배": 1, "몫": None}] + assert book[ledger.key(lines[12])]["짝"][0]["로직"] == docs[12]["바탕"]["로직"] + assert got["별칭"] > 0 + # 다시 = 그대로 · 고름 대기 = 고른 기초로 SK + again = client.post(url, json={}).json() + assert again["만듦"] == [] and again["그대로"] == len(docs) + wait = got["고름대기"][0] + code = (wait["후보"] or [{"로직": "GF000113"}])[0]["로직"] + picked = client.post(url, json={"바탕": {str(wait["번"]): {"로직": code}}}).json() + assert [x["번"] for x in picked["만듦"]] == [wait["번"]] + assert _sk_docs(folder)[wait["번"]]["바탕"]["로직"] == code + assert client.post(url, json={"바탕": {"9999": {"로직": code}}}).status_code == 404 + assert client.post(url, json={"바탕": {str(wait["번"]): {"로직": "XX"}}}).status_code == 400 + # 안 이은 줄 남으면 만들기 막힘 · 미리 보기 = 같은 줄 · 고르면 만듦 + body = {"층": "personal", "키": key, "번": 1, "줄": []} + left = client.post(LEFT, json=body).json() + res = client.post(BUILD, json=body) + assert res.status_code == 409 and left["수"] > 0 + assert res.json()["detail"]["남은줄"] == left["남은줄"] + assert not list((folder / "precombo").glob("SC-*.json")) # 막힘 = 저장 0 + picks = [{"차례": x["차례"], "뺌": "우리에 없음"} for x in left["남은줄"]] + ok = client.post(BUILD, json={**body, "줄": picks}) + assert ok.status_code == 200, ok.text + doc = json.loads((folder / "precombo" / f"{ok.json()['참조']['키']}.json").read_bytes()) + refs_sk = [x for x in doc["담은로직"] if str((x.get("참조") or {}).get("키")).startswith("SK")] + assert refs_sk # 단가산출 줄 = SK 참조(고정 줄 대신) + assert client.post(LEFT, json={**body, "줄": picks}).json()["수"] == 0 + assert master_hash() == before + + +def _eq(one: dict) -> str: + return str((one.get("입력값") or {}).get("기계") or "") + + +def test_노임_이름_이음_기계_EQ_이음_사용자_이음(client): + with store.at(layers.personal_dir(7, 42)): + linker = hfix.Linker(by_name=True) + labor = {"갈래": "노임", "이름": "보통인부", "단위": "인"} + price = {"노무비": hfix.Decimal(1), "재료비": hfix.Decimal(0), "경비": hfix.Decimal(0)} + assert linker.link(labor, price)["노임"] == "LB000002" # 단가 달라도 이름으로 + machine = {"갈래": "기계", "이름": "덤프트럭", "규격": "15ton", "단위": "hr"} + code = linker.link(machine, price)["기계"] + assert code and "덤프트럭" in ours.eq_of(_eq_code(code))[0] + line = {"갈래": "자재", "이름": "없는자재", "규격": "", "단위": "개", "수량": 2, + "단가": {"재료비": 10}, "차례": 0} # fmt: skip + one = hfix.line_of(line, [], {"파일": "x", "호표": 1}, set(), linker, {"노임": "LB000002"}) + assert one["고정"]["이음"] == {"노임": "LB000002", "자재": None, "기계": None} + assert hfix.linked(one) and not hfix.linked(hfix.line_of(line, [], {}, set(), linker)) + + +def _eq_code(key: str) -> str: + """EQ 키(EQ000001) → 기계 번호(0101-0007).""" + return next(k for k, v in m01.loaded()[1].numbers["EQ"].items() if v.get("키") == key) diff --git a/resources/tester/test_m02_hopyo_build.py b/resources/tester/test_m02_hopyo_build.py index d6e0bd709..d3f4f6293 100644 --- a/resources/tester/test_m02_hopyo_build.py +++ b/resources/tester/test_m02_hopyo_build.py @@ -19,13 +19,18 @@ DIG = "구조물터파기(인력100%)|토사|m3" FILL = "구조물되메우기(인력100%)||m3" SOIL = "잔토처리(인력100%)||m3" BUILD = "/api/m02/layers/{}/hopyo-build" +LEFT = "/api/m02/layers/{}/hopyo-unlinked" LEDGER = "/api/m02/layers/{}/hopyo-ledger" -def _build(client, no, lines=None, layer="personal", want=200, **extra): +def _build(client, no, lines=None, layer="personal", want=200, decide=True, **extra): + """`decide` = 남은 줄(729-2 · 이음 없는 고정 줄)을 「우리에 없음」으로 고른 뒤 만듦(옛 고정 줄 꼴 그대로).""" body = {"층": "personal", "키": "HP-000001", "번": no, **extra} if lines is not None: body["줄"] = lines + if decide and want == 200 and "통째" not in extra: + left = client.post(LEFT.format(layer), json=body).json().get("남은줄") or [] + body["줄"] = [*(lines or []), *({"차례": x["차례"], "뺌": "우리에 없음"} for x in left)] res = client.post(BUILD.format(layer), json=body) assert res.status_code == want, res.text return res.json() @@ -84,7 +89,9 @@ def test_확인한_줄만_별칭_수량_빠진_줄_장부(client): ] assert doc["자재"] == [] # 표지석 = 몸에 없음 → 고정 줄 assert got["비교"]["엑셀"] == 1415169 and got["비교"]["우리"] > 0 - assert got["다시씀"] is False and got["장부"] == {"씀": 3} + assert got["다시씀"] is False and got["장부"] == { + "씀": 4 + } # + 표지석 「우리에 없음」(729-2 고름) book = _book(mine) assert book[DIG]["갈래"] == "짝" and book[DIG]["짝"] == [ {"로직": "GF000101", "입력값": {"지역": "서울"}, "배": 1, "몫": None} @@ -177,7 +184,8 @@ def test_전국_자재_키_없이_본뜬에(client): } ] assert [m["까닭"] for m in got["빠진줄"] if m["차례"] == 0] == [hb.NATIONAL] - assert got["장부"]["씀"] == 0 + national = hs.read(mine, "HP-000001")["호표"][0]["줄"][0] + assert hb.ledger.key(national) not in _book(mine) # 전국 줄 = 장부 안 씀 _build(client, 1, [{"차례": 1, "대상": [{"전국": True, "배": 1}]}], want=400) # 로직 줄 both = [{"차례": 0, "대상": [{"전국": True, "자재": "MT004258", "배": 1}]}] _build(client, 1, both, want=400) @@ -276,7 +284,8 @@ def test_장부_길_보기_고치기_권한(client): assert _post(client).status_code == 200 _build(client, 3, [{"차례": 1, "대상": [{"로직": "GF000101"}]}]) rows = client.get(LEDGER.format("personal")).json()["줄"] - assert [(r["열쇠"], r["층"], r["고침"]) for r in rows] == [(DIG, "personal", True)] + assert (DIG, "personal", True) in [(r["열쇠"], r["층"], r["고침"]) for r in rows] + assert {r["갈래"] for r in rows if r["열쇠"] != DIG} == {"우리에 없음"} # 729-2 고른 남은 줄 put = { "줄": {DIG: {"갈래": "짝", "짝": [{"로직": "GF000103", "배": 2}]}, SOIL: {"갈래": "안 씀"}} } diff --git a/resources/tester/test_m02_hopyo_flow.py b/resources/tester/test_m02_hopyo_flow.py index cdb995510..d0062d5d6 100644 --- a/resources/tester/test_m02_hopyo_flow.py +++ b/resources/tester/test_m02_hopyo_flow.py @@ -56,8 +56,12 @@ def _find(client, key: str, name: str) -> tuple[int, dict]: raise KeyError(name) -def _build(client, key: str, no: int, lines: list, **extra) -> dict: +def _build(client, key: str, no: int, lines: list, decide: bool = False, **extra) -> dict: + """`decide` = 남은 줄(729-2)을 「우리에 없음」으로 고른 뒤 만듦.""" body = {"층": "personal", "키": key, "번": no, "줄": lines, **extra} + if decide: + left = client.post("/api/m02/layers/personal/hopyo-unlinked", json=body).json()["남은줄"] + body["줄"] = [*lines, *({"차례": x["차례"], "뺌": "우리에 없음"} for x in left)] res = client.post("/api/m02/layers/personal/hopyo-build", json=body) assert res.status_code == 200, res.text return res.json() @@ -127,7 +131,7 @@ def test_섞인_줄_몫_배(client): screen = _node({"real": [line]}) assert screen["lines"][0]["checkable"] and not screen["lines"][0]["checked"] one = {k: part[k] for k in ("로직", "입력값", "배", "몫")} - made = _build(client, "HP-000001", no, [{"차례": line["차례"], "대상": [one]}]) + made = _build(client, "HP-000001", no, [{"차례": line["차례"], "대상": [one]}], decide=True) doc = _doc(made["참조"]) at = next(i for i, x in enumerate(doc["담은로직"]) if x.get("로직") == "GF000211") assert doc["담은로직"][at]["수량"] is None # 723-1 — 수량 저장 안 함 · 엑셀 수량 = 출처 기록 @@ -140,9 +144,13 @@ def test_막는_길(client): "/api/m02/layers/personal/hopyo-build", json={"층": "personal", "키": "HP-000001", "번": 1, "줄": []}, ) - assert ( - empty.status_code == 200 and empty.json()["줄수"]["품셈"] == 0 - ) # 빈 몸 = 모두 고정(705-3) + left = empty.json()["detail"]["남은줄"] # 729-2 — 안 이은 줄 남음 = 안 만듦 + assert empty.status_code == 409 and left + peek = client.post( + "/api/m02/layers/personal/hopyo-unlinked", + json={"층": "personal", "키": "HP-000001", "번": 1, "줄": []}, + ).json() + assert peek == {"남은줄": left, "수": len(left)} money = client.post( "/api/m02/layers/personal/hopyo-build", json={"층": "personal", "키": "HP-000001", "번": 1, "줄": [{"차례": 1, "금액": 1}]},