diff --git a/M02_MasterTemplete/M02_Hopyo_Build.py b/M02_MasterTemplete/M02_Hopyo_Build.py index 0dc19505c..89ce8a19f 100644 --- a/M02_MasterTemplete/M02_Hopyo_Build.py +++ b/M02_MasterTemplete/M02_Hopyo_Build.py @@ -5,7 +5,7 @@ 몸이 비면 = 모두 고정(705-3 · [모두 고정으로 들여오기]). 서버 재검사(브라우저 값을 받아 적지 않음 · 금액 · 품 칸 없음) — 차례 = 그 호표 줄 · 대상 하나 = 돈 로직 · M01 자재품목 · 확인된 호표 중 하나 · 입력 이름 = 그 로직 입력 · 배 0 넘음 · 몫 (0, 1] · -호표 = 만들 층에 확인된 사전조합(없으면 409 「제N호표 먼저 확인」). +호표 = 만들 층에 확인된 사전조합(확인 안 된 하위 = 저장 때 하위 먼저 함께 만듦 · `M02_Hopyo_Kids.first` · 777-1). 호표 하나 = 사전조합 하나 — 이름 = 품명 + 규격 · 단위 = 엑셀 단위 · 구분 「실무자료」 · 상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일, 원본 [층, 주인], 확인 {누가, 때}} · 만드는 층 = 부르는 층(일위대가 문서 층). @@ -18,7 +18,8 @@ 「전국」 자재(361-1 · 키 · 단가 없음 · 서버가 줄에서 다시 셈) = 일위대가 자재 칸이 M01 키만 받아 `본뜬.전국자재 [{이름, 규격, 단위, 수량, 세 칸}]` 에 적고 빠진 줄에 까닭(금액 없음 · 지역은 프로젝트에서) · 장부엔 안 남김. -같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 확인한 줄은 별칭 장부에 남음 +같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 없으면 같은 내용 SC 재사용(`재사용` · `M02_Hopyo_Kids.save`) · +`하위` = 부르는 하위 호표 `[{번, 키, 새로}]`(부모 줄 = 하위 사본) · 확인한 줄은 별칭 장부에 남음 (`M02_Hopyo_Ledger` · 호표 참조 줄 뺌). 빠짐 0(705-3) — 확인 안 한 줄 · 노임 · % · 「우리에 없음」 줄 = 고정 줄(`M02_Hopyo_Fixed` · 엑셀 숫자 그대로) · 확인 안 한 호표 줄 = 확인된 하위 사전조합 참조(단위 같음 · 아니면 고정) · 빠진 줄 = 「안 씀」 · @@ -31,13 +32,12 @@ `{비율: 기준}` = 고정 줄 + 그 이음 · 남은 줄(`Fixed.linked` 아님 · 「우리에 없음」 고른 줄 뺌) 있으면 409 `{까닭, 남은줄}`(저장 안 함 · `save` 거짓 = 막지 않고 `남은줄` 만) · 미리 보기 = `unlinked`(쓰기 없음). 수량(742-1 · 요구 584-1) = 엑셀 수량을 줄 · 자재 저장 수량(`{손}`)으로 · 같은 값 `본뜬.실무수량 {줄: {차례: 수}, -자재: {코드: 수}}`(출처 기록 · 엑셀 대조 `compare` — 하위 호표 것까지 `excel_given`). +자재: {코드: 수}}`(출처 기록 · 엑셀 대조 `M02_Hopyo_Compare` — 하위 호표 것까지 `excel_given`). """ from __future__ import annotations -import json -from decimal import ROUND_FLOOR, ROUND_HALF_UP, Decimal +from decimal import Decimal from pathlib import Path from typing import Any @@ -45,6 +45,7 @@ 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_Kids as hkids 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 @@ -52,7 +53,12 @@ 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 +from M02_MasterTemplete import M02_Library_Same as lsame from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.M02_Hopyo_Compare import ( # noqa: F401 — 부르는 쪽 이름 그대로 + compare, + excel_given, +) ORIGIN = "실무자료" LINES_MAX = 500 @@ -69,23 +75,8 @@ def mark(key: str, no: int, at: int | None = None) -> str: return head if at is None else f"{head} {at + 1}째 줄" -def _origins(folder: Path): - """층 사전조합 가운데 실무자료 본뜬 → (키, 본뜬, 판).""" - for path in sorted((Path(folder) / "precombo").glob("SC-*.json")): - try: - raw = path.read_bytes() - doc = json.loads(raw) - except (OSError, ValueError): - continue - got = doc.get("본뜬") if isinstance(doc, dict) else None - if isinstance(got, dict) and got.get("층") == ORIGIN: - yield path.stem, got, raw - - -def _same_source(got: dict[str, Any], source: refs.Here | None) -> bool: - """본뜬 원본 층이 같음 — 원본 없는 옛 것 · `source` None 은 가리지 않음.""" - have = got.get("원본") - return source is None or not have or tuple(have) == tuple(source) +_origins = hkids.entries # B09 가 빌려 씀(또 칸까지) +_same_source = hkids.same_source def confirmed(folder: str | Path, source: refs.Here | None = None) -> dict[str, str]: @@ -97,31 +88,6 @@ def confirmed(folder: str | Path, source: refs.Here | None = None) -> dict[str, } -def _save(doc: dict[str, Any]) -> tuple[dict[str, Any], bool]: - """(참조, 다시씀) — 같은 본뜬(키 · 원본)이 있으면 그 키에 덮음 · 없으면 새 키 · 엑셀 수량 = 저장(742-1).""" - hands = { - "줄": {str(i): x["수량"]["손"] for i, x in enumerate(doc["담은로직"]) if x.get("수량")}, - "자재": {m["코드"]: m["수량"]["손"] for m in doc.get("자재") or [] if m.get("수량")}, - } - doc = {**doc, "본뜬": {**doc["본뜬"], "실무수량": hands}} - at = refs.here() - want = doc["본뜬"] - have = next( - ( - (key, store.version_of(raw)) - for key, got, raw in _origins(store.root()) - if got.get("키") == want["키"] and _same_source(got, tuple(want["원본"])) - ), - None, - ) - if have: - store.write("precombo", have[0], have[1], doc) - key = have[0] - else: - key = refs.create("precombo", doc)["이름"] - return {"층": at[0], "주인": at[1], "키": key}, bool(have) - - def _num(value: Decimal) -> int | float: value = value.normalize() return int(value) if value == value.to_integral_value() else float(value) @@ -163,10 +129,12 @@ class Builder: self.done = confirmed(folder, self.source) self.known = ledger._known() self.mat_names = {x["자재"]: x["이름"] for x in hopyo_match.targets()["자재"]} + self.units = {int(h["번"]) for h in self.doc.get("호표") or []} 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.same, self.reused = lsame.Same(), False # 같은 내용 SC 찾기(한 요청 메모) self.strict = True # 남은 줄 있으면 409(붙여넣기 `build_all` = 거짓) self.doc_id = ledger.doc_id(source, key) # 739-1 이 불러오기에서 이은 짝 @@ -185,8 +153,10 @@ class Builder: child = int(raw["호표"]) if child == self.no: raise _bad(f"호표가 자기를 담음 「{where}」") - if mark(self.key, child) not in self.done: - raise _bad(f"제{child}호표 먼저 확인", 409) + if mark(self.key, child) not in self.done and not self.dry: + raise _bad( + f"제{child}호표 먼저 확인", 409 + ) # 저장 길은 하위 먼저 만든 뒤(`hkids.first`) times, part = ledger.times_of(raw, where) return {"호표": child, "배": times, "몫": part} @@ -311,7 +281,7 @@ class Builder: ref = { "층": refs.here()[0], "주인": refs.here()[1], - "키": self.done[mark(self.key, one["호표"])], + "키": self.done.get(mark(self.key, one["호표"]), "SC-미리"), } lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) return @@ -336,7 +306,9 @@ class Builder: "자재": [], "본뜬": self._origin(f"{mark(self.key, self.no, at)} {one['로직']}", stamp), } - ref = {"키": "SC-미리"} if self.dry else _save(wrap)[0] # 미리 보기 = 쓰기 없음 + ref = ( + {"키": "SC-미리"} if self.dry else hkids.save(wrap, self.same)[0] + ) # 미리 보기 = 쓰기 없음 lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) book: dict[str, dict[str, Any]] = {} @@ -395,13 +367,15 @@ class Builder: 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)) + sub = [{"번": n, "키": self.done.get(mark(self.key, n))} for n in sorted(kids)] if not save: ledger.write(self.folder, book, self.who, file or None, self.doc_id) - head = {"문서": doc, "빠진줄": missing, "남은줄": left} + head = {"문서": doc, "빠진줄": missing, "남은줄": left, "하위": sub} return {**head, "줄수": {**count, "이음후보": linker.near}} - ref, again = _save(doc) + ref, again, self.reused = hkids.save(doc, self.same) wrote = ledger.write(self.folder, book, self.who, file or None, self.doc_id) - out = {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}} + out = {"참조": ref, "다시씀": again, "재사용": self.reused, "빠진줄": missing, "하위": sub} + out["장부"] = {"씀": wrote} return {**out, "줄수": {**count, "이음후보": linker.near}} def unlinked(self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]]) -> list: @@ -418,7 +392,9 @@ class Builder: if finger.kind_of(line) != "호표" and ref.get("종류") != "일위": return None no = int(ref.get("번") or 0) - made = self.done.get(mark(self.key, no)) + made = self.done.get(mark(self.key, no)) or ( + "SC-미리" if self.dry and no in self.units else None + ) 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: @@ -457,59 +433,6 @@ 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 excel_given(ref: dict[str, Any], depth: int = 1) -> dict[str, Any]: - """`본뜬.실무수량` → 안 줄 알약 값(723-1 · 하위 사전조합까지) — 엑셀 대조 전용.""" - doc = refs.read_ref(ref) if depth <= refs.DEPTH else None - if not isinstance(doc, dict): - return {} - rid, hands = refs.ref_id(ref), (doc.get("본뜬") or {}).get("실무수량") or {} - out = {f"{rid}#{i}": v for i, v in (hands.get("줄") or {}).items()} - out |= {f"{rid}#자재|{code}": v for code, v in (hands.get("자재") or {}).items()} - for one in refs.refs_in(doc): - out |= excel_given(one, depth + 1) - return out - - -def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]: - """만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가(참고 · 판정 아님) — 우리 = 엑셀 끝수 규칙(내림 · - 반올림 중 엑셀과 같은 쪽 · `_excel_round`) · 수량 = 엑셀 수량(`excel_given` · 723-1).""" - head = {k: ref[k] for k in ("층", "주인", "키")} - got = refs.Expand(m01.loaded()[1], given=excel_given(head))(head) - base = got.get("기준") or {} - 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 - same = ours is not None and abs(ours - (excel or 0)) < 1 # 705-3 — 엑셀 금액 없음 = 0 - return {"엑셀": excel, "우리": ours, "차이": gap, "같음": same, "까닭": got.get("까닭")} - - def unlinked( folder: Path, source: Path, @@ -520,6 +443,7 @@ def unlinked( ) -> dict[str, Any]: """안 이은 줄 미리 보기(729-3) — 몸 = 만들기 몸 → `{남은줄, 수}`(저장 없음).""" maker = Builder(folder, source, key, no, None) + maker.dry = True # 하위 확인 전 호표 대상 = 저장 때 함께 만듦(409 아님) checked, pairs = maker.check(list(lines or []), whole) left = maker.unlinked(checked, pairs) return {"남은줄": left, "수": len(left)} @@ -535,10 +459,16 @@ def build( who: Any = None, ) -> dict[str, Any]: """실무 호표(`source` 층 파일) + 확인한 줄 → 지금 층(`folder`) 사전조합 · - 별칭 장부 — `{참조, 이름, 다시씀, 빠진줄, 비교, 장부}`(부르는 쪽이 `store.at(folder)`).""" + 별칭 장부 — `{참조, 이름, 다시씀, 재사용, 빠진줄, 하위, 비교, 장부}`(부르는 쪽이 `store.at(folder)`) · + 확인 안 된 하위 호표 = 먼저 서버 체크 제안대로 함께 만듦(777-1 · 실패 = 409 「하위 제N호표 실패」).""" + kids = ( + hkids.first(folder, source, key, no, who, lines) if whole is None else [] + ) # 통째 = 하위 줄 안 씀 + new = {x["번"] for x in kids} maker = Builder(folder, source, key, no, who) checked, pairs = maker.check(list(lines or []), whole) made = maker.build(checked, pairs) + made["하위"] = [{**x, "새로": x["번"] in new} for x in made["하위"]] return {**made, "이름": maker.hopyo.get("이름"), "비교": compare(made["참조"], maker.hopyo)} @@ -581,12 +511,16 @@ def build_all( "실패": [], "만듦": [], } + shared, failed = lsame.Same(), set() for no in _order(units): out["줄"] += len(units[no].get("줄") or []) + bad = next((k for k in hkids.kids_of(units[no]) if k in failed), None) try: maker = Builder(folder, source, key, no, who) - maker.strict = strict + maker.strict, maker.same = strict, shared have = maker.done.get(mark(key, no)) + if bad is not None and not have: # 777-1 — 하위 실패 연쇄 까닭 따로 + raise store.StoreError(409, {"까닭": f"하위 제{bad}호표 실패", "하위": bad}) if have: out["그대로"] += 1 ref = {"층": refs.here()[0], "주인": refs.here()[1], "키": have} @@ -600,6 +534,7 @@ def build_all( except store.StoreError as e: why = e.detail if isinstance(e.detail, dict) else {"까닭": str(e.detail)} out["실패"].append({"번": no, **why}) + failed.add(no) continue if got["같음"]: out["같음"] += 1 diff --git a/M02_MasterTemplete/M02_Hopyo_Compare.py b/M02_MasterTemplete/M02_Hopyo_Compare.py new file mode 100644 index 000000000..501c8f629 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Compare.py @@ -0,0 +1,66 @@ +"""M02 실무 호표 만든 일위대가 ↔ 엑셀 호표 단가 대조(PLAN 705-3 · 723-1 · 742-1 · 777-1 `M02_Hopyo_Build` 에서 떼어 냄). + +참고 · 판정 아님 — 우리 = 엑셀 끝수 꼴(EST 비목 합 내림 · STmate 줄 반올림 · 계 = 줄 계 합 내림) 중 엑셀과 같은 쪽 · +수량 = 엑셀 수량(`본뜬.실무수량` · 하위 사전조합까지 `excel_given`). +""" + +from __future__ import annotations + +from decimal import ROUND_FLOOR, ROUND_HALF_UP, Decimal +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs + +# 엑셀 끝수 꼴(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 excel_given(ref: dict[str, Any], depth: int = 1) -> dict[str, Any]: + """`본뜬.실무수량` → 안 줄 알약 값(723-1 · 하위 사전조합까지) — 엑셀 대조 전용.""" + doc = refs.read_ref(ref) if depth <= refs.DEPTH else None + if not isinstance(doc, dict): + return {} + rid, hands = refs.ref_id(ref), (doc.get("본뜬") or {}).get("실무수량") or {} + out = {f"{rid}#{i}": v for i, v in (hands.get("줄") or {}).items()} + out |= {f"{rid}#자재|{code}": v for code, v in (hands.get("자재") or {}).items()} + for one in refs.refs_in(doc): + out |= excel_given(one, depth + 1) + return out + + +def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]: + """만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가(참고 · 판정 아님) — 우리 = 엑셀 끝수 규칙(내림 · + 반올림 중 엑셀과 같은 쪽 · `_excel_round`) · 수량 = 엑셀 수량(`excel_given` · 723-1).""" + head = {k: ref[k] for k in ("층", "주인", "키")} + got = refs.Expand(m01.loaded()[1], given=excel_given(head))(head) + base = got.get("기준") or {} + 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 + same = ours is not None and abs(ours - (excel or 0)) < 1 # 705-3 — 엑셀 금액 없음 = 0 + return {"엑셀": excel, "우리": ours, "차이": gap, "같음": same, "까닭": got.get("까닭")} diff --git a/M02_MasterTemplete/M02_Hopyo_Forget.py b/M02_MasterTemplete/M02_Hopyo_Forget.py index adfca6def..52626f5e6 100644 --- a/M02_MasterTemplete/M02_Hopyo_Forget.py +++ b/M02_MasterTemplete/M02_Hopyo_Forget.py @@ -5,7 +5,7 @@ HP · 격자 · 맞춤셈 캐시 지움 · 그 HP 로 만든 이 층 일위대가(SC) · 단가산출(SK) 엑셀 사본 걷음 (`본뜬` → 출처 `{층 「실무자료」, 파일, 때, 누가}` + 전국자재 · 복사원본 · SC 줄 메모 「실무 …」 걷음 · `본뜬.실무수량` = 모든 값이 그 줄 저장 `수량.손` 과 같을 때만 - 걷음(742-1 · 다르면 남김 = 금액 그대로)) · SK `본뜬.또` = 그 HP 몫만 뺌 · + 걷음(742-1 · 다르면 남김 = 금액 그대로)) · SK · SC `본뜬.또` = 그 HP 몫만 뺌 · 문서 몸(줄 · 덮개 · 더한줄 · 별칭 · 구분)과 별칭 장부는 그대로 · 끝낸 기록 없음(같은 엑셀 다시 올림 됨 · 키 번은 `불러옴.json` 번으로 다시 안 씀). 옛 파일 정리 `tidy`(한 번 · 층 폴더마다 · 미리 보기 = 표만) — 옛 HP(지문 없음) · 하루 지난 HP 끝냄 · @@ -113,7 +113,11 @@ def forget(folder: str | Path, gone: Callable[[str], bool], apply: bool = True) got = doc["본뜬"] if not any(hit(x) for x in [got, *(got.get("또") or [])] if isinstance(x, dict)): continue - new = strip_sc(doc) if kind == "precombo" else strip_sk(doc, hit) + new = strip_sk(doc, hit) if kind == "sangeun" or not hit(got) else strip_sc(doc) + if kind == "precombo" and ( + also := [x for x in got.get("또") or [] if isinstance(x, dict) and not hit(x)] + ): + new["본뜬"]["또"] = also # 777-1 같은 내용 SC 를 나눠 쓴 다른 불러오기 몫은 그대로 if new != doc: out.append(path.stem) if apply: diff --git a/M02_MasterTemplete/M02_Hopyo_Item.py b/M02_MasterTemplete/M02_Hopyo_Item.py index f1b0fb1cf..519a24168 100644 --- a/M02_MasterTemplete/M02_Hopyo_Item.py +++ b/M02_MasterTemplete/M02_Hopyo_Item.py @@ -3,6 +3,7 @@ [구조물 조합으로] = 확인 창 몸(체크한 줄 · 통째 · `M02_Hopyo_Build.check` 그대로)으로 줄을 만들어(`build(save=False)` · 사전조합 SC 문서 안 씀 · 옛 [사전조합 생성] 길은 그대로) 이 층 구조물집계표에 새 항목(SU · `M02_Item_Personal.create_item` · 경로 「실무자료」) + 그 일위대가 문서에 줄을 담음(수량 = 1 단위 몫 `{손}`). +확인 안 된 하위 호표 = 먼저 일위대가로 함께 만듦(777-1 `M02_Hopyo_Kids.first` · 줄 = 하위 사본 · 못 만들면 고정 줄). 규격 묶음 = 같은 파일에서 이름 · 단위 같고 줄 구성(줄 이름 · 단위 차례) 같은 · 규격만 다른 호표(5-A 「구성 같은 묶음」) → 한 항목의 규격 열(704-4) — 열 이름 = 규격 글에서 달라지는 칸(「H=1.5」 → 이름 H · 열 1.5) · 아니면 이름 「규격」 · 열마다 같은 몸으로 만든 줄 수량(줄 차례 같음 · 다르면 400) · 기본 열 = 연 호표. @@ -15,6 +16,7 @@ from pathlib import Path from typing import Any from M02_MasterTemplete import M02_Hopyo_Build as hb +from M02_MasterTemplete import M02_Hopyo_Kids as hkids from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Item_Personal as personal from M02_MasterTemplete import M02_Labor_Spec as spec @@ -82,6 +84,10 @@ def siblings(source: Path, key: str, no: int) -> dict[str, Any]: def _doc(folder: Path, source: Path, key: str, no: int, lines: list, whole: Any, who: Any) -> dict: + try: # 777-1 — 하위 호표 먼저(엑셀 단가 한 줄로 납작 안 함) · 못 만들면 지금처럼 고정 줄 + hkids.first(folder, source, key, no, who, lines) if whole is None else None + except store.StoreError: + pass maker = hb.Builder(folder, source, key, no, who) checked, pairs = maker.check(list(lines or []), whole) return {**maker.build(checked, pairs, save=False), "호표": maker.hopyo} diff --git a/M02_MasterTemplete/M02_Hopyo_Kids.py b/M02_MasterTemplete/M02_Hopyo_Kids.py new file mode 100644 index 000000000..94bd4c66a --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Kids.py @@ -0,0 +1,247 @@ +"""M02 실무 호표가 다른 호표를 부를 때 = 일위대가 둘(PLAN 777-1 · 요구 621-4 · 요구 581 「SK · SC 서로 독립」). + +하위 함께 만들기 `first` — 부모 저장(`M02_Hopyo_Build.build` · [구조물 조합으로])에서 확인 안 된 하위 호표(호표 줄 · + 호표 대상 · 깊이 끝까지)를 하위 먼저 그 하위의 서버 체크 제안 몸(`proposal`)으로 만듦(409 「먼저 확인」 대신) · + 부모 줄 = 하위 사본(740-1 그대로) · 하위 실패 = 409 `{까닭 「하위 제N호표 실패 — …」, 하위 N, 하위남은줄}`. + 화면 캐시 고름은 화면이 하위를 먼저 보냄(`M02_MasterTemplete_Hopyo_Kids.ts`). +`proposal` 짝 = 화면 `bodyFor` · `wholeFor`(`M02_MasterTemplete_Hopyo_Kids.ts` · 캐시 빈 것) — 체크 제안 줄 · 대상 꼴 같음. +같은 내용 일위대가 재사용 `save` — 같은 본뜬(키 · 원본)이면 그 SC(혼자 쓰면 덮음 · 나눠 쓰면 내용 같을 때만) · + 아니면 이 층 같은 이름 · 같은 내용 SC(`M02_Library_Same.find_same` + 수량까지) 의 `본뜬.또` 에 출처를 더해 씀 + (SK 꼴 `M02_Hopyo_Sangeun.Maker.write`) · 없으면 새로 → 다시 불러와도 SC 가 안 늚. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Finger as finger +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Library_Flat as lflat +from M02_MasterTemplete import M02_Library_Same as lsame +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.M02_Hopyo_Sangeun import _same_mark + +ORIGIN = "실무자료" +KIND = "precombo" + + +# ── 같은 내용 재사용 ───────────────────────────────────────────────── +def entries(folder: str | Path): + """층 사전조합 실무자료 출처 → (키, 본뜬 · 또 한 칸, 판) — 또 = 같은 내용으로 이 SC 를 쓴 뒤 출처.""" + for path in sorted((Path(folder) / KIND).glob("SC-*.json")): + try: + raw = path.read_bytes() + doc = json.loads(raw) + except (OSError, ValueError): + continue + got = doc.get("본뜬") if isinstance(doc, dict) else None + if isinstance(got, dict) and got.get("층") == ORIGIN: + for one in [got, *(x for x in got.get("또") or [] if isinstance(x, dict))]: + yield path.stem, one, raw + + +def same_source(got: dict[str, Any], source: refs.Here | None) -> bool: + """본뜬 원본 층이 같음 — 원본 없는 옛 것 · `source` None 은 가리지 않음.""" + have = got.get("원본") + return source is None or not have or tuple(have) == tuple(source) + + +def _bare(value: Any) -> Any: + """사본 블록 `주인`(몸 지문 · 메모까지 셈) 뺌 — 셈 내용은 `몸` 이 다 가짐.""" + if isinstance(value, list): + return [_bare(x) for x in value] + if not isinstance(value, dict): + return value + return {k: _bare(x) for k, x in value.items() if not (k == "주인" and "몸" in value)} + + +def _rows(doc: dict[str, Any], part: str) -> list[str]: + return sorted(json.dumps(lsame.norm(_bare(x)), ensure_ascii=False, sort_keys=True) for x in doc.get(part) or []) # fmt: skip + + +def body(doc: dict[str, Any]) -> list: + """셈 내용 그대로(수량까지 · 하위 = 펼친 사본 몸) — `find_same` 지문은 SC 수량을 안 봄.""" + flat = lflat.flat_doc(KIND, doc) + head = [lsame.unit(flat.get("단위")), lsame.norm(flat.get("규격"))] + return [*head, _rows(flat, "담은로직"), _rows(flat, "자재")] + + +def _entry(doc: dict[str, Any]) -> dict[str, Any]: + return {**doc["본뜬"], "이름": doc["이름"]} + + +def _split(old: dict[str, Any], mine: dict[str, Any]) -> tuple[dict, list]: + got = old.get("본뜬") or {} + own = {k: v for k, v in got.items() if k != "또"} + rest = [x for x in got.get("또") or [] if isinstance(x, dict) and not _same_mark(x, mine)] + return own, rest + + +def _put(key: str, raw: bytes, old: dict, own: dict, also: list, doc: dict | None = None) -> None: + new = {**(doc or old), "본뜬": {**own, "또": also} if also else own} + if new != old: + store.write(KIND, key, store.version_of(raw), new) + + +def _empty(root: Path, doc: dict[str, Any]) -> list[str]: + """빈 호표(줄 · 자재 없음 = 지문 없음) — 이름 · 단위 같은 실무자료 SC.""" + if doc.get("담은로직") or doc.get("자재"): + return [] + out = [] + for path in sorted((root / KIND).glob("SC-*.json")): + try: + got = json.loads(path.read_bytes()) + except (OSError, ValueError): + continue + same = (got.get("이름"), lsame.unit(got.get("단위"))) == (doc["이름"], lsame.unit(doc.get("단위"))) # fmt: skip + if same and (got.get("본뜬") or {}).get("층") == ORIGIN: + out.append(path.stem) + return out + + +def save(doc: dict[str, Any], same: lsame.Same | None = None) -> tuple[dict[str, Any], bool, bool]: + """(참조, 다시씀, 재사용) — 엑셀 수량 = 저장(742-1) · `same` = 한 요청 안 지문 메모.""" + hands = { + "줄": {str(i): x["수량"]["손"] for i, x in enumerate(doc["담은로직"]) if x.get("수량")}, + "자재": {m["코드"]: m["수량"]["손"] for m in doc.get("자재") or [] if m.get("수량")}, + } + doc = {**doc, "본뜬": {**doc["본뜬"], "실무수량": hands}} + same, root, mine = same or lsame.Same(), store.root(), doc["본뜬"] + here = refs.here() + ref = lambda key: {"층": here[0], "주인": here[1], "키": key} # noqa: E731 + have = next( + ( + (key, raw) + for key, got, raw in entries(root) + if got.get("키") == mine["키"] and same_source(got, tuple(mine["원본"])) + ), + None, + ) + if have: + key, raw = have + old = json.loads(raw) + own, rest = _split(old, mine) + owner, alike = _same_mark(own, mine), body(old) == body(doc) + if owner and (not rest or alike): # 혼자 · 내용 같음 = 제자리 덮음(나눈 출처 그대로) + _put(key, raw, old, mine, rest, doc) + same.add(root, KIND, key, doc) + return ref(key), True, False + if alike: # 남의 SC 를 같은 내용으로 씀 — 제 출처 칸만 + _put(key, raw, old, own, [*rest, _entry(doc)]) + return ref(key), True, False + head = {k: v for k, v in rest[0].items() if k != "이름"} if owner else own + _put(key, raw, old, head, rest[1:] if owner else rest) # 내용 달라짐 = 떼어 냄 + for key in same.find_same(root, KIND, doc) or _empty(root, doc): + raw = (root / KIND / f"{key}.json").read_bytes() + old = json.loads(raw) + if old.get("이름") == doc["이름"] and body(old) == body(doc): # 이름까지 = 같은 호표 + own, rest = _split(old, mine) + _put(key, raw, old, own, [*rest, _entry(doc)]) + return ref(key), False, True + key = refs.create(KIND, doc)["이름"] + same.add(root, KIND, key, doc) + return ref(key), False, False + + +# ── 하위 함께 만들기 ───────────────────────────────────────────────── +def kids_of(hopyo: dict[str, Any]) -> list[int]: + """호표 줄이 부르는 호표 번(줄 차례).""" + out = [] + for line in hopyo.get("줄") or []: + ref = line.get("참조") or {} + if finger.kind_of(line) == "호표" or ref.get("종류") == "일위": + out.append(int(ref.get("번") or 0)) + return out + + +def order(units: dict[int, dict[str, Any]], no: int, also: Any = ()) -> list[int]: + """`no` 아래 하위 호표(+ `also` 와 그 아래) — 하위 먼저 · `no` 뺌 · 고리는 끊음.""" + out: list[int] = [] + seen = {int(no)} + + def visit(at: int) -> None: + for kid in kids_of(units.get(at) or {}): + if kid not in seen and kid in units: + seen.add(kid) + visit(kid) + out.append(kid) + + visit(int(no)) + for kid in also: + if kid not in seen and kid in units: + seen.add(kid) + visit(kid) + out.append(kid) + return out + + +def _target(p: dict[str, Any]) -> dict[str, Any]: + """대상 → 만들기 몸 대상(화면 `toTarget` 짝).""" + times = 1 if p.get("배") is None else p["배"] + if p.get("호표") is not None: + return {"호표": p["호표"], "배": times} + link = next((k for k in ("노임", "기계", "비율") if p.get(k)), None) + if link: + return {link: p[link], "배": 1} + head = ( + {"로직": p["로직"], "입력값": p.get("입력값") or {}} + if p.get("로직") + else {"전국": True} + if p.get("전국") + else {"자재": p.get("자재")} + ) + return {**head, "배": times, **({"몫": p["몫"]} if p.get("몫") is not None else {})} + + +def proposal(hopyo: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, Any] | None]: + """서버 체크 제안 몸 (줄, 통째) — 보기(`M02_Hopyo_Line.hopyo_view`) 체크 제안 줄만(화면 `bodyFor` 짝).""" + whole = hopyo.get("통째") + if whole and whole.get("체크"): + return [], {"대상": [_target(p) for p in whole.get("대상") or []]} + lines = [] + for line in hopyo.get("줄") or []: + got = line.get("맞춤") or {} + parts = got.get("대상") if isinstance(got.get("대상"), list) else [] + if got.get("상태") == "%": + ok = bool(parts and parts[0].get("비율")) + else: + ok = bool(parts) and all((p.get("배") or 0) > 0 for p in parts) + if got.get("체크") and ok: + lines.append({"차례": line["차례"], "대상": [_target(p) for p in parts]}) + return lines, None + + +def first( + folder: Path, source: Path, key: str, no: int, who: Any, lines: Any = () +) -> list[dict[str, Any]]: + """확인 안 된 하위 호표(호표 줄 + 몸 `lines` 의 호표 대상)를 하위 먼저 서버 체크 제안대로 만듦 → + `[{번, 키}]`(새로 만든 것) · + 실패 = 409 `{까닭 「하위 제N호표 실패 — …」, 하위, 하위남은줄}`(만든 하위는 남음).""" + from M02_MasterTemplete import M02_Hopyo_Build as hb + from M02_MasterTemplete import M02_Hopyo_Match as hm + + units = {int(h["번"]): h for h in hopyo_store.read(source, key).get("호표") or []} + also = [ + t["호표"] for x in lines or [] for t in x.get("대상") or [] if t.get("호표") is not None + ] + todo = order(units, no, also) + done = hb.confirmed(folder, refs.where(source)) if todo else {} + made: list[dict[str, Any]] = [] + for kid in todo: + if hb.mark(key, kid) in done: + continue + try: + lines, whole = proposal(hm.view(source, key, kid, to=folder)["호표"]) + maker = hb.Builder(folder, source, key, kid, who) + got = maker.build(*maker.check(lines, whole)) + except store.StoreError as e: + why = e.detail if isinstance(e.detail, dict) else {"까닭": str(e.detail)} + left = why.get("남은줄") or [] + detail = {"까닭": f"하위 제{kid}호표 실패 — {why.get('까닭')}", "하위": kid} + raise store.StoreError(409, {**detail, "하위남은줄": left}) from e + done[hb.mark(key, kid)] = got["참조"]["키"] + made.append({"번": kid, "키": got["참조"]["키"]}) + return made diff --git a/M02_MasterTemplete/M02_Hopyo_Line.py b/M02_MasterTemplete/M02_Hopyo_Line.py index 3ebb01590..c85ab4a69 100644 --- a/M02_MasterTemplete/M02_Hopyo_Line.py +++ b/M02_MasterTemplete/M02_Hopyo_Line.py @@ -256,7 +256,7 @@ def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, A target = {"호표": no, "이름": h.get("이름", ""), "단위": h.get("단위"), "입력값": {}} target.update(못읽음=[], 출처={}, 환산=conv, 몫=None, 배=conv["배"]) ok = bool(made) and conv["배"] is not None and conv["갈래"] != "추정" - text = f"확인된 일위대가 {made}" if made else f"제{no}호표 먼저 확인" + text = f"하위 일위대가 {made} 사본" if made else f"제{no}호표 — 저장 때 하위로 함께 만듦" return {**out, "상태": "호표", "체크": ok, "대상": [target], "글": text} if kind == "노임": return {**out, "상태": "노임", "글": "노임 · 그릇 없음"} diff --git a/M02_MasterTemplete/M02_Hopyo_SkLink.py b/M02_MasterTemplete/M02_Hopyo_SkLink.py index 171e352a5..68e5e035f 100644 --- a/M02_MasterTemplete/M02_Hopyo_SkLink.py +++ b/M02_MasterTemplete/M02_Hopyo_SkLink.py @@ -29,7 +29,7 @@ WHY = { "별도계상": "자재 후보 여럿 · 없음", "노임": "같은 이름 노임 없음", "기계": "같은 기계(EQ) 없음 · 여럿", - "호표": "하위 호표 확인 전 · 단위 다름", + "호표": "하위 호표 단위 다름 · 같은 하위 둘째", "%": "밑수가 다른 줄 금액 합 아님", } diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 8705b2a01..495e26a53 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -16,8 +16,6 @@ from __future__ import annotations -import importlib -import inspect from pathlib import Path from typing import Any, Literal @@ -27,11 +25,13 @@ from pydantic import BaseModel, ConfigDict, Field from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel +from M02_MasterTemplete import M02_Hopyo_Fit as hopyo_fit from M02_MasterTemplete import M02_Hopyo_Forget as hopyo_forget from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid from M02_MasterTemplete import M02_Hopyo_Item as hopyo_item from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match +from M02_MasterTemplete import M02_Hopyo_Ours as hopyo_ours from M02_MasterTemplete import M02_Hopyo_Paste as hopyo_paste from M02_MasterTemplete import M02_Hopyo_Relink as hopyo_relink from M02_MasterTemplete import M02_Hopyo_Sangeun as hopyo_sangeun @@ -211,17 +211,7 @@ def new_material( HOPYO_MAX_BYTES = 20 * 1024 * 1024 -def _later(module: str, name: str): - """맞춤 엔진(가 창) 함수 — 아직 없으면 None(올리기 · 시동은 그대로).""" - try: - got = importlib.import_module(f"M02_MasterTemplete.{module}") - except ImportError: - return None - return getattr(got, name, None) - - -if (_warm := _later("M02_Hopyo_Ours", "warm")) is not None: - _warm() # 시동 — 우리 지문 캐시(디스크) 읽어 둠 +hopyo_ours.warm() # 시동 — 우리 지문 캐시(디스크) 읽어 둠 def _who(session: Session) -> dict[str, Any]: @@ -242,8 +232,7 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404) grid = None row = hopyo_store.save(store.root(), name, parsed, who, grid, mark) - if (ensure := _later("M02_Hopyo_Fit", "ensure")) is not None: - ensure(store.root(), row["키"]) # 맞춤 뒤 셈(일감 줄 · 화면 안 기다림) + hopyo_fit.ensure(store.root(), row["키"]) # 맞춤 뒤 셈(일감 줄 · 화면 안 기다림) return row @@ -254,9 +243,8 @@ def hopyo_list( """실무자료 목록 `{줄: [{키, 파일, 올린때, 올린이, 시트, 호표수, 어긋남}]}`.""" folder = _root(session, layer, owner) rows = hopyo_store.rows(folder) - if (ensure := _later("M02_Hopyo_Fit", "ensure")) is not None: - for row in rows: # 지금 판 맞춤셈 없는 파일 = 목록 열 때 뒤 셈 예약(358-3 ③) - ensure(folder, row["키"]) + for row in rows: # 지금 판 맞춤셈 없는 파일 = 목록 열 때 뒤 셈 예약(358-3 ③) + hopyo_fit.ensure(folder, row["키"]) return {"줄": rows} @@ -328,11 +316,9 @@ def hopyo_one( session: Session = Depends(verify_session), ) -> dict: """호표 하나 — 줄마다 맞춤(상태 · 체크 제안 · 대상 · 후보 5) · `to` = 만들 층(없으면 파일 층 · - 호표 참조 줄 체크 · 만든 · 맞춤 엔진이 `to` 를 받을 때만).""" + 호표 참조 줄 체크 · 만든).""" folder = _root(session, layer, owner) - if to is None or "to" not in inspect.signature(hopyo_match.view).parameters: - return _call(folder, hopyo_match.view, folder, key, no) - target = _root(session, to, to_owner) + target = _root(session, to, to_owner) if to is not None else None return _call(folder, lambda: hopyo_match.view(folder, key, no, to=target)) @@ -395,13 +381,12 @@ class BuildWhole(_Strict): class BuildBody(_Strict): """실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` + 확인한 `줄` · - 직접 줄 호표 = `통째` · 같은 본뜬은 늘 덮음(`새로` = 옛 화면 호환 · 안 씀).""" + 직접 줄 호표 = `통째` · 같은 본뜬은 늘 덮음.""" 층: Layer 주인: str | None = None 키: str 번: int - 새로: bool = False 줄: list[BuildLine] = Field(default_factory=list, max_length=500) 통째: BuildWhole | None = None @@ -414,7 +399,7 @@ def hopyo_make( session: Session = Depends(verify_session), ) -> dict: """[확인하고 사전조합 만들기] — 확인한 줄만(이 층 쓰기 · 파일 층 읽기) · 별칭 장부 씀 → - `{참조, 이름, 다시씀, 빠진줄, 비교, 장부}` · 몸 없음 400 · 하위 호표 확인 전 409.""" + `{참조, 이름, 다시씀, 재사용, 빠진줄, 하위, 비교, 장부}` · 하위 호표 확인 전 = 함께 만듦(실패 409).""" folder = _root(session, layer, owner, write=True) source = _root(session, body.층, body.주인) lines = [one.model_dump(exclude_none=True) for one in body.줄] diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts index e9a35acef..c999e5627 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts @@ -29,7 +29,6 @@ import { openModal } from "@ui/ui_template_modal"; import { t as L } from "@ui/ui_template_locale"; import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; import { - buildHopyo, guessHopyo, readHopyoOne, type BuildLine, @@ -41,6 +40,7 @@ import { type HopyoPart, type HopyoScope, } from "./M02_MasterTemplete_Hopyo_Api"; +import { buildWithKids as buildHopyo } from "./M02_MasterTemplete_Hopyo_Kids"; // 777-1 하위 함께 import { builtWord, openHopyoChoose, targetText } from "./M02_MasterTemplete_Hopyo_Choose"; import { checkTables, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Kids.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Kids.ts new file mode 100644 index 000000000..de3aa001a --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Kids.ts @@ -0,0 +1,130 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Kids.ts + * 일위대가 실무자료 만들기 몸 · 하위 호표 함께(PLAN 777-1 · 요구 621-4) — 호표가 다른 호표를 부르면 일위대가 둘 + * (하위 일위대가 따로 + 부모 줄 = 하위 사본). + * · bodyFor · wholeFor = 확인 창 캐시 위 체크 제안(짝 = 서버 `M02_Hopyo_Kids.proposal` · 캐시 빈 것) + * · unmadeKids = 고른 호표 줄이 부르는 확인 안 된 하위 호표(깊이 끝까지 · 하위 먼저) — [저장]이 그 캐시대로 함께 만듦 + * · buildWithKids = 확인 창 [생성] — 확인 안 된 하위를 그 캐시대로 먼저 → 부모(실패 = 「하위 제N호표 실패」) + * · 캐시 없는 하위도 서버가 부모 저장 때 제안대로 함께 만듦(`M02_Hopyo_Kids.first`) — 화면은 캐시 고름을 살림 + * · kidsText = 결과 글 「· 하위 제4호표 SC-11(사본 담음)」 · leftAt = 하위 실패의 남은 줄 = 그 하위 [고르기] + * ========================================================================== */ + +import { t as L } from "@ui/ui_template_locale"; +import { StaleError } from "./M02_MasterTemplete_Api_Fetch"; +import { + buildHopyo, + readHopyoOne, + type HopyoBuilt, + type HopyoOne, + type HopyoScope, +} from "./M02_MasterTemplete_Hopyo_Api"; +import { bodyOf, draftKey, loadDraft, partsOf, toTarget } from "./M02_MasterTemplete_Hopyo_Check"; + +type Body = Parameters[2]; +type Whole = Parameters[3]; +type From = Parameters[1]; + +/** 호표 하나의 몸 — 확인 창 캐시 위 체크 제안 · 통째면 줄 없음 */ +export function bodyFor(scope: HopyoScope, key: string, one: HopyoOne): Body { + const h = one.호표; + const d = loadDraft(draftKey(scope, key, h.번)); + return h.통째 && (d.통째 ?? h.통째.체크) ? [] : bodyOf(h.줄, d); +} +export function wholeFor(scope: HopyoScope, key: string, one: HopyoOne): Whole { + const h = one.호표; + const d = loadDraft(draftKey(scope, key, h.번)); + return h.통째 && (d.통째 ?? h.통째.체크) ? { 대상: h.통째.대상.map(toTarget) } : null; +} + +/** (줄, 통째) 몸 한 벌 */ +export const bodyPair = (scope: HopyoScope, key: string, one: HopyoOne): [Body, Whole] => [ + bodyFor(scope, key, one), + wholeFor(scope, key, one), +]; + +/** 호표 줄이 부르는 호표 번(체크 안 한 호표 줄 · 캐시에서 고른 호표 대상까지) */ +const kidNos = (scope: HopyoScope, key: string, one: HopyoOne): number[] => { + const d = loadDraft(draftKey(scope, key, one.호표.번)); + return one.호표.줄.flatMap((l) => + partsOf(l, d).flatMap((p) => (p.호표 !== undefined ? [p.호표] : [])), + ); +}; + +/** [저장] 할 호표 — 함께 만드는 하위 · 체크 줄(통째) 있음 · 하위를 부름(호표 줄 = 하위 사본) */ +export function readyFor(scope: HopyoScope, key: string, one: HopyoOne, picked: number[]): boolean { + const [lines, whole] = bodyPair(scope, key, one); + return ( + !picked.includes(one.호표.번) || !!lines.length || !!whole || !!kidNos(scope, key, one).length + ); +} + +/** 확인 안 된 하위 호표 보기 — 하위 먼저 · `ones` 와 이미 만든 하위(그 아래도) 뺌 */ +export async function unmadeKids( + scope: HopyoScope, + key: string, + target: HopyoScope, + ones: HopyoOne[], +): Promise { + const out: HopyoOne[] = []; + const seen = new Set(ones.map((one) => one.호표.번)); + async function visit(one: HopyoOne): Promise { + for (const no of kidNos(scope, key, one)) { + if (seen.has(no)) continue; + seen.add(no); + const kid = await readHopyoOne(scope, key, no, target); + if (kid.호표.만든) continue; + await visit(kid); + out.push(kid); + } + } + for (const one of ones) await visit(one); + return out; +} + +/** 하위 실패 — 까닭 「하위 제N호표 실패 — …」 · 남은 줄은 그 하위 몫(`leftAt`) */ +function kidFail(error: unknown, no: number): StaleError { + const left = (error as { detail?: { 남은줄?: unknown } } | null)?.detail?.남은줄; + const why = error instanceof Error ? error.message : String(error); + const text = `${L("M02_HopyoKidFail").replace("{n}", String(no))} — ${why}`; + return new StaleError(text, { 까닭: text, 하위: no, 하위남은줄: left ?? [] }); +} + +/** 확인 창 [생성] — 확인 안 된 하위를 그 캐시대로 먼저 만들고 부모 */ +export async function buildWithKids( + target: HopyoScope, + from: From, + 줄: Body, + 통째?: Whole, +): Promise { + const scope = { layer: from.층, owner: from.주인 }; + const me = await readHopyoOne(scope, from.키, from.번, target); + for (const kid of await unmadeKids(scope, from.키, target, [me])) { + const no = kid.호표.번; + const body = [bodyFor(scope, from.키, kid), wholeFor(scope, from.키, kid)] as const; + await buildHopyo(target, { ...from, 번: no }, ...body).catch((error: unknown) => { + throw kidFail(error, no); + }); + } + return buildHopyo(target, from, 줄, 통째); +} + +/** 하위 실패면 (그 하위 남은 줄 몸, 하위 번) · 아니면 그대로 — 결과 표 [고르기]가 그 하위를 엶 */ +export function leftAt(error: unknown, no: number): [unknown, number] { + const d = (error as { detail?: { 하위?: number; 하위남은줄?: unknown } } | null)?.detail; + return d?.하위 !== undefined ? [{ detail: { 남은줄: d.하위남은줄 } }, d.하위] : [error, no]; +} + +/** 결과 글 꼬리 — 같은 내용 재사용 · 하위 일위대가(사본 담음 · 함께 만듦) */ +export function kidsText(got: HopyoBuilt): string { + const more = got as HopyoBuilt & { + 재사용?: boolean; + 하위?: { 번: number; 키: string | null; 새로?: boolean }[]; + }; + const kids = (more.하위 ?? []).map((k) => { + const head = L("M02_HopyoKid") + .replace("{n}", String(k.번)) + .replace("{key}", k.키 ?? ""); + return ` · ${head}${k.새로 ? ` · ${L("M02_HopyoKidNew")}` : ""}`; + }); + return (more.재사용 ? ` · ${L("M02_HopyoReused")}` : "") + kids.join(""); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index b9316c596..be12de6ef 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -6,8 +6,8 @@ * 오른쪽 = 호표 목록(체크 · 제N호표 [수정] · 찾기 · 제안 n/m — 다 연결이면 녹색 · 뒤 셈 중이면 3 초마다 다시) + [저장]. * · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 영역 고름 * · [수정] = 그 호표 확인 창(`openHopyoCheck` · 줄 누름과 따로) - * · [저장] = 체크한 호표마다 체크 줄(확인 창 캐시 · 없으면 서버 제안)로 하위 호표부터 차례로 `/hopyo-build` — - * 물음 한 번(확인 창 안 연 호표 수) · 체크 줄 없는 호표 건너뜀 · 하위 확인 전(409)은 안내 · + * · [저장] = 체크한 호표 + 확인 안 된 하위 호표(777-1 · `Hopyo_Kids`)마다 체크 줄(확인 창 캐시 · 없으면 서버 제안)로 + * 하위 호표부터 차례로 `/hopyo-build` — 물음 한 번 · 체크 줄 없는 고른 호표 건너뜀 · 하위 실패 = 그 하위 [고르기] · * 결과 표(사전조합 키 · 빠진 줄 · 값 차이) · `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄) * · [모두 고정으로 들여오기](705-3) = 물음 한 번 → 서버 `/hopyo-build-all`(호표 모두 · 하위 먼저 · 엑셀 숫자 그대로 · * 이미 만든 호표 그대로) → 알림 「호표 n · 줄 m · 품셈 줄 a · 고정 줄 b · 빠짐 0 · 엑셀 합계 = 우리 합계 n/n」 + 다음 걸음 @@ -27,7 +27,6 @@ import { openModal } from "@ui/ui_template_modal"; import { t as L } from "@ui/ui_template_locale"; import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types"; import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; -import { StaleError } from "./M02_MasterTemplete_Api_Fetch"; import { buildHopyo, buildHopyoAll, @@ -45,7 +44,6 @@ import { type HopyoView, } from "./M02_MasterTemplete_Hopyo_Api"; import { - bodyOf, draftKey, hasDraft, HOPYO_BUILT, @@ -53,8 +51,8 @@ import { openHopyoCheck, partsOf, POLL_MS, - toTarget, } from "./M02_MasterTemplete_Hopyo_Check"; +import { bodyPair, kidsText, leftAt, readyFor, unmadeKids } from "./M02_MasterTemplete_Hopyo_Kids"; import { builtWord } from "./M02_MasterTemplete_Hopyo_Choose"; import { openRelink, relinkKeys } from "./M02_MasterTemplete_Hopyo_Relink"; import { leftButton } from "./M02_MasterTemplete_Hopyo_Left"; @@ -83,7 +81,7 @@ export interface HopyoSheetOptions { } const ILWI = "일위대가"; -const DANSAN = "단가산출"; +const DANSAN = ["단가산출", "단가산출근거"]; // 형식별 블록 시트(EST · STmate) const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); const won = (v: number | null | undefined): string => @@ -392,7 +390,8 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { poll(file); if (grid instanceof Error) return void left.replaceChildren(note(grid.message)); await mountSheet(grid); - if (opt.단산행 !== undefined) sheet?.select(rowsText(opt.단산행), DANSAN); + const rows = opt.단산행 === undefined ? null : rowsText(opt.단산행); + if (rows) DANSAN.some((name) => sheet?.select(rows, name)); else if (current !== null) focus(current); } catch (error) { if (!closed) { @@ -413,17 +412,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { ], }); - /** 호표 하나의 몸 — 확인 창 캐시 위 체크 제안 · 통째면 줄 없음 */ - function bodyFor(one: HopyoOne): Parameters[2] { - const h = one.호표; - const d = loadDraft(draftKey(opt.scope, opt.key, h.번)); - return h.통째 && (d.통째 ?? h.통째.체크) ? [] : bodyOf(h.줄, d); - } - function wholeFor(one: HopyoOne): Parameters[3] { - const h = one.호표; - const d = loadDraft(draftKey(opt.scope, opt.key, h.번)); - return h.통째 && (d.통째 ?? h.통째.체크) ? { 대상: h.통째.대상.map(toTarget) } : null; - } + const body = (one: HopyoOne) => bodyPair(opt.scope, opt.key, one); const headCell = (no: number, name: string): HTMLElement => el("td", { text: `제${no}호표 ${name}`.trim() }); @@ -448,11 +437,12 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { skipped.push(el("tr", { children: [headCell(no, ""), el("td", { text: why(error) })] })); } } + ones.push(...(await unmadeKids(opt.scope, opt.key, target, ones))); // 777-1 하위 함께 } finally { hideLoadingOverlay(); paintGo(); } - const ready = ones.filter((one) => bodyFor(one).length || wholeFor(one)); + const ready = ones.filter((one) => readyFor(opt.scope, opt.key, one, nos)); // 하위 함께 for (const one of ones) if (!ready.includes(one)) skipped.push( @@ -488,7 +478,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { const cell = headCell(no, h.이름); try { const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no }; - const got = await buildHopyo(target, from, bodyFor(one), wholeFor(one)); + const got = await buildHopyo(target, from, ...body(one)); ok += 1; opt.onImport?.(got, h); rows.push( @@ -496,18 +486,16 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { attrs: { "data-built": got.참조.키 }, children: [ cell, - el("td", { text: `${got.참조.키} · ${builtWord(got)}` }), + el("td", { text: `${got.참조.키} · ${builtWord(got)}${kidsText(got)}` }), el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }), el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }), ], }), ); } catch (error) { - const left = leftButton(error, { scope: opt.scope, target, key: opt.key, 파일: opt.파일, 번: no }); // prettier-ignore - const text = - left || !(error instanceof StaleError) - ? `실패 — ${why(error)}` - : `안내 — ${why(error)} · 하위 호표를 함께 고르거나 먼저 저장`; + const [err, kid] = leftAt(error, no); // 하위 실패 = 그 하위 남은 줄 + const left = leftButton(err, { scope: opt.scope, target, key: opt.key, 파일: opt.파일, 번: kid }); // prettier-ignore + const text = `실패 — ${why(error)}`; rows.push(el("tr", { children: [cell, el("td", { text }), ...(left ? [el("td", { children: [left] })] : [])] })); // prettier-ignore } } diff --git a/resources/tester/test_718_1_hopyo_import.py b/resources/tester/test_718_1_hopyo_import.py index 044b1efe2..d3864fcfe 100644 --- a/resources/tester/test_718_1_hopyo_import.py +++ b/resources/tester/test_718_1_hopyo_import.py @@ -28,7 +28,11 @@ def test_이어서_끝내기_지움_겹침_막음(client): assert _keys(client) == ["HP-000001"] made = client.post(ALL, json={"층": "personal", "키": "HP-000001"}).json() n = len(made["만듦"]) # 729-2 — 남은 줄 있는 호표 = 실패(남은줄) - assert n and n + len(made["실패"]) == 68 and all(x["남은줄"] for x in made["실패"]) + assert ( + n + and n + len(made["실패"]) == 68 + and all(x.get("남은줄") or x.get("하위") for x in made["실패"]) + ) hs.remember(folder, {"장부 줄": {"갈래": "안 씀"}}) # 별칭 장부 = 끝내기 뒤 남음 cache = fit.cache_path(folder, "HP-000001") cache.write_text("{}", encoding="utf-8") diff --git a/resources/tester/test_777_1_hopyo_kids.py b/resources/tester/test_777_1_hopyo_kids.py new file mode 100644 index 000000000..7b3a17a25 --- /dev/null +++ b/resources/tester/test_777_1_hopyo_kids.py @@ -0,0 +1,191 @@ +"""777-1 호표가 다른 호표를 부를 때 = 일위대가 둘(요구 621-4 · `M02_Hopyo_Kids`). + +- 하위 확인 전 부모 저장 = 409 없이 하위 · 부모 둘(하위 = 서버 체크 제안대로 · 부모 줄 = 하위 사본 · 답 `하위`) +- 하위가 남은 줄로 못 만들면 = 409 「하위 제N호표 실패」 · `하위` · `하위남은줄`(부모 · 하위 안 씀) +- 같은 엑셀 두 번([끝내기] 뒤 다시 올림) = 같은 내용 SC 재사용(`본뜬.또`) · SC 안 늚 · 끝내면 그 몫만 뺌 +- 깊이 3 사슬 = 손자 → 자식 → 부모 차례로 · 부모 안 사본 안에 사본 +- 서버 체크 제안 몸(`proposal`) = 화면 `bodyFor` 꼴(체크 줄만 · % = 밑수 고른 것만 · 통째) +층은 임시 폴더(공용 storage 안 씀). +""" + +import json + +import pytest + +from M02_MasterTemplete import M02_Hopyo_Build as hb +from M02_MasterTemplete import M02_Hopyo_Fit as fit +from M02_MasterTemplete import M02_Hopyo_Forget as forget +from M02_MasterTemplete import M02_Hopyo_Kids as hk +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 + +# 올리기 길 시험의 `client` 마당을 같이 씀 +# ruff: noqa: F811 +from resources.tester.test_705_3_hopyo_fixed import DOCS, FILES +from resources.tester.test_m02_hopyo_excel import _post, client, master_hash # noqa: F401 + +BUILD = "/api/m02/layers/personal/hopyo-build" +LEFT = "/api/m02/layers/personal/hopyo-unlinked" +GEO = DOCS / FILES["거창"] + + +def _mine(): + return layers.personal_dir(7, 42) + + +def _scs(): + return sorted(p.stem for p in (_mine() / "precombo").glob("SC-*.json")) + + +def _doc(key): + return json.loads((_mine() / "precombo" / f"{key}.json").read_bytes()) + + +def _decided(client, body): + left = client.post(LEFT, json=body).json()["남은줄"] + return {**body, "줄": [*body["줄"], *({"차례": x["차례"], "뺌": "우리에 없음"} for x in left)]} + + +def _geo(client, key="HP-000001"): + assert _post(client, data=GEO.read_bytes(), name=GEO.name).status_code == 200 + fit.run_job(_mine(), key) # 숫자 판정 · 단가산출 맞춤셈(서버 체크 제안이 씀) + + +def test_하위_확인_전_부모_저장_둘_생김(client): + before = master_hash() + _geo(client) + body = _decided(client, {"층": "personal", "키": "HP-000001", "번": 3, "줄": []}) + res = client.post(BUILD, json=body) + assert res.status_code == 200, res.text # 409 「제4호표 먼저 확인」 없음 + got = res.json() + kid = hb.confirmed(_mine())["HP-000001 제4호표"] + assert got["하위"] == [{"번": 4, "키": kid, "새로": True}] + assert hb.confirmed(_mine())["HP-000001 제3호표"] == got["참조"]["키"] != kid + copy = next(x for x in _doc(got["참조"]["키"])["담은로직"] if "참조" in x) + assert copy["참조"]["층"] == "사본" and copy["참조"]["키"] == kid # 부모 줄 = 하위 사본(740-1) + again = client.post(BUILD, json=body).json() # 다시 저장 = 하위 그대로 · 부모 덮음 + assert again["하위"] == [{"번": 4, "키": kid, "새로": False}] and again["다시씀"] is True + assert master_hash() == before + + +def test_하위_실패_까닭_따로_아무것도_안_씀(client): + assert _post(client).status_code == 200 # 북부청 임도 제45호표 → 제46호표(남은 줄 셋) + body = {"층": "personal", "키": "HP-000001", "번": 45, "줄": []} + res = client.post(BUILD, json=body) + assert res.status_code == 409, res.text + why = res.json()["detail"] + assert why["까닭"].startswith("하위 제46호표 실패 — ") and why["하위"] == 46 + assert why["하위남은줄"] and "남은줄" not in why # 부모 [고르기]로 잘못 안 엶 — 하위 몫 + assert hb.confirmed(_mine()) == {} + left = client.post(LEFT, json=body).json()["남은줄"] # 미리 보기 = 하위 호표 줄은 함께 만듦 + assert all(x["갈래"] != "호표" for x in left) + + +def test_같은_엑셀_두_번_SC_안_늚(client): + assert _post(client).status_code == 200 + folder = _mine() + with store.at(folder): + first = hb.build_all(folder, folder, "HP-000001") + made = _scs() + assert first["실패"] == [] and len(made) <= first["호표"] + 3 + keys = {x["번"]: x["키"] for x in first["만듦"]} + forget.finish(folder, "HP-000001") + assert hb.confirmed(folder) == {} # 끝낸 뒤 = 출처만(키 뺌) + up = _post(client).json() + assert up["키"] == "HP-000002" and not up.get("이어서") + with store.at(folder): + second = hb.build_all(folder, folder, "HP-000002") + assert _scs() == made # 같은 내용 = 재사용 · 안 늚 + assert {x["번"]: x["키"] for x in second["만듦"]} == keys + assert (second["같음"], second["다름"]) == (first["같음"], first["다름"]) + shared = [k for k in made if any(x["키"].startswith("HP-000002 ") for x in _doc(k)["본뜬"].get("또", []))] # fmt: skip + assert shared and set(hb.confirmed(folder)) == {hb.mark("HP-000002", n) for n in keys} + forget.finish(folder, "HP-000002") # 그 몫만 뺌 + assert _scs() == made and not any(_doc(k)["본뜬"].get("또") for k in made) + + +def test_깊이_3_사슬(client, monkeypatch): + _geo(client) + folder = _mine() + units = {int(h["번"]): h for h in hs.read(folder, "HP-000001")["호표"]} + deep = { + no: hk.order(units, no) + for no in units + if any(hk.kids_of(units[k]) for k in hk.kids_of(units[no]) if k in units) + } + top = min(deep) + plain = hk.proposal + + def decided(hopyo): # 하위 남은 줄 = 「우리에 없음」(화면 [고르기] 몫을 시험이 대신) + lines, _ = plain({**hopyo, "통째": None}) # 줄로(통째면 하위 줄이 빠짐 — 사슬을 보려고) + body = {"층": "personal", "키": "HP-000001", "번": hopyo["번"], "줄": lines} + return _decided(client, body)["줄"], None + + monkeypatch.setattr(hk, "proposal", decided) + body = _decided(client, {"층": "personal", "키": "HP-000001", "번": top, "줄": []}) + res = client.post(BUILD, json=body) + assert res.status_code == 200, res.text + done = hb.confirmed(folder) + assert all(hb.mark("HP-000001", k) in done for k in deep[top]) # 손자까지 다 만듦 + new = [x["번"] for x in res.json()["하위"] if x["새로"]] + assert new and set(new) <= set(deep[top]) + + def depth(doc): + inner = [ + x["참조"]["몸"] for x in doc.get("담은로직") or [] if (x.get("참조") or {}).get("몸") + ] + return 1 + max((depth(x) for x in inner), default=0) + + assert depth(_doc(done[hb.mark("HP-000001", top)])) == 3 # 부모 안 사본 안에 사본 + + +@pytest.mark.parametrize( + ("view", "want"), + [ + ( + { + "줄": [ + { + "차례": 0, + "맞춤": { + "체크": True, + "상태": "로직", + "대상": [{"로직": "GF1", "입력값": {"a": 1}, "배": 2, "몫": 0.5}], + }, + }, # fmt: skip + { + "차례": 1, + "맞춤": {"체크": False, "상태": "로직", "대상": [{"로직": "GF2", "배": 1}]}, + }, # fmt: skip + {"차례": 2, "맞춤": {"체크": True, "상태": "%", "대상": [{"비율": None}]}}, + { + "차례": 3, + "맞춤": {"체크": True, "상태": "호표", "대상": [{"호표": 4, "배": None}]}, + }, # fmt: skip + { + "차례": 4, + "맞춤": {"체크": True, "상태": "자재", "대상": [{"자재": "MT1", "배": 0}]}, + }, # fmt: skip + { + "차례": 5, + "맞춤": {"체크": True, "상태": "자재", "대상": [{"전국": True, "배": 3}]}, + }, # fmt: skip + ] + }, + ( + [ + {"차례": 0, "대상": [{"로직": "GF1", "입력값": {"a": 1}, "배": 2, "몫": 0.5}]}, + {"차례": 5, "대상": [{"전국": True, "배": 3}]}, + ], + None, + ), + ), + ( + {"줄": [], "통째": {"체크": True, "대상": [{"로직": "GF1", "배": 1}]}}, + ([], {"대상": [{"로직": "GF1", "입력값": {}, "배": 1}]}), + ), + ], +) +def test_서버_체크_제안_몸_화면_짝(view, want): + assert hk.proposal(view) == want diff --git a/resources/tester/test_m02_hopyo_build.py b/resources/tester/test_m02_hopyo_build.py index c37f6dc43..10843575f 100644 --- a/resources/tester/test_m02_hopyo_build.py +++ b/resources/tester/test_m02_hopyo_build.py @@ -1,6 +1,6 @@ """일위대가 실무자료 → 사전조합 만들기(PLAN 324-3 · 338-3 · `M02_Hopyo_Build`) — 확인한 줄만 · 몸 없음 400 · 빠진 줄 까닭 · 로직 여럿 같은 별칭 · 같은 로직 감쌈 · 배 · 몫 · 자재 · 통째 · -재검사 400(없는 로직 · 모르는 입력 · 배 ≤ 0 · 금액 칸) · 하위 호표 409 → 확인 뒤 참조 · 장부 씀 · +재검사 400(없는 로직 · 모르는 입력 · 배 ≤ 0 · 금액 칸) · 하위 실패 409(777-1) → 확인 뒤 참조 · 장부 씀 · `본뜬.확인` · 덮음 · 옛 즉시 확정 길 404 · 장부 길(보기 · 고치기 · 권한) · picks 확인 · master_data 원본 그대로. 층은 임시 폴더. """ @@ -244,7 +244,10 @@ def test_몸_없음_재검사_400(client): def test_하위_호표_먼저_확인_퍼센트_빠짐_통째(client): assert _post(client).status_code == 200 ref = [{"차례": 0, "대상": [{"호표": 46, "배": 1}]}] - assert "제46호표 먼저 확인" in _build(client, 45, ref, want=409)["detail"] + why = _build(client, 45, ref, want=409)[ + "detail" + ] # 777-1 — 하위 먼저 함께(제안대로) · 남은 줄로 실패 + assert why["까닭"].startswith("하위 제46호표 실패") and why["하위"] == 46 and why["하위남은줄"] child = _build(client, 46, [{"차례": 0, "뺌": "안 씀"}]) assert [x["까닭"] for x in child["빠진줄"]] == ["뺌 — 안 씀"] # % 줄 = 비율 고정 줄(705-3) rate = _doc(layers.personal_dir(7, 42), child["참조"]["키"])["담은로직"][-1]["고정"] diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index e2cc9988c..529c15b3e 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -140,7 +140,11 @@ def test_새_상태_체크_제안(client): assert dig["대상"][0]["로직"] == "GC000083" and len(dig["후보"]) == 5 assert "특별인부 엑셀 0.3542 · 우리 0.3704" in dig["꼬리표"] ref = _hopyo(client, 45)["고화매트재료비"] - assert ref["상태"] == "호표" and ref["체크"] is False and ref["글"] == "제46호표 먼저 확인" + assert ( + ref["상태"] == "호표" + and ref["체크"] is False + and ref["글"] == "제46호표 — 저장 때 하위로 함께 만듦" + ) # 777-1 assert ref["대상"][0]["호표"] == 46 and ref["대상"][0]["단위"] == "㎡" pct = _hopyo(client, 46)["잡재료비"] assert pct["상태"] == "%" and pct["체크"] is False diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 8d49cad8a..63c0a456a 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -319,7 +319,9 @@ def test_옛_즉시_확정과_바로_만들기_없음(): save = sheet.split("async function save(")[1].split("async function makeAll(")[0] assert 'showConfirmDialog(ask, "저장")' in save and "hasDraft(" in save assert "건너뜀 — 체크 줄 없음" in save and "await makeAll(ready, skipped);" in save - assert "buildOrder(" in sheet and "error instanceof StaleError" in sheet # 하위 먼저 · 409 안내 + assert ( + "buildOrder(" in sheet and "unmadeKids(" in sheet and "leftAt(" in sheet + ) # 하위 먼저 · 함께(777-1) edit = sheet.split("const editButton =")[1].split("function paintList(")[0] assert '"수정"' in edit and "e.stopPropagation();" in edit and "openHopyoCheck({" in edit row = sheet.split("function paintList(")[1] diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 7c9678d3c..b3c8429c7 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -297,6 +297,10 @@ export const ui_locales_m2 = { M02_LeftMakeSk: ["SK 만들기", "Make SK"], M02_LeftSkNone: ["만든 단가산출 없음 — 고름 대기 그대로", "Nothing made — still waiting"], M02_LeftChoose: ["고르기", "Pick"], + M02_HopyoKid: ["하위 제{n}호표 {key}(사본 담음)", "Sub sheet {n} {key} (copy inside)"], + M02_HopyoKidNew: ["함께 만듦", "made together"], + M02_HopyoKidFail: ["하위 제{n}호표 실패", "Sub sheet {n} failed"], + M02_HopyoReused: ["같은 내용 일위대가 다시 씀", "Same unit-price reused"], M02_LeftFind: ["찾기", "Find"], M02_LeftMachine: ["기계", "Machine"], M02_LeftNoneTip: [