From ba2785cc495bb00c72acda227fec94cc111d31ee Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 21:23:57 +0900 Subject: [PATCH] =?UTF-8?q?fix(M02=20=EC=8B=A4=EB=AC=B4=EC=9E=90=EB=A3=8C)?= =?UTF-8?q?:=20358-3=20=EB=8B=A8=EA=B3=84=20=EB=8C=80=EC=A1=B0=20=EB=B3=B4?= =?UTF-8?q?=EC=9E=84=20=E2=80=94=20=EC=9E=A5=EB=B6=80=20=EC=A7=9D=EB=B3=B4?= =?UTF-8?q?=EB=8B=A4=20=EC=88=AB=EC=9E=90=20=ED=8C=90=EC=A0=95=20=EB=A7=9E?= =?UTF-8?q?=EC=9D=8C=20=EB=A8=BC=EC=A0=80=20=C2=B7=20=E3=80=8C=EC=9E=A5?= =?UTF-8?q?=EB=B6=80=EC=99=80=20=EB=8B=A4=EB=A6=84=E3=80=8D=20=C2=B7=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=88=98=20=3D=20=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=EC=B0=BD=20=EC=88=98=20=C2=B7=20=EC=97=B0=EA=B2=B0=ED=95=A0=20?= =?UTF-8?q?=C2=B7=20=EC=97=B0=EA=B2=B0=EB=90=9C=20=EB=91=90=20=EC=B9=B8=20?= =?UTF-8?q?=C2=B7=20=EB=AA=A9=EB=A1=9D=20=EC=97=B4=20=EB=95=8C=20=EB=92=A4?= =?UTF-8?q?=20=EC=85=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 장부 짝(정확 열쇠)이 숫자 판정 맞음 1위를 안 담으면 판정 먼저 + 꼬리표 「장부와 다름」 · 짝은 후보 끝 덤(장부 줄 안 지움) - 체크 제안 한 규칙(_check) — 목록(_brief) · 확인 창(_numeric) 같음 · 응답 연결할(% · 노임 · 뺌 밖) · 연결된 - 목록 길(GET hopyo)이 지금 판 맞춤셈 없는 파일을 뒤 셈 예약 - 실물 북부청(사용자 장부 포함 · 읽기만) 체크 제안 113 → 204 / 415 · 장부와 다름 24줄 · 자재 체크 94 / 142 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- M02_MasterTemplete/M02_Hopyo_Line.py | 76 ++++++++++++++----- M02_MasterTemplete/M02_Hopyo_Match.py | 5 +- M02_MasterTemplete/M02_Labor_Router.py | 6 +- .../M02_MasterTemplete_Hopyo_Api.ts | 6 ++ resources/tester/test_m02_hopyo_match.py | 35 ++++++++- 5 files changed, 102 insertions(+), 26 deletions(-) diff --git a/M02_MasterTemplete/M02_Hopyo_Line.py b/M02_MasterTemplete/M02_Hopyo_Line.py index 9cee76c6b..cacdc3c2d 100644 --- a/M02_MasterTemplete/M02_Hopyo_Line.py +++ b/M02_MasterTemplete/M02_Hopyo_Line.py @@ -4,7 +4,8 @@ 상태 차례 = ① % ② 호표 참조(만들 층에 확인된 사전조합이면 체크) ③ 노임(그릇 없음) ④ 장부 「우리에 없음 · -안 씀」 → 뺌 ⑤ 장부 짝 → 별칭(근거 다름 · 숫자 판정 맞음이 다른 로직이면 체크 안 함) ⑥ +안 씀」 → 뺌 ⑤ 장부 짝 → 별칭(근거 다름이면 체크 안 함 · 숫자 판정 맞음이 다른 로직이면 +⑥ 판정 먼저 + 꼬리표 「장부와 다름」 · 짝은 후보 끝 덤 · 358-3) ⑥ 맞춤셈 판정(맞음 = 체크 · 다른 점 · 후보) ⑦ 셈 전 · 숫자 없음 → 이름 · 근거 후보(「셈 중」 · 「숫자 판정 없음」). @@ -38,6 +39,9 @@ from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as store SKIP = ("우리에 없음", "안 씀") +LOOSE = "별칭 · 규격 다름" +LEDGER_OFF = "장부와 다름" +OUTSIDE = ("%", "노임", "뺌") # 연결 셈 밖(358-3 ②) class Ctx: @@ -118,6 +122,26 @@ def _qty(entry: dict, found: dict | None) -> dict: return {"엑셀": ex, "우리": us, "같음": same} +def _picked(entry: dict) -> list[dict]: + """체크할 조합 — 로직이 하나뿐이면 조합 아님(기계 몫은 꼬리표).""" + combo = [c for c in entry.get("조합") or [] if c["체크"]] + return combo if len(combo) > 1 else [] + + +def _check(entry: dict, line: dict, ctx: Ctx) -> bool: + """체크 제안 한 규칙(358-3 · 목록 수 = 확인 창) — 판정 맞음 · 대상(조합 또는 1위) 있음 · + 대상마다 환산 배 있음(추정 아님).""" + codes = [c["로직"] for c in _picked(entry)] or (entry.get("차례") or [])[:1] + if entry["판정"]["상태"] != "맞음" or not codes: + return False + for code in codes: + unit = (ctx.names.get(code) or {}).get("단위", "") + conv = hm.factor(line.get("단위"), unit, line.get("규격")) + if conv["배"] is None or conv["갈래"] == "추정": + return False + return True + + def _numeric(line: dict, ctx: Ctx, entry: dict) -> dict: """⑥ 맞춤셈 판정 → 상태 · 대상 · 후보 · 품 · 글 · 꼬리표.""" judged = entry["판정"] @@ -125,8 +149,7 @@ def _numeric(line: dict, ctx: Ctx, entry: dict) -> dict: top = (entry.get("차례") or [None])[0] found = (entry.get("옵션") or {}).get(top) use = state in ("맞음", "다른 점") - combo = [c for c in entry.get("조합") or [] if c["체크"]] - combo = combo if len(combo) > 1 else [] # 체크할 로직이 하나뿐이면 조합 아님(기계 몫은 꼬리표) + combo = _picked(entry) if combo: targets = [ _target( @@ -147,8 +170,7 @@ def _numeric(line: dict, ctx: Ctx, entry: dict) -> dict: if any("블록" in t["출처"].values() for t in targets): tags.append("옵션 — 블록에서") tags += [f"근거 {b} {n}" for b, n in (entry.get("지문") or {}).get("출처") or []] - ok = all(t["배"] is not None and t["환산"]["갈래"] != "추정" for t in targets) - out = {"상태": state, "체크": state == "맞음" and bool(targets) and ok, "대상": targets} + out = {"상태": state, "체크": _check(entry, line, ctx), "대상": targets} out.update(후보=entry.get("후보") or [], 글=judged["글"], 꼬리표=tags, 품=_qty(entry, found)) if found and found.get("갈리는칸"): out["갈리는칸"] = found["갈리는칸"] @@ -225,12 +247,15 @@ def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, A entry = ctx.cache.get(fit.unit_key(line)) if kind not in hm.MATERIAL_KINDS else None if alias and alias.get("갈래") in SKIP: return {**out, "상태": "뺌", "뺌": alias["갈래"], "별칭": _alias_info(alias, False)} + if alias and alias.get("갈래") == "짝" and alias.get("짝") and _ledger_off(entry, alias): + loose, alias = alias, None # 숫자 판정 맞음 = 장부 짝 아닌 로직 → 판정 먼저(358-3 ①) + loose = {**loose, "_까닭": LEDGER_OFF} if alias and alias.get("갈래") == "짝" and alias.get("짝"): got = _aliased(line, ctx, alias, entry) elif kind in hm.MATERIAL_KINDS: got = _material(line, ctx, full) elif entry and entry["판정"]["글"] not in (fit.NO_NUMBER, "우리 로직 준비 중"): - got = _numeric(line, ctx, entry) if full else _brief(entry) + got = _numeric(line, ctx, entry) if full else _brief(entry, line, ctx) else: why = "셈 중" if not ctx.calc["끝"] else fit.NO_NUMBER got = _names_only(line, ctx, why, full) @@ -257,10 +282,17 @@ def _grouped(got: dict, line: dict, ctx: Ctx) -> dict: return {**got, "후보": [mark(c) for c in got.get("후보") or []], "이름후보": named} +def _ledger_off(entry: dict | None, alias: dict) -> bool: + """장부 짝(정확 열쇠)이 숫자 판정 맞음 1위를 안 담음.""" + codes = {p.get("로직") for p in alias.get("짝") or []} + return bool(entry and entry["판정"]["상태"] == "맞음" and entry["차례"][0] not in codes) + + def _loose(got: dict, alias: dict, line: dict, ctx: Ctx) -> dict: - """규격 다른 별칭 — 꼬리표 「별칭 · 규격 다름」 + 짝 로직 · 자재가 후보에 없으면 끝에 덤(상태 · - 대상 · 옵션은 이 줄 숫자 그대로).""" - tags = [*(got.get("꼬리표") or []), "별칭 · 규격 다름"] + """규격 다른 별칭 · 숫자 판정과 다른 장부 짝 — 꼬리표(「별칭 · 규격 다름」 · 「장부와 다름」) + + 짝 로직 · 자재가 후보에 없으면 끝에 덤(상태 · 대상 · 옵션은 이 줄 숫자 그대로).""" + word = alias.get("_까닭") or LOOSE + tags = [*(got.get("꼬리표") or []), word] have = {c.get("로직") or c.get("자재") for c in got.get("후보") or []} have |= {t.get("로직") or t.get("자재") for t in got.get("대상") or []} extra = [] @@ -272,13 +304,17 @@ def _loose(got: dict, alias: dict, line: dict, ctx: Ctx) -> dict: for k, v in _pair(p, line, ctx).items() if k in ("로직", "자재", "이름", "단위") } - extra.append({**one, "점수": None, "판정": "후보", "글": "별칭 · 규격 다름"}) + extra.append({**one, "점수": None, "판정": "후보", "글": word}) return {**got, "꼬리표": tags, "후보": [*(got.get("후보") or []), *extra]} -def _brief(entry: dict) -> dict: - state = entry["판정"]["상태"] - return {"상태": state, "체크": state == "맞음", "글": entry["판정"]["글"]} +def _brief(entry: dict, line: dict, ctx: Ctx) -> dict: + """목록 수(`full` 아님) — 체크는 확인 창과 같은 `_check`.""" + return { + "상태": entry["판정"]["상태"], + "체크": _check(entry, line, ctx), + "글": entry["판정"]["글"], + } def _alias_info(alias: dict, basis_off: bool) -> dict: @@ -287,7 +323,7 @@ def _alias_info(alias: dict, basis_off: bool) -> dict: def _aliased(line: dict, ctx: Ctx, alias: dict, entry: dict | None) -> dict: - """⑤ 장부 짝(정확 열쇠) — 체크 = 근거 같음 · 숫자 판정 맞음이 다른 로직 아님.""" + """⑤ 장부 짝(정확 열쇠 · 숫자 판정 맞음 1위를 담음 또는 판정 맞음 아님) — 체크 = 근거 같음.""" targets = [_pair(p, line, ctx) for p in alias["짝"]] codes = {t["로직"] for t in targets if t.get("로직")} theirs = ((line.get("근거") or {}).get("출처")) or [] @@ -295,12 +331,9 @@ def _aliased(line: dict, ctx: Ctx, alias: dict, entry: dict | None) -> dict: off = bool(theirs and codes) and all( hm.clause(theirs, table[c])[0] < 0.9 for c in codes if c in table ) - other = bool(entry and entry["판정"]["상태"] == "맞음" and entry["차례"][0] not in codes) - check = not off and not other tags = ["별칭 — " + " · ".join(str(x) for x in (alias.get("층"), alias.get("때")) if x)] tags += ["근거 다름"] * off - tags += [f"숫자 판정 맞음 = {entry['차례'][0]}"] if other else [] - out = {"상태": "별칭", "체크": check, "대상": targets, "글": "별칭 장부 짝", "꼬리표": tags} + out = {"상태": "별칭", "체크": not off, "대상": targets, "글": "별칭 장부 짝", "꼬리표": tags} out["별칭"] = _alias_info(alias, off) if entry and entry.get("품"): out["품"] = _qty(entry, (entry.get("옵션") or {}).get(next(iter(codes), None))) @@ -327,10 +360,13 @@ def whole(hopyo: dict, ctx: Ctx) -> dict | None: def hopyo_view(hopyo: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, Any]: - """호표 하나 — 줄마다 맞춤 · `맞춤` = [체크 제안 수, 줄 수] · 통째 · 셈 · 만든.""" + """호표 하나 — 줄마다 맞춤 · `맞춤` = [체크 제안 수, 줄 수] · 연결할(% · 노임 · 뺌 밖 줄 수) · + 연결된(그중 체크 제안 수 · 358-3) · 통째 · 셈 · 만든.""" lines = [{**one, "맞춤": match_line(one, ctx, full)} for one in hopyo.get("줄") or []] check = sum(1 for one in lines if one["맞춤"]["체크"]) - out = {**hopyo, "줄": lines, "맞춤": [check, len(lines)]} + need = [one for one in lines if one["맞춤"]["상태"] not in OUTSIDE] + out = {**hopyo, "줄": lines, "맞춤": [check, len(lines)], "연결할": len(need)} + out["연결된"] = sum(1 for one in need if one["맞춤"]["체크"]) if full: made = ctx.done.get(hopyo_build.mark(ctx.key, int(hopyo["번"]))) out.update(통째=whole(hopyo, ctx), 셈=ctx.calc) diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py index 5f77d3b4e..88c7fa902 100644 --- a/M02_MasterTemplete/M02_Hopyo_Match.py +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -455,11 +455,12 @@ def view(folder: Path, key: str, no: int | None = None, to: Path | None = None) doc = ctx.doc if no is None: hopyos = [lines.hopyo_view(h, ctx, False) for h in doc.get("호표") or []] - keep = ("번", "이름", "규격", "단위", "금액", "맞춤", "행") + keep = ("번", "이름", "규격", "단위", "금액", "맞춤", "행", "연결할", "연결된") brief = [{k: h.get(k) for k in keep if k in h} for h in hopyos] ready = [sum(h["맞춤"][0] for h in hopyos), sum(h["맞춤"][1] for h in hopyos)] head = {**hopyo_store.brief(doc), "검산": doc.get("검산"), "셈": ctx.calc} - return {**head, "호표": brief, "맞춤": ready} + links = {k: sum(h[k] for h in hopyos) for k in ("연결할", "연결된")} + return {**head, "호표": brief, "맞춤": ready, **links} hopyo = ctx.units.get(int(no)) if hopyo is None: raise store.StoreError(404, f"없는 호표 「제{no}호표」") diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 7480fb174..96ee36f62 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -215,7 +215,11 @@ def hopyo_list( ) -> dict: """실무자료 목록 `{줄: [{키, 파일, 올린때, 올린이, 시트, 호표수, 어긋남}]}`.""" folder = _root(session, layer, owner) - return {"줄": hopyo_store.rows(folder)} + rows = hopyo_store.rows(folder) + if (ensure := _later("M02_Hopyo_Fit", "ensure")) is not None: + for row in rows: # 지금 판 맞춤셈 없는 파일 = 목록 열 때 뒤 셈 예약(358-3 ③) + ensure(folder, row["키"]) + return {"줄": rows} @router.get("/layers/{layer}/hopyo-picks") diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index 2b1d01c28..8f223012f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -36,6 +36,9 @@ export interface HopyoBrief { 금액: Money; /** [체크 제안 수, 줄 수] */ 맞춤: [number, number]; + /** 연결할 줄 수(% · 노임 · 뺌 밖 · 358-3) · 연결된 줄 수(그중 체크 제안 — 확인 창과 한 규칙) */ + 연결할?: number; + 연결된?: number; /** 일위대가 시트 행 [시작, 끝](엑셀 1부터 · 329 · 옛 파일 없음) */ 행?: [number, number]; } @@ -58,6 +61,9 @@ export interface HopyoView extends HopyoFile { 검산: { 어긋남: string[]; 총괄표: number }; 호표: HopyoBrief[]; 맞춤: [number, number]; + /** 파일 전체 연결할 · 연결된 줄 수(358-3) */ + 연결할?: number; + 연결된?: number; /** 뒤 숫자 판정 셈(끝나면 맞춤 수가 바뀜) */ 셈?: HopyoCalc; } diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index 761e25592..55eb7c88c 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -159,9 +159,10 @@ def test_별칭_체크_규칙(client): assert got["상태"] == "별칭" and got["체크"] is True and got["대상"][0]["출처"] == {} assert got["별칭"]["층"] == "personal" and not got["별칭"]["근거다름"] first = _hopyo(client, 1) - other = first["레미콘타설(장비사용)"] # 숫자 판정 맞음 = GC000198 → 체크 안 함 - assert other["상태"] == "별칭" and other["체크"] is False - assert "숫자 판정 맞음 = GC000198" in other["꼬리표"] + other = first["레미콘타설(장비사용)"] # 숫자 판정 맞음 = GC000198 ≠ 장부 짝 → 판정 먼저(358-3) + assert other["상태"] == "맞음" and other["체크"] is True and "별칭" not in other + assert other["대상"][0]["로직"] == "GC000198" and "장부와 다름" in other["꼬리표"] + assert "GC000199" in {c["로직"] for c in other["후보"]} # 장부 짝 = 후보 끝 덤(안 지움) loose = first["합판거푸집"] # 규격 4회 ↔ 장부 6회 — 느슨 열쇠 = 꼬리표 · 후보 덤만(229-4 ①) assert loose["상태"] == "맞음" and "별칭" not in loose and "별칭 · 규격 다름" in loose["꼬리표"] assert all("별칭" not in t["출처"].values() for t in loose["대상"]) @@ -327,3 +328,31 @@ def test_북부청_자재_별도계상_맞음_늚(client): assert pipe["상태"] == "후보" and "139.8" in pipe["후보"][0]["규격"] mortar = seen[("드라이모르터", "조적용레미탈")] # 이름 오타 · 규격 같음 = 1위 후보 assert mortar["후보"][0]["자재"] == "MT004251" + + +# ── 단계 대조 보임(358-3) — 목록 수 = 확인 창 수 · 연결 두 칸 · 목록 열 때 뒤 셈 ───────────── +def test_목록_수_확인_창_수_한_규칙(client): + _ready(client) + whole = client.get(f"{URL.format('personal')}/HP-000001").json() + need = done = 0 + for brief in whole["호표"]: + one = client.get(f"{URL.format('personal')}/HP-000001/{brief['번']}").json()["호표"] + lines = one["줄"] + assert brief["맞춤"] == [sum(x["맞춤"]["체크"] for x in lines), len(lines)], brief["번"] + inside = [x for x in lines if x["맞춤"]["상태"] not in ("%", "노임", "뺌")] + assert (brief["연결할"], brief["연결된"]) == (one["연결할"], one["연결된"]) + assert one["연결할"] == len(inside) and one["연결된"] == sum( + x["맞춤"]["체크"] for x in inside + ) + need, done = need + one["연결할"], done + one["연결된"] + assert (whole["연결할"], whole["연결된"]) == (need, done) and 0 < done <= need < 415 + pct = next(h for h in whole["호표"] if h["번"] == 46) # 잡재료비 % 줄 = 셈 밖 + assert pct["연결할"] < pct["맞춤"][1] + + +def test_목록_열_때_뒤_셈_예약(client, monkeypatch): + assert _post(client).status_code == 200 + asked = [] + monkeypatch.setattr(fit, "ensure", lambda folder, key: asked.append(key) or {"끝": False}) + rows = client.get(URL.format("personal")).json()["줄"] + assert [r["키"] for r in rows] == asked == ["HP-000001"]