feat(830-10): M01 재료 찾기가 지역 칸을 봄 — 로직 1,520 시험 계산 전후 다름 0

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-14 20:42:29 +09:00
co-authored by Claude Opus 5.5
parent 86cf20d997
commit 98f3ab59cb
11 changed files with 49 additions and 22 deletions
@@ -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,
+6 -3
View File
@@ -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,
+2 -2
View File
@@ -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:
+1 -3
View File
@@ -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]
@@ -76,6 +76,9 @@ export interface ElementBrief {
별칭?: string;
/** 재료 후보 줄 — 값 열 다섯(지역·계약종별별 값) */
값들?: Record<string, unknown>;
/** 재료 후보 줄 — 지역 · 상하차 칸(830-10) */
지역?: string | null;
상하차?: string | null;
}
export interface LogicOne {
@@ -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[] => {
+3 -2
View File
@@ -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" },
+1 -1
View File
@@ -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);
});
@@ -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
+3 -2
View File
@@ -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
+4 -2
View File
@@ -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: