Files
Aislo/M02_MasterTemplete/M02_Labor_Doc.py
T

543 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장).
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬,
담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?, 고름?, 승격?, 자재표시?} |
{참조: {층, 주인, 키}, 별칭, 메모, 수량}],
자재: [{코드, 이름, 규격, 단위, 수량, 자재구분?, 별도계상?, 운반비?}], 계산값}` —
이름은 구조물 열 이름(따로 안 둠).
수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}`
(손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값).
금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은
반영 없음 · 자재 기준 단가 = `master_material.element` 기본값 · 자재 줄에 값 열 다섯(`가격`) ·
기준단가가 된 칸 이름(`기준칸`)도 실음(43-3).
켜쌓기율(85-2) = 표 주문 「골쌓기 기준」 로직만 · % · 없음 = 100 · 그 로직 세부항목 줄 전부 수량 × 율
(`layered` · `run_line` 한 곳 → 문서 · 프로젝트 사본 · 서고 같은 함수).
참조 줄(단가승격 · 사전조합 · 50-3) = 기준이 가리킨 문서 1 단위 값(펼침 `M02_Labor_Ref.Expand`) ·
분모 = 그 문서 단위 · 배 1 · 알약 키 = `ref_id` · 같은 참조 두 번 금지 · 위 층만.
짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` —
거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
로직 자재(103-1) = 담은 로직 맨 위 층 호표 재료 줄(`logic_mats` · 줄마다 `재료`) ·
고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와
같은 품목 두 번은 [저장] 때 400.
승격(134-1) = 로직 줄 「단가승격」 표시(`true` 만 · 없음 = 단가산출) · 금액 무관 · B09 호표 몫.
자재 세 칸(148-4) = `MARKS` 허용 값만(그 밖 · 빈 것 버림 · 금액 무관) — 사용자 자재 = 줄 덧칸 ·
로직 자재 = 로직 줄 `자재표시 {호표 차례: {세 칸}}`(차례가 재료 줄인 것만 · 틀리면 버림).
"""
from __future__ import annotations
import json
import re
from decimal import Decimal, InvalidOperation
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_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
mm = mf.mp.mm
COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비
MATERIAL_HEAD = "자재|"
_MAT_COLS = ("코드", "이름", "규격", "단위")
MARKS = {
"자재구분": ("일반", "관급", "사급"),
"별도계상": ("별도계상", "직접계산"),
"운반비": ("포함", "미포함"),
}
ALIAS_MAX = 40 # 별칭(44-6) 글자 수 상한 — 화면 칸과 같음
_BRACE = re.compile(r"\{([^{}]+)\}") # 품목 조건의 {입력}
def _bad(text: str) -> store.StoreError:
return store.StoreError(400, text)
def _number(raw: Any) -> Decimal | None:
"""칸 값 · 손 값 → Decimal — 수가 아니면 None(쉼표는 뗌)."""
if raw is None or isinstance(raw, bool):
return None
try:
got = Decimal(str(raw).replace(",", "").strip())
except InvalidOperation:
return None
return got if got.is_finite() else None
def _specs(logic: dict) -> list[dict]:
"""로직 입력 꼴 — 흐름 로직은 「설계 때 정할 것」."""
if isinstance(logic.get("흐름"), dict):
return mf.inputs_of(logic)
return list(logic.get("입력") or [])
def _qty(value: Any, where: str) -> dict | None:
if value is None:
return None
hand = _number(value.get("손")) if isinstance(value, dict) else None
if hand is None:
raise _bad(f"{where} · 수량은 null 또는 {{손: 수}}")
return {"손": int(hand) if hand == hand.to_integral_value() else float(hand)}
# ── 정리 · 검사 ───────────────────────────────────────────────────────
def clean(doc: Any) -> dict[str, Any]:
"""서버가 받는 꼴로 — 모르는 로직 · 같은 로직 두 번 · 없는 자재 · 수량 꼴이 틀리면 400 ·
모르는 입력 이름 · 빈 입력값은 버림 · 자재 이름 · 규격 · 단위는 M01 것."""
if not isinstance(doc, dict):
raise _bad("일위대가 문서가 묶음 아님")
whole = m01.loaded()[1]
lines, seen, here = [], set(), None
for at, one in enumerate(doc.get("담은로직") or [], start=1):
where = f"단가산출 로직 {at}째"
if not isinstance(one, dict):
raise _bad(f"{where} · 묶음 아님")
if one.get("참조") is not None and not one.get("로직"):
here = here or refs.here()
ref = refs.check_ref(one["참조"], where, here)
if refs.ref_id(ref) in seen:
raise _bad(f"{where} · 같은 참조를 두 번 담음 「{ref['키']}」")
seen.add(refs.ref_id(ref))
memo, alias = one.get("메모"), _alias(one.get("별칭"), where)
lines.append(
{
"참조": ref,
"별칭": alias,
"메모": str(memo) if memo else None,
"수량": _qty(one.get("수량"), where),
}
)
continue
code = str(one.get("로직") or "")
try:
logic = whole.logic(code)
except mf.FormulaError as e:
raise _bad(f"{where} · {e}") from e
if code in seen:
raise _bad(f"{where} · 같은 로직을 두 번 담음 「{code}」")
seen.add(code)
names = {str(spec.get("이름")) for spec in _specs(logic)}
given = one.get("입력값") or {}
if not isinstance(given, dict):
raise _bad(f"{where} · 입력값이 묶음 아님")
memo, alias = one.get("메모"), _alias(one.get("별칭"), where)
rate = _rate(one.get("켜쌓기율"), code, where)
picks = _picks(one.get("고름"), logic, whole, where)
lines.append(
{
"로직": code,
"별칭": alias,
"메모": str(memo) if memo else None,
"입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")},
"수량": _qty(one.get("수량"), where),
**({"켜쌓기율": rate} if rate is not None else {}),
**({"고름": picks} if picks else {}),
**({"승격": True} if one.get("승격") is True else {}),
**(
{"자재표시": shown} if (shown := _mat_marks(one.get("자재표시"), logic)) else {}
),
}
)
mats, codes = [], set()
for at, one in enumerate(doc.get("자재") or [], start=1):
where = f"자재 {at}째"
code = str(one.get("코드") or "") if isinstance(one, dict) else ""
try:
row = whole.get(code) if code[:2] == mm.MARKET else None
except mf.FormulaError:
row = None
if row is None:
raise _bad(f"{where} · M01 자재품목에 없는 코드 「{code}」")
if code in codes:
raise _bad(f"{where} · 같은 자재를 두 번 담음 「{code}」")
codes.add(code)
mats.append(
{
"코드": code,
**{k: row.get(k) or "" for k in _MAT_COLS[1:]},
"수량": _qty(one.get("수량"), where),
**marks(one),
}
)
out = {
"종류": "일위대가",
"판": doc.get("판") or 1,
"열": doc.get("열"),
"단위": str(doc["단위"]) if doc.get("단위") else None,
"본뜬": doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None,
"담은로직": lines,
"자재": mats,
}
if doc.get("일위대가번"):
out["일위대가번"] = doc["일위대가번"]
return out
def _alias(alias: Any, where: str) -> str | None:
if alias is not None and not isinstance(alias, str):
raise _bad(f"{where} · 별칭은 글")
alias = (alias or "").strip()
if len(alias) > ALIAS_MAX:
raise _bad(f"{where} · 별칭은 {ALIAS_MAX}자까지")
return alias or None
def _picks(value: Any, logic: dict, whole: Any, where: str) -> dict[str, str] | None:
"""고름(103-1) = `{호표 차례: MT키}` — 맨 위 층 재료 줄 · M01 자재품목 키만 · 빈 것 = None."""
if not value:
return None
if not isinstance(value, dict):
raise _bad(f"{where} · 고름은 {{호표 차례: MT키}}")
rows = logic.get("호표") or []
out = {}
for at, key in value.items():
n = int(at) if str(at).isdigit() else -1
if not 0 <= n < len(rows) or rows[n].get("종류") != "재료":
raise _bad(f"{where} · 고름 {at}째는 재료 줄 아님")
try:
ok = str(key)[:2] == mm.MARKET and whole.get(str(key)) is not None
except mf.FormulaError:
ok = False
if not ok:
raise _bad(f"{where} · 고름 「{key}」 M01 자재품목에 없음")
out[str(n)] = str(key)
return out
def marks(one: Any) -> dict[str, str]:
"""자재 세 칸(148-4) — 허용 값만."""
got = one if isinstance(one, dict) else {}
return {k: got[k] for k, allowed in MARKS.items() if got.get(k) in allowed}
def _mat_marks(value: Any, logic: dict) -> dict[str, dict[str, str]] | None:
"""로직 자재 세 칸(148-4) `{호표 차례: {세 칸}}` — 맨 위 층 재료 줄 차례 · 허용 값만 ·
빈 것 = None."""
rows = logic.get("호표") or []
out = {}
for at, got in (value if isinstance(value, dict) else {}).items():
n = int(at) if str(at).isdigit() else -1
if 0 <= n < len(rows) and rows[n].get("종류") == "재료" and (kept := marks(got)):
out[str(n)] = kept
return out or None
def _rate(value: Any, code: str, where: str) -> int | float | None:
"""켜쌓기율(85-2) — 0 초과 200 이하 · 소수 한 자리 · 100 · 대상 밖 로직은 None(안 둠)."""
if value is None or value == "":
return None
rate = _number(value)
if rate is None or not Decimal(0) < rate <= Decimal(200):
raise _bad(f"{where} · 켜쌓기율은 0 초과 200 이하 %")
if rate != rate.quantize(Decimal("0.1")):
raise _bad(f"{where} · 켜쌓기율은 소수 한 자리까지")
if rate == 100 or code not in labor_inputs.layered():
return None
return int(rate) if rate == rate.to_integral_value() else float(rate)
def layered(got: dict[str, Any], rate: Any) -> dict[str, Any]:
"""엔진 답 × 켜쌓기율(85-2) — 세부항목 줄 전부 수량 · 금액 · 비목 · 비목 합 · 계 · 단가는 그대로."""
if rate in (None, 100) or "결과" in got:
return got
times = Decimal(str(rate)) / 100
def mul(value: Any) -> Any:
return value * times if isinstance(value, Decimal) else value
lines = [
{
**x,
"수량": mul(x.get("수량")),
"금액": mul(x.get("금액")),
"비목": {k: mul(v) for k, v in (x.get("비목") or {}).items()},
}
for x in got.get("줄") or []
]
sums = {k: mul(got[k]) for k in COSTS}
return {**got, "줄": lines, **sums, "계": sum(sums.values())}
def from_combo(key: str) -> dict[str, Any]:
"""옛 일위대가 조합(마스터 UA = 실무문서 · 회사 UC · 개인 UP) → 일위대가 문서 몸 —
담은로직 · 단위 복사 · 입력값 {} · 수량 null · 자재 [] · `본뜬` = 그 조합(층 · 주인 · 키).
없는 조합은 404."""
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
row = combos.find(key)
if row is None:
raise store.StoreError(404, f"없는 일위대가 조합 「{key}」")
layer, owner = combos.where(key) or (None, None)
return {
"단위": row.get("단위"),
"본뜬": {"층": layer, "주인": owner, "키": key},
"담은로직": [
{"로직": one.get("로직"), "메모": one.get("메모"), "입력값": {}, "수량": None}
for one in row.get("담은로직") or []
if isinstance(one, dict)
],
"자재": [],
}
# ── 알약 · 면적 값 ────────────────────────────────────────────────────
def material_key(code: str) -> str:
return f"{MATERIAL_HEAD}{code}"
def pills(doc: dict) -> list[dict[str, Any]]:
"""면적산출 알약 — 로직 물량 알약(`combo_pills` 그대로) + 참조 알약(`refs.pill`) +
자재 알약 「자재|MT키」."""
logic = combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"]
logic += [refs.pill(one["참조"]) for one in doc.get("담은로직") or [] if _is_ref(one)]
mats = [
{
"키": material_key(m["코드"]),
"이름": m.get("이름") or m["코드"],
"단위": m.get("단위") or "",
"자재": {k: m.get(k) or "" for k in _MAT_COLS},
}
for m in doc.get("자재") or []
]
return [*logic, *mats]
def area_values(area: Any) -> dict[str, Decimal]:
"""면적산출 문서 `연결` 의 알약 → 칸 값(식 칸 = 서버 `계산값` · 그 밖 = 칸 값) · 수만."""
out: dict[str, Decimal] = {}
if not isinstance(area, dict):
return out
calc = area.get("계산값") or {}
for sheet in area.get("시트") or []:
cells, got = sheet.get("칸") or {}, calc.get(sheet.get("id")) or {}
for link in sheet.get("연결") or []:
at = link.get("칸")
raw = (got.get(at) or {}).get("수")
if raw is None:
raw = (cells.get(at) or {}).get("값")
value = _number(raw)
if value is not None and link.get("키"):
out[str(link["키"])] = value
return out
def _area_of(column: Any) -> dict[str, Decimal]:
"""지금 층 그 열 면적산출의 알약 값 — 파일이 없으면 빈 것."""
try:
path = store._path("area", str(column or ""))
except store.StoreError:
return {}
return area_values(json.loads(path.read_bytes())) if path.is_file() else {}
# ── 계산 ──────────────────────────────────────────────────────────────
def _money(value: Any) -> dict[str, Decimal] | None:
if not isinstance(value, dict):
return None
return {k: _number(value.get(k)) or Decimal(0) for k in (*COSTS, "계")}
def reflect(base: dict[str, Decimal], qty: Decimal | None, times: Decimal) -> dict | None:
"""반영 = 기준 × 수량 ÷ 배 — 수량이 없으면 None."""
if qty is None:
return None
return {k: base[k] * qty / times for k in (*COSTS, "계")}
def _sum(rows: list[dict | None]) -> dict[str, Decimal]:
return {k: sum((r[k] for r in rows if r), Decimal(0)) for k in (*COSTS, "계")}
def _pick(hand: Any, key: Any, given: dict[str, Decimal]) -> tuple[Decimal | None, str]:
"""(수량, 출처) — 손 · 칸(알약 키) · 없음."""
if isinstance(hand, dict) and _number(hand.get("손")) is not None:
return _number(hand["손"]), "손"
if key and key in given:
return given[key], "칸"
return None, "없음"
def _is_ref(one: Any) -> bool:
return isinstance(one, dict) and isinstance(one.get("참조"), dict)
def line_id(one: dict) -> Any:
"""줄 가름 — 로직 키 · 참조 줄은 `ref_id`(짝 `Labor_Calc.ts` `lineId`)."""
return refs.ref_id(one["참조"]) if _is_ref(one) else one.get("로직")
def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]:
"""기준이 선 계산 답에 수량 · 반영 · 합을 채움 — 수량만 바뀌면 이것만 다시
(짝: `Labor_Calc.ts` `settle` · 자재 = 재료비)."""
have = {str(k): v for k, raw in given.items() if (v := _number(raw)) is not None}
hands = {line_id(one): one.get("수량") for one in doc.get("담은로직") or []}
mhands = {one.get("코드"): one.get("수량") for one in doc.get("자재") or []}
zero = dict.fromkeys((*COSTS, "계"), Decimal(0))
lines, mats, bases, reflects = [], [], [], []
for line in result.get("줄") or []:
qty, source = _pick(hands.get(line_id(line)), line.get("알약"), have)
base = _money(line.get("기준"))
done = reflect(base, qty, _number(line.get("배")) or Decimal(1)) if base else None
bases.append(base)
reflects.append(done)
lines.append({**line, "수량": qty, "수량출처": source, "반영": done})
for one in result.get("자재") or []:
qty, source = _pick(mhands.get(one.get("코드")), one.get("알약"), have)
price = _number(one.get("기준단가"))
cost = price * qty if price is not None and qty is not None else None
bases.append({**zero, "재료비": price, "계": price} if price is not None else None)
reflects.append({**zero, "재료비": cost, "계": cost} if cost is not None else None)
mats.append({**one, "수량": qty, "수량출처": source, "반영": cost})
return _json({"줄": lines, "자재": mats, "기준합": _sum(bases), "반영합": _sum(reflects)})
def material_price(master: Any, code: str, unit: str | None) -> dict[str, Any]:
"""자재 한 줄 기준 — `{기준단가, 가격: {값 열 다섯}, 기준칸, 까닭}` · 기준단가 = `mm.element`
(값 열 가운데 낮은 값) · 기준칸 = 그 값이 나온 열 이름(출처 `키.열`) · 원본 자료는 읽기만."""
row = master.get(code) or {}
prices = {slot: row.get(slot) for slot in mm.SLOTS}
try:
price, source = mm.element(master, code, {}, unit or None)
except mf.FormulaError as e:
return {"기준단가": None, "가격": prices, "기준칸": None, "까닭": str(e)}
slot = str(source).split(".")[-1] if source else None
return {
"기준단가": price,
"가격": prices,
"기준칸": slot if slot in mm.SLOTS else None,
"까닭": None if price is not None else "단가 없음",
}
def calc(
doc: dict,
given: dict[str, Any] | None = None,
*,
whole: Any = None,
read: Any = None,
pills: list[dict] | None = None,
) -> dict[str, Any]:
"""`doc` = 정리한 문서 · `given` = {알약 키: 값}(없으면 지금 층 면적산출 파일에서) —
줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음).
프로젝트 사본 셈(142-1)도 이 한 벌 — `whole` = 사본 `단가.json`(`M02_Item_Prices.load_prices`) ·
`read` = 참조 → 굳힌 `참조사본` · `pills` = 사본 `알약`(가져온 때 것). 없으면 지금 M01 · 층."""
whole = m01.loaded()[1] if whole is None else whole
expand = refs.Expand(whole) if read is None else refs.Expand(whole, read)
if pills is None:
pills = combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"]
by_logic = {code: pill for pill in pills for code in pill.get("로직") or []}
lines = []
for one in doc.get("담은로직") or []:
if _is_ref(one):
lines.append(ref_line(one["참조"], expand(one["참조"])))
continue
code = one["로직"]
pill = by_logic.get(code) or {}
line = {
"로직": code,
"알약": pill.get("키"),
"분모": pill.get("단위"),
"배": pill.get("배") or 1,
}
got = run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름"))
lines.append({**line, **got})
mats = [
{
"코드": one["코드"],
"알약": material_key(one["코드"]),
**material_price(whole, one["코드"], one.get("단위")),
}
for one in doc.get("자재") or []
]
have = _area_of(doc.get("열")) if given is None else given
return settle(_json({"줄": lines, "자재": mats}), doc, have)
def run_line(
whole: Any, code: str, given: Any, rate: Any = None, picks: Any = None
) -> dict[str, Any]:
"""로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값 · × 켜쌓기율 · 고른 재료 = 엔진
`choosing`) — `{기준, 줄, 재료, 까닭}` · 돈 아닌 로직은 `{결과, 기준: None}`(반영 없음)."""
try:
logic = whole.logic(code)
auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None}
with mf.mp.choosing(picks):
got = layered(mf.run(whole, code, {**auto, **m01._dec(given or {})}), rate)
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
return {"기준": None, "까닭": str(e) or type(e).__name__}
if "결과" in got:
return {"결과": got["결과"], "기준": None, "까닭": None}
# 「줄」 = 그 로직 재료 · 노무 · 경비 줄(줄 누름 상세 · 50-4)
money = {k: got[k] for k in (*COSTS, "계")}
mats = logic_mats(whole, logic, got["줄"], picks)
return {"기준": money, "줄": got["줄"], "재료": mats, "까닭": None}
def logic_mats(whole: Any, logic: dict, lines: list[dict], picks: Any) -> list[dict[str, Any]]:
"""로직 자재(103-1) — 맨 위 층 호표 재료 줄마다 엔진 답 줄(같은 차례) 그대로 · 금액은 로직
재료비 안(합에 안 더함) · `걸림` = 품목 조건에 든 입력 이름(중간값이면 입력 전부 — 그 입력이
바뀌면 화면이 고름을 풂 · 짝 `M02_Labor_LogicMats_Model.ts` `releaseTied`)."""
if isinstance(logic.get("흐름"), dict):
return [] # 흐름 로직 재료 자리는 입력 이름 — 이번엔 안 보임
names = {str(spec.get("이름")) for spec in _specs(logic)}
out = []
for n, item in enumerate(logic.get("호표") or []):
if item.get("종류") != "재료" or n >= len(lines):
continue
line, goods = lines[n], lines[n].get("품목")
try:
row = (whole.get(goods["키"]) if goods else None) or {}
except mf.FormulaError:
row = {}
cond = item.get("요소")
texts = cond.values() if isinstance(cond, dict) else [cond]
tied = {w for t in texts if isinstance(t, str) for w in _BRACE.findall(t)}
out.append(
{
"차례": n,
"이름": item.get("이름") or "",
"단위": item.get("단위") or "",
**{k: line.get(k) for k in ("수량", "단가", "금액", "까닭")},
"품목": goods,
"가격": {slot: row.get(slot) for slot in mm.SLOTS},
"고름": str(n) in (picks or {}),
"걸림": sorted(tied if tied <= names else names) if tied else [],
}
)
return out
def ref_line(ref: dict, got: dict[str, Any]) -> dict[str, Any]:
"""참조 줄 계산 답 — 분모 = 가리킨 문서 단위 · 배 1 · `안` = 사전조합 안 줄(한 층)."""
line = {"참조": ref, "이름": got.get("이름"), "알약": refs.ref_id(ref), "분모": got.get("단위")}
line.update({"배": 1, "기준": got.get("기준"), "까닭": got.get("까닭")})
if "줄" in got: # 단가승격 = 원 로직 키 · 표(줄 누름 상세 · 50-3 ④)
line.update({"원로직": got.get("로직"), "줄": got["줄"]})
return {**line, "안": got["안"]} if "안" in got else line
def _json(value: Any) -> Any:
"""Decimal → JSON 수(정수면 int)."""
if isinstance(value, Decimal):
return int(value) if value == value.to_integral_value() else float(value)
if isinstance(value, list):
return [_json(v) for v in value]
if isinstance(value, dict):
return {k: _json(v) for k, v in value.items()}
return value
def server(doc: Any) -> dict[str, Any]:
"""[저장] · 복사 때 서버 정본 — 정리 + 지금 면적 값으로 `계산값`(브라우저 값 버림) ·
사용자 자재가 로직 자재 품목과 같으면 400(103-1 · 같은 품목 두 번 셈)."""
got = clean(doc)
out = calc(got)
inside = {x["품목"]["키"] for one in out["줄"] for x in one.get("재료") or [] if x.get("품목")}
twice = sorted({m["코드"] for m in got["자재"]} & inside)
if twice:
raise _bad(f"자재 「{', '.join(twice)}」 는 로직 자재에 있음 — 같은 품목 두 번 담음")
return {**got, "계산값": out}