From dd9ebad8d16b42633c723d7daf7a4e4aa2b06156 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 14 Nov 2026 20:57:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(830-10):=20=EB=B6=88=EB=9F=AC=EC=98=A4?= =?UTF-8?q?=EA=B8=B0=20=EC=9E=AC=EB=A3=8C=20=EC=B0=BE=EA=B8=B0=EB=A5=BC=20?= =?UTF-8?q?=EC=A7=80=EC=97=AD=20=C2=B7=20=EC=83=81=ED=95=98=EC=B0=A8=20?= =?UTF-8?q?=EC=B9=B8=EC=97=90=20=EB=A7=9E=EC=B6=A4=20=E2=80=94=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A8=20=EC=A4=84=20=EA=B8=80=EC=9D=80=20=EB=B9=8C?= =?UTF-8?q?=EB=8D=94=20=EA=B7=9C=EC=B9=99=EC=9C=BC=EB=A1=9C=20=ED=8C=90?= =?UTF-8?q?=EB=8F=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_Mat_Place: 가져온 이름 · 규격 → (뺀 이름, 뺀 규격, 지역, 상하차)(material_merge 판독 한 벌) · 고르는 차례 rank - 고르는 차례 = 상하차 맞음(글 없으면 -) › 값 있는 줄 › 줄 지역 › 전국 · 지역 모름 › 서울 › 경기 › 그 밖(값 가운데에 가까운 줄) - Elem_Alias(_same · one · region_of) · Hopyo_Match(목록에 지역 · 상하차 · 값 · 이름 같음 = 뺀 이름 + 지역 · 상하차 같음) · Hopyo_Material(_places · 변형 지역 = 지역 칸 · _prefer) · MatSame._pick · MatFind 후보 글 = 지역 칸 - 다시 고르기 · 찾기 창 · 줄 풍선말 후보 글에 지역 · 상하차(M01 placeNote 한 벌) - 시험: 814_1 · m02_hopyo_match 기대를 지역 칸 꼴로 · 새 지킴 test_830_10_material_region 21 - 불러오기 지킴 812_1 · 814_1 · 815_1 · 817_1 · 827_2 · 827_3 · 828_1 · 828_2 · 778_5 ×2 · 793_2 ×2 · 797_1 ×2 · m02_hopyo_match 통과 · typecheck 0 Co-Authored-By: Claude Opus 5.5 --- A00_Common/line_stack_material.ts | 7 +- M02_MasterTemplete/M02_Elem_Alias.py | 31 ++-- M02_MasterTemplete/M02_Hopyo_MatSame.py | 11 +- M02_MasterTemplete/M02_Hopyo_Match.py | 17 +- M02_MasterTemplete/M02_Hopyo_Material.py | 25 ++- .../M02_MasterTemplete_Find_Pick.ts | 6 +- .../M02_MasterTemplete_Hopyo_MatPick.ts | 7 +- M02_MasterTemplete/M02_Mat_Place.py | 78 ++++++++++ M02_MasterTemplete/M02_San_MatFind.py | 6 + .../tester/test_814_1_import_decisions.py | 4 +- .../tester/test_830_10_material_region.py | 146 ++++++++++++++++++ resources/tester/test_m02_hopyo_match.py | 7 +- 12 files changed, 305 insertions(+), 40 deletions(-) create mode 100644 M02_MasterTemplete/M02_Mat_Place.py create mode 100644 resources/tester/test_830_10_material_region.py diff --git a/A00_Common/line_stack_material.ts b/A00_Common/line_stack_material.ts index a16aeb9c4..a50317865 100644 --- a/A00_Common/line_stack_material.ts +++ b/A00_Common/line_stack_material.ts @@ -14,6 +14,7 @@ import type { FormulaDoc } from "../common_util/common_util_formula_doc"; import type { Picked } from "../common_util/common_util_line_stack_pick"; import { call, type Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import { at } from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api"; +import { placeNote } from "../M01_MasterData/M01_MasterData_Api_Fetch"; import type { MachineCtx } from "./line_stack_machine"; interface Cand { @@ -21,6 +22,8 @@ interface Cand { 이름?: string; 규격?: string; 단위?: string; + 지역?: string; + 상하차?: string; } export interface MatEntry { @@ -47,8 +50,8 @@ const LINKED = "이어짐"; export const MAT_PICKED = "aislo:mat-picked"; const enc = encodeURIComponent; const headOf = (d: FormulaDoc): Head | undefined => (d as { 가져온?: Head }).가져온; -const text = (c: { 이름?: string; 규격?: string }): string => - `${c.이름 ?? ""} ${c.규격 ?? ""}`.trim(); +const text = (c: { 이름?: string; 규격?: string; 지역?: string; 상하차?: string }): string => + [`${c.이름 ?? ""} ${c.규격 ?? ""}`.trim(), placeNote(c)].filter(Boolean).join(" · "); /** 그 단가 기호 줄의 재료 판정 칸 */ export const matEntry = (d: FormulaDoc, sym: string | undefined): MatEntry | undefined => diff --git a/M02_MasterTemplete/M02_Elem_Alias.py b/M02_MasterTemplete/M02_Elem_Alias.py index d909a67be..8424f1ca6 100644 --- a/M02_MasterTemplete/M02_Elem_Alias.py +++ b/M02_MasterTemplete/M02_Elem_Alias.py @@ -6,9 +6,11 @@ 기계 찾기(`M02_Machine_Find.alias`) · 일위대가 호표 줄(`M02_Hopyo_Units`) - `est_code(코드)` = EST 원파일 자재 자원 코드(M…) ↔ 우리 자재 줄 `출처` 칸 「자재품목 M…」 같은 줄 · 공사 직접 번호(M0000000…)는 빼고 이름 길 -- `exact(이름, 규격)` = 지역 꼬리 뗀 이름 · 규격이 `master_alias.norm` 으로 같은 자재 줄(가까운 규격 아님) -- `one(줄, 키들)` = 같은 품목(지역 꼬리 뗀 이름 · 규격 같음)의 지역 줄 여럿 → 줄 지역 › 서울 › 같은 단위 › - 키 차례 하나 · 다른 품목이 섞이면 None(후보 여럿) +- `exact(이름, 규격)` = 지역 · 인도 조건 글 뗀 이름 · 규격(`M02_Mat_Place.line`)이 `master_alias.norm` 으로 같은 + 자재 줄(가까운 규격 아님) +- `one(줄, 키들)` = 같은 품목(지역 · 인도 조건 글 뗀 이름 · 규격 같음)의 지역 · 상하차 줄 여럿 → 상하차 › 줄 지역 › + 전국 › 서울 › 경기 › 같은 단위 › 그 밖 값 가운데 › 키 차례 하나(`M02_Mat_Place.rank` · 830-10) · 다른 품목이 + 섞이면 None(후보 여럿) """ from __future__ import annotations @@ -18,6 +20,7 @@ from functools import lru_cache from typing import Any from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Mat_Place as place import master_alias as am # noqa: E402 — M01 store 가 scripts 경로를 넣음 @@ -29,21 +32,19 @@ PRICES = ( "거래가격", "일반가격", ) # 자재 사급 값 칸 다섯(조달청 뺌) -BASE_REGION = "서울" _CODE = re.compile(r"\bM\d{10}\b") LOCAL = "M0000000" # 공사 직접 번호(파일마다 다른 품목 · 거창 치즐 = 우리 표 Air Hammer) = 코드로 안 이음 -_REGION = re.compile(r"\s*,\s*([가-힣]{2,4})\s*$") # 자재 이름 지역 꼬리 「, 서울」 def region_of(name: Any) -> tuple[str, str]: - """자재 이름 → (지역 꼬리 뗀 이름, 지역 | 빈 글).""" - text = str(name or "") - m = _REGION.search(text) - return (text[: m.start()], m.group(1)) if m else (text, "") + """가져온 자재 이름 → (지역 · 인도 조건 글 뗀 이름, 지역 | 빈 글) — 우리 줄 지역은 지역 칸.""" + got = place.line(name, "") + return got["이름"], "" if got["지역"] == place.NONE else got["지역"] def _same(name: Any, spec: Any) -> tuple[str, str]: - return am.norm(region_of(name)[0]), am.norm(spec) + got = place.line(name, spec) + return am.norm(got["이름"]), am.norm(got["규격"]) @lru_cache(maxsize=2) @@ -96,15 +97,17 @@ def exact(name: Any, spec: Any) -> list[str]: def one(line: dict[str, Any], found: list[str]) -> str | None: - """같은 품목 지역 줄 여럿 → 하나(줄 지역 › 서울 › 같은 단위 › 키) · 다른 품목 섞임 = None.""" + """같은 품목 지역 · 상하차 줄 여럿 → 하나(`place.rank` 상하차 › 지역 차례 › 같은 단위 › 값 가운데 › 키) · + 다른 품목 섞임 = None.""" rows = {k: row(MARKET, k) for k in found} if not rows or len({_same(r.get("이름"), r.get("규격")) for r in rows.values()}) != 1: return None - mine, unit = region_of(line.get("이름"))[1], am.norm(line.get("단위")) + want, unit = place.line(line.get("이름"), line.get("규격")), am.norm(line.get("단위")) + mid = place.middle(list(rows.values())) def rank(k: str) -> tuple: - where = region_of(rows[k].get("이름"))[1] - return (where != mine if mine else True, where != BASE_REGION, am.norm(rows[k].get("단위")) != unit, k) # fmt: skip + *head, gap = place.rank(rows[k], want["지역"], want["상하차"], mid) + return (*head, am.norm(rows[k].get("단위")) != unit, gap, k) return min(rows, key=rank) diff --git a/M02_MasterTemplete/M02_Hopyo_MatSame.py b/M02_MasterTemplete/M02_Hopyo_MatSame.py index 32fb4fac3..3c8dbf335 100644 --- a/M02_MasterTemplete/M02_Hopyo_MatSame.py +++ b/M02_MasterTemplete/M02_Hopyo_MatSame.py @@ -28,6 +28,7 @@ from typing import Any from M02_MasterTemplete import M02_Hopyo_Match as hm from M02_MasterTemplete import M02_Hopyo_Material as hmat +from M02_MasterTemplete import M02_Mat_Place as place SPELL = ( (r"칠렌", "틸렌"), @@ -189,16 +190,20 @@ def _scored(line: dict[str, Any]) -> tuple[dict[str, Any], list[tuple[tuple, dic def _pick(group: list[dict[str, Any]], line: dict[str, Any]) -> dict[str, Any]: - """한 품목의 변형 줄들 → 하나(줄 관급/사급 › 줄 지역 › 서울 › 같은 단위 › 엑셀 차례 — `M02_Elem_Alias.one` 과 같은 차례).""" + """한 품목의 변형 줄들 → 하나(줄 관급/사급 › 상하차 › 지역 차례 › 같은 단위 › 값 가운데 › 엑셀 차례 — + `M02_Elem_Alias.one` 과 같은 차례 · `M02_Mat_Place.rank` · 830-10).""" supply, regions = hmat._wants(line) unit = hm.mm.plain_unit(line.get("단위")) + load = place.line(line.get("이름"), line.get("규격"))["상하차"] + mid = place.middle([x["row"]["one"] for x in group]) def rank(r: dict[str, Any]) -> tuple: + *head, gap = place.rank(r["one"], regions, load, mid) return ( supply is not None and r["supply"] not in (None, supply), - bool(regions) and not hmat._hit(r, regions), - "서울" not in r["region"], + *head, r["key"][1] != unit, + gap, r["at"], ) diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py index 28e0ef581..3c691fe70 100644 --- a/M02_MasterTemplete/M02_Hopyo_Match.py +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -19,6 +19,7 @@ from typing import Any from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_Mat_Place as place mf = m01.cm.mf mp = mf.mp @@ -233,6 +234,9 @@ def _targets(whole_id: int) -> dict[str, Any]: "이름": name, "규격": str(row.get("규격") or ""), "단위": str(row.get("단위") or ""), + "지역": str(row.get("지역") or place.NONE), # 830-10 지역 · 상하차 칸 + "상하차": str(row.get("상하차") or place.NONE), + "값": place.value(row), "plain": mm.plain(name), "spec": mm.plain_spec(row.get("규격")), "base": (base := re.sub(r"-(사급|관급)$", "", name.strip())), @@ -268,7 +272,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} @@ -276,7 +280,16 @@ def _exact(line: dict[str, Any], pool: str, got: dict[str, Any]) -> list[dict[st """② 이름 같음 · 씨앗 — 겹치면 여럿(부르는 쪽이 후보로 돌림).""" name, spec = mm.plain(line.get("이름")), mm.plain_spec(line.get("규격")) same = got["이름"][pool].get(name) or [] - if pool == "자재": + if ( + pool == "자재" + ): # 830-10 — 가져온 이름의 지역 · 인도 조건 글 = 우리 지역 · 상하차 칸(옛 온 이름 같음과 같은 뜻) + want = place.line(line.get("이름"), line.get("규격")) + same = [ + m + for m in got["이름"]["자재"].get(mm.plain(want["이름"])) or [] + if (m["지역"], m["상하차"]) == (want["지역"], want["상하차"]) + ] + spec = mm.plain_spec(want["규격"]) return [m for m in same if m["spec"] == spec] or (same if len(same) == 1 else []) if same: return same diff --git a/M02_MasterTemplete/M02_Hopyo_Material.py b/M02_MasterTemplete/M02_Hopyo_Material.py index 03207ea4f..812e72e62 100644 --- a/M02_MasterTemplete/M02_Hopyo_Material.py +++ b/M02_MasterTemplete/M02_Hopyo_Material.py @@ -8,7 +8,7 @@ 맞음 = ① 이름 · 규격 같음 하나(`Match._exact`) ② 바탕 이름 · 단위 · 규격 맞음 1 이 한 품목뿐 (지역 · 관급/사급만 다른 변형 = 한 품목 · 같은 단위 먼저 · 줄 글의 관급/사급 먼저 · 없으면 별도계상 줄 = 관급 · 재료비 줄 = 사급). -지역(361-1) = 줄 글에 지역 낱말 → 그 지역 변형(꼬리표 「지역 — 줄 글」) · 없으면 지역 없는 +지역(361-1 · 830-10) = 우리 줄은 지역 칸 · 줄 글에 지역 낱말 → 그 지역 변형(꼬리표 「지역 — 줄 글」) · 없으면 지역 없는 변형((전국) 품목) · 그것도 없으면(레미콘) 「전국」 = 키 · 단가 없는 자리표(`national` · 바탕 이름(전국)-관급/사급 · 규격 · 단위 · 꼬리표 「전국 — 지역은 프로젝트에서」) · 자동 고름 없음 · 지역 변형은 고르기 창에서 사용자가. @@ -25,6 +25,7 @@ from functools import lru_cache from typing import Any from M02_MasterTemplete import M02_Hopyo_Match as hm +from M02_MasterTemplete import M02_Mat_Place as place mm = hm.mm APART = "별도계상" @@ -55,8 +56,8 @@ def _nfkc(text: Any) -> str: @lru_cache(maxsize=1) def _places() -> frozenset[str]: - """곳 낱말 — M01 레미콘 이름의 지역 · 시군 낱말(자료에서 · 「홍천군」 · 「미원면」).""" - names = (one["이름"] for one in hm.targets()["자재"] if one["이름"].startswith("레미콘(")) + """곳 낱말 — M01 레미콘 줄 지역 칸의 지역 · 시군 낱말(자료에서 · 「홍천군」 · 「미원면」).""" + names = (one["지역"] for one in hm.targets()["자재"] if one["이름"].startswith("레미콘")) words = {w for n in names for w in re.findall(r"[가-힣]{2,}", _nfkc(n))} return frozenset(words - {"레미콘", "조합공통품목", *SUPPLY}) @@ -120,7 +121,6 @@ def _pool(got_id: int) -> tuple[list[dict[str, Any]], dict[str, list[dict[str, A rows, index = [], {} for at, one in enumerate(hm.targets()["자재"]): name, spec = _nfkc(one["이름"]), _nfkc(one["규격"]) - words = set(re.findall(r"[가-힣]+", name)) row = { "one": one, "at": at, @@ -128,7 +128,7 @@ def _pool(got_id: int) -> tuple[list[dict[str, Any]], dict[str, list[dict[str, A "nums": _nums(spec), "ranges": _ranges(spec), "spec": mm.plain_spec(spec), - "region": sorted(w for w in words if _region(w) and w not in NO_REGION), + "region": sorted(place.words(one["지역"])), # 830-10 지역 칸 "supply": next((w for w in SUPPLY if w in name), None), "key": ( tuple(sorted(_nums(spec))), @@ -210,18 +210,15 @@ def _hit(r: dict[str, Any], regions: list[str]) -> bool: def _prefer(rows: list[dict[str, Any]], line: dict[str, Any]) -> list[dict[str, Any]]: - """변형 차례 — 줄 글의 관급/사급 · 지역 먼저 · 지역 없는 것 · 엑셀(M01) 차례.""" + """변형 차례 — 줄 글의 관급/사급 › 상하차 › 지역(줄 지역 › 전국 · 지역 없음 › 서울 › 경기 › 값 가운데) › + 엑셀(M01) 차례(`M02_Mat_Place.rank` · 830-10).""" supply, regions = _wants(line) + load = place.line(line.get("이름"), line.get("규격"))["상하차"] + mid = place.middle([r["one"] for r in rows]) def rank(r: dict[str, Any]) -> tuple: - hit = _hit(r, regions) - return ( - supply is not None and r["supply"] not in (None, supply), - bool(regions) and not hit, - bool(r["region"]), - bool(r["region"]) or not any(w in r["one"]["이름"] for w in NO_REGION), # (전국) 먼저 - r["at"], - ) + *head, gap = place.rank(r["one"], regions, load, mid) + return (supply is not None and r["supply"] not in (None, supply), *head, gap, r["at"]) return sorted(rows, key=rank) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts index c46667bce..c6e0e57dd 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts @@ -34,6 +34,7 @@ import { createTabs } from "@ui/ui_template_tabs"; import { createText } from "@ui/ui_template_text"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import type { LogicSummary } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import { placeNote } from "../M01_MasterData/M01_MasterData_Api_Fetch"; import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch"; import { fetchLibLogics, fetchStandard, type StandardRow } from "./M02_MasterTemplete_Labor_Api"; import type { LaborFixed, LaborRef } from "./M02_MasterTemplete_Labor_Calc"; @@ -156,6 +157,9 @@ interface PickItem { 이름: string; 규격?: string | null; 단위?: string | null; + /** 재료 지역 · 상하차 칸(830-10) */ + 지역?: string | null; + 상하차?: string | null; 값: unknown; } /** 표 한 줄 + 누름 */ @@ -278,7 +282,7 @@ export function openFindPicker(opt: FindPickOptions): void { 규격: e.규격, 단위: e.단위, 금액: e.값, - 구분: join(kind, m.구분, put ? "" : L("M02_FindElementLater")), + 구분: join(kind, m.구분, placeNote(m), put ? "" : L("M02_FindElementLater")), 출처: `M01 ${m.ref}`, dim: !put, go: !put diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_MatPick.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_MatPick.ts index 9ed9af697..e0187ecb4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_MatPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_MatPick.ts @@ -20,12 +20,16 @@ import { MAT_PICKED } from "../A00_Common/line_stack_material"; import { openStackPick } from "../A00_Common/line_stack_pick"; import { call } from "./M02_MasterTemplete_Api_Fetch"; import { at, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; +import { placeNote } from "../M01_MasterData/M01_MasterData_Api_Fetch"; export interface MatCand { 키: string; 이름: string; 규격: string; 단위: string; + /** 지역 · 상하차 칸(830-10) — 같은 이름 · 규격 줄을 가름 */ + 지역?: string; + 상하차?: string; 단서?: string; } @@ -60,7 +64,8 @@ const LINKED = "이어짐"; const TONE: Record = { [LINKED]: "success", "우리 표에 없음": "neutral" }; const enc = encodeURIComponent; const why = (e: unknown): string => (e instanceof Error ? e.message : String(e)); -const label = (c: { 이름: string; 규격: string }): string => `${c.이름} ${c.규격}`.trim(); +const label = (c: { 이름: string; 규격: string; 지역?: string; 상하차?: string }): string => + [`${c.이름} ${c.규격}`.trim(), placeNote(c)].filter(Boolean).join(" · "); export function createMatPick(opt: { scope: HopyoScope; key: string; target: HopyoScope }) { const to = `?to=${opt.target.layer}${opt.target.owner ? `&to_owner=${enc(opt.target.owner)}` : ""}`; diff --git a/M02_MasterTemplete/M02_Mat_Place.py b/M02_MasterTemplete/M02_Mat_Place.py new file mode 100644 index 000000000..220ccb5f2 --- /dev/null +++ b/M02_MasterTemplete/M02_Mat_Place.py @@ -0,0 +1,78 @@ +"""불러오기 재료 지역 · 상하차 한 벌(PLAN 830-10 · 요구 839) — 서버 단독 · 읽기만. + +- `line(이름, 규격)` = 가져온 줄 글(경쟁사 이름 · 규격 — 지역 · 인도 조건 글이 그대로 옴)을 M01 재료 표 빌더와 + 같은 규칙(`material_merge.split_load` · `split_region`)으로 읽음 → {이름, 규격, 지역, 상하차} · 글 없음 = -. +- 우리 줄 = M01 재료 표 지역 · 상하차 칸(이름 글로 읽지 않음). +- `rank(우리 줄, 바라는 것, 가운데 값)` = 같은 품목 줄 여럿에서 고르는 차례 — + 상하차가 줄 글과 같음(글 없으면 -) › 값 있는 줄(M01 검색어 고르기와 같음) › 지역: 줄 지역 › 전국(지역 모름 - 포함) › 서울 › 경기 › + 그 밖(값이 가운데 값에 가까운 줄) — 조달청 지역 규칙과 같은 차례(요구 830). +""" + +from __future__ import annotations + +import re +from statistics import median +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 # noqa: F401 — scripts 경로를 넣음 + +import material_merge as mmerge # noqa: E402 + +NONE = mmerge.NONE +NATION = ("전국", "전지역", NONE) +PRICES = ("물가자료", "유통물가", "물가정보", "거래가격", "일반가격") # 사급 값 칸(조달청 뺌) + + +def line(name: Any, spec: Any) -> dict[str, str]: + """가져온 줄 → {이름, 규격(지역 · 인도 조건 글 뺌), 지역, 상하차}.""" + n, s, load, _ = mmerge.split_load({"이름": str(name or ""), "규격": str(spec or "")}) + n, s, where = mmerge.split_region(n, s) + return {"이름": n, "규격": s, "지역": where, "상하차": load} + + +def words(where: Any) -> list[str]: + """지역 글 → 한글 낱말(전국류 뺌) — 「경상북도(칠곡군)」 → 경상북도 · 칠곡군.""" + return [w for w in re.findall(r"[가-힣]{2,}", str(where or "")) if w not in NATION] + + +def hit(where: Any, want: Any) -> bool: + """우리 지역 글이 줄 지역 글과 맞음 — 같은 글 · 낱말 앞 두 글자가 하나라도 같음(「경북」 · 「경북제일」).""" + if not want or want == NONE: + return False + if str(where) == str(want): + return True + mine = words(want) if isinstance(want, str) else list(want) + return any(a[:2] == b[:2] for a in mine for b in words(where)) + + +def value(row: dict) -> Any: + """사급 값 칸 다섯 가운데 낮은 값(줄에 「값」 이 있으면 그것) — 없음 = None.""" + if "값" in row: + return row["값"] + got = [row[c] for c in PRICES if row.get(c) is not None] + return min(got) if got else None + + +def middle(rows: list[dict]) -> Any: + got = [v for v in (value(r) for r in rows) if v is not None] + return median(got) if got else None + + +def rank(row: dict, want_where: Any, want_load: str, mid: Any) -> tuple: + """고르는 차례 열쇠(작을수록 먼저) — (상하차 다름, 값 없음, 지역 차례, 가운데 값과의 거리).""" + where = str(row.get("지역") or NONE) + if hit(where, want_where): + tier = 0 + elif where in NATION or not words(where): + tier = 1 + else: + tier = 2 if where.startswith("서울") else 3 if where.startswith("경기") else 4 + v = value(row) + gap = ( + 0.0 + if tier < 4 or mid is None + else float("inf") + if v is None + else abs(float(v) - float(mid)) + ) + return ((row.get("상하차") or NONE) != (want_load or NONE), v is None, tier, gap) diff --git a/M02_MasterTemplete/M02_San_MatFind.py b/M02_MasterTemplete/M02_San_MatFind.py index d1d523ed4..abe20c7ce 100644 --- a/M02_MasterTemplete/M02_San_MatFind.py +++ b/M02_MasterTemplete/M02_San_MatFind.py @@ -352,5 +352,11 @@ def _cands(line: dict, narrowed: list[dict], used: list) -> list[tuple]: def _pub(one: dict, why: str) -> tuple: + from M02_MasterTemplete import M02_Elem_Alias as ea + got = {"키": one.get("자재"), **{k: str(one.get(k) or "") for k in ("이름", "규격", "단위")}} + row = ea.row(ea.MARKET, got["키"]) if got["키"] else {} + got |= { + k: str(one.get(k) or row.get(k) or "") for k in ("지역", "상하차") + } # 830-10 다시 고르기 창 return tuple((got | ({"단서": why} if why else {})).items()) diff --git a/resources/tester/test_814_1_import_decisions.py b/resources/tester/test_814_1_import_decisions.py index 929d32719..9e0bd47c2 100644 --- a/resources/tester/test_814_1_import_decisions.py +++ b/resources/tester/test_814_1_import_decisions.py @@ -166,7 +166,9 @@ def test_oil_power_material_links() -> None: assert got[dim.OPTION]["연결"]["값"] == "일반용(갑)Ⅰ 저압" ply, _ = link.m01("재료", {"이름": "합판(내수)", "규격": "12t×1220×2440mm", "단위": "㎡"}) nail, _ = link.m01("재료", {"이름": "철못", "규격": "N 25", "단위": "kg"}) - assert whole.get(ply)["이름"] == "합판(내수), 서울" and whole.get(nail)["규격"] == "N 25" + assert (whole.get(ply)["이름"], whole.get(ply)["지역"]) == ("합판(내수)", "서울") and whole.get( + nail + )["규격"] == "N 25" assert link.m01("재료", {"이름": "접착제", "규격": "", "단위": "kg"}) == (None, None) diff --git a/resources/tester/test_830_10_material_region.py b/resources/tester/test_830_10_material_region.py new file mode 100644 index 000000000..a9ddd7f29 --- /dev/null +++ b/resources/tester/test_830_10_material_region.py @@ -0,0 +1,146 @@ +"""830-10 지킴 — 재료 표 지역 칸 분리 · 상하차 재분석 · M01 로직이 같은 줄 · 불러오기가 지역 칸으로 고름(요구 839). + +- 지역 칸 = 단위 바로 뒤 · 상하차 앞 · 빈 칸 없음(모름 = -) · 이름 · 규격에 지역 글 0 · 인도 조건 글 0 +- 꼴 일곱 판독(빌더 = `material_merge.split_region`) · 지역 아닌 업체 · 산지 · 수종 글은 그대로 +- 상하차: (상차포함) = 상차 · (상차제외) = - · 같은 이름 · 규격 둘이 상하차 칸으로 갈림 +- 줄 · 키 = 24,027 · 지역 칸만 다른 줄 겹침 없음(이름 · 규격 · 단위 · 지역 · 상하차 같은 줄 = 830-9 때 겹침뿐) +- M01 로직: 검색어 지역 낱말 · 자재지역 입력이 지역 칸과 맞대져 고치기 전 줄(시멘트 전국 · 포항 모래 · 포항 레미콘) +- 불러오기: 가져온 이름의 지역 · 인도 조건 글 판독 · 줄 지역 › 전국 › 서울 › 경기 › 그 밖 · 상하차 맞는 줄 +""" + +from __future__ import annotations + +import json +import sys +from collections import Counter +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +for p in (ROOT, ROOT / "resources/master_data/scripts"): + if str(p) not in sys.path: + sys.path.insert(0, str(p)) + +import material_merge as mm # noqa: E402 + +MASTER = ROOT / "resources/master_data" + + +@pytest.fixture(scope="module") +def rows(): + return json.loads((MASTER / "재료_자재품목.json").read_text(encoding="utf-8"))["줄"] + + +def test_지역_칸_자리_빈칸_없음(rows): + cols = list(mm.COLUMNS) + assert cols[cols.index("단위") + 1 : cols.index("단위") + 3] == ["지역", "상하차"] + assert len(rows) == 24027 and all(str(r["지역"]).strip() for r in rows) + got = Counter(r["지역"] != "-" for r in rows) + assert got[True] == 6604 and got[False] == 17423 + + +@pytest.mark.parametrize( + "name,spec,want", + [ + ("합판(내수), 서울", "12t", ("합판(내수)", "12t", "서울")), # F1 + ("레미콘(서울)-사급", "25-18-120", ("레미콘-사급", "25-18-120", "서울")), # F2 + ("레미콘(관급)-경상북도(칠곡군)", "", ("레미콘(관급)", "", "경상북도(칠곡군)")), # F3 + ("이형철근(SD400,제주)-관급", "HD=10mm", ("이형철근(SD400)-관급", "HD=10mm", "제주")), # F4 + ("H 형강(서울)", "SS275", ("H 형강", "SS275", "서울")), # F5 + ("모래(건조상태·전국)", "", ("모래(건조상태)", "", "전국")), + ("도로용혼합골재(충남지역,관급)", "40mm", ("도로용혼합골재(관급)", "40mm", "충남지역")), + ("STEEL PIPE", "42C (서울)", ("STEEL PIPE", "42C", "서울")), # F6 + ("콘크리트근가", "0.7M(제주지역)", ("콘크리트근가", "0.7M", "제주지역")), + ("운반비", "포항", ("운반비", "", "포항")), # F7 + ("도로용혼합골재(관급)", "농산개발(홍성), 75mm", None), # 업체 · 지역 아님 + ("조경석(관급)", "R5이상~15미만,보령웅천석(보령개발)", None), # 산지 + ("레미콘(관급)-거양", "25-21-120", None), # 제조사 + ("묘목, 잣나무", "1-0", None), # 수종 + ("모래(건조상태·지역별)", "", None), + ], +) +def test_꼴_일곱_판독(name, spec, want): + assert mm.split_region(name, spec) == (want or (name, spec, "-")) + + +def test_이름_규격에_지역_인도_조건_글_0(rows): + assert not [r for r in rows if mm.split_region(r["이름"], r["규격"])[2] != "-"] + assert not [r for r in rows if mm.split_load(r)[3]] + assert {r["지역"] for r in rows if r["이름"] == "레미콘-사급"} >= {"서울", "포항"} + cut = [r for r in rows if mm.CUT in str(r["비고"] or "")] + assert len(cut) == 30 and all(r["지역"].count("(") > r["지역"].count(")") for r in cut) + + +def test_상하차_재분석(rows): + assert mm.split_load({"이름": "폐기물운반비(상차포함)", "규격": "30km이하"})[:3] == ( + "폐기물운반비", + "30km이하", + "상차", + ) + assert mm.split_load({"이름": "폐기물운반비(상차제외)", "규격": ""})[2:] == ("-", "상차제외") + waste = [r for r in rows if r["이름"] == "폐기물운반비"] + assert Counter(r["상하차"] for r in waste) == {"상차": 10, "-": 10} + assert len({(r["규격"], r["상하차"]) for r in waste}) == 20 # 상하차 칸으로 갈림 + assert Counter(r["상하차"] for r in rows) == {"-": 23633, "상차": 135, "하차": 259} + + +def test_지역만_다른_줄_겹침_없음(rows): + keys = Counter((r["이름"], r["규격"], r["단위"], r["지역"], r["상하차"]) for r in rows) + dup = {k: n for k, n in keys.items() if n > 1} + assert ( + all(k[3] == "-" for k in dup) and sum(dup.values()) == 866 + ) # EST 에서 온 겹침(830-9 와 같음) + + +def test_로직이_같은_줄(): + import check_master as cm + import master_formula as mf + + files = cm.load() + whole = mf.Master(files) + rows = {str(r["키"]): r for d in files.values() if d.get("그룹") == "로직" for r in d["줄"]} + + def picked(key: str, name: str) -> str: + got = mf.run(whole, key, cm.시험입력(rows[key])) + return next(x["품목"]["키"] for x in got["줄"] if x.get("이름") == name) + + assert picked("GC000628", "시멘트") == "MT004176" # 검색어 「시멘트 전국」 + assert picked("GC000677", "모래") == "MT003834" # 자재지역 입력(포항) + assert picked("GF000174", "기초콘크리트(레미콘) 무근") == "MT004876" # 포항 레미콘 + + +def test_불러오기가_지역_칸으로_고름(): + from M02_MasterTemplete import M02_Elem_Alias as ea + from M02_MasterTemplete import M02_Hopyo_Match as hm + from M02_MasterTemplete import M02_Mat_Place as place + + assert place.line("모 래, 서울, 도착도", "") == { + "이름": "모 래", + "규격": "", + "지역": "서울", + "상하차": "하차", + } + pick = lambda name, spec: ea.row( + ea.MARKET, ea.one({"이름": name, "규격": spec}, ea.exact(name, spec)) + ) # noqa: E731, E501 + assert pick("모르타르, 부산", "1:3")["지역"] == "부산" # 줄 지역 + assert pick("모르타르", "1:3")["지역"] == "서울" # 전국 없음 → 서울 + assert pick("레미콘(포항)-사급", "25-18-120")["지역"] == "포항" + got = hm._exact({"이름": "레미콘(서울)-사급", "규격": "25-18-120"}, "자재", hm.targets()) + assert [(x["이름"], x["지역"]) for x in got] == [("레미콘-사급", "서울")] # 옛 온 이름 같음 + row = lambda where, v=1: {"지역": where, "상하차": "-", "물가자료": v} # noqa: E731 + order = sorted( + [ + row("부산", 300), + row("경기"), + row("서울"), + row("전국"), + row("대구", 120), + row("울산", 100), + ], + key=lambda r: place.rank(r, "", "-", 120), + ) + assert [r["지역"] for r in order] == ["전국", "서울", "경기", "대구", "울산", "부산"] + assert place.rank(row("부산"), "부산", "-", None)[2] == 0 + assert place.rank({**row("서울"), "상하차": "하차"}, "", "-", None)[0] # 상하차 다름 = 뒤 diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index 93611b8d8..ffffc048d 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -137,10 +137,13 @@ def test_전국_지역_줄글_전국품목(): assert hmat.national(paid)["이름"] == "레미콘(전국)-사급" # 재료비 줄 = 사급 꼬리 named = {**REMICON, "비고": "강원도 강릉시"} # 줄 글에 지역 = 그 지역(원문) rows, tags = hmat.sure(hmat.clean(named)[0]) - assert rows[0]["one"]["이름"] == "레미콘(관급)-강원도(강릉시)" and tags == [hmat.REGION_TAG] + assert (rows[0]["one"]["이름"], 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 형강(서울)" + assert (seoul["이름"], seoul["지역"]) == ("H 형강", "서울") # 830-10 지역 칸