From a2c2ad92de0fc610e68efa7149324fcbaf4e083d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 23:03:56 +0900 Subject: [PATCH] =?UTF-8?q?fix(M02=20=EC=8B=A4=EB=AC=B4=EC=9E=90=EB=A3=8C)?= =?UTF-8?q?:=20361-1=20=EC=A7=80=EC=97=AD=EB=A7=8C=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EC=9E=90=EC=9E=AC=20=3D=20=EC=A0=84=EA=B5=AD=20=E2=80=94=20?= =?UTF-8?q?=EC=A4=84=20=EA=B8=80=20=EC=A7=80=EC=97=AD=20=C2=B7=20(?= =?UTF-8?q?=EC=A0=84=EA=B5=AD)=20=ED=92=88=EB=AA=A9=20=C2=B7=20=EC=97=86?= =?UTF-8?q?=EC=9C=BC=EB=A9=B4=20=ED=82=A4=20=EC=97=86=EB=8A=94=20=EC=A0=84?= =?UTF-8?q?=EA=B5=AD=20=EC=9E=90=EB=A6=AC=ED=91=9C=20=C2=B7=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=20=EC=B0=BD=20=EC=A7=80=EC=97=AD=20=EB=B3=80?= =?UTF-8?q?=ED=98=95=20=EC=A0=91=EC=9D=8C=20=C2=B7=20=EC=A0=84=EA=B5=AD=20?= =?UTF-8?q?=EB=90=98=EB=8F=8C=EB=A6=BC=20=C2=B7=20=EB=A7=8C=EB=93=A4?= =?UTF-8?q?=EA=B8=B0=20=EB=B3=B8=EB=9C=AC.=EC=A0=84=EA=B5=AD=EC=9E=90?= =?UTF-8?q?=EC=9E=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 맞춤: 줄 글 지역 = 그 지역 변형(꼬리표 「지역 — 줄 글」) · 지역 없는 변형((전국) 품목 먼저) · 없으면(레미콘) 「전국」 자리표(바탕 이름(전국)-관급/사급 · 규격 · 단위 · 키 · 단가 없음 · 꼬리표 「전국 — 지역은 프로젝트에서」) · 옛 「못 가림 — N개 중 첫째」 없앰 - 후보 = 그 품목 변형 모두(`변형`) · 고르기 창 1위 / 전국 / 지역 변형(접힘) · 대상 꼴 `{전국: true}` - 미리 셈 · 만들기: 전국 대상은 서버가 줄에서 다시 셈 · 일위대가 자재 칸이 M01 키만 받아 사전조합 `본뜬.전국자재` 에 적고 빠진 줄 까닭 · 장부엔 안 남김 · 만들기 몸 꼴에 `전국` 칸 - 시험: 전국 · 줄 글 지역 · (전국) 품목 · 고르기 지역 → 전국 되돌림 · 만들기 결과 · 화면 도우미 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- M02_MasterTemplete/M02_Hopyo_Build.py | 37 ++++++++- M02_MasterTemplete/M02_Hopyo_Line.py | 23 ++++-- M02_MasterTemplete/M02_Hopyo_Match.py | 2 +- M02_MasterTemplete/M02_Hopyo_Material.py | 55 +++++++++++-- M02_MasterTemplete/M02_Labor_Router.py | 1 + .../M02_MasterTemplete_Hopyo_Api.ts | 6 ++ .../M02_MasterTemplete_Hopyo_Check.ts | 6 +- .../M02_MasterTemplete_Hopyo_CheckRows.ts | 5 +- .../M02_MasterTemplete_Hopyo_Choose.ts | 18 ++++- resources/tester/helper_m02_hopyo_check.cjs | 20 +++++ resources/tester/test_m02_hopyo_build.py | 28 +++++++ resources/tester/test_m02_hopyo_flow.py | 4 +- resources/tester/test_m02_hopyo_match.py | 77 ++++++++++++++++++- resources/tester/test_m02_hopyo_screen.py | 16 +++- 14 files changed, 266 insertions(+), 32 deletions(-) diff --git a/M02_MasterTemplete/M02_Hopyo_Build.py b/M02_MasterTemplete/M02_Hopyo_Build.py index aaf612349..882836014 100644 --- a/M02_MasterTemplete/M02_Hopyo_Build.py +++ b/M02_MasterTemplete/M02_Hopyo_Build.py @@ -14,7 +14,10 @@ 사전조합으로 감싸 참조(같은 로직 두 번 금지) · 자재 = {코드, 수량 {손}, 세 칸}(같은 자재 = 수량 더함 · 세 칸 = `Material.marks` 별도계상 · 자재구분 · 운반비 · 358-2) · 로직 자재와 같은 품목 = 사용자 자재에서 빼고 - 세 칸만 그 로직 `자재표시` 로(빠진 줄에 까닭) · 호표 = 확인된 사전조합 참조. + 세 칸만 그 로직 `자재표시` 로(빠진 줄에 까닭) · 호표 = 확인된 사전조합 참조 · + 「전국」 자재(361-1 · 키 · 단가 없음 · 서버가 줄에서 다시 셈) = 일위대가 자재 칸이 M01 키만 + 받아 `본뜬.전국자재 [{이름, 규격, 단위, 수량, 세 칸}]` 에 적고 빠진 줄에 까닭(금액 없음 · + 지역은 프로젝트에서) · 장부엔 안 남김. 같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 확인한 줄은 별칭 장부에 남음 (`M02_Hopyo_Ledger` · 호표 참조 줄 뺌). 빠진 줄(확인 안 함 · 뺌 · %) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가(참고). @@ -44,6 +47,7 @@ UNCHECKED = "확인 안 함" PERCENT = "% 줄 — 우리 로직 덧줄이 셈" WHOLE = "호표 통째로 대신" TWICE = "로직 자재와 같은 품목 — 세 칸만 로직 자재표시로(수량 = 로직 셈)" +NATIONAL = "전국 — 지역은 프로젝트에서(자재 키 · 단가 없음 · 본뜬.전국자재)" def mark(key: str, no: int, at: int | None = None) -> str: @@ -142,7 +146,9 @@ class Builder: raise _bad(f"없는 호표 「제{no}호표」", 404) # ── 재검사 ─────────────────────────────────────────────────────── - def _target(self, raw: dict[str, Any], where: str) -> dict[str, Any]: + def _target(self, raw: dict[str, Any], where: str, line: dict | None = None) -> dict[str, Any]: + if raw.get("전국"): + return self._national(raw, where, line) if raw.get("호표") is None: return ledger.check_pair( {k: v for k, v in raw.items() if k != "호표"}, where, self.known @@ -157,6 +163,16 @@ class Builder: times, part = ledger.times_of(raw, where) return {"호표": child, "배": times, "몫": part} + def _national(self, raw: dict[str, Any], where: str, line: dict | None) -> dict[str, Any]: + """「전국」 대상 — 줄에서 서버가 다시 셈(이름 · 규격 · 단위 = 서버 것).""" + if set(raw) - {"전국", "배", "몫", "입력값"} or raw.get("입력값"): + raise _bad(f"전국 대상은 전국 · 배 · 몫만 「{where}」") + one = hmat.national(line) if line is not None else None + if one is None: + raise _bad(f"전국 자재 없음 — 지역 변형만 있는 자재 줄만 「{where}」") + times, part = ledger.times_of(raw, where) + return {**{k: one[k] for k in ("전국", "이름", "규격", "단위")}, "배": times, "몫": part} + def check(self, lines: list[dict[str, Any]], whole: dict[str, Any] | None) -> tuple[dict, list]: """몸 재검사 → ({차례: 확인 줄}, 통째 대상) — 틀리면 400 · 하위 호표 409.""" if whole is not None and lines: @@ -180,7 +196,7 @@ class Builder: if len(targets) > 20: raise _bad(f"대상은 스물 이하 「{where}」") out[at] = {"뺌": raw.get("뺌")} if raw.get("뺌") else { - "대상": [self._target(t, where) for t in targets] + "대상": [self._target(t, where, have[at]) for t in targets] } # fmt: skip pairs = [self._target(t, f"제{self.no}호표 통째") for t in (whole or {}).get("대상") or []] if any("호표" in p for p in pairs) or len(pairs) > 20: @@ -204,10 +220,21 @@ class Builder: stamp = datetime.now().isoformat(timespec="seconds") hopyo, file = self.hopyo, self.doc.get("파일") or "" lines, mats, missing, used, mat_lines = [], {}, [], set(), {} + nation: dict[tuple, dict[str, Any]] = {} def put(line: dict[str, Any], one: dict[str, Any], alias: str | None) -> None: qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(one["배"])) memo = _memo(line, one) + if "전국" in one: + at = (one["이름"], one["규격"], one["단위"]) + have = nation.get(at) + hand = qty + Decimal(str(have["수량"]["손"])) if have else qty + head = {"이름": at[0], "규격": at[1], "단위": at[2], "수량": {"손": _num(hand)}} + nation[at] = {**head, **(hmat.marks(line, at[0]) if not have else { + k: have[k] for k in labor.MARKS if k in have + })} # fmt: skip + missing.append(_missing(line, NATIONAL)) + return if "자재" in one: have = mats.get(one["자재"]) hand = qty + Decimal(str(have["수량"]["손"])) if have else qty @@ -270,7 +297,7 @@ class Builder: alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None for one in got["대상"]: put(line, one, alias) - pairs = [p for p in got["대상"] if "호표" not in p] + pairs = [p for p in got["대상"] if "호표" not in p and "전국" not in p] if pairs: book[ledger.key(line)] = self._row(line, "짝", pairs) for code in self._inside(lines, mats): # 로직 자재와 같은 품목 = 세 칸만 로직 자재표시로 @@ -285,6 +312,8 @@ class Builder: "자재": list(mats.values()), "본뜬": self._origin(mark(self.key, self.no), stamp), } + if nation: + doc["본뜬"]["전국자재"] = list(nation.values()) ref, again = _save(doc) wrote = ledger.write(self.folder, book, self.who, file or None) return {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}} diff --git a/M02_MasterTemplete/M02_Hopyo_Line.py b/M02_MasterTemplete/M02_Hopyo_Line.py index cacdc3c2d..bfe215ac8 100644 --- a/M02_MasterTemplete/M02_Hopyo_Line.py +++ b/M02_MasterTemplete/M02_Hopyo_Line.py @@ -198,7 +198,8 @@ def _names_only(line: dict, ctx: Ctx, why: str, full: bool) -> dict: def _material(line: dict, ctx: Ctx, full: bool) -> dict: """자재 · 별도계상 줄(358-2 · `M02_Hopyo_Material`) — 「별도계상」 글 = 꼬리표 · 맞음 = - 이름 · 규격 같음 하나 · 또는 바탕 이름 · 규격 수 같은 한 품목(변형 = 첫째 + 꼬리표).""" + 이름 · 규격 같음 하나 · 또는 바탕 이름 · 규격 수 같은 한 품목(줄 글 지역 · 지역 없는 것 · + 「전국」 자리표 · 361-1) · 후보 = 그 품목 변형 모두(`변형` · 고르기 창이 접음).""" me, apart = hmat.clean(line) tags = [hmat.APART] if apart else [] found = hm._exact(me, "자재", ctx.got) @@ -207,14 +208,14 @@ def _material(line: dict, ctx: Ctx, full: bool) -> dict: sure, more = hmat.sure(me) found, text, tags = [r["one"] for r in sure], "바탕 이름 · 규격 같음", tags + more if found: - one = found[0] - conv = hm.factor(line.get("단위"), one["단위"], line.get("규격")) - target = {**hm._public(one), "입력값": {}, "못읽음": [], "출처": {}, "환산": conv} - target.update(몫=None, 배=conv["배"]) + target = _mat_target(found[0], line) + conv = target["환산"] ok = conv["배"] is not None and conv["갈래"] != "추정" + many = len(found) > 1 cands = [ {**hm._public(x), "점수": None, "판정": "맞음" if not at else "후보", "글": text} - for at, x in enumerate(found[:6]) + | ({"변형": True} if many and "자재" in x else {}) + for at, x in enumerate(found) ] out = {"상태": "맞음", "체크": ok, "대상": [target], "후보": cands if full else []} return {**out, "글": text, "꼬리표": tags} @@ -223,6 +224,14 @@ def _material(line: dict, ctx: Ctx, full: bool) -> dict: return {"상태": state, "체크": False, "대상": [], "후보": cands, "꼬리표": tags} +def _mat_target(one: dict, line: dict, times: Any = None) -> dict: + """자재 · 「전국」 자리표 → 대상(배 = 환산 · `times` 주면 그것).""" + conv = hm.factor(line.get("단위"), one["단위"], line.get("규격")) + target = {**hm._public(one), "입력값": {}, "못읽음": [], "출처": {}, "환산": conv} + ok = isinstance(times, (int, float)) and not isinstance(times, bool) and times > 0 + return {**target, "몫": None, "배": times if ok else conv["배"]} + + def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, Any]: """줄 → 맞춤(설계 3-3) — `full` 아니면 상태 · 체크만(호표 목록 수).""" out: dict[str, Any] = {"열쇠": hm.line_key(line), "체크": False, "대상": [], "후보": []} @@ -421,6 +430,8 @@ def guess( targets.append(one) elif t.get("자재") in ctx.mats: targets.append(_pair({"자재": t["자재"], "배": t.get("배")}, line, ctx)) + elif t.get("전국") and (one := hmat.national(line)): + targets.append(_mat_target(one, line, t.get("배"))) else: raise store.StoreError(400, "돈 로직 · 우리 자재가 아님") qty = _qty(entry, {"우리품": option.ours_qty(total)}) diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py index 88c7fa902..2a2bf69c7 100644 --- a/M02_MasterTemplete/M02_Hopyo_Match.py +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -273,7 +273,7 @@ def _seeds() -> dict[str, set[str]]: def _public(one: dict[str, Any]) -> dict[str, Any]: - keep = ("로직", "자재", "이름", "규격", "단위") + keep = ("로직", "자재", "전국", "이름", "규격", "단위") return {k: one[k] for k in keep if k in one} diff --git a/M02_MasterTemplete/M02_Hopyo_Material.py b/M02_MasterTemplete/M02_Hopyo_Material.py index a3d269972..1a5a03b39 100644 --- a/M02_MasterTemplete/M02_Hopyo_Material.py +++ b/M02_MasterTemplete/M02_Hopyo_Material.py @@ -6,8 +6,12 @@ 자재 규격 수에 있는 몫(단위 배 1 · 10 · 1000 · 2 % 허용 · 면목 0.03 m = 30 mm) · 줄 괄호 글 낱말(「몸통」)은 자재 이름에 들어야 함. 맞음 = ① 이름 · 규격 같음 하나(`Match._exact`) ② 바탕 이름 · 단위 · 규격 맞음 1 이 한 품목뿐 -(지역 · 관급/사급만 다른 변형 = 한 품목 · 같은 단위 먼저 · 줄 글의 관급/사급 · 지역 먼저 · -없으면 별도계상 줄 = 관급 · 재료비 줄 = 사급 · 지역 없는 것 · M01 차례 → 꼬리표 「못 가림」). +(지역 · 관급/사급만 다른 변형 = 한 품목 · 같은 단위 먼저 · 줄 글의 관급/사급 먼저 · 없으면 +별도계상 줄 = 관급 · 재료비 줄 = 사급). +지역(361-1) = 줄 글에 지역 낱말 → 그 지역 변형(꼬리표 「지역 — 줄 글」) · 없으면 지역 없는 +변형((전국) 품목) · 그것도 없으면(레미콘) 「전국」 = 키 · 단가 없는 자리표(`national` · +바탕 이름(전국)-관급/사급 · 규격 · 단위 · 꼬리표 「전국 — 지역은 프로젝트에서」) · 자동 고름 없음 · +지역 변형은 고르기 창에서 사용자가. 그 밖 = 후보 다섯(변형은 하나로 접고 글에 수). 곳 낱말 = M01 레미콘 이름에서(자료). 가져오기 세 칸(`marks`) = 별도계상(표시 → 별도계상 · 아니면 직접계산) · 자재구분(고른 자재 이름 · 줄 글) · 운반비(「운반비 미포함 · 별도」 → 미포함). 로직 판정과 무관. @@ -33,6 +37,9 @@ REGIONS = ( *"여주 이천 가평 연천".split(), ) SKIP_WORDS = frozenset({"조합공통품목"}) +NATIONAL = "전국" +NATIONAL_TAG = "전국 — 지역은 프로젝트에서" +REGION_TAG = "지역 — 줄 글" _APART = re.compile(r"\(?\s*별도\s*계상\s*\)?") _NUM = re.compile(r"\d+(?:\.\d+)?") _RANGE = re.compile(r"(\d+(?:\.\d+)?)\s*(<=|≤|<)\s*[A-Za-z]\s*(<=|≤|<)\s*(\d+(?:\.\d+)?)") @@ -60,8 +67,8 @@ def _paren_words(name: str) -> set[str]: return {w for g in re.findall(r"\(([^()]*)\)", name) for w in re.findall(r"[가-힣]{2,}", g)} -def base(name: Any) -> str: - """바탕 이름 — 괄호 글 · 끝 지역 · 관급/사급 꼬리 뗌(정규화).""" +def _bare(name: Any) -> str: + """괄호 글 · 끝 지역 · 관급/사급 꼬리 뗀 글(정규화 전 · 「레미콘(서울)-사급」 → 레미콘).""" text = _nfkc(name) while "(" in text and (cut := re.sub(r"\([^()]*\)", "", text)) != text: text = cut @@ -71,7 +78,12 @@ def base(name: Any) -> str: text = text[: m.start()] for w in SUPPLY: text = text[: -len(w)] if text.endswith(w) and len(text) > len(w) else text - return mm.plain(text) + return text.strip() + + +def base(name: Any) -> str: + """바탕 이름 — `_bare` 정규화.""" + return mm.plain(_bare(name)) def clean(line: dict[str, Any]) -> tuple[dict[str, Any], bool]: @@ -166,16 +178,21 @@ def _wants(line: dict[str, Any]) -> tuple[str | None, list[str]]: return supply, [w for w in words if _region(w) and w not in NO_REGION] +def _hit(r: dict[str, Any], regions: list[str]) -> bool: + return any(w[:2] == x[:2] for w in regions for x in r["region"]) + + def _prefer(rows: list[dict[str, Any]], line: dict[str, Any]) -> list[dict[str, Any]]: """변형 차례 — 줄 글의 관급/사급 · 지역 먼저 · 지역 없는 것 · 엑셀(M01) 차례.""" supply, regions = _wants(line) def rank(r: dict[str, Any]) -> tuple: - hit = any(w[:2] == x[:2] for w in regions for x in r["region"]) + hit = _hit(r, regions) return ( supply is not None and r["supply"] not in (None, supply), bool(regions) and not hit, bool(r["region"]), + not any(w in r["one"]["이름"] for w in NO_REGION), # 「(전국)」 품목 먼저(361-1) r["at"], ) @@ -201,8 +218,30 @@ def sure(line: dict[str, Any]) -> tuple[list[dict[str, Any]], list[str]]: if not full or len({r["key"] for r in full}) > 1: return [], [] full = _prefer(full, line) - tags = [f"지역 · 관급/사급 못 가림 — {len(full)}개 중 첫째"] if len(full) > 1 else [] - return full, tags + supply, regions = _wants(line) + if any(_hit(r, regions) for r in full): + return full, [REGION_TAG] + if len(full) == 1 or any(not r["region"] for r in full): + return full, [] + return [_national(full[0], supply), *full], [NATIONAL_TAG] + + +def _national(row: dict[str, Any], supply: str | None) -> dict[str, Any]: + """「전국」 자리표(361-1) — 지역 변형만 있는 품목 · 키 · 단가 없음 · 이름(전국)-관급/사급.""" + one = row["one"] + name = f"{_bare(one['이름'])}({NATIONAL})" + (f"-{supply}" if supply else "") + spec = _nfkc(one["규격"]) + for w in SKIP_WORDS: # 「조합공통품목, 25-21-120」(관급) → 25-21-120 + spec = re.sub(rf"{w}[\s,]*", "", spec) + return {"one": {"전국": True, "이름": name, "규격": spec.strip(" ,"), "단위": one["단위"]}} + + +def national(line: dict[str, Any]) -> dict[str, Any] | None: + """자재 · 별도계상 줄(다듬기 전)의 「전국」 자리표 — 없으면 None(만들기 · 미리 셈 재검사).""" + if line.get("갈래") not in hm.MATERIAL_KINDS: + return None + rows, _ = sure(clean(line)[0]) + return next((r["one"] for r in rows if r["one"].get("전국")), None) def candidates(line: dict[str, Any], limit: int = 5) -> list[dict[str, Any]]: diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 96ee36f62..e0ad37249 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -334,6 +334,7 @@ class BuildTarget(_Strict): 로직: str | None = None 자재: str | None = None 호표: int | None = None + 전국: bool | None = None # 「전국」 자재(361-1 · 키 없음 · 서버가 줄에서 다시 셈) 입력값: dict[str, Any] = Field(default_factory=dict) 배: float = 1 몫: float | None = None diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index 8f223012f..7f6089c9d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -89,6 +89,10 @@ export interface HopyoTarget { 로직?: string; 자재?: string; 호표?: number; + /** 「전국」 자리표(361-1) — 지역 변형만 있는 자재 · 키 · 단가 없음 */ + 전국?: boolean; + /** 같은 품목 지역 · 관급/사급 변형(361-1 · 고르기 창이 접음) */ + 변형?: boolean; 이름?: string; 규격?: string; 단위?: string | null; @@ -190,6 +194,8 @@ export interface BuildTarget { 로직?: string; 자재?: string; 호표?: number; + /** 「전국」 자재(361-1) — 이름 · 규격 · 단위는 서버가 줄에서 다시 셈 */ + 전국?: boolean; 입력값?: Record; 배: number; 몫?: number | null; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts index c07f6e4cf..3fbf4520d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts @@ -117,7 +117,11 @@ export function checkable(line: HopyoLine, d: CheckDraft): boolean { /** 대상 → 만들기 몸 대상(이름 · 품 같은 보이기 칸은 안 보냄) */ export function toTarget(p: HopyoPart): BuildTarget { if (p.호표 !== undefined) return { 호표: p.호표, 배: p.배 ?? 1 }; - const head = p.로직 ? { 로직: p.로직, 입력값: p.입력값 ?? {} } : { 자재: p.자재 }; + const head = p.로직 + ? { 로직: p.로직, 입력값: p.입력값 ?? {} } + : p.전국 + ? { 전국: true } + : { 자재: p.자재 }; return { ...head, 배: p.배 ?? 1, ...(p.몫 != null ? { 몫: p.몫 } : {}) }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts index e14bbd11d..7ef66d841 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts @@ -120,7 +120,8 @@ function partLines(line: HopyoLine, parts: HopyoPart[], ctx: RowsContext): HTMLE times.addEventListener("change", () => ctx.times(line, at, times.value === "" ? null : Number(times.value)), ); - const key = p.로직 ?? p.자재 ?? (p.호표 !== undefined ? `제${p.호표}호표` : ""); + const key = + p.로직 ?? p.자재 ?? (p.호표 !== undefined ? `제${p.호표}호표` : p.전국 ? "전국" : ""); return el("div", { className: "m02-hopyo__part", attrs: { title: p.어디 ?? "" }, @@ -247,7 +248,7 @@ function matRow(line: HopyoLine, ctx: RowsContext): HTMLElement { el("td", { className: "m02-labrow__no", text: String(line.차례 + 1) }), el("td", { className: "m02-labrow__code", - text: parts.map((x) => x.자재 ?? "").join(" · ") || "—", + text: parts.map((x) => x.자재 ?? (x.전국 ? "전국" : "")).join(" · ") || "—", }), el("td", { className: "m02-labrow__name m02-hopyo__parts", diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts index cf8022f09..1aa2637f7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts @@ -6,6 +6,7 @@ * 뜻 낱말 안 겹치는 「후보」는 회색 끝) · 같은 이름이면 책(산림 · 건설) · 판정 꼬리표 · 근거 꼬리표(근거 맞음 초록) * · 고름 = [후보](한 로직) · 또는 체크 + 몫 칸 → [일괄 적용(n)](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움 * · [다른 로직 검색] = `openLogicPicker` 에 품명 첫 뜻 낱말 · [다른 자재 검색] = 자재 줄 · 재료비 있는 줄(358-2) + * · 자재 줄(361-1) = 1위 / 전국(자리표 · 지역 고른 뒤 되돌림) / 지역 변형(접힘 · 하나 고르면 그 지역) / 그 밖 * 저장 안 함 — 고른 것은 부르는 쪽 캐시(확인 창)로만 감. * ========================================================================== */ @@ -25,6 +26,10 @@ import { pickMaterial } from "./M02_Material_Pick"; import "./M02_MasterTemplete_Hopyo.css"; const MATERIAL = new Set(["자재", "별도계상"]); +const FOLD_TEXT: Record = { + "지역 변형": "하나 고르면 그 지역 · 「전국」으로 되돌림은 위", + 숫자: "이름 · 출처 안 닮음 · 숫자 지문만", +}; const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text }); const num = (v: number): string => String(Math.round(v * 10000) / 10000); @@ -131,6 +136,8 @@ export function candGroups( if (material) return [ { 제목: "1위", 접힘: false, 후보: top }, + { 제목: "전국", 접힘: false, 후보: take(all.filter((c) => c.전국)) }, + { 제목: "지역 변형", 접힘: true, 후보: take(all.filter((c) => c.변형)) }, { 제목: "그 밖 후보", 접힘: false, 후보: take(all.slice(1)) }, ].filter((g) => g.후보.length); const named = m.이름후보 ?? []; @@ -156,7 +163,7 @@ export function sameNames(groups: CandGroup[]): Set { /** 후보 단추 글 — 「이름 [단위] · 키」 · 같은 이름이면 책 두 글자(산림 · 건설) */ export const candText = (c: HopyoTarget, twins: Set): string => - `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}${twins.has(c.이름 ?? "") && c.책 ? ` · ${c.책.slice(0, 2)}` : ""}`; + `${targetText(c)} · ${c.로직 ?? c.자재 ?? "키 없음"}${twins.has(c.이름 ?? "") && c.책 ? ` · ${c.책.slice(0, 2)}` : ""}`; /** 자재 고름 — 자재 · 별도계상 줄 · 재료비 있는 단가산출 줄(358-2 · P.V.C파이프설치 등) */ export const canPickMaterial = (line: Pick): boolean => @@ -165,7 +172,7 @@ export const canPickMaterial = (line: Pick): boo /** 고른 후보 → 만들기 대상(배 1 · 몫 있으면 배 = 몫) */ export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]): BuildTarget[] => picked.map(({ c, 몫 }) => ({ - ...(c.로직 ? { 로직: c.로직 } : { 자재: c.자재 }), + ...(c.로직 ? { 로직: c.로직 } : c.전국 ? { 전국: true } : { 자재: c.자재 }), 입력값: {}, 배: 몫 ?? 1, 몫, @@ -220,7 +227,7 @@ export function openHopyoChoose(opt: ChooseOptions): void { const tag = basisTag(c.근거); return el("div", { className: `m02-hopyo__cand${weakCand(c) ? " is-weak" : ""}`, - attrs: { "data-cand": c.로직 ?? c.자재 ?? "" }, + attrs: { "data-cand": c.로직 ?? c.자재 ?? (c.전국 ? "전국" : "") }, children: [ box, createButton({ @@ -260,7 +267,10 @@ export function openHopyoChoose(opt: ChooseOptions): void { const fold = el("details", { className: "m02-hopyo__group", attrs: { "data-group": g.제목 }, - children: [el("summary", { text: `${title} — 이름 · 출처 안 닮음 · 숫자 지문만` }), list], + children: [ + el("summary", { text: `${title} — ${FOLD_TEXT[g.제목] ?? FOLD_TEXT.숫자}` }), + list, + ], }); return fold; }); diff --git a/resources/tester/helper_m02_hopyo_check.cjs b/resources/tester/helper_m02_hopyo_check.cjs index 88fd7c174..0a2a22aa1 100644 --- a/resources/tester/helper_m02_hopyo_check.cjs +++ b/resources/tester/helper_m02_hopyo_check.cjs @@ -148,6 +148,7 @@ let out0 = null; toTarget: [ check.toTarget({ 호표: 46, 이름: "고화매트", 입력값: {}, 배: 2.3 }), check.toTarget({ 자재: "MT1", 이름: "레미콘", 입력값: {}, 배: 1, 몫: null }), + check.toTarget({ 전국: true, 이름: "레미콘(전국)-관급", 입력값: {}, 배: 1, 몫: null }), ], newest: [a(), b(), c()], dropped: (check.dropDraft(key), store.has(key)), @@ -200,6 +201,25 @@ let out0 = null; material: choose .candGroups({ 후보: [{ 자재: "M1" }, { 자재: "M2" }] }, true) .map((x) => x.제목), + // 361-1 지역만 다른 자재 — 1위 전국 · 지역 변형 접힘 · 고르면 그 지역 · 전국 되돌림 + national: (() => { + const all = [ + { 전국: true, 이름: "레미콘(전국)-관급", 규격: "25-21-120", 단위: "㎥" }, + { 자재: "M1", 이름: "레미콘(서울)-사급", 변형: true }, + { 자재: "M2", 이름: "레미콘(관급)-강원도(강릉시)", 변형: true }, + ]; + const g = choose.candGroups({ 후보: all }, true); + const id = (c) => c.자재 ?? (c.전국 ? "전국" : ""); + return { + groups: g.map((x) => [x.제목, x.접힘, x.후보.map(id)]), + text: choose.candText(all[0], new Set()), + region: choose.pickedTargets([{ c: all[2], 몫: null }]), + back: choose.pickedTargets([{ c: all[0], 몫: null }]), + hit: choose + .candGroups({ 후보: [all[2], all[0], all[1]] }, true) + .map((x) => [x.제목, x.후보.map(id)]), + }; + })(), pick: [ choose.canPickMaterial({ 갈래: "단가산출", 단가: { 재료비: 10 } }), choose.canPickMaterial({ 갈래: "단가산출", 단가: { 재료비: null } }), diff --git a/resources/tester/test_m02_hopyo_build.py b/resources/tester/test_m02_hopyo_build.py index 132f61375..16e3ae284 100644 --- a/resources/tester/test_m02_hopyo_build.py +++ b/resources/tester/test_m02_hopyo_build.py @@ -146,6 +146,34 @@ def test_자재_세_칸_로직_자재_두_번_막음(client): assert any(m["까닭"] == hb.TWICE and m["차례"] == 0 for m in got["빠진줄"]) +def test_전국_자재_키_없이_본뜬에(client): + """361-1 — 「전국」 자재 = 자재 칸(M01 키만) 아니고 `본뜬.전국자재` · 이름 · 규격 · 단위 = + 서버가 줄에서 다시 셈(몸은 이름 칸 못 받음) · 빠진 줄 까닭 · 장부엔 안 남김 · 자리표 없는 줄 = 400.""" + assert _post(client).status_code == 200 + line = {"차례": 0, "대상": [{"전국": True, "배": 1}]} # 레미콘 별도계상 줄 + got = _build(client, 1, [line]) + mine = layers.personal_dir(7, 42) + doc = _doc(mine, got["참조"]["키"]) + assert doc["자재"] == [] and not any(m.get("코드") for m in doc["자재"]) + qty = hs.read(mine, "HP-000001")["호표"][0]["줄"][0]["수량"] + assert doc["본뜬"]["전국자재"] == [ + { + "이름": "레미콘(전국)-관급", + "규격": "25-21-120", + "단위": "㎥", + "수량": {"손": qty}, + "별도계상": "별도계상", + "자재구분": "관급", + } + ] + assert [m["까닭"] for m in got["빠진줄"] if m["차례"] == 0] == [hb.NATIONAL] + assert got["장부"]["씀"] == 0 + _build(client, 1, [{"차례": 1, "대상": [{"전국": True, "배": 1}]}], want=400) # 로직 줄 + both = [{"차례": 0, "대상": [{"전국": True, "자재": "MT004258", "배": 1}]}] + _build(client, 1, both, want=400) + _build(client, 1, [{"차례": 0, "대상": [{"전국": True, "입력값": {"x": 1}}]}], want=400) + + def test_몸_없음_재검사_400(client): assert _post(client).status_code == 200 one = {"로직": "GF000101", "배": 1} diff --git a/resources/tester/test_m02_hopyo_flow.py b/resources/tester/test_m02_hopyo_flow.py index fc0643847..f05e2b9f2 100644 --- a/resources/tester/test_m02_hopyo_flow.py +++ b/resources/tester/test_m02_hopyo_flow.py @@ -92,7 +92,9 @@ def test_끝까지_확인한_줄만_별칭(client): assert {p["로직"] for p in two} == {"GC000206", "GC000207"} # 다른 점 → 사용자가 체크 body = body + [{"차례": rebar["차례"], "대상": two}] made = _build(client, "HP-000001", no, body) - assert made["다시씀"] is False and made["장부"]["씀"] == len(body) + national = [x for x in body if any(t.get("전국") for t in x.get("대상", []))] # 361-1 + assert national and all(x["대상"] == [{"전국": True, "배": 1}] for x in national) + assert made["다시씀"] is False and made["장부"]["씀"] == len(body) - len(national) assert all(m["까닭"] for m in made["빠진줄"]) # 확인 안 함 · % 줄 doc = _doc(made["참조"]) logics = [x for x in doc["담은로직"] if x.get("로직")] diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index 55eb7c88c..9a633a0c2 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -115,11 +115,25 @@ def test_새_상태_체크_제안(client): assert remicon["품"]["같음"]["콘크리트공"] and remicon["품"]["엑셀"]["인력"]["콘크리트공"] plywood = first["합판거푸집"] assert plywood["상태"] == "맞음" and set(plywood["갈리는칸"]) == {"측압", "폼타이규격"} - mat = first["레미콘(25-21-120)"] # 358-2 — 「별도계상」 글 = 표시 · 지역 변형 = 첫째 + 꼬리표 + mat = first["레미콘(25-21-120)"] # 358-2 · 361-1 — 「별도계상」 표시 · 지역 변형만 = 전국 assert mat["상태"] == "맞음" and mat["체크"] is True and "별도계상" in mat["꼬리표"] - assert mat["대상"][0]["규격"].endswith("25-21-120") and "레미콘" in mat["대상"][0]["이름"] - assert any(t.startswith("지역 · 관급/사급 못 가림") for t in mat["꼬리표"]) - assert all(c["규격"].endswith("25-21-120") for c in mat["후보"]) # 변형 = 고를 수 있게 + assert mat["대상"][0] | {"환산": None} == { + "전국": True, + "이름": "레미콘(전국)-관급", + "규격": "25-21-120", + "단위": "㎥", + "입력값": {}, + "못읽음": [], + "출처": {}, + "환산": None, + "몫": None, + "배": 1, + } # 특정 지역 키 없음 + assert "전국 — 지역은 프로젝트에서" in mat["꼬리표"] + assert not any("못 가림" in t for t in mat["꼬리표"]) + assert mat["후보"][0]["전국"] and len(mat["후보"]) == 129 # 전국 + 지역 변형 128(접어 고름) + assert all(c["변형"] and c["자재"] for c in mat["후보"][1:]) + assert all(c["규격"].endswith("25-21-120") for c in mat["후보"]) dig = _hopyo(client, 3)["구조물터파기(인력100%)"] assert dig["상태"] == "다른 점" and dig["체크"] is False assert dig["대상"][0]["로직"] == "GC000083" and len(dig["후보"]) == 5 @@ -330,6 +344,61 @@ def test_북부청_자재_별도계상_맞음_늚(client): assert mortar["후보"][0]["자재"] == "MT004251" +# ── 지역만 다른 자재 = 전국(361-1) ───────────────────────────────────── +REMICON = {"이름": "레미콘(25-21-120)", "규격": "별도계상", "단위": "㎥", "갈래": "별도계상"} +H_BEAM = {"이름": "H 형강", "규격": "SS275, 100*100*6*8mm", "단위": "Ton", "갈래": "자재"} + + +def test_전국_지역_줄글_전국품목(): + from M02_MasterTemplete import M02_Hopyo_Material as hmat + + rows, tags = hmat.sure(hmat.clean(REMICON)[0]) # 지역 낱말 없음 · (전국) 품목 없음 + assert rows[0]["one"] == { + "전국": True, + "이름": "레미콘(전국)-관급", + "규격": "25-21-120", + "단위": "㎥", + } + assert tags == [hmat.NATIONAL_TAG] and all("자재" in r["one"] for r in rows[1:]) + paid = {**REMICON, "규격": "", "갈래": "자재", "단가": {"재료비": 90000}} + assert hmat.national(paid)["이름"] == "레미콘(전국)-사급" # 재료비 줄 = 사급 꼬리 + named = {**REMICON, "비고": "강원도 강릉시"} # 줄 글에 지역 = 그 지역(원문) + rows, tags = hmat.sure(hmat.clean(named)[0]) + assert rows[0]["one"]["이름"] == "레미콘(관급)-강원도(강릉시)" and tags == [hmat.REGION_TAG] + assert hmat.national(named) is None + rows, tags = hmat.sure(hmat.clean(H_BEAM)[0]) # (전국) 품목 있음 = 그것 + assert rows[0]["one"]["자재"] == "MT001694" and tags == [] + assert hmat.national(H_BEAM) is None + seoul = hmat.sure(hmat.clean({**H_BEAM, "비고": "서울"})[0])[0][0]["one"] + assert seoul["이름"] == "H 형강(서울)" + + +def test_전국_고르기_지역_되돌림(client): + _ready(client) + no, at = ( + 1, + next( + x["차례"] + for x in client.get(f"{URL.format('personal')}/HP-000001/1").json()["호표"]["줄"] + if x["이름"] == "레미콘(25-21-120)" + ), + ) + path = f"{URL.format('personal')}/HP-000001/{no}/guess" + seoul = client.post(path, json={"차례": at, "대상": [{"자재": "MT004258", "배": 1}]}) + assert seoul.status_code == 200 and seoul.json()["대상"][0]["자재"] == "MT004258" + back = client.post(path, json={"차례": at, "대상": [{"전국": True, "배": 1}]}) + assert back.status_code == 200, back.text + got = back.json()["대상"][0] + assert got["전국"] and "자재" not in got and got["이름"] == "레미콘(전국)-관급" + other = next( # 전국 자리표 없는 줄(로직 줄) = 400 + x["차례"] + for x in client.get(f"{URL.format('personal')}/HP-000001/1").json()["호표"]["줄"] + if x["이름"] == "레미콘타설(장비사용)" + ) + bad = client.post(path, json={"차례": other, "대상": [{"전국": True, "배": 1}]}) + assert bad.status_code == 400 + + # ── 단계 대조 보임(358-3) — 목록 수 = 확인 창 수 · 연결 두 칸 · 목록 열 때 뒤 셈 ───────────── def test_목록_수_확인_창_수_한_규칙(client): _ready(client) diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index e03f1fbab..a008f6684 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -129,7 +129,11 @@ def test_확인_창_체크한_줄만_몸(): assert got["sure"] == [1, 2] # [맞음 · 별칭 일괄 체크] assert got["compare"] == "콘크리트공 0.1091 → 0.1091 ✓ · 굴착기 0.29 → 0.31 ≠" assert got["compareMany"] == "굴착기 0.1594 → 0.0889 ≠" # 같은 장비 여러 줄 = 합쳐 한 줄 - assert got["toTarget"] == [{"호표": 46, "배": 2.3}, {"자재": "MT1", "배": 1}] + assert got["toTarget"] == [ + {"호표": 46, "배": 2.3}, + {"자재": "MT1", "배": 1}, + {"전국": True, "배": 1}, # 361-1 — 키 없음 · 이름은 서버가 + ] assert got["newest"] == [False, True, True] # 같은 줄 늦은 응답 버림 · 다른 줄은 따로 assert got["dropped"] is False # 만들기 성공 = 캐시 지움 @@ -145,6 +149,16 @@ def test_고르기_창_도우미(): ] assert got["groups"]["text"][:2] == ["되메우기 · A · 산림", "되메우기 · C · 건설"] assert got["groups"]["material"] == ["1위", "그 밖 후보"] + nat = got["groups"]["national"] # 361-1 + assert nat["groups"] == [["1위", False, ["전국"]], ["지역 변형", True, ["M1", "M2"]]] + assert nat["text"] == "레미콘(전국)-관급 25-21-120 [㎥] · 키 없음" + assert nat["region"] == [{"자재": "M2", "입력값": {}, "배": 1, "몫": None}] + assert nat["back"] == [{"전국": True, "입력값": {}, "배": 1, "몫": None}] + assert nat["hit"] == [ # 줄 글 지역 = 1위 · 전국 = 되돌릴 자리 · 남은 변형 접힘 + ["1위", ["M2"]], + ["전국", ["전국"]], + ["지역 변형", ["M1"]], + ] assert got["groups"]["pick"] == [True, False, True] # 재료비 있는 단가산출 줄도 자재 검색 assert got["picked"] == [ {"로직": "GC000206", "입력값": {}, "배": 1, "몫": None},