feat(일위대가실무): 324-3 실무 호표 → 우리 일위대가 사전조합 만들기 · 사전조합 줄 별칭 살림

- M02_Hopyo_Build: 호표 = 사전조합 하나(이름 = 품명 + 규격 · 엑셀 단위 · 구분 실무자료 · 상세구분 파일 · 본뜬 실무자료 표시) · 로직 줄 {별칭 = 엑셀 품명 40 자 · 메모 「실무 0.14㎥ × 1 · 규격」 · 입력값 · 수량 손 = 엑셀 × 환산} · 자재 줄(값 없음 = 별도계상 · 아니면 직접계산 · 같은 자재 수량 더함) · 같은 로직 둘째 = 한 줄 사전조합으로 감쌈 · 일위N = 먼저 만들어 참조 · 순환 = 빠진 줄 · 같은 층 · 파일 · 호표 = 다시 씀 · 새로 = 그 키에 덮음 · 빠진 줄 + 엑셀 금액 · 만든 값 ↔ 엑셀 단가
- M02_Labor_Ref._combo_lines: 별칭 있으면 남김(한 줄)
- 길 POST /layers/{층}/hopyo-build(이 층 쓰기 · 파일 층 읽기)
- 시험 test_m02_hopyo_build 5 통과 · 곁 시험 84 통과

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:15:59 +09:00
co-authored by Claude Opus 5.5
parent 5e33dda958
commit 736aa405cf
4 changed files with 382 additions and 0 deletions
+204
View File
@@ -0,0 +1,204 @@
"""M02 일위대가 실무자료 → 우리 일위대가 사전조합(PLAN 324-3).
호표 하나 = 사전조합 하나 — 이름 = 품명 + 규격 · 단위 = 엑셀 단위 · 구분 「실무자료」 ·
상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일} ·
만드는 층 = 부르는 층(일위대가 문서 층). 들어가는 줄(`M02_Hopyo_Match` 의 `들어감`)만:
로직 = {로직, 별칭 = 엑셀 품명 40 자, 메모 = 「실무 0.14㎥ × 1 · 규격」, 입력값,
수량 {손 = 엑셀 수량 × 환산}} · 같은 로직 둘째부터 = 그 줄 하나짜리 사전조합으로 감싸 참조
(같은 로직 두 번 금지) · 자재 = {코드, 수량 {손}, 별도계상 = 엑셀 값 없음 「별도계상」 ·
아니면 「직접계산」}(같은 자재 = 수량 더함) · 일위N = 그 호표 사전조합을 먼저 만들어 참조
(순환 = 빠진 줄).
같은 층 · 같은 파일 · 같은 호표 = 있던 것을 다시 씀 · `새로` = 그 키에 다시 만들어 덮음(감싼 것도).
빠진 줄(% · 못 맞춤 · 빼기 · 환산 입력 · 추정) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가.
엔진 · 일위대가 계산은 그대로(`M02_Labor_Ref.Expand`).
"""
from __future__ import annotations
import json
from decimal import Decimal
from pathlib import Path
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Match as 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
from M02_MasterTemplete import M02_MasterTemplete_Store as store
ORIGIN = "실무자료"
WHY = {
"%": "% 줄 — 우리 로직 덧줄이 셈",
"빼기": "뺌(확정)",
"후보": "못 맞춤 — 후보 확정 필요",
"없음": "못 맞춤",
}
def mark(key: str, no: int, at: int | None = None) -> str:
"""본뜬 키 — 호표 「HP-000001 제3호표」 · 감싼 줄 「… 2째 줄」."""
head = f"{key} 제{no}호표"
return head if at is None else f"{head} {at + 1}째 줄"
def _found(origin: str) -> tuple[str, str] | None:
"""지금 층 사전조합 가운데 본뜬 = 이 실무 표시 → (키, 판)."""
for path in sorted((store.root() / "precombo").glob("SC-*.json")):
try:
doc = json.loads(path.read_bytes())
except (OSError, ValueError):
continue
got = doc.get("본뜬") if isinstance(doc, dict) else None
if isinstance(got, dict) and got.get("층") == ORIGIN and got.get("키") == origin:
return path.stem, store.version_of(path.read_bytes())
return None
def _save(doc: dict[str, Any], fresh: bool) -> tuple[dict[str, Any], bool]:
"""(참조, 다시씀) — 있으면 그대로(`fresh` 면 그 키에 덮음) · 없으면 새 키."""
at = refs.here()
have = _found(doc["본뜬"]["키"])
if have and not fresh:
return {"층": at[0], "주인": at[1], "키": have[0]}, True
if have:
store.write("precombo", have[0], have[1], doc)
key = have[0]
else:
key = refs.create("precombo", doc)["이름"]
return {"층": at[0], "주인": at[1], "키": key}, bool(have)
def _num(value: Decimal) -> int | float:
value = value.normalize()
return int(value) if value == value.to_integral_value() else float(value)
def _memo(line: dict[str, Any], times: Any) -> str:
qty = line.get("수량")
spec = str(line.get("규격") or "").strip()
head = f"실무 {qty if qty is not None else ''}{line.get('단위') or ''} × {times}"
return f"{head} · {spec}" if spec else head
def _missing(line: dict[str, Any], why: str) -> dict[str, Any]:
keep = ("차례", "이름", "규격", "수량", "단위")
return {
**{k: line.get(k) for k in keep},
"금액": (line.get("금액") or {}).get("계"),
"까닭": why,
}
class Builder:
"""한 번 만들기 — 파일 · 층 기억 · 우리 쪽 목록을 한 번 읽고 호표마다 만든 참조를 기억."""
def __init__(self, source: Path, folder: Path, key: str, fresh: bool) -> None:
self.doc = hopyo_store.read(source, key)
self.key, self.fresh = key, fresh
self.memo, self.units = match.memories(folder), match.units_of(self.doc)
self.got = match.targets()
self.made: dict[int, dict[str, Any]] = {}
self.missing: dict[int, list[dict[str, Any]]] = {}
def hopyo(self, no: int) -> dict[str, Any]:
found = next((h for h in self.doc.get("호표") or [] if int(h["번"]) == int(no)), None)
if found is None:
raise store.StoreError(404, f"없는 호표 「제{no}호표」")
return found
def build(self, no: int, trail: tuple[int, ...] = ()) -> dict[str, Any]:
"""호표 → 사전조합 참조 — 일위N 은 먼저(순환은 빠진 줄)."""
if no in self.made:
return self.made[no]
hopyo = match.match_hopyo(self.hopyo(no), self.memo, self.units, True, self.got)
lines, mats, missing = [], {}, []
used: set[str] = set()
for line in hopyo["줄"]:
got = line["맞춤"]
if not got["들어감"]:
conv = got.get("환산") or {}
why = WHY.get(got["상태"]) or (
"추정 환산 — 확정 필요" if conv.get("갈래") == "추정" else "환산 입력 필요"
)
missing.append(_missing(line, why))
continue
times = got["환산"]["배"]
qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(times))
target = got["대상"]
alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None
memo = _memo(line, times)
if "자재" in target:
code = target["자재"]
apart = "별도계상" if line.get("갈래") == "별도계상" else "직접계산"
have = mats.get(code)
hand = qty + Decimal(str(have["수량"]["손"])) if have else qty
mats[code] = {"코드": code, "수량": {"손": _num(hand)}, "별도계상": apart}
continue
if "호표" in target:
child = int(target["호표"])
if child in (*trail, no):
missing.append(_missing(line, "순환 — 호표가 서로 담음"))
continue
ref = self.build(child, (*trail, no))
lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}})
continue
one = {"로직": target["로직"], "입력값": got.get("입력값") or {}, "메모": memo}
if target["로직"] not in used:
used.add(target["로직"])
lines.append({**one, "별칭": alias, "수량": {"손": _num(qty)}})
continue
wrap = { # 같은 로직 둘째 — 한 줄 사전조합으로 감쌈
"이름": f"{hopyo['이름']} — {line.get('이름')}",
"단위": target.get("단위"),
"구분": ORIGIN,
"상세구분": self.doc.get("파일") or "",
"담은로직": [{**one, "별칭": alias, "수량": {"손": 1}}],
"자재": [],
"본뜬": self._origin(mark(self.key, no, line["차례"])),
}
ref, _ = _save(wrap, self.fresh)
lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}})
name = " ".join(x for x in (hopyo["이름"], hopyo.get("규격")) if x)
doc = {
"이름": name,
"단위": hopyo.get("단위") or None,
"구분": ORIGIN,
"상세구분": self.doc.get("파일") or "",
"담은로직": lines,
"자재": list(mats.values()),
"본뜬": self._origin(mark(self.key, no)),
}
ref, again = _save(doc, self.fresh)
self.made[no] = {**ref, "다시씀": again}
self.missing[no] = missing
return self.made[no]
def _origin(self, origin: str) -> dict[str, Any]:
return {"층": ORIGIN, "주인": None, "키": origin, "파일": self.doc.get("파일")}
def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]:
"""만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가."""
got = refs.Expand(m01.loaded()[1])({k: ref[k] for k in ("층", "주인", "키")})
base = got.get("기준") or {}
ours = float(base["계"]) if "계" in base else None
excel = (hopyo.get("금액") or {}).get("계")
gap = round(ours - excel, 1) if ours is not None and excel is not None else None
return {"엑셀": excel, "우리": ours, "차이": gap, "까닭": got.get("까닭")}
def build(folder: Path, source: Path, key: str, no: int, fresh: bool = False) -> dict[str, Any]:
"""실무 호표(`source` 층 파일) → 지금 층(`folder`) 사전조합 —
`{참조, 이름, 다시씀, 빠진줄, 비교}`(부르는 쪽이 `store.at(folder)`)."""
maker = Builder(source, folder, key, fresh)
made = maker.build(int(no))
hopyo = maker.hopyo(int(no))
ref = {k: made[k] for k in ("층", "주인", "키")}
return {
"참조": ref,
"이름": hopyo.get("이름"),
"다시씀": made["다시씀"],
"빠진줄": maker.missing.get(int(no), []),
"비교": compare(ref, hopyo),
}
+1
View File
@@ -210,6 +210,7 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]:
if "로직" in one:
head["입력값"] = one["입력값"]
head.update({k: one[k] for k in ("켜쌓기율", "고름", "자재표시") if k in one})
head.update({"별칭": one["별칭"]} if one["별칭"] else {}) # 실무 품명(324-3)
lines.append({**head, "수량": one["수량"] or {"손": 1}, "메모": one["메모"]})
at_ = here()
me = {"층": at_[0], "주인": at_[1], "키": key}
+25
View File
@@ -18,6 +18,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build
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
@@ -266,6 +267,30 @@ def hopyo_memory(
return _call(folder, hopyo_match.confirm, folder, body.줄)
class BuildBody(BaseModel):
"""실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` · `새로` = 있던 것을 덮음."""
층: Layer
주인: str | None = None
키: str
번: int
새로: bool = False
@router.post("/layers/{layer}/hopyo-build")
def hopyo_make(
layer: Layer,
body: BuildBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""실무 호표 → 우리 일위대가 사전조합(이 층 쓰기 · 파일 층 읽기) —
`{참조, 이름, 다시씀, 빠진줄, 비교}`."""
folder = _root(session, layer, owner, write=True)
source = _root(session, body.층, body.주인)
return _call(folder, hopyo_build.build, folder, source, body.키, body.번, body.새로)
@router.delete("/layers/{layer}/hopyo/{key}")
def hopyo_delete(
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
+152
View File
@@ -0,0 +1,152 @@
"""일위대가 실무자료 → 사전조합 만들기(PLAN 324-3 · `M02_Hopyo_Build`) — 별칭 남음 ·
수량 = 엑셀 × 환산 · 자재 · 같은 로직 둘 = 감쌈 · 일위N 참조 · 순환 ·
같은 호표 다시 = 같은 사전조합 · 새로 = 덮음 ·
빠진 줄 · 값 비교 · 길 권한 · master_data 원본 그대로. 층은 임시 폴더.
"""
import json
from M02_MasterTemplete import M02_Hopyo_Store as hs
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
DIG = "구조물터파기(인력100%)|토사|m3"
FILL = "구조물되메우기(인력100%)||m3"
SOIL = "잔토처리(인력100%)||m3"
STONE = "임도표지석(받침,글새김포함)|900x600x200x150|m3"
MEMORY = "/api/m02/layers/{}/hopyo-memory"
BUILD = "/api/m02/layers/{}/hopyo-build"
def _remember(client, layer="personal", **lines):
res = client.post(MEMORY.format(layer), json={"줄": lines})
assert res.status_code == 200, res.text
def _build(client, no, layer="personal", want=200, **extra):
body = {"층": "personal", "키": "HP-000001", "번": no, **extra}
res = client.post(BUILD.format(layer), json=body)
assert res.status_code == want, res.text
return res.json()
def _doc(layer_dir, key):
return json.loads((layer_dir / "precombo" / f"{key}.json").read_bytes())
def test_만들기_별칭_수량_메모_빠진_줄_비교(client):
before = master_hash()
assert _post(client).status_code == 200
_remember(
client,
**{
DIG: {"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 1},
FILL: {"로직": "GF000113", "환산": 2},
},
)
got = _build(client, 3)
mine = layers.personal_dir(7, 42)
doc = _doc(mine, got["참조"]["키"])
assert (doc["이름"], doc["단위"], doc["구분"]) == ("임도표지석 910×600×150", "개소", "실무자료")
assert doc["상세구분"].endswith("(공통단가).xls")
assert doc["본뜬"]["층"] == "실무자료" and doc["본뜬"]["키"] == "HP-000001 제3호표"
assert doc["담은로직"] == [
{
"로직": "GF000101",
"입력값": {"지역": "서울"},
"별칭": "구조물터파기(인력100%)",
"수량": {"손": 0.14},
"메모": "실무 0.14㎥ × 1 · 토사",
},
{
"로직": "GF000113",
"입력값": {},
"별칭": "구조물되메우기(인력100%)",
"수량": {"손": 0.1}, # 엑셀 0.05 × 환산 2
"메모": "실무 0.05㎥ × 2",
},
]
assert [(x["이름"], x["금액"]) for x in got["빠진줄"]] == [
("임도표지석(받침,글새김포함)", 1400000),
("잔토처리(인력100%)", 3097.1),
]
assert got["비교"]["엑셀"] == 1415169 and got["비교"]["우리"] > 0
assert got["비교"]["차이"] == round(got["비교"]["우리"] - 1415169, 1)
assert master_hash() == before
def test_자재_같은_로직_감쌈_다시씀_새로(client):
assert _post(client).status_code == 200
_remember(
client,
**{
DIG: {"로직": "GF000101", "환산": 1},
SOIL: {"로직": "GF000101", "환산": 1},
STONE: {"자재": "MT004258", "환산": 1},
},
)
got = _build(client, 3)
mine = layers.personal_dir(7, 42)
doc = _doc(mine, got["참조"]["키"])
first, wrapped = doc["담은로직"]
assert first["로직"] == "GF000101" and first["별칭"] == "구조물터파기(인력100%)"
assert wrapped["별칭"] == "잔토처리(인력100%)" and wrapped["수량"] == {"손": 0.09}
inner = _doc(mine, wrapped["참조"]["키"])
assert inner["본뜬"]["키"] == "HP-000001 제3호표 4째 줄"
assert inner["담은로직"][0]["로직"] == "GF000101" and inner["담은로직"][0]["수량"] == {"손": 1}
assert [(m["코드"], m["수량"], m["별도계상"]) for m in doc["자재"]] == [
("MT004258", {"손": 1}, "직접계산")
]
files = sorted(p.name for p in (mine / "precombo").glob("*.json"))
again = _build(client, 3)
assert again["다시씀"] is True and again["참조"] == got["참조"]
assert sorted(p.name for p in (mine / "precombo").glob("*.json")) == files
_remember(client, **{SOIL: {"빼기": True}})
fresh = _build(client, 3, 새로=True)
assert fresh["참조"] == got["참조"] and fresh["다시씀"] is True
assert len(_doc(mine, got["참조"]["키"])["담은로직"]) == 1 # 덮음
assert sorted(p.name for p in (mine / "precombo").glob("*.json")) == files
def test_일위N_참조_먼저_만듦(client):
assert _post(client).status_code == 200
got = _build(client, 45)
mine = layers.personal_dir(7, 42)
lines = _doc(mine, got["참조"]["키"])["담은로직"]
ref = lines[0]
assert ref["별칭"] == "고화매트재료비" and ref["수량"] == {"손": 2.3}
child = _doc(mine, ref["참조"]["키"])
assert child["이름"] == "고화매트재료비" and child["본뜬"]["키"] == "HP-000001 제46호표"
assert any(x["까닭"].startswith("% 줄") for x in _build(client, 46)["빠진줄"])
def test_순환은_빠진_줄(client):
folder = layers.personal_dir(7, 42)
_post(client) # 층 뼈대
line = {"차례": 0, "이름": "남", "규격": "", "수량": 1, "단위": "식", "갈래": "호표"}
money = {"계": 1, "노무비": 1, "재료비": None, "경비": None}
hopyos = [
{"번": 1, "이름": "가", "규격": "", "단위": "식", "금액": money, "줄": [
{**line, "참조": {"종류": "일위", "번": 2}, "금액": money}]},
{"번": 2, "이름": "나", "규격": "", "단위": "식", "금액": money, "줄": [
{**line, "참조": {"종류": "일위", "번": 1}, "금액": money}]},
] # fmt: skip
key = hs.save(folder, "loop.xls", {"시트": "일위대가", "호표": hopyos, "검산": {}}, None)["키"]
got = _build(client, 1, 키=key)
assert got["빠진줄"] == []
inner = _doc(folder, _doc(folder, got["참조"]["키"])["담은로직"][0]["참조"]["키"])
assert inner["담은로직"] == [] # 제2호표 안 제1호표 = 순환 → 빠짐
def test_길_권한과_위_층_파일(client):
client.who.update(role="SYSTEM_ADMIN")
assert _post(client, "system").status_code == 200
client.who.update(role="USER")
_build(client, 3, layer="system", 층="system", want=403) # 마스터 쓰기 = 시스템 관리자
got = _build(client, 3, 층="system") # 마스터 파일 → 내 층 사전조합
assert got["참조"]["층"] == "personal" and got["참조"]["주인"] == "7/42"
assert _build(client, 99, 층="system", want=404)
assert client.get(URL.format("personal")).json()["줄"] == [] # 파일은 마스터에만