From e23fec8c9ca8cee6c81dc1dbe9bd827ecaa3bc80 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 07:17:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(786-1):=20=EB=B3=84=EB=8F=84=EA=B3=84?= =?UTF-8?q?=EC=83=81=20=EC=A4=84=20=ED=92=88=EB=AA=A9=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=97=86=EC=95=B0=20=E2=80=94=20=ED=9B=84=EB=B3=B4?= =?UTF-8?q?=20=EA=B3=A0=EC=A0=95=20=EC=A4=84=20=ED=95=98=EB=82=98=20=C2=B7?= =?UTF-8?q?=20SC=20=ED=95=A9=20=EB=B0=96=20=EA=B3=A0=EC=A0=95=20=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Row.judge: 별도계상 줄은 M01 · 사전 · 장부 후보 안 찾고 고정 줄 하나(품목 「프로젝트에서」 표지) · 갈래 수 그대로 - Make: 별도계상 고정 줄 = 단가 0 · 메모 「별도계상 · 품목은 프로젝트에서」(합 밖) - Picks.ts: apartPick 표지 셈 - 시험 642_3 · 642_5 · 642_6 Picks 몫을 새 꼴로 고침(옛 단언: 레미콘 자재 목록 별도계상 칸) Co-Authored-By: Claude Sonnet 5.5 --- M02_MasterTemplete/M02_Hopyo_Make.py | 11 +++++++++ M02_MasterTemplete/M02_Hopyo_Row.py | 16 ++++++++----- .../logic/M02_MasterTemplete_Hopyo_Picks.ts | 4 ++++ resources/tester/test_642_3_row.py | 18 ++++++++++++++- resources/tester/test_642_5_make.py | 20 ++++++++++++---- resources/tester/test_642_6_screen.py | 23 +++++++++++++++++++ 6 files changed, 80 insertions(+), 12 deletions(-) diff --git a/M02_MasterTemplete/M02_Hopyo_Make.py b/M02_MasterTemplete/M02_Hopyo_Make.py index 5f5067296..8becc8fef 100644 --- a/M02_MasterTemplete/M02_Hopyo_Make.py +++ b/M02_MasterTemplete/M02_Hopyo_Make.py @@ -7,6 +7,7 @@ SC(`M02_Hopyo_Build.Builder` 가 줄 차례 · 확인 안 한 줄 = 모두 고정 길 그대로) — 한 엑셀 줄 = 고른 것마다 우리 줄(같은 별칭 = 엑셀 품명 · 줄 `본뜬 {호표, 차례, 행}`) · 수량 {손 = 엑셀 수량 × 몫 × 배} · 기초 = 로직 줄(같은 로직 둘째 = 한 줄 SC 사본) · 재료 · 유가 = 자재 목록(별도계상) · 인력 · 기계 · 유가 이음 · + 별도계상 줄(`고정` + 별도계상 · 786-1) = 단가 0 고정 줄(합 밖 · 메모 「별도계상 · 품목은 프로젝트에서」) · 율 = 고정 줄 `비율 {기준, 비목}` · 고정 = 엑셀 단가 고정 줄 · 단가산출 · 일위대가 = 참조 (`단산N` = 같은 옮기기 블록 SK · `제N호표` = 같은 HP 호표 SC — 하위 먼저 · 확인 안 된 하위 = 모두 고정). SK(블록) = 바탕 없음 + 더한줄 — 기초 `{종류 로직, 입력값, 수량 = 몫}` · 참조 · 단위 줄(`항목`)에 이은 @@ -44,6 +45,7 @@ _SUB = {"단가산출": re.compile(r"^단산(\d+)$"), "일위대가": re.compile LINK = {"인력": "노임", "기계": "기계", "유가": "유가"} OLD_PICK = {"인력": "노임", "기계": "기계", "재료": "자재", "유가": "자재"} NEED = "입력 필요" +APART_MEMO = "별도계상 · 품목은 프로젝트에서" COVER = "이미 옮긴 것이 있음 — 덮으려면 [덮어 옮기기](덮음)" @@ -156,6 +158,13 @@ class Lines: ref = {"키": "SC-미리"} if b.dry else hkids.save(wrap, b.same)[0] # 미리 보기 = 쓰기 없음 self.lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) + def apart(self, line: dict, alias: str | None, mark: dict) -> None: + """별도계상 줄(786-1) = 합 밖 고정 줄 — 단가 0(셈에 안 듦) · 품명 · 규격 · 단위 · 수량 그대로 · + 품목은 프로젝트에 복사할 때 정함(메모 표지).""" + zero = {**line, "단가": {}, "금액": {}} + self.put(zero, {"이음": dict.fromkeys(hfix.fixed.LINKS)}, alias, None, mark) + self.lines[-1]["메모"] = f"{APART_MEMO} · {self.lines[-1].get('메모') or ''}".strip(" ·") + def picks(self, at: int, line: dict, picks: list[dict]) -> None: """고른 것(검사 · 다시 셈 끝난 것) → 그 엑셀 줄의 우리 줄 · 자재.""" alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None @@ -175,6 +184,8 @@ class Lines: elif kind == "율": self.put(line, {"이음": {"비율": p["비목"]}}, alias, None, mark) self.lines[-1]["고정"]["비율"] = {"기준": p["기준"], "비목": p["비목"]} + elif kind == "고정" and p.get("별도계상"): + self.apart(line, alias, mark) elif kind == "고정": self.put(line, {"이음": dict.fromkeys(hfix.fixed.LINKS)}, alias, None, mark) else: diff --git a/M02_MasterTemplete/M02_Hopyo_Row.py b/M02_MasterTemplete/M02_Hopyo_Row.py index 2d534ef9e..c139154b7 100644 --- a/M02_MasterTemplete/M02_Hopyo_Row.py +++ b/M02_MasterTemplete/M02_Hopyo_Row.py @@ -13,7 +13,8 @@ 그 밖 = 고정. 후보 찾기 차례 = 장부(`Ledger.recall` · 고른 것 꼴) › M01 표마다(LB · MT · MO · EQ) › M02 층 SK · SC 같은 이름(위 층까지 · 참고). -판정 = 제안 있음(장부 짝 · 후보 하나 · 별도계상 · 하위 호표 · 율) · 2차 필요(단가산출 줄 = +판정 = 제안 있음(장부 짝 · 후보 하나 · 별도계상 = 고정 줄 하나(`품목` 표지) · 하위 호표 · 율) · + 2차 필요(단가산출 줄 = `Bind` · `Solve` 몫) · 후보 여럿 · 후보 없음(고정 금액 제안). 고른 것 = `{갈래 기초|단가산출|일위대가|인력|재료|유가|기계|율|고정, 키, 이름, 규격, 단위, 단가, 입력값, 출처, 몫, 비목?, 기준?, 별도계상?, 층?, 주인?}`(설계 2-1 · 만들기 = `Make` 몫). @@ -310,9 +311,13 @@ def _rate_pick(line: dict[str, Any]) -> dict[str, Any]: return one +# 별도계상 줄 표지 `품목` — 품목은 프로젝트에 복사할 때 정함(요구 645-2) +ITEM_LATER = "프로젝트에서" + + def _fixed(line: dict[str, Any], apart: bool = False) -> dict[str, Any]: price = {k: _plain((line.get("단가") or {}).get(k)) or 0 for k in COSTS} - more = {"별도계상": True} if apart else {} + more = {"별도계상": True, "품목": ITEM_LATER} if apart else {} return pick( "고정", None, line.get("이름"), line.get("규격"), line.get("단위"), 단가=price, **more ) @@ -354,12 +359,13 @@ def judge( out["번"] = no qty = {"엑셀": line.get("수량"), "배": 1, "갈래": "같음"} picks: list[dict[str, Any]] = [] + if why == "별도계상": # 모아서 한 번에 셈 — 품목 후보 · 장부 짝 안 찾음(고정 줄 하나 · 786-1) + return {**out, "후보": [_fixed(line, True)], "판정": "제안 있음", "수량": qty} found = None if folder is not None: found = ledger.recall(line, folder, book, doc=doc) if found and found.get("갈래") == "짝": picks = _from_ledger(found) - apart = why == "별도계상" for k in kinds if not picks else (): if k == "일위대가" and no is not None: picks.append(pick("일위대가", f"제{no}호표", line.get("이름"), line.get("규격"), @@ -374,8 +380,6 @@ def judge( picks += m01_picks(k, line) if not found: picks += [p for p in (docs or {}).get(mm.plain(line.get("이름")), []) if p["갈래"] in kinds] - if apart: - picks = [{**p, "별도계상": True} for p in picks] or [_fixed(line, True)] picks = list({(p["갈래"], p.get("키"), p.get("층")): p for p in picks}.values()) if found and found.get("갈래") in ("짝", "우리에 없음", "안 씀"): out["장부"] = {k: found.get(k) for k in ("갈래", "층", "제안", "규격다름")} @@ -385,7 +389,7 @@ def judge( found and found.get("갈래") in ("짝", "안 씀", "우리에 없음") and not found.get("규격다름") ): verdict = "제안 있음" - elif apart or kinds[0] in ("일위대가", "율") or len(picks) == 1: + elif kinds[0] in ("일위대가", "율") or len(picks) == 1: verdict = "제안 있음" elif picks: verdict = "후보 여럿" diff --git a/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts index 0e7aa0988..cc53a6caa 100644 --- a/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts +++ b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts @@ -6,6 +6,7 @@ * · 제안 = 미리 체크로만(캐시에 없으면 서버 제안) · 확인 = 사용자 [확인] · [호표 제안 모두 확인] 누름만(자동 없음) * · 기초 고른 것 `필요` = 엔진이 쓰는데 못 읽은 칸(입력 필요) + 엇갈림 칸 — 값이 비면 그 줄 확인 · 옮김 막음 * · 옮기기 몸 = 확인한 줄 · 블록만 · `필요` 걷음 · 줄 `배`(단위 환산 사용자 값) 얹음 · 금액 · 품 칸 안 보냄(서버가 다시 셈) + * · 별도계상 줄 = 서버 제안이 고정 줄 하나(`apartPick`) — 고름 없이 [확인] · [옮기기] 됨(막는 칸 없음) * · 칠 = 확인함 success · 품 다름 warning · 고를 것 info · 후보 없음 danger * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). * ========================================================================== */ @@ -95,6 +96,9 @@ export const linePicks = (d: JudgeDraft, no: number, row: JudgeRow): DraftPick[] export const blockPicks = (d: JudgeDraft, s: Solved): DraftPick[] => d.블록[String(s.번)]?.고른 ?? solvedPicks(s); +/** 별도계상 줄(786-1) — 고정 줄 하나 · 품목은 프로젝트에 복사할 때 정함 · 고름 없이 확인 · 옮김 */ +export const apartPick = (p: Pick): boolean => p.갈래 === "고정" && p.별도계상 === true; + export const samePick = (a: Pick, b: Pick): boolean => a.갈래 === b.갈래 && a.키 === b.키 && a.이름 === b.이름 && (a.규격 ?? "") === (b.규격 ?? ""); diff --git a/resources/tester/test_642_3_row.py b/resources/tester/test_642_3_row.py index 12578d2f3..b40e1997e 100644 --- a/resources/tester/test_642_3_row.py +++ b/resources/tester/test_642_3_row.py @@ -87,7 +87,23 @@ def test_북부청_줄_판_판정과_후보(bukbu): assert all(set(x["갈래"]) <= set(row.KINDS) and x["후보"] is not None for x in lines) first = {x["행"]: x for x in out[1]} # 제1호표(설계 2-10 기대표) assert first[6]["갈래"] == ["재료"] and first[6]["근거"] == "별도계상" - assert first[6]["판정"] == "제안 있음" and all(p["별도계상"] for p in first[6]["후보"]) + assert first[6]["판정"] == "제안 있음" and len(first[6]["후보"]) == 1 + apart = [x for x in lines if x["근거"] == "별도계상"] # 786-1 별도계상 64 줄 = 고정 줄 하나 + assert len(apart) == 64 and all( + x["갈래"] == ["재료"] and x["판정"] == "제안 있음" for x in apart + ) + for x in apart: + (p,) = x["후보"] + assert (p["갈래"], p["키"], p["별도계상"], p["품목"]) == ( + "고정", + None, + True, + "프로젝트에서", + ) + assert p["단위"] and p["단가"] == {"노무비": 0, "재료비": 0, "경비": 0} and "장부" not in x + assert all( + p["갈래"] != "고정" or "품목" not in p for x in lines for p in x["후보"] if x not in apart + ) assert first[7]["번"] == 46 and first[7]["판정"] == "2차 필요" assert first[10]["갈래"] == ["재료"] and first[10]["근거"].startswith("사전 자재단가") rate = next(x for x in lines if x["갈래"] == ["율"]) diff --git a/resources/tester/test_642_5_make.py b/resources/tester/test_642_5_make.py index aa449eb04..e9d96cf21 100644 --- a/resources/tester/test_642_5_make.py +++ b/resources/tester/test_642_5_make.py @@ -85,8 +85,11 @@ def test_판정_검산_옮기기(client): a, b = _base(rebar, 0), _base(rebar, 1) b["입력값"] = {**b["입력값"], "구분": "토목"} # 엇갈림 = 사용자 고름 a["몫"] = 0.5 - mat = first["줄"][0] # 레미콘 별도계상 - cement = {**mat["후보"][0], "별도계상": True} + mat = first["줄"][0] # 레미콘 별도계상 — 786-1 후보 = 고정 줄 하나(품목은 프로젝트에서) + assert mat["판정"] == "제안 있음" and len(mat["후보"]) == 1 + cement = mat["후보"][0] + assert (cement["갈래"], cement["키"], cement["별도계상"]) == ("고정", None, True) + assert cement["품목"] == "프로젝트에서" stuck = {"갈래": "기초", "키": "GC000211", "입력값": {"유형": "보통"}} ex3 = _base(form) # 예시 3 GC000211 보통 · 4회 · 높이감률 · 구멍땜 = 입력 필요 url = f"{API}/hopyo/{KEY}/check" @@ -155,9 +158,16 @@ def test_판정_검산_옮기기(client): if (x.get("고정") or {}).get("출처", {}).get("줄") == form["차례"] ) assert "본뜬" not in held # 막힌 줄 = 고정 줄(엑셀 숫자) - cement_row = next(m for m in sc1["자재"] if m["코드"] == cement["키"]) - assert labor.mark_of(cement_row, "별도계상") == "별도계상" - assert cement_row["수량"]["손"] == mat["수량"]["엑셀"] + held_apart = next( + x for x in sc1["담은로직"] if (x.get("본뜬") or {}).get("차례") == mat["차례"] + ) + fx = held_apart["고정"] # 별도계상 줄 = 합 밖 고정 줄(단가 0 · 자재 목록에 안 넣음) + assert fx["이름"] == mat["이름"] and fx["규격"] == "별도계상" and not any(fx["단가"].values()) + assert held_apart["수량"]["손"] == mat["수량"]["엑셀"] + assert held_apart["메모"].startswith("별도계상 · 품목은 프로젝트에서") + without = {**sc1, "담은로직": [x for x in sc1["담은로직"] if x is not held_apart]} + got_sum, base_sum = labor.server(sc1)["계산값"], labor.server(without)["계산값"] + assert got_sum["반영합"] == base_sum["반영합"] # 서버 정본 셈 — 별도계상 줄은 합에 안 듦 sk = _doc("sangeun", out["단가산출"][0]["키"]) assert sk["바탕"] is None and [x["로직"] for x in sk["더한줄"]] == ["GC001140"] diff --git a/resources/tester/test_642_6_screen.py b/resources/tester/test_642_6_screen.py index f6f5fe10a..fbd4cc05b 100644 --- a/resources/tester/test_642_6_screen.py +++ b/resources/tester/test_642_6_screen.py @@ -125,6 +125,29 @@ def test_호표_제안_모두_확인은_다풀린_제안만(judged): assert got[2]["수"] == 1 and list(got[2]["body"]["호표"]["1"]) == ["0"] +def test_별도계상_줄은_고름없이_확인_옮김(judged): + """786-1 — 별도계상 줄 = 고정 줄 하나 · 막는 칸 없음 · [확인] 하면 옮기기 몸에 들어감.""" + row = judged[1][0] # 레미콘 별도계상 + got = _run( + [ + {"fn": "rowDefaults", "args": [row]}, + {"fn": "suggestReady", "args": [row]}, + {"fn": "confirmable", "args": [{"$": 0}]}, + {"fn": "blockedOf", "args": [{"$": 0}]}, + {"fn": "apartPick", "args": [row["후보"][0]]}, + {"fn": "emptyDraft", "args": []}, + {"fn": "putLine", "args": [{"$": 5}, 1, 0, {"고른": {"$": 0}, "확인": True}]}, + {"fn": "moveBody", "args": [{"$": 6}, FILE]}, + ] + ) + (p,) = got[0] + assert p["갈래"] == "고정" and p["별도계상"] is True and p["품목"] == "프로젝트에서" + assert got[1] is True and got[2] is True and got[3] == [] and got[4] is True + assert got[7]["막힘"] == [] and got[7]["수"] == 1 + (moved,) = got[7]["body"]["호표"]["1"]["0"] + assert moved["갈래"] == "고정" and moved["별도계상"] is True and "필요" not in moved + + def test_예시1_단산10_입력필요0(judged): row = _row(judged, 2, 10) got = _run(