From 98f3ab59cbd2487c46027adea039a92a399863ff Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 14 Nov 2026 20:42:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(830-10):=20M01=20=EC=9E=AC=EB=A3=8C=20?= =?UTF-8?q?=EC=B0=BE=EA=B8=B0=EA=B0=80=20=EC=A7=80=EC=97=AD=20=EC=B9=B8?= =?UTF-8?q?=EC=9D=84=20=EB=B4=84=20=E2=80=94=20=EB=A1=9C=EC=A7=81=201,520?= =?UTF-8?q?=20=EC=8B=9C=ED=97=98=20=EA=B3=84=EC=82=B0=20=EC=A0=84=ED=9B=84?= =?UTF-8?q?=20=EB=8B=A4=EB=A6=84=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - master_material.label = 이름 + 지역 칸 · 검색어 지역 낱말 · 자재지역 입력 · 고르는 차례(master_pick)가 이름 글 대신 지역 칸과 맞대짐 - 로직 1,520: 같음 1,517 · 전부터 멈춤 3(GC001199 ~ 1201) — 지역 칸만 고쳤을 때 다르던 11(시멘트 전국 · 포항 모래 · 포항 레미콘)이 원래 줄로 - 계산 줄 품목 칸에 지역 · 상하차 · M01 고르기 창(가격 연결 · 로직 재료 후보) 글에 지역 · 상하차(모름 「-」 뺌) - M01 표 찾기(Store_Find) 낱말이 지역 칸에도 맞음 — 「레미콘 서울」 · 저장 뒤 찾기 기억 지움도 지역 칸 포함 - 새 줄 지역 기본 「-」 · test_m01_pick 합판 서울 기대를 지역 칸으로 - check_master 틀 · 본문 · 로직 · 조합 · 목차 0 · 단위 27 · 배 7(전과 같음) Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_Api_Fetch.ts | 6 ++++++ M01_MasterData/M01_MasterData_Store.py | 9 ++++++--- M01_MasterData/M01_MasterData_Store_Find.py | 4 ++-- M01_MasterData/M01_MasterData_Store_Save.py | 4 +--- M01_MasterData/M01_MasterData_UI_Logic_Api.ts | 3 +++ M01_MasterData/M01_MasterData_UI_Logic_Pick.ts | 11 +++++++++-- M01_MasterData/M01_MasterData_UI_Pick.ts | 5 +++-- M01_MasterData/M01_MasterData_UI_Rows.ts | 2 +- resources/master_data/scripts/master_material.py | 16 +++++++++++----- resources/master_data/scripts/master_pick.py | 5 +++-- resources/tester/test_m01_pick.py | 6 ++++-- 11 files changed, 49 insertions(+), 22 deletions(-) diff --git a/M01_MasterData/M01_MasterData_Api_Fetch.ts b/M01_MasterData/M01_MasterData_Api_Fetch.ts index c4a48851b..9f5badd3e 100644 --- a/M01_MasterData/M01_MasterData_Api_Fetch.ts +++ b/M01_MasterData/M01_MasterData_Api_Fetch.ts @@ -190,10 +190,16 @@ export interface PickItem { 이름: string; 규격: string; 단위: string; + 지역?: string | null; + 상하차?: string | null; 값: unknown; 관급: boolean; } +/** 재료 줄 지역 · 상하차 글(830-10 · 요구 839) — 「-」(모름)은 뺌 · 둘 다 없으면 빈 글 */ +export const placeNote = (it: { 지역?: string | null; 상하차?: string | null }): string => + [it.지역, it.상하차].filter((v) => v && v !== "-").join(" · "); + export const fetchPick = ( kind: PickKind, q: string, diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index b14656d57..6d123d2e3 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -487,12 +487,15 @@ def pick(kind: str, q: str, limit: int) -> dict: row.get("값") is None or row.get("구분") == "미확보" or row.get("상태") == "추정" ): continue - text = _squash(f"{row.get('이름')} {row.get('규격')} {' '.join(row.get('옛이름') or [])}") + text = _squash(f"{row.get('이름')} {row.get('규격')} {row.get('지역') or ''} {' '.join(row.get('옛이름') or [])}") # fmt: skip if all(w in text or w == _squash(row.get("키")) for w in words): hits.append( { "ref": str(row.get("키")), - **{k: row.get(k) for k in ("원문번호", "구분", "이름", "규격", "단위")}, + **{ + k: row.get(k) + for k in ("원문번호", "구분", "이름", "규격", "단위", "지역", "상하차") + }, # fmt: skip "값": _lowest(row), "관급": row.get(cm.mm.GOVT) is not None, } @@ -504,7 +507,7 @@ def _market_brief(row: dict) -> dict: """후보 한 줄 — 값 열 다섯을 다 주고 `값` 에 시험 계산이 쓸 낮은 값(없으면 null).""" return { "ref": str(row.get("키")), - **{k: row.get(k) for k in ("구분", "상세구분", "이름", "규격", "단위")}, + **{k: row.get(k) for k in ("구분", "상세구분", "이름", "규격", "단위", "지역", "상하차")}, "값들": {s: row.get(s) for s in cm.mf.SLOTS}, "값": _lowest(row), "관급": row.get(cm.mm.GOVT) is not None, diff --git a/M01_MasterData/M01_MasterData_Store_Find.py b/M01_MasterData/M01_MasterData_Store_Find.py index 47b64ceba..fd2281131 100644 --- a/M01_MasterData/M01_MasterData_Store_Find.py +++ b/M01_MasterData/M01_MasterData_Store_Find.py @@ -1,4 +1,4 @@ -"""찾기 맞춤 — 낱말(띄어 나눔)마다 키 · 원문번호 · 이름 · 옛이름 · 별칭 가운데 하나에 들면 맞음. +"""찾기 맞춤 — 낱말(띄어 나눔)마다 키 · 원문번호 · 이름 · 옛이름 · 별칭 · 지역(재료 · 830-10) 가운데 하나에 들면 맞음. 띄어쓰기 · 대소문자 무시 · 별칭 = 설계자가 로직을 찾는 다른 이름(쉼표로 여럿 · 208-1). 짝: `ui_template/ui_template_logic_find.ts` `matchLogic`(화면 쪽) · @@ -7,7 +7,7 @@ from __future__ import annotations -FIELDS = ("키", "원문번호", "이름", "옛이름", "별칭") +FIELDS = ("키", "원문번호", "이름", "옛이름", "별칭", "지역") def squash(text) -> str: diff --git a/M01_MasterData/M01_MasterData_Store_Save.py b/M01_MasterData/M01_MasterData_Store_Save.py index c305d6bf2..9adb408a7 100644 --- a/M01_MasterData/M01_MasterData_Store_Save.py +++ b/M01_MasterData/M01_MasterData_Store_Save.py @@ -49,9 +49,7 @@ def _index(files: dict, whole, moved: list[dict]): setattr(out, name, getattr(whole, name)) return out found = getattr(whole, "_found", None) or {} - texts = [ - (mm.plain(r.get("이름")).replace("×", "x"), mm.plain_spec(r.get("규격")), r) for r in moved - ] + texts = [(mm.label(r).replace("×", "x"), mm.plain_spec(r.get("규격")), r) for r in moved] def hit(memo_key) -> bool: # `master_pick._search` 의 거름과 같음 — 걸리면 답이 바뀔 수 있음 term, kind, detail = memo_key[:3] diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index edf0cdcdf..8b82b791e 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -76,6 +76,9 @@ export interface ElementBrief { 별칭?: string; /** 재료 후보 줄 — 값 열 다섯(지역·계약종별별 값) */ 값들?: Record; + /** 재료 후보 줄 — 지역 · 상하차 칸(830-10) */ + 지역?: string | null; + 상하차?: string | null; } export interface LogicOne { diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Pick.ts b/M01_MasterData/M01_MasterData_UI_Logic_Pick.ts index 71cd94649..78acb533f 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Pick.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Pick.ts @@ -25,6 +25,7 @@ import { type LogicOne, type LogicRow, } from "./M01_MasterData_UI_Logic_Api"; +import { placeNote } from "./M01_MasterData_Api_Fetch"; import { formatNumber, type PickDone, type PickMode } from "./M01_MasterData_UI_Logic_Edit"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import { openModal } from "@ui/ui_template_modal"; @@ -101,7 +102,11 @@ function itemRow( const text = el("div", { className: "m01-logic__stack", children: [ - el("strong", { text: `${item.이름 ?? ""} ${item.규격 ?? ""}`.trim() }), + el("strong", { + text: [`${item.이름 ?? ""} ${item.규격 ?? ""}`.trim(), placeNote(item)] + .filter(Boolean) + .join(" · "), + }), el("span", { className: "ui-text ui-text--muted", text: [item.ref, note].filter(Boolean).join(" · "), @@ -170,7 +175,9 @@ const label = (it: Brief): string => { .filter(([, v]) => v !== null && v !== undefined) .map(([k, v]) => `${k} ${formatNumber(v)}`); const prices = cols.length ? cols : [formatNumber(it.값) || tx("Mat_NoPrice")]; - return [`${it.이름 ?? ""} ${it.규격 ?? ""}`.trim(), ...prices].join(" · "); + return [`${it.이름 ?? ""} ${it.규격 ?? ""}`.trim(), placeNote(it), ...prices] + .filter(Boolean) + .join(" · "); }; const oldNames = (one: LogicOne, line: HoLine): string[] => { diff --git a/M01_MasterData/M01_MasterData_UI_Pick.ts b/M01_MasterData/M01_MasterData_UI_Pick.ts index 68dcd53c7..e73b4784c 100644 --- a/M01_MasterData/M01_MasterData_UI_Pick.ts +++ b/M01_MasterData/M01_MasterData_UI_Pick.ts @@ -8,7 +8,7 @@ import { createEmpty } from "@ui/ui_template_empty"; import { openModal } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; import { t as L } from "@ui/ui_template_locale"; -import { fetchPick, type PickItem, type PickKind } from "./M01_MasterData_Api_Fetch"; +import { fetchPick, placeNote, type PickItem, type PickKind } from "./M01_MasterData_Api_Fetch"; import { show } from "./M01_MasterData_UI_Cells"; /** 지역이 갈리는 재료 — 이름·규격만 적어 두고 지역은 계산 때 입력. */ @@ -75,7 +75,8 @@ export function openPickModal(opt: PickOptions): void { list.replaceChildren( ...(items.length ? items.map((it) => { - const tag = it.관급 ? ` · ${L("M01_Govt")}` : ""; + const tag = + (it.관급 ? ` · ${L("M01_Govt")}` : "") + (placeNote(it) && ` · ${placeNote(it)}`); const b = el("button", { className: "m01-procure__item ui-list-row ui-sidebar-row", attrs: { type: "button", "data-size": "36" }, diff --git a/M01_MasterData/M01_MasterData_UI_Rows.ts b/M01_MasterData/M01_MasterData_UI_Rows.ts index 66e1b452d..8a2a57cab 100644 --- a/M01_MasterData/M01_MasterData_UI_Rows.ts +++ b/M01_MasterData/M01_MasterData_UI_Rows.ts @@ -250,7 +250,7 @@ export function renderRows( const add = createButton({ label: L("M01_RowAdd"), variant: "ghost" }); add.addEventListener("click", () => { const fresh = blank(d.rows[0] ?? FALLBACK); - if (file === MARKET) fresh[LOAD] = LOADS[0]; + if (file === MARKET) Object.assign(fresh, { [LOAD]: LOADS[0], 지역: LOADS[0] }); // 모름 = -(830-10) const col = ["구분", "세부분류"].find((c) => c in fresh); addRow(file, d.version, sub && col ? { ...fresh, [col]: sub } : fresh); }); diff --git a/resources/master_data/scripts/master_material.py b/resources/master_data/scripts/master_material.py index fc522f25b..27964e23e 100644 --- a/resources/master_data/scripts/master_material.py +++ b/resources/master_data/scripts/master_material.py @@ -44,6 +44,12 @@ def plain(s) -> str: return re.sub(r"\s+", "", re.sub(r"[φΦ∅⌀]", "ø", s)) +def label(row: dict) -> str: + """맞대기 글 = 이름 + 지역 칸(요구 839 · 830-10) — 검색어 · 자재지역의 지역 낱말이 지역 칸과 맞대짐.""" + where = row.get("지역") + return plain(row.get("이름")) + ("" if where in (None, "", "-") else plain(where)) + + def plain_spec(s) -> str: return plain(s).replace("×", "x").replace("*", "x") @@ -67,14 +73,14 @@ def _lowest(master: mf.Master, key: str) -> tuple[object, str | None]: def _market_index(master: mf.Master) -> list[tuple[str, str, str, str, str]]: - """(품명 뿌리, 이름, 규격, 단위, 키) — 「레미콘(관급)-경상북도(울진군)」 뿌리 = 레미콘.""" + """(품명 뿌리, 이름 + 지역, 규격, 단위, 키) — 「레미콘(관급)」 뿌리 = 레미콘 · 지역 = 지역 칸(`label`).""" if not hasattr(master, "_market"): master._market = [] for key, row in master.index.get("MT", {}).items(): name = plain(row.get("이름")) root = re.sub(r"\(.*?\)", "", name.split(",")[0]).split("-")[0] master._market.append( - (root, name, plain_spec(row.get("규격")), plain_unit(row.get("단위")), key) + (root, label(row), plain_spec(row.get("규격")), plain_unit(row.get("단위")), key) ) return master._market @@ -99,7 +105,7 @@ def _tariff(master: mf.Master, label_ref, cond: dict, env: dict) -> list[str]: def _region(master: mf.Master, label_ref, cond: dict, env: dict, unit) -> list[str]: - """후보 조건{이름 · 규격 · 지역: 입력} — 뿌리가 이름으로 끝나고 이름에 지역 글자 · 단위 같음.""" + """후보 조건{이름 · 규격 · 지역: 입력} — 뿌리가 이름으로 끝나고 이름 · 지역 칸에 지역 글자 · 단위 같음.""" if cond.get("지역") == "입력" and REGION not in env: raise mf.FormulaError(f"「{master.label(label_ref)}」 입력 「{REGION}」 없음") region = plain(env.get(REGION, "")) if cond.get("지역") == "입력" else "" @@ -149,7 +155,7 @@ def candidates( ) -> list[str]: """고르기 조건 안 자재품목 키 — 구분 · 상세구분 · 이름 · 규격 낱말이 모두 든 줄 · 파일 차례대로. `unit` 을 주면 그 단위(호표 줄 단위)와 같은 줄만 — 단위가 다르면 다른 물건. - 입력 `자재지역` 이 있으면 이름에 그 지역이 든 줄만(그런 줄이 없으면 조건 안 전부). + 입력 `자재지역` 이 있으면 이름 · 지역 칸에 그 지역이 든 줄만(그런 줄이 없으면 조건 안 전부). 조건에 `검색어` 가 있으면 기본 검색어 찾기(`master_pick`) · 빈 검색어 = 자재표에 없는 품목(후보 없음).""" if mp.empty_term(cond): @@ -169,7 +175,7 @@ def candidates( and (not need or plain_unit(row.get("단위")) == need) ] region = plain((env or {}).get(REGION, "")) - near = [k for k in hits if region in plain(master.get(k).get("이름"))] if region else [] + near = [k for k in hits if region in label(master.get(k))] if region else [] return near or hits diff --git a/resources/master_data/scripts/master_pick.py b/resources/master_data/scripts/master_pick.py index 61b09a4c0..c4ea97fc7 100644 --- a/resources/master_data/scripts/master_pick.py +++ b/resources/master_data/scripts/master_pick.py @@ -103,7 +103,7 @@ def _search(master, cond: dict, env: dict | None = None, unit: str | None = None def rank(key): one = rows[key] - name = mm.plain(one.get("이름")) + name = mm.label(one) # 이름 + 지역 칸(830-10) return ( not mm._offers(master, key), bool(want) and mm.plain_unit(one.get("단위")) != want, @@ -123,7 +123,7 @@ def pick(master, cond: dict, env: dict, unit: str | None = None) -> str: def priced(master, item: dict, ref, env: dict, index: int | None): """재료 줄 단가 — (단가, 출처, 까닭, 단위경고). 고른 품목(`고름`)이 있으면 그것 · 검색어가 있으면 찾기 · 없으면 옛 길. 자재 단가는 줄 단위로 환산 · 성격이 다르면 그대로 두고 경고. - 넷째 = 줄에 덧붙일 칸 — `단위경고` · `품목`(잡힌 품목 키 · 이름 · 규격 · 단위 · 값 칸).""" + 넷째 = 줄에 덧붙일 칸 — `단위경고` · `품목`(잡힌 품목 키 · 이름 · 규격 · 단위 · 지역 · 상하차 · 값 칸).""" price, source, why, warn = _priced(master, item, ref, env, index) extra = {"단위경고": warn} if warn else {} key, _, slot = (source or "").partition(".") @@ -137,6 +137,7 @@ def priced(master, item: dict, ref, env: dict, index: int | None): "이름": row.get("이름"), "규격": row.get("규격"), "단위": row.get("단위"), + **{c: row[c] for c in ("지역", "상하차") if c in row}, # 830-10 "값칸": slot, } return price, source, why, extra diff --git a/resources/tester/test_m01_pick.py b/resources/tester/test_m01_pick.py index b55767e83..5b7186b46 100644 --- a/resources/tester/test_m01_pick.py +++ b/resources/tester/test_m01_pick.py @@ -137,12 +137,14 @@ def test_재료_줄마다_품목_칸(client) -> None: i for i, x in enumerate(lines) if x.get("품목", {}).get("이름", "").startswith("합판(내수)") ) first = lines[idx]["품목"] - assert first["이름"] == "합판(내수), 서울" and first["값칸"] + assert (first["이름"], first["지역"]) == ("합판(내수)", "서울") and first[ + "값칸" + ] # 830-10 지역 칸 assert {"키", "이름", "규격", "단위", "값칸"} <= set(first) lines = client.post("/api/m01/calc", json={**body, "고름": {str(idx): "MT000019"}}).json()[ "lines" ] - assert lines[idx]["품목"]["키"] == "MT000019" and "인천" in lines[idx]["품목"]["이름"] + assert lines[idx]["품목"]["키"] == "MT000019" and "인천" in lines[idx]["품목"]["지역"] def test_목록_검색은_띄어쓰기를_무시(client) -> None: