feat(일위대가실무): 324-2 맞춤 — 엑셀 구성 줄 → 돈 로직 · 우리 자재 · 규격 입력값 · 환산 · 층 기억
- M02_Hopyo_Match: 정규화(master_material.plain · plain_spec · plain_unit) · 차례 = 층 기억(같은 층 → 위 층) → 이름 같음 · 일위대가조합.json 메모 씨앗(읽기만) → 정리 이름 · 후보 5(두 글자 묶음 + 단위 + 산림품셈 · 규격 같은 자재 덤) · 후보는 확정 뒤에만 들어감
- 규격 → 입력값: 고르기 글(품명 괄호 포함 · 낱말 머리) · 기계 고르기이름 · D= H= H: B= T= L= L3= · 천 단위 쉼표 · 못 읽음 표시
- 환산: 같은 단위 1 · 단위표 · ㎡→㎥ T · m→㎡ B · m→㎥ B×T = 추정 · 두 단위 로직 · 그 밖 입력
- 길: GET /hopyo/{키}(호표마다 맞춤 n/m) · GET /hopyo/{키}/{번}(줄 맞춤 · 후보) · POST …/guess(미리 채움) · POST /hopyo-memory(확정 · 빼기 · 지움 = 층 쓰기)
- 시험 test_m02_hopyo_match 15 통과(+ 324-1 15)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
"""M02 일위대가 실무자료 맞춤(PLAN 324-2) — 엑셀 구성 줄 → 우리 돈 로직 · 자재 · 입력값 · 환산.
|
||||
|
||||
차례 = ① 층 기억(같은 층 → 위 층 · 확정분) ② 이름 같음(정규화) ·
|
||||
씨앗(일위대가조합.json 메모 · 읽기만)
|
||||
③ 정리 이름 같음(괄호 · 기계 낱말) ④ 후보 5(글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤) —
|
||||
③ ④ 는 사용자 확정(층 기억) 뒤에만 들어감.
|
||||
줄 갈래: 단가산출 · 기계 · 기타 = 로직 / 자재 · 별도계상 = 자재(M01 자재품목) / 호표 = 그 호표 /
|
||||
% = 안 들어감(로직 덧줄이 셈).
|
||||
규격 → 입력값 = 고르기 글이 규격(품명 괄호 글 포함)에 있으면 그 값(기계 = 고르기이름) ·
|
||||
이름 붙은 수 D= H= H: B= T= L= L3= 는 입력 단위로 · 못 읽으면 빈 값(자동값) + 표시.
|
||||
환산(우리 수량 = 엑셀 수량 × 배): 같은 단위 1 · 같은 성격 = 단위표 · ㎡→㎥ × T · m→㎡ × B ·
|
||||
m→㎥ × B × T = 「추정」(확정 뒤에만) · 두 단위 로직 = 단위 입력으로 고르고 1 · 그 밖 = 사용자 입력.
|
||||
M01 은 읽기만(로직 별칭 안 씀).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
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_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
mf = m01.cm.mf
|
||||
mp = mf.mp
|
||||
mm = mp.mm
|
||||
SEED_NAME = "일위대가조합.json"
|
||||
LOGIC_KINDS = ("단가산출", "기계", "기타")
|
||||
MATERIAL_KINDS = ("자재", "별도계상")
|
||||
READY = ("기억", "같음", "호표")
|
||||
MACH_WORDS = (
|
||||
("불도우저", "불도저"),
|
||||
("굴삭기", "굴착기"),
|
||||
("브레이카", "브레이커"),
|
||||
("로울러", "롤러"),
|
||||
("크로울러", "크롤러"),
|
||||
)
|
||||
_SEED_TAIL = re.compile(r"\s*\(실무 수는 옛 판 값\)\s*$")
|
||||
_PAREN = re.compile(r"\([^()]*\)")
|
||||
_THOUSANDS = re.compile(r"(=\d{1,3}),(\d{3})(?!\d)")
|
||||
_NAMED = re.compile(r"(?<![A-Za-z])([A-Za-z]\d?)\s*[=:]\s*(\d+(?:\.\d+)?)\s*(mm|cm|m)?", re.I)
|
||||
# 이름 붙은 수 → 입력 이름 낱말(긴 낱말이 앞섬 · 「뒷길이」 = L3)
|
||||
LETTERS = {
|
||||
"D": ("관경", "직경", "구경", "지름"),
|
||||
"H": ("높이",),
|
||||
"B": ("폭", "너비"),
|
||||
"T": ("두께",),
|
||||
"L": ("거리", "길이"),
|
||||
"L3": ("뒷길이",),
|
||||
}
|
||||
|
||||
|
||||
# ── 정규화 ───────────────────────────────────────────────────────────
|
||||
def line_key(line: dict[str, Any]) -> str:
|
||||
"""기억 열쇠 = 품명 | 규격 | 단위(정규화)."""
|
||||
return "|".join(
|
||||
(
|
||||
mm.plain(line.get("이름")),
|
||||
mm.plain_spec(line.get("규격")),
|
||||
mm.plain_unit(line.get("단위")),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def tidy(name: Any) -> str:
|
||||
"""정리 이름 — 괄호 글 · 기계 옛 낱말 · 빈칸."""
|
||||
text = str(name or "")
|
||||
for old, new in MACH_WORDS:
|
||||
text = text.replace(old, new)
|
||||
return mm.plain(_PAREN.sub("", text))
|
||||
|
||||
|
||||
def _spec_text(text: Any) -> str:
|
||||
out = str(text or "")
|
||||
for old, new in MACH_WORDS:
|
||||
out = out.replace(old, new)
|
||||
return mm.plain_spec(out).replace(",", "").replace("~", "∼")
|
||||
|
||||
|
||||
def numbers(spec: Any) -> dict[str, tuple[Decimal, str | None]]:
|
||||
"""규격의 이름 붙은 수 `{글자: (값, 단위)}` — 천 단위 쉼표(`D=1,000mm`)를 먼저 붙임."""
|
||||
text = _THOUSANDS.sub(r"\1\2", unicodedata.normalize("NFKC", str(spec or "")))
|
||||
return {
|
||||
letter.upper(): (Decimal(value), unit.lower() if unit else None)
|
||||
for letter, value, unit in _NAMED.findall(text)
|
||||
}
|
||||
|
||||
|
||||
def _in_unit(value: Decimal, have: str | None, want: Any) -> Decimal:
|
||||
"""길이 값을 입력 단위로 — 한쪽이 모르는 단위면 그대로."""
|
||||
scale = mp.UNITS["길이"]
|
||||
want = mm.plain_unit(want) if want else None
|
||||
if have in scale and want in scale:
|
||||
return value * Decimal(scale[have]) / Decimal(scale[want])
|
||||
return value
|
||||
|
||||
|
||||
def _json(value: Decimal) -> int | float:
|
||||
value = value.normalize()
|
||||
return int(value) if value == value.to_integral_value() else float(value)
|
||||
|
||||
|
||||
def _meters(found: tuple[Decimal, str | None] | None) -> Decimal | None:
|
||||
if found is None:
|
||||
return None
|
||||
return _in_unit(found[0], found[1] or "m", "m")
|
||||
|
||||
|
||||
# ── 규격 → 입력값 ─────────────────────────────────────────────────────
|
||||
def spec_inputs(line: dict[str, Any], specs: list[dict[str, Any]]) -> tuple[dict, list[str]]:
|
||||
"""(입력값, 못 읽은 입력 이름) — 고르기 글 · 고르기이름 · 이름 붙은 수 · 두 단위."""
|
||||
raw = f"{line.get('규격') or ''} {' '.join(_PAREN.findall(str(line.get('이름'))))}"
|
||||
pool = _spec_text(raw)
|
||||
named = numbers(line.get("규격"))
|
||||
tokens = [_spec_text(w) for w in re.split(r"[,\s/()]+", raw)]
|
||||
unit = mm.plain_unit(line.get("단위"))
|
||||
values, unread = {}, []
|
||||
for spec in specs:
|
||||
name = str(spec.get("이름") or "")
|
||||
picks = spec.get("고르기") or []
|
||||
labels = spec.get("고르기이름") or {}
|
||||
words = [(w, letter) for letter, ws in LETTERS.items() for w in ws if w in name]
|
||||
letter = max(words, key=lambda x: len(x[0]))[1] if words else None
|
||||
got: Any = None
|
||||
if letter in named:
|
||||
value = _json(_in_unit(*named[letter], spec.get("단위")))
|
||||
got = value if not picks or value in picks else None
|
||||
if got is None and picks:
|
||||
hits = []
|
||||
for opt in picks:
|
||||
label = str(labels.get(opt) or opt)
|
||||
for text in (label, _PAREN.sub("", label)):
|
||||
flat = _spec_text(text)
|
||||
if len(flat) > 1 and (flat in pool or (unit and flat == f"{unit}당")):
|
||||
hits.append((len(flat), opt))
|
||||
# 규격 낱말이 고르기 글 머리(「4회」 → 「4회사용시」)
|
||||
hits += [(len(w), opt) for w in tokens if len(w) > 1 and flat.startswith(w)]
|
||||
got = max(hits, key=lambda x: x[0])[1] if hits else None
|
||||
if got is not None:
|
||||
values[name] = got
|
||||
elif spec.get("자동값") in (None, ""):
|
||||
unread.append(name)
|
||||
return values, unread
|
||||
|
||||
|
||||
# ── 환산 ─────────────────────────────────────────────────────────────
|
||||
def factor(have: Any, want: Any, spec: Any = "") -> dict[str, Any]:
|
||||
"""`{배, 갈래}` — 우리 수량 = 엑셀 수량 × 배 · 배 None = 사용자 입력."""
|
||||
units = [u.strip() for u in str(want or "").split("·") if u.strip()]
|
||||
plain = mm.plain_unit(have)
|
||||
if len(units) > 1:
|
||||
ok = plain in {mm.plain_unit(u) for u in units}
|
||||
return {"배": 1 if ok else None, "갈래": "두 단위" if ok else "입력"}
|
||||
if plain == mm.plain_unit(want):
|
||||
return {"배": 1, "갈래": "같음"}
|
||||
a, b = mp.kind_of(have), mp.kind_of(want)
|
||||
if a is not None and a == b:
|
||||
scale = mp.UNITS[a]
|
||||
ratio = Decimal(scale[plain]) / Decimal(scale[mm.plain_unit(want)])
|
||||
return {"배": _json(ratio), "갈래": "단위표"}
|
||||
found = numbers(spec)
|
||||
width, thick = _meters(found.get("B")), _meters(found.get("T"))
|
||||
by = {("넓이", "부피"): thick, ("길이", "넓이"): width}
|
||||
if (a, b) == ("길이", "부피") and width is not None and thick is not None:
|
||||
by[(a, b)] = width * thick
|
||||
got = by.get((a, b))
|
||||
if got:
|
||||
return {"배": _json(got), "갈래": "추정"}
|
||||
return {"배": None, "갈래": "입력"}
|
||||
|
||||
|
||||
def fits(have: Any, want: Any) -> bool:
|
||||
"""단위가 맞음 — 같음 · 같은 성격 · 두 단위 중 하나(후보 점수 덤)."""
|
||||
units = {mm.plain_unit(u) for u in str(want or "").split("·") if u.strip()}
|
||||
return mm.plain_unit(have) in units or mp.same_kind(have, want)
|
||||
|
||||
|
||||
# ── 우리 쪽 목록(한 번 읽어 둠) ─────────────────────────────────────────
|
||||
def _grams(text: str) -> set[str]:
|
||||
return {text[i : i + 2] for i in range(len(text) - 1)} or {text}
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _targets(whole_id: int) -> dict[str, Any]:
|
||||
"""로직(돈) · 자재품목 — 정규 이름 · 정리 이름 · 묶음 · 단위."""
|
||||
files, whole = m01.loaded()
|
||||
rows = [r for d in files.values() if d.get("그룹") == "로직" for r in d.get("줄") or []]
|
||||
pills = combo_store.combo_pills({"담은로직": [{"로직": r.get("키")} for r in rows]})["물량"]
|
||||
unit_of = {code: pill["단위"] for pill in pills for code in pill["로직"]} # 돈 로직만
|
||||
logics, mats = [], []
|
||||
for row in rows:
|
||||
if str(row.get("키")) in unit_of:
|
||||
name = str(row.get("이름") or "")
|
||||
logics.append(
|
||||
{
|
||||
"로직": str(row["키"]),
|
||||
"이름": name,
|
||||
"단위": unit_of[str(row["키"])],
|
||||
"산림": "산림" in str(row.get("구분") or ""),
|
||||
"plain": mm.plain(name),
|
||||
"tidy": tidy(name),
|
||||
}
|
||||
)
|
||||
for key, row in whole.index.get(mm.MARKET, {}).items():
|
||||
name = str(row.get("이름") or "")
|
||||
mats.append(
|
||||
{
|
||||
"자재": str(key),
|
||||
"이름": name,
|
||||
"규격": str(row.get("규격") or ""),
|
||||
"단위": str(row.get("단위") or ""),
|
||||
"plain": mm.plain(name),
|
||||
"spec": mm.plain_spec(row.get("규격")),
|
||||
"tidy": tidy(re.sub(r"-(사급|관급)$", "", name.strip())),
|
||||
}
|
||||
)
|
||||
names: dict[str, dict[str, list]] = {"로직": {}, "자재": {}}
|
||||
for pool, group in (("로직", logics), ("자재", mats)):
|
||||
for one in group:
|
||||
one["grams"] = _grams(one["tidy"])
|
||||
names[pool].setdefault(one["plain"], []).append(one)
|
||||
return {"로직": logics, "자재": mats, "이름": names, "씨앗": _seeds()}
|
||||
|
||||
|
||||
def targets() -> dict[str, Any]:
|
||||
return _targets(id(m01.loaded()[1]))
|
||||
|
||||
|
||||
def _seeds() -> dict[str, set[str]]:
|
||||
"""씨앗 `{정규 실무 글: {로직 키}}` — 일위대가조합.json 담은로직 메모(읽기만)."""
|
||||
try:
|
||||
doc = json.loads((Path(m01.FOLDER) / SEED_NAME).read_bytes())
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
out: dict[str, set[str]] = {}
|
||||
for row in doc.get("줄") or []:
|
||||
for one in row.get("담은로직") or []:
|
||||
memo = _SEED_TAIL.sub("", str(one.get("메모") or ""))
|
||||
if memo and one.get("로직"):
|
||||
out.setdefault(mm.plain(memo), set()).add(str(one["로직"]))
|
||||
return out
|
||||
|
||||
|
||||
def _public(one: dict[str, Any]) -> dict[str, Any]:
|
||||
keep = ("로직", "자재", "이름", "규격", "단위")
|
||||
return {k: one[k] for k in keep if k in one}
|
||||
|
||||
|
||||
def _exact(line: dict[str, Any], pool: str, got: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""② 이름 같음 · 씨앗 — 겹치면 여럿(부르는 쪽이 후보로 돌림)."""
|
||||
name, spec = mm.plain(line.get("이름")), mm.plain_spec(line.get("규격"))
|
||||
same = got["이름"][pool].get(name) or []
|
||||
if pool == "자재":
|
||||
return [m for m in same if m["spec"] == spec] or (same if len(same) == 1 else [])
|
||||
if same:
|
||||
return same
|
||||
codes = got["씨앗"].get(mm.plain(f"{line.get('이름')}{line.get('규격')}")) or got["씨앗"].get(
|
||||
name
|
||||
)
|
||||
return [x for x in got["로직"] if x["로직"] in (codes or ())]
|
||||
|
||||
|
||||
def candidates(
|
||||
line: dict[str, Any], pool: str, got: dict[str, Any] | None = None, limit: int = 5
|
||||
) -> list[dict[str, Any]]:
|
||||
"""③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit`."""
|
||||
me = tidy(line.get("이름"))
|
||||
grams, unit = _grams(me), line.get("단위")
|
||||
text = mm.plain_spec(f"{line.get('이름')}{line.get('규격')}")
|
||||
scored, seen = [], set()
|
||||
for one in (got or targets())[pool]:
|
||||
if not grams & one["grams"]:
|
||||
continue
|
||||
score = 2 * len(grams & one["grams"]) / (len(grams) + len(one["grams"]))
|
||||
score += 1 if one["tidy"] == me else 0
|
||||
score += 0.15 if fits(unit, one["단위"]) else 0
|
||||
score += 0.05 if one.get("산림") else 0
|
||||
score += 0.5 if one.get("spec") and one["spec"] in text else 0
|
||||
scored.append((score, one))
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
out = []
|
||||
for score, one in scored:
|
||||
label = (one["이름"], one.get("규격"), one["단위"])
|
||||
if label in seen:
|
||||
continue
|
||||
seen.add(label)
|
||||
out.append({**_public(one), "점수": round(score, 3)})
|
||||
if len(out) == limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
# ── 층 기억 ───────────────────────────────────────────────────────────
|
||||
def chain(folder: Path) -> list[Path]:
|
||||
"""기억을 볼 층 폴더 — 같은 층 → 위 층(개인 → 회사 → 마스터)."""
|
||||
layer, owner = refs.where(folder)
|
||||
out = [Path(folder)]
|
||||
if layer == "personal":
|
||||
out.append(refs.folder_of("company", str(owner).split("/")[0]))
|
||||
if layer in ("personal", "company"):
|
||||
out.append(Path(store.FOLDER))
|
||||
return out
|
||||
|
||||
|
||||
def memories(folder: Path) -> list[dict[str, dict[str, Any]]]:
|
||||
return [hopyo_store.memory(one) for one in chain(folder)]
|
||||
|
||||
|
||||
def recall(line: dict[str, Any], memo: list[dict[str, dict[str, Any]]]) -> dict[str, Any] | None:
|
||||
key = line_key(line)
|
||||
return next((one[key] for one in memo if key in one), None)
|
||||
|
||||
|
||||
# ── 줄 하나 맞춤 ───────────────────────────────────────────────────────
|
||||
def _logic_specs(code: str) -> list[dict[str, Any]]:
|
||||
got = labor_inputs.logic_inputs([code])["로직"]
|
||||
return got[0]["입력"] if got else []
|
||||
|
||||
|
||||
def _target_of(remembered: dict[str, Any], got: dict[str, Any]) -> dict[str, Any] | None:
|
||||
if remembered.get("로직"):
|
||||
return next((x for x in got["로직"] if x["로직"] == remembered["로직"]), None)
|
||||
if remembered.get("자재"):
|
||||
return next((x for x in got["자재"] if x["자재"] == remembered["자재"]), None)
|
||||
return None
|
||||
|
||||
|
||||
def match_line(
|
||||
line: dict[str, Any],
|
||||
memo: list[dict[str, dict[str, Any]]],
|
||||
units: dict[int, str],
|
||||
full: bool = True,
|
||||
got: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""줄 → `{열쇠, 상태, 대상, 입력값, 못읽음, 환산, 들어감, 후보?}` —
|
||||
`units` = {호표 번: 단위}(일위N 참조) · `full` = 입력값 · 후보까지(아니면 상태 · 환산만)."""
|
||||
out: dict[str, Any] = {"열쇠": line_key(line), "대상": None, "입력값": {}, "못읽음": []}
|
||||
kind = line.get("갈래")
|
||||
pool = "자재" if kind in MATERIAL_KINDS else "로직"
|
||||
remembered = recall(line, memo)
|
||||
got = got or targets()
|
||||
if kind == "%":
|
||||
return {**out, "상태": "%", "환산": None, "들어감": False}
|
||||
if remembered and remembered.get("빼기"):
|
||||
return {**out, "상태": "빼기", "환산": None, "들어감": False}
|
||||
if kind == "호표":
|
||||
no = int((line.get("참조") or {}).get("번") or 0)
|
||||
conv = factor(line.get("단위"), units.get(no), line.get("규격"))
|
||||
target = {"호표": no, "단위": units.get(no)}
|
||||
ready = no in units and conv["배"] is not None and conv["갈래"] != "추정"
|
||||
return {**out, "상태": "호표", "대상": target, "환산": conv, "들어감": ready}
|
||||
if remembered and (target := _target_of(remembered, got)):
|
||||
conv = {"배": remembered.get("환산"), "갈래": "기억"}
|
||||
state = {"상태": "기억", "대상": _public(target), "입력값": remembered.get("입력값") or {}}
|
||||
return {**out, **state, "환산": conv, "들어감": conv["배"] is not None}
|
||||
found = _exact(line, pool, got)
|
||||
if len(found) == 1:
|
||||
target = found[0]
|
||||
conv = factor(line.get("단위"), target["단위"], line.get("규격"))
|
||||
out.update(상태="같음", 대상=_public(target), 환산=conv)
|
||||
out["들어감"] = conv["배"] is not None and conv["갈래"] != "추정"
|
||||
if full and "로직" in target:
|
||||
out["입력값"], out["못읽음"] = spec_inputs(line, _logic_specs(target["로직"]))
|
||||
return out
|
||||
out.update(상태="후보" if found else "없음", 환산=None, 들어감=False)
|
||||
if not full:
|
||||
return out
|
||||
top = [{**_public(x), "점수": 9} for x in found]
|
||||
same = [_public(x) for x in found]
|
||||
rest = [
|
||||
c
|
||||
for c in candidates(line, pool, got)
|
||||
if {k: v for k, v in c.items() if k != "점수"} not in same
|
||||
]
|
||||
out["후보"] = (top + rest)[:5]
|
||||
return {**out, "상태": "후보" if out["후보"] else "없음"}
|
||||
|
||||
|
||||
def guess(folder: Path, key: str, no: int, at: int, target: dict[str, Any]) -> dict[str, Any]:
|
||||
"""후보 · 고른 대상 하나 미리 채움 — `{대상, 입력값, 못읽음, 환산}`(확정 전 화면 보기)."""
|
||||
line = _line(folder, key, no, at)
|
||||
found = _target_of(target if isinstance(target, dict) else {}, targets())
|
||||
if found is None:
|
||||
raise store.StoreError(400, "돈 로직 · 우리 자재가 아님")
|
||||
target = _public(found)
|
||||
conv = factor(line.get("단위"), target.get("단위"), line.get("규격"))
|
||||
if not target.get("로직"):
|
||||
return {"대상": target, "입력값": {}, "못읽음": [], "환산": conv}
|
||||
values, unread = spec_inputs(line, _logic_specs(str(target["로직"])))
|
||||
return {"대상": target, "입력값": values, "못읽음": unread, "환산": conv}
|
||||
|
||||
|
||||
def _line(folder: Path, key: str, no: int, at: int) -> dict[str, Any]:
|
||||
doc = hopyo_store.read(folder, key)
|
||||
hopyo = next((h for h in doc.get("호표") or [] if int(h["번"]) == int(no)), None)
|
||||
lines = (hopyo or {}).get("줄") or []
|
||||
if not 0 <= int(at) < len(lines):
|
||||
raise store.StoreError(404, f"없는 줄 「제{no}호표 {at}」")
|
||||
return lines[int(at)]
|
||||
|
||||
|
||||
def match_hopyo(
|
||||
hopyo: dict[str, Any],
|
||||
memo: list[dict[str, dict[str, Any]]],
|
||||
units: dict[int, str],
|
||||
full: bool = True,
|
||||
got: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""호표 하나 — 줄마다 맞춤 · `맞춤` = [들어감 수, 줄 수]."""
|
||||
got = got or targets()
|
||||
lines = [
|
||||
{**one, "맞춤": match_line(one, memo, units, full, got)} for one in hopyo.get("줄") or []
|
||||
]
|
||||
ready = sum(1 for one in lines if one["맞춤"]["들어감"])
|
||||
return {**hopyo, "줄": lines, "맞춤": [ready, len(lines)]}
|
||||
|
||||
|
||||
def units_of(doc: dict[str, Any]) -> dict[int, str]:
|
||||
return {int(h["번"]): str(h.get("단위") or "") for h in doc.get("호표") or []}
|
||||
|
||||
|
||||
def view(folder: Path, key: str, no: int | None = None) -> dict[str, Any]:
|
||||
"""실무자료 보기 — `no` 없으면 호표마다 맞춤 수만 · 있으면 그 호표 줄마다 맞춤 · 후보."""
|
||||
doc = hopyo_store.read(folder, key)
|
||||
memo, units = memories(folder), units_of(doc)
|
||||
if no is None:
|
||||
got = targets()
|
||||
hopyos = [match_hopyo(h, memo, units, False, got) for h in doc.get("호표") or []]
|
||||
brief = [{k: h[k] for k in ("번", "이름", "규격", "단위", "금액", "맞춤")} for h in hopyos]
|
||||
ready = [sum(h["맞춤"][0] for h in hopyos), sum(h["맞춤"][1] for h in hopyos)]
|
||||
return {**hopyo_store.brief(doc), "검산": doc.get("검산"), "호표": brief, "맞춤": ready}
|
||||
hopyo = next((h for h in doc.get("호표") or [] if int(h["번"]) == int(no)), None)
|
||||
if hopyo is None:
|
||||
raise store.StoreError(404, f"없는 호표 「제{no}호표」")
|
||||
return {**hopyo_store.brief(doc), "호표": match_hopyo(hopyo, memo, units)}
|
||||
|
||||
|
||||
# ── 확정 저장 ─────────────────────────────────────────────────────────
|
||||
def confirm(folder: Path, entries: dict[str, Any]) -> dict[str, Any]:
|
||||
"""확정 · 빼기 · 지움 → 층 기억 — `{열쇠: {로직|자재|빼기, 입력값, 환산} | None}`.
|
||||
로직 = 돈 로직 · 자재 = M01 자재품목 · 환산 = 0 넘는 수(빼기는 안 봄)."""
|
||||
if not isinstance(entries, dict) or not entries:
|
||||
raise store.StoreError(400, "확정할 줄이 없음")
|
||||
got = targets()
|
||||
logics = {x["로직"] for x in got["로직"]}
|
||||
mats = {x["자재"] for x in got["자재"]}
|
||||
stamp = datetime.now().isoformat(timespec="seconds")
|
||||
clean: dict[str, Any] = {}
|
||||
for key, raw in entries.items():
|
||||
if raw is None:
|
||||
clean[str(key)] = None
|
||||
continue
|
||||
if not isinstance(raw, dict) or str(key).count("|") != 2:
|
||||
raise store.StoreError(400, f"확정 꼴이 틀림 「{key}」")
|
||||
if raw.get("빼기") is True:
|
||||
clean[str(key)] = {"빼기": True, "확정": stamp}
|
||||
continue
|
||||
code, mat = str(raw.get("로직") or ""), str(raw.get("자재") or "")
|
||||
if (code and code not in logics) or (mat and mat not in mats) or bool(code) == bool(mat):
|
||||
raise store.StoreError(400, f"돈 로직 · 우리 자재 하나만 「{key}」")
|
||||
times = raw.get("환산")
|
||||
number = None if isinstance(times, bool) else _number(times)
|
||||
if number is None or number <= 0:
|
||||
raise store.StoreError(400, f"환산은 0 넘는 수 「{key}」")
|
||||
values = raw.get("입력값") or {}
|
||||
if not isinstance(values, dict):
|
||||
raise store.StoreError(400, f"입력값이 묶음 아님 「{key}」")
|
||||
head = {"로직": code} if code else {"자재": mat}
|
||||
clean[str(key)] = {**head, "입력값": values, "환산": _json(number), "확정": stamp}
|
||||
return {"기억": hopyo_store.remember(folder, clean)}
|
||||
|
||||
|
||||
def _number(raw: Any) -> Decimal | None:
|
||||
try:
|
||||
got = Decimal(str(raw).replace(",", "").strip())
|
||||
except ArithmeticError:
|
||||
return None
|
||||
return got if got.is_finite() else None
|
||||
@@ -19,6 +19,7 @@ from pydantic import BaseModel
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
@@ -208,9 +209,61 @@ def hopyo_upload(
|
||||
def hopyo_read(
|
||||
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
|
||||
) -> dict:
|
||||
"""실무자료 하나(호표 · 줄 · 검산)."""
|
||||
"""실무자료 하나 — 검산 · 호표마다 `맞춤` [들어감, 줄 수] · 파일 `맞춤` 합."""
|
||||
folder = _root(session, layer, owner)
|
||||
return _call(folder, hopyo_store.read, folder, key)
|
||||
return _call(folder, hopyo_match.view, folder, key)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/hopyo/{key}/{no}")
|
||||
def hopyo_one(
|
||||
layer: Layer,
|
||||
key: str,
|
||||
no: int,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""호표 하나 — 줄마다 맞춤(상태 · 대상 · 입력값 · 환산 · 들어감 · 후보 5)."""
|
||||
folder = _root(session, layer, owner)
|
||||
return _call(folder, hopyo_match.view, folder, key, no)
|
||||
|
||||
|
||||
class GuessBody(BaseModel):
|
||||
"""`차례` = 호표 안 줄(0부터) · `대상` = {로직} | {자재}."""
|
||||
|
||||
차례: int
|
||||
대상: dict[str, Any]
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/hopyo/{key}/{no}/guess")
|
||||
def hopyo_guess(
|
||||
layer: Layer,
|
||||
key: str,
|
||||
no: int,
|
||||
body: GuessBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""고른 대상 미리 채움(저장 안 함) — 규격 → 입력값 · 환산."""
|
||||
folder = _root(session, layer, owner)
|
||||
return _call(folder, hopyo_match.guess, folder, key, no, body.차례, body.대상)
|
||||
|
||||
|
||||
class MemoryBody(BaseModel):
|
||||
"""`줄` = {줄 열쇠: {로직|자재, 입력값, 환산} | {빼기: true} | null(지움)}."""
|
||||
|
||||
줄: dict[str, Any]
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/hopyo-memory")
|
||||
def hopyo_memory(
|
||||
layer: Layer,
|
||||
body: MemoryBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""맞춤 확정 · 빼기 → 층 기억(층 쓰기) — 다음부터 같은 줄은 자동."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
return _call(folder, hopyo_match.confirm, folder, body.줄)
|
||||
|
||||
|
||||
@router.delete("/layers/{layer}/hopyo/{key}")
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""일위대가 실무자료 맞춤(PLAN 324-2 · `M02_Hopyo_Match`) — 규격 → 입력값 · 단위 · 환산 ·
|
||||
맞춤 차례 · 기억 우선 · 길(보기 · 미리 채움 · 확정) · master_data 원본 그대로. 층은 임시 폴더.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hm
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
# 올리기 길 시험의 `client` 마당을 같이 씀
|
||||
# ruff: noqa: F811
|
||||
from resources.tester.test_m02_hopyo_excel import URL, _post, client, master_hash # noqa: F401
|
||||
|
||||
mm = hm.mm
|
||||
|
||||
|
||||
# ── 규격 · 단위 · 환산 ─────────────────────────────────────────────────
|
||||
def test_규격_이름_붙은_수():
|
||||
assert hm.numbers("D=1,000mm,일반형") == {"D": (1000, "mm")}
|
||||
assert hm.numbers("H:1.2m") == {"H": (hm.Decimal("1.2"), "m")}
|
||||
assert {k: float(v) for k, (v, _) in hm.numbers("B=2.3,H=0.4,야면석").items()} == {
|
||||
"B": 2.3,
|
||||
"H": 0.4,
|
||||
}
|
||||
assert hm.numbers("L3=35cm적용,장비사용") == {"L3": (35, "cm")}
|
||||
assert hm.numbers("Φ40×200") == {} and mm.plain_spec("Φ40×200") == "ø40x200"
|
||||
|
||||
|
||||
def test_단위_정규화():
|
||||
got = [mm.plain_unit(u) for u in ("㎥", "M", "Ton", "TON", "EA", "㎡")]
|
||||
assert got == ["m3", "m", "톤", "톤", "개", "m2"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("have", "want", "spec", "times", "kind"),
|
||||
[
|
||||
("㎥", "㎥", "", 1, "같음"),
|
||||
("M", "m", "", 1, "같음"),
|
||||
("kg", "ton", "", 0.001, "단위표"),
|
||||
("㎡", "㎥", "T=150mm", 0.15, "추정"),
|
||||
("M", "㎡", "B=2.3,H=0.4", 2.3, "추정"),
|
||||
("M", "㎥", "B=2.3,T=0.2", 0.46, "추정"),
|
||||
("개소", "㎥", "", None, "입력"),
|
||||
("㎥", "㎡ · ㎥", "", 1, "두 단위"),
|
||||
("M", "㎡ · ㎥", "", None, "입력"),
|
||||
],
|
||||
)
|
||||
def test_환산(have, want, spec, times, kind):
|
||||
assert hm.factor(have, want, spec) == {"배": times, "갈래": kind}
|
||||
|
||||
|
||||
SPECS = [
|
||||
{"이름": "뒷길이", "단위": "㎝", "고르기": [25, 35, 45], "자동값": None},
|
||||
{"이름": "관경", "단위": "mm", "자동값": None},
|
||||
{"이름": "설치높이", "단위": "m", "자동값": None},
|
||||
{
|
||||
"이름": "기계",
|
||||
"고르기": ["0201-0020", "0201-0060"],
|
||||
"고르기이름": {
|
||||
"0201-0020": "굴착기(무한궤도) 0.2㎥",
|
||||
"0201-0060": "굴착기(무한궤도) 0.6㎥",
|
||||
},
|
||||
"자동값": None,
|
||||
},
|
||||
{"이름": "횟수", "고르기": ["3회사용시", "4회사용시"], "자동값": None},
|
||||
{"이름": "단위당", "고르기": ["㎡당", "㎥당"], "자동값": None},
|
||||
{"이름": "지역", "고르기": ["전국평균", "서울"], "자동값": "전국평균"},
|
||||
{"이름": "보정작업", "고르기": ["해당 없음", "전석섞인토사"], "자동값": None},
|
||||
]
|
||||
|
||||
|
||||
def test_규격에서_입력값():
|
||||
line = {"이름": "찰붙임(4회)", "규격": "L3=35cm적용,굴삭기0.6㎥,D=1,000mm,H:1.2m", "단위": "㎥"}
|
||||
values, unread = hm.spec_inputs(line, SPECS)
|
||||
assert values == {
|
||||
"뒷길이": 35,
|
||||
"관경": 1000,
|
||||
"설치높이": 1.2,
|
||||
"기계": "0201-0060",
|
||||
"횟수": "4회사용시",
|
||||
"단위당": "㎥당",
|
||||
}
|
||||
assert unread == ["보정작업"] # 지역 = 자동값 · 못 읽은 것만 표시
|
||||
other = {"이름": "찰붙임", "규격": "L3=40cm", "단위": "㎡"}
|
||||
values, unread = hm.spec_inputs(other, SPECS[:1])
|
||||
assert values == {} and unread == ["뒷길이"] # 고르기 밖 수 = 빈 값 + 표시
|
||||
|
||||
|
||||
# ── 맞춤 차례 ────────────────────────────────────────────────────────
|
||||
def _hopyo(client, no, layer="personal"):
|
||||
res = client.get(f"{URL.format(layer)}/HP-000001/{no}")
|
||||
assert res.status_code == 200, res.text
|
||||
return {one["이름"]: one["맞춤"] for one in res.json()["호표"]["줄"]}
|
||||
|
||||
|
||||
def test_이름_같음_후보_호표_퍼센트(client):
|
||||
before = master_hash()
|
||||
assert _post(client).status_code == 200
|
||||
first = _hopyo(client, 1)
|
||||
plywood = first["합판거푸집"]
|
||||
assert plywood["상태"] == "같음" and plywood["대상"]["로직"] == "GF000160"
|
||||
assert plywood["입력값"] == {"횟수": "4회사용시"} and plywood["들어감"] is True
|
||||
remicon = first["레미콘(25-21-120)"]
|
||||
assert remicon["상태"] == "후보" and remicon["들어감"] is False
|
||||
assert all(c["규격"] == "25-21-120" for c in remicon["후보"][:3]) # 규격 같은 자재 먼저
|
||||
dig = _hopyo(client, 3)["구조물터파기(인력100%)"]
|
||||
assert dig["상태"] == "후보" and len(dig["후보"]) == 5
|
||||
assert dig["후보"][0]["이름"].startswith("구조물터파기")
|
||||
ref = _hopyo(client, 45)["고화매트재료비"]
|
||||
assert ref["상태"] == "호표" and ref["대상"] == {"호표": 46, "단위": "㎡"} and ref["들어감"]
|
||||
pct = _hopyo(client, 46)["잡재료비"]
|
||||
assert pct["상태"] == "%" and pct["들어감"] is False
|
||||
whole = client.get(f"{URL.format('personal')}/HP-000001").json()
|
||||
assert whole["맞춤"][1] == 415 and 0 < whole["맞춤"][0] < 415
|
||||
assert next(h for h in whole["호표"] if h["번"] == 61)["맞춤"] == [0, 0]
|
||||
assert master_hash() == before
|
||||
|
||||
|
||||
def test_미리_채움_확정_기억_우선(client):
|
||||
assert _post(client).status_code == 200
|
||||
url = f"{URL.format('personal')}/HP-000001/3/guess"
|
||||
got = client.post(url, json={"차례": 1, "대상": {"로직": "GF000101"}}).json()
|
||||
assert got["환산"] == {"배": 1, "갈래": "같음"} and got["대상"]["단위"] == "㎥"
|
||||
assert client.post(url, json={"차례": 1, "대상": {"로직": "XX"}}).status_code == 400
|
||||
key = _hopyo(client, 3)["구조물터파기(인력100%)"]["열쇠"]
|
||||
assert key == "구조물터파기(인력100%)|토사|m3"
|
||||
body = {"줄": {key: {"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 1}}}
|
||||
assert client.post("/api/m02/layers/personal/hopyo-memory", json=body).status_code == 200
|
||||
dig = _hopyo(client, 3)["구조물터파기(인력100%)"]
|
||||
assert (dig["상태"], dig["대상"]["로직"], dig["입력값"], dig["들어감"]) == (
|
||||
"기억",
|
||||
"GF000101",
|
||||
{"지역": "서울"},
|
||||
True,
|
||||
)
|
||||
# 이름 같음보다 기억이 앞섬 · 빼기
|
||||
plywood = _hopyo(client, 1)["합판거푸집"]["열쇠"]
|
||||
out = {"줄": {plywood: {"빼기": True}}}
|
||||
assert client.post("/api/m02/layers/personal/hopyo-memory", json=out).status_code == 200
|
||||
assert _hopyo(client, 1)["합판거푸집"]["상태"] == "빼기"
|
||||
clear = {"줄": {plywood: None}}
|
||||
assert client.post("/api/m02/layers/personal/hopyo-memory", json=clear).status_code == 200
|
||||
assert _hopyo(client, 1)["합판거푸집"]["상태"] == "같음"
|
||||
|
||||
|
||||
def test_확정_검사와_위_층_기억(client):
|
||||
bad = [
|
||||
{"로직": "GC000422", "환산": 1}, # 돈 아닌 로직
|
||||
{"로직": "GF000101", "환산": 0},
|
||||
{"로직": "GF000101", "자재": "MT004258", "환산": 1},
|
||||
{"환산": 1},
|
||||
]
|
||||
for one in bad:
|
||||
res = client.post("/api/m02/layers/personal/hopyo-memory", json={"줄": {"a|b|c": one}})
|
||||
assert res.status_code == 400, one
|
||||
assert client.post("/api/m02/layers/system/hopyo-memory", json={"줄": {}}).status_code == 403
|
||||
client.who.update(role="SYSTEM_ADMIN")
|
||||
key = "구조물터파기(인력100%)|토사|m3"
|
||||
body = {"줄": {key: {"로직": "GF000103", "환산": 1}}}
|
||||
assert client.post("/api/m02/layers/system/hopyo-memory", json=body).status_code == 200
|
||||
client.who.update(role="USER")
|
||||
assert _post(client).status_code == 200
|
||||
assert _hopyo(client, 3)["구조물터파기(인력100%)"]["대상"]["로직"] == "GF000103" # 위 층
|
||||
mine = {"줄": {key: {"로직": "GF000101", "환산": 1}}}
|
||||
assert client.post("/api/m02/layers/personal/hopyo-memory", json=mine).status_code == 200
|
||||
assert _hopyo(client, 3)["구조물터파기(인력100%)"]["대상"]["로직"] == "GF000101" # 같은 층 먼저
|
||||
assert hm.chain(layers.personal_dir(7, 42))[1] == layers.company_dir(7)
|
||||
Reference in New Issue
Block a user