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:
2026-10-14 22:12:00 +09:00
co-authored by Claude Opus 5.5
parent 63ba44a717
commit 5e33dda958
3 changed files with 710 additions and 2 deletions
+488
View File
@@ -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
+55 -2
View File
@@ -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}")
+167
View File
@@ -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)