feat(715-1a): M02 단가산출(SK) 문서 꼴 · 검사 · 줄 정체 kind:키#n · 덮개 · 더한줄 · 뺀줄 · 고친 줄 순수 함수
- 새 M02_Sangeun_Doc.py — clean(꼴 400 · 열린입력 = 배율만 · 원값 버림) · line_ids · patched(고름 · 자재표시 cid 열쇠 → 새 차례 · 짝잃음 남김) - 엔진 안 만짐 · 시험 test_715_sangeun_doc.py Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
"""M02 단가산출(SK) 문서 — 꼴 · 검사 · 줄 정체 · 고친 줄(PLAN 715-1a · 요구서 내역12 4장 12 · 13).
|
||||
|
||||
문서 `{층}/sangeun/SK-######.json` = M01 로직에 연결 + 바꾼 것만 —
|
||||
{종류: "단가산출", 키, 이름, 단위(필수), 구분, 상세구분, 본뜬,
|
||||
바탕: {로직: GF…} | {참조: {층, 주인, 키: SK-…}} | null(바탕 없음 = 더한줄만),
|
||||
입력값 {이름: 값}, 열린입력 [이름](설계 때 정함 · 그 SK 품 덮개 = 배율만), 켜쌓기율?,
|
||||
고름 {cid: MT키}, 자재표시 {cid: {세 칸}},
|
||||
덮개 {cid: {qty | 배율, labor, material, expense, 원값}},
|
||||
더한줄 [{종류: 인력|재료|기계, 요소, 이름, 단위, 수량, 비목?, 단가?}
|
||||
| {종류: 로직, 로직, 입력값, 수량} | {참조 | 고정, 이름?, 단위?, 수량}],
|
||||
뺀줄 [cid], 바탕판}
|
||||
줄 정체 cid = `kind:키#n`(B09 702 덮개 꼴) — kind = labor · material · machine · logic · pct(덧줄) ·
|
||||
키 = 요소 키(로직 줄 = 부르는 GC 키 · 조건 재료 = 이름 · 덧줄 = 이름 · 더한줄 참조 = 그 키) ·
|
||||
n = 같은 머리 차례 · 바탕 줄(M01 로직 줄 · 바탕 SK 의 고친 줄)에서 셈 — 뺀줄 · 더한줄 뒤 같은 줄.
|
||||
고름 · 자재표시도 cid 열쇠(호표 차례 열쇠면 뺀줄 뒤 엉뚱한 줄에 붙음) → `patched` 가 차례로 바꿈.
|
||||
덮개 = 숫자만(식 안 받음 · 4-1 확정 4) — qty = 품 수 · 배율 = 품 × 율 · labor · material · expense =
|
||||
줄 단가(로직 줄 = 노 · 재 · 경 묶음) · 덧줄 = qty(율 %) · 배율만 · `원값` · `바탕판` = 저장 때
|
||||
서버가 셈(브라우저 값 안 받음). 짝 잃은 덮개(M01 개정으로 줄이 사라짐) = 지우지 않고 `짝잃음` 알림.
|
||||
엔진 입력 덧칸(715-1b · `master_pick`) = 호표 줄 `단가`(수) · 로직 줄 `값묶음{노무비 · 재료비 · 경비}`
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
KIND = "sangeun"
|
||||
TITLE = "단가산출"
|
||||
PREFIX = "SK"
|
||||
KEY = re.compile(r"^SK-\d{6}$")
|
||||
PARTS = {"labor": "노무비", "material": "재료비", "expense": "경비"}
|
||||
COVER_KEYS = ("qty", "배율", *PARTS)
|
||||
KINDS = {"인력": "labor", "재료": "material", "기계": "machine", "로직": "logic"}
|
||||
ADD_PART = {"인력": "노무비", "재료": "재료비", "기계": "경비"}
|
||||
_CID = re.compile(r"^(labor|material|machine|logic|pct):.*#\d+$")
|
||||
_CALL = re.compile(r"^\s*로직\(\s*([A-Z]{2}\d{6})")
|
||||
_PCT = re.compile(r"^(?P<x>.+)\*(?P<y>[^*]+)/\s*100\s*$", re.S) # 「X * Y / 100」
|
||||
_NAME = re.compile(r"^[\w가-힣]+$")
|
||||
|
||||
|
||||
class DocError(ValueError):
|
||||
def __init__(self, text: str, status: int = 400) -> None:
|
||||
super().__init__(text)
|
||||
self.status = status
|
||||
|
||||
|
||||
def _num(raw: Any, where: str) -> Decimal:
|
||||
try:
|
||||
got = Decimal(str(raw).replace(",", "").strip())
|
||||
except (InvalidOperation, TypeError):
|
||||
got = None
|
||||
if raw is None or isinstance(raw, bool) or got is None or not got.is_finite():
|
||||
raise DocError(f"{where} · 수가 아님 「{raw}」")
|
||||
return got
|
||||
|
||||
|
||||
def plain(value: Decimal) -> int | float:
|
||||
return int(value) if value == value.to_integral_value() else float(value)
|
||||
|
||||
|
||||
def text(value: Any) -> str:
|
||||
"""식 안에 넣을 수 글 — 지수 꼴 없이."""
|
||||
got = value if isinstance(value, Decimal) else Decimal(str(value))
|
||||
return format(got.normalize(), "f")
|
||||
|
||||
|
||||
# ── 줄 정체 ───────────────────────────────────────────────────────────
|
||||
def _head(item: dict, extra: bool) -> str:
|
||||
if extra:
|
||||
return f"pct:{item.get('이름') or ''}"
|
||||
kind = KINDS.get(str(item.get("종류")), "logic")
|
||||
elem = item.get("요소")
|
||||
if isinstance(item.get("참조"), dict):
|
||||
key = str(item["참조"].get("키") or "")
|
||||
elif isinstance(item.get("고정"), dict):
|
||||
key = str(item["고정"].get("이름") or "")
|
||||
elif kind == "logic":
|
||||
found = _CALL.match(str(elem or ""))
|
||||
key = found.group(1) if found else str(item.get("이름") or "")
|
||||
else:
|
||||
key = elem if isinstance(elem, str) else str(item.get("이름") or "")
|
||||
return f"{kind}:{key}"
|
||||
|
||||
|
||||
def line_ids(row: dict) -> list[str]:
|
||||
"""로직 줄(호표 → 덧줄 차례)마다 cid — 같은 머리 n번째 = `#n`."""
|
||||
seen: dict[str, int] = {}
|
||||
out = []
|
||||
pairs = [(x, False) for x in row.get("호표") or []] + [(x, True) for x in row.get("덧줄") or []]
|
||||
for item, extra in pairs:
|
||||
head = _head(item, extra)
|
||||
seen[head] = seen.get(head, -1) + 1
|
||||
out.append(f"{head}#{seen[head]}")
|
||||
return out
|
||||
|
||||
|
||||
# ── 정리 · 검사 ───────────────────────────────────────────────────────
|
||||
def _cid(raw: Any, where: str) -> str:
|
||||
if not isinstance(raw, str) or not _CID.match(raw):
|
||||
raise DocError(f"{where} · 줄 정체는 kind:키#n 「{raw}」")
|
||||
return raw
|
||||
|
||||
|
||||
def _base(raw: Any) -> dict | None:
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, dict) and isinstance(raw.get("로직"), str) and raw["로직"].strip():
|
||||
return {"로직": raw["로직"].strip()}
|
||||
if isinstance(raw, dict) and isinstance(raw.get("참조"), dict):
|
||||
ref = raw["참조"]
|
||||
if not KEY.match(str(ref.get("키") or "")):
|
||||
raise DocError("바탕 참조 키는 SK-######")
|
||||
return {"참조": {"층": ref.get("층"), "주인": ref.get("주인"), "키": str(ref["키"])}}
|
||||
raise DocError("바탕은 {로직} · {참조} · null")
|
||||
|
||||
|
||||
def _given(raw: Any, where: str) -> dict:
|
||||
if raw is None:
|
||||
return {}
|
||||
if not isinstance(raw, dict):
|
||||
raise DocError(f"{where} · 입력값이 묶음 아님")
|
||||
return {str(k): v for k, v in raw.items() if v not in (None, "")}
|
||||
|
||||
|
||||
def _cover(raw: Any, where: str, opened: bool) -> dict:
|
||||
if not isinstance(raw, dict):
|
||||
raise DocError(f"{where} · 덮개는 묶음")
|
||||
unknown = set(raw) - {*COVER_KEYS, "원값"}
|
||||
if unknown:
|
||||
raise DocError(f"{where} · 모르는 덮개 칸 {sorted(unknown)}")
|
||||
out = {k: plain(_num(raw[k], f"{where} {k}")) for k in COVER_KEYS if raw.get(k) is not None}
|
||||
if "qty" in out and "배율" in out:
|
||||
raise DocError(f"{where} · 품은 qty · 배율 중 하나")
|
||||
if opened and "qty" in out:
|
||||
raise DocError(f"{where} · 옵션을 열어 둔 단가산출은 품 배율만")
|
||||
if any(v < 0 for v in out.values()):
|
||||
raise DocError(f"{where} · 음수 덮개")
|
||||
if not out:
|
||||
raise DocError(f"{where} · 빈 덮개")
|
||||
return out
|
||||
|
||||
|
||||
def _added(raw: Any, where: str, whole: Any) -> dict:
|
||||
"""더한줄 하나 — 수량은 숫자(식 안 받음)."""
|
||||
if not isinstance(raw, dict):
|
||||
raise DocError(f"{where} · 묶음 아님")
|
||||
qty = plain(_num(raw.get("수량"), f"{where} 수량"))
|
||||
name, unit = str(raw.get("이름") or "").strip(), str(raw.get("단위") or "").strip()
|
||||
if isinstance(raw.get("참조"), dict):
|
||||
ref = raw["참조"]
|
||||
if not re.match(r"^(SK|SC)-\d{6}$", str(ref.get("키") or "")):
|
||||
raise DocError(f"{where} · 참조 키는 SK · SC")
|
||||
head: dict[str, Any] = {"참조": {k: ref.get(k) for k in ("층", "주인", "키")}}
|
||||
elif isinstance(raw.get("고정"), dict):
|
||||
from M02_MasterTemplete import M02_Labor_Fixed as fixed
|
||||
|
||||
try:
|
||||
head = {"고정": fixed.clean(raw["고정"], where, whole) if whole else raw["고정"]}
|
||||
except Exception as e: # noqa: BLE001 — 고정 줄 검사 = StoreError(400)
|
||||
raise DocError(str(getattr(e, "detail", e))) from e
|
||||
if head["고정"].get("비율"):
|
||||
raise DocError(f"{where} · 비율 고정 줄은 단가산출에 못 담음 — 덧줄 율로")
|
||||
elif raw.get("종류") == "로직":
|
||||
code = str(raw.get("로직") or "")
|
||||
row = _logic(whole, code, where)
|
||||
names = {str(s.get("이름")) for s in (row or {}).get("입력") or []}
|
||||
given = _given(raw.get("입력값"), where)
|
||||
head = {"종류": "로직", "로직": code, "입력값": given}
|
||||
if row is not None:
|
||||
head["입력값"] = {k: v for k, v in given.items() if k in names}
|
||||
name = name or str(row.get("이름") or code)
|
||||
elif raw.get("종류") in ADD_PART:
|
||||
key = str(raw.get("요소") or "")
|
||||
got = _element(whole, key, where)
|
||||
part = str(raw.get("비목") or ADD_PART[raw["종류"]])
|
||||
if part not in PARTS.values():
|
||||
raise DocError(f"{where} · 비목은 노무비 · 재료비 · 경비")
|
||||
head = {"종류": raw["종류"], "요소": key, "비목": part}
|
||||
if raw.get("단가") is not None:
|
||||
head["단가"] = plain(_num(raw["단가"], f"{where} 단가"))
|
||||
name = name or str((got or {}).get("이름") or key)
|
||||
unit = unit or str((got or {}).get("단위") or "")
|
||||
else:
|
||||
raise DocError(f"{where} · 종류는 인력 · 재료 · 기계 · 로직 · 참조 · 고정")
|
||||
return {**head, "이름": name, "단위": unit, "수량": qty}
|
||||
|
||||
|
||||
def _logic(whole: Any, code: str, where: str) -> dict | None:
|
||||
if whole is None:
|
||||
return None
|
||||
try:
|
||||
return whole.logic(code)
|
||||
except Exception as e: # noqa: BLE001 — 엔진 FormulaError(가져오기 둘레 피함)
|
||||
raise DocError(f"{where} · {e}") from e
|
||||
|
||||
|
||||
def _element(whole: Any, key: str, where: str) -> dict | None:
|
||||
if not key:
|
||||
raise DocError(f"{where} · 요소 키가 빔")
|
||||
if whole is None:
|
||||
return None
|
||||
try:
|
||||
return whole.get(key)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise DocError(f"{where} · 없는 요소 「{key}」") from e
|
||||
|
||||
|
||||
def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str, Any]:
|
||||
"""서버가 받는 꼴로 — 틀리면 `DocError`(400). `base_row` = 패치를 얹을 바탕 줄(입력 이름 ·
|
||||
열린입력 검사) · `whole` = M01(요소 · 로직 · 자재 키 검사). 짝 없는 cid 는 받음(셈 때 알림) ·
|
||||
`원값` · `바탕판` 은 서버 몫이라 버림."""
|
||||
if not isinstance(doc, dict):
|
||||
raise DocError("단가산출 문서가 묶음 아님")
|
||||
key = str(doc.get("키") or "")
|
||||
if not KEY.match(key):
|
||||
raise DocError("단가산출 키는 SK-######")
|
||||
name, unit = str(doc.get("이름") or "").strip(), str(doc.get("단위") or "").strip()
|
||||
if not name:
|
||||
raise DocError("단가산출 이름이 빔")
|
||||
if not unit:
|
||||
raise DocError("단가산출 단위가 빔 — 1 단위 값의 단위")
|
||||
base = _base(doc.get("바탕"))
|
||||
if base and "로직" in base and whole is not None and base_row is None:
|
||||
base_row = _logic(whole, base["로직"], "바탕")
|
||||
names = {str(s.get("이름")) for s in (base_row or {}).get("입력") or []}
|
||||
given = _given(doc.get("입력값"), "입력값")
|
||||
opened = [str(x) for x in doc.get("열린입력") or []]
|
||||
if base_row is not None:
|
||||
given = {k: v for k, v in given.items() if k in names}
|
||||
bad = [x for x in opened if x not in names or x in given]
|
||||
if bad:
|
||||
raise DocError(f"열린입력은 바탕 입력 중 값 안 넣은 것만 {bad}")
|
||||
out: dict[str, Any] = {"종류": TITLE, "키": key, "이름": name, "단위": unit, "바탕": base}
|
||||
out.update({k: str(doc.get(k) or "").strip() for k in ("구분", "상세구분")})
|
||||
out["본뜬"] = doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None
|
||||
out["입력값"], out["열린입력"] = given, opened
|
||||
if doc.get("켜쌓기율") is not None:
|
||||
out["켜쌓기율"] = plain(_num(doc["켜쌓기율"], "켜쌓기율"))
|
||||
picks = {}
|
||||
for cid, mt in (doc.get("고름") or {}).items():
|
||||
mt = str(mt or "")
|
||||
if not mt.startswith("MT") or (whole is not None and _element(whole, mt, "고름") is None):
|
||||
raise DocError(f"고름 「{cid}」 · M01 자재품목 키 아님 「{mt}」")
|
||||
picks[_cid(cid, "고름")] = mt
|
||||
out["고름"] = picks
|
||||
out["자재표시"] = _marks(doc.get("자재표시"))
|
||||
out["덮개"] = {
|
||||
_cid(cid, "덮개"): _cover(one, f"덮개 「{cid}」", bool(opened))
|
||||
for cid, one in (doc.get("덮개") or {}).items()
|
||||
}
|
||||
out["더한줄"] = [
|
||||
_added(one, f"더한줄 {at}째", whole) for at, one in enumerate(doc.get("더한줄") or [], 1)
|
||||
]
|
||||
drop = [_cid(x, "뺀줄") for x in doc.get("뺀줄") or []]
|
||||
if len(set(drop)) != len(drop):
|
||||
raise DocError("뺀줄에 같은 줄 두 번")
|
||||
out["뺀줄"] = drop
|
||||
return out
|
||||
|
||||
|
||||
def _marks(raw: Any) -> dict[str, dict[str, str]]:
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
out = {}
|
||||
for cid, one in (raw or {}).items() if isinstance(raw, dict) else ():
|
||||
got = labor.marks(one)
|
||||
if got:
|
||||
out[_cid(cid, "자재표시")] = got
|
||||
return out
|
||||
|
||||
|
||||
# ── 고친 줄 ───────────────────────────────────────────────────────────
|
||||
def _covered(item: dict, cover: dict, extra: bool) -> tuple[dict, str | None]:
|
||||
"""덮개 한 줄을 로직 줄에 — (고친 줄, 못 덮은 까닭)."""
|
||||
item = dict(item)
|
||||
if extra: # 덧줄 = 율(qty) · 배율만
|
||||
if any(k in cover for k in PARTS):
|
||||
return item, "덧줄은 율만 고침"
|
||||
formula = str(item.get("식") or "")
|
||||
if "배율" in cover:
|
||||
item["식"] = f"({formula}) * {text(cover['배율'])}"
|
||||
return item, None
|
||||
found = _PCT.match(formula)
|
||||
if not found:
|
||||
return item, "덧줄 식이 「X * 율 / 100」 꼴 아님"
|
||||
item["식"] = f"{found.group('x').strip()} * {text(cover['qty'])} / 100"
|
||||
return item, None
|
||||
if "qty" in cover:
|
||||
item["수량"] = text(cover["qty"])
|
||||
elif "배율" in cover:
|
||||
item["수량"] = f"({item.get('수량')}) * {text(cover['배율'])}"
|
||||
prices = {PARTS[k]: Decimal(str(cover[k])) for k in PARTS if k in cover}
|
||||
if not prices:
|
||||
return item, None
|
||||
if item.get("종류") == "로직":
|
||||
item["값묶음"] = {**dict(item.get("값묶음") or {}), **prices}
|
||||
return item, None
|
||||
part = str(item.get("비목") or "")
|
||||
if set(prices) != {part}:
|
||||
return item, f"비목 다름 — 이 줄은 {part or '비목 없음'}"
|
||||
item["단가"] = prices[part]
|
||||
return item, None
|
||||
|
||||
|
||||
def _add_item(one: dict) -> dict:
|
||||
"""더한줄 → 엔진 호표 줄 — 참조 · 고정 줄 `값묶음` 은 셈(`M02_Sangeun_Calc`)이 채움."""
|
||||
head = {"이름": one.get("이름") or "", "단위": one.get("단위") or ""}
|
||||
qty = text(one["수량"])
|
||||
if "참조" in one or "고정" in one:
|
||||
kept = {k: one[k] for k in ("참조", "고정") if k in one}
|
||||
return {**head, "종류": "로직", "요소": None, "수량": qty, **kept, "값묶음": None}
|
||||
if one.get("종류") == "로직":
|
||||
args = "".join(
|
||||
f", {k if _NAME.match(k) else repr(k)}="
|
||||
+ (text(v) if isinstance(v, (int, float, Decimal)) else f"'{v}'")
|
||||
for k, v in (one.get("입력값") or {}).items()
|
||||
)
|
||||
return {**head, "종류": "로직", "요소": f"로직({one['로직']}{args})", "수량": qty}
|
||||
item = {**head, "종류": one["종류"], "요소": one["요소"], "수량": qty, "비목": one["비목"]}
|
||||
return {**item, **({"단가": Decimal(str(one["단가"]))} if "단가" in one else {})}
|
||||
|
||||
|
||||
def patched(
|
||||
row: dict, doc: dict, picks: dict | None = None, shown: dict | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""바탕 줄(M01 로직 줄 · 바탕 SK 의 고친 줄) + SK 패치 → 고친 줄(순수 · 엔진 안 부름).
|
||||
`picks` · `shown` = 바탕 쪽 차례 열쇠 고름 · 자재표시(바탕 SK 사슬) → 새 차례로 옮김.
|
||||
돌려줌 = {줄, 고름 {차례: MT}, 자재표시 {차례: 칸}, 정체 [새 줄 cid · 더한줄 = `add:#k`],
|
||||
짝잃음 [{cid, 까닭}]}."""
|
||||
items, extras = list(row.get("호표") or []), list(row.get("덧줄") or [])
|
||||
ids = line_ids(row)
|
||||
covers, drop = doc.get("덮개") or {}, set(doc.get("뺀줄") or [])
|
||||
mine = doc.get("고름") or {}
|
||||
marks = doc.get("자재표시") or {}
|
||||
lost = [
|
||||
{"cid": c, "까닭": "뺀줄 — 바탕에 없는 줄"} for c in doc.get("뺀줄") or [] if c not in ids
|
||||
]
|
||||
new_items, new_extras, cids, out_picks, out_marks = [], [], [], {}, {}
|
||||
for at, cid in enumerate(ids):
|
||||
if cid in drop:
|
||||
continue
|
||||
extra = at >= len(items)
|
||||
item = extras[at - len(items)] if extra else items[at]
|
||||
if cid in covers:
|
||||
item, why = _covered(item, covers[cid], extra)
|
||||
if why:
|
||||
lost.append({"cid": cid, "까닭": why})
|
||||
if extra:
|
||||
new_extras.append(item)
|
||||
continue
|
||||
n = str(len(new_items))
|
||||
old = (picks or {}).get(str(at))
|
||||
if cid in mine and item.get("종류") != "재료":
|
||||
lost.append({"cid": cid, "까닭": "고름 — 재료 줄 아님"})
|
||||
elif cid in mine or old:
|
||||
out_picks[n] = mine.get(cid) or old
|
||||
if (shown or {}).get(str(at)) or cid in marks:
|
||||
out_marks[n] = marks.get(cid) or shown[str(at)]
|
||||
new_items.append(item)
|
||||
cids.append(cid)
|
||||
for k, one in enumerate(doc.get("더한줄") or []):
|
||||
new_items.append(_add_item(one))
|
||||
cids.append(f"add:#{k}")
|
||||
cids += [c for at, c in enumerate(ids) if at >= len(items) and c not in drop]
|
||||
for name, keys in (("덮개", covers), ("고름", mine), ("자재표시", marks)):
|
||||
lost += [{"cid": c, "까닭": f"{name} — 바탕에 없는 줄"} for c in keys if c not in ids]
|
||||
return {
|
||||
"줄": {**row, "호표": new_items, "덧줄": new_extras},
|
||||
"고름": out_picks,
|
||||
"자재표시": out_marks,
|
||||
"정체": cids,
|
||||
"짝잃음": lost,
|
||||
}
|
||||
|
||||
|
||||
def empty_row(doc: dict) -> dict[str, Any]:
|
||||
"""바탕 없는 단가산출의 바탕 줄 — 입력 · 줄 없음(더한줄만 셈)."""
|
||||
return {
|
||||
"키": doc.get("키"),
|
||||
"이름": doc.get("이름"),
|
||||
"결과단위": f"원/{doc.get('단위') or ''}",
|
||||
"입력": [],
|
||||
"중간": [],
|
||||
"호표": [],
|
||||
"덧줄": [],
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"""M02 단가산출(SK) 문서 꼴 · 줄 정체 · 고친 줄(PLAN 715-1a · `M02_Sangeun_Doc`) — 엔진 안 부름."""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
|
||||
ROW = {
|
||||
"키": "GX000001",
|
||||
"결과단위": "원/㎡",
|
||||
"입력": [{"이름": "높이"}, {"이름": "돌", "고르기": ["깬잡석", "견치돌"]}],
|
||||
"호표": [
|
||||
{
|
||||
"종류": "인력",
|
||||
"요소": "LB000033",
|
||||
"이름": "석공",
|
||||
"수량": "높이 * 0.1",
|
||||
"비목": "노무비",
|
||||
},
|
||||
{"종류": "재료", "요소": {"검색어": "모래"}, "이름": "모래", "수량": "1", "비목": "재료비"},
|
||||
{"종류": "인력", "요소": "LB000033", "이름": "석공", "수량": "0.2", "비목": "노무비"},
|
||||
{"종류": "재료", "요소": {"검색어": "자갈"}, "이름": "자갈", "수량": "2", "비목": "재료비"},
|
||||
{
|
||||
"종류": "로직",
|
||||
"요소": "로직(GC000276, 기계='5401-0017')",
|
||||
"이름": "굴착기",
|
||||
"수량": "0.5",
|
||||
},
|
||||
],
|
||||
"덧줄": [{"이름": "공구손료", "식": "(석공 * LB000033) * 3 / 100", "비목": "경비"}],
|
||||
}
|
||||
|
||||
|
||||
def _doc(**extra):
|
||||
return {
|
||||
"키": "SK-000001",
|
||||
"이름": "돌쌓기 내 값",
|
||||
"단위": "㎡",
|
||||
"바탕": {"로직": "GX000001"},
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def test_line_ids_kind_key_n():
|
||||
assert sd.line_ids(ROW) == [
|
||||
"labor:LB000033#0",
|
||||
"material:모래#0",
|
||||
"labor:LB000033#1",
|
||||
"material:자갈#0",
|
||||
"logic:GC000276#0",
|
||||
"pct:공구손료#0",
|
||||
]
|
||||
|
||||
|
||||
def test_clean_shape_and_refusals():
|
||||
got = sd.clean(_doc(입력값={"높이": 3, "모름": 1, "돌": ""}, 원값=1), ROW)
|
||||
assert got["입력값"] == {"높이": 3} and got["종류"] == "단가산출" and got["뺀줄"] == []
|
||||
for bad, why in (
|
||||
({"키": "SC-000001"}, "SK-"),
|
||||
({"이름": " "}, "이름"),
|
||||
({"단위": ""}, "단위"),
|
||||
({"바탕": {"참조": {"키": "SC-000001"}}}, "SK-"),
|
||||
({"덮개": {"labor:LB000033#0": {"qty": "식"}}}, "수가 아님"),
|
||||
({"덮개": {"labor:LB000033#0": {"qty": 1, "배율": 2}}}, "하나"),
|
||||
({"덮개": {"labor:LB#x": {"qty": 1}}}, "kind:키#n"),
|
||||
({"뺀줄": ["material:모래#0", "material:모래#0"]}, "두 번"),
|
||||
({"열린입력": ["높이"], "입력값": {"높이": 3}}, "열린입력"),
|
||||
({"더한줄": [{"종류": "인력", "요소": "LB000002"}]}, "수량"),
|
||||
({"더한줄": [{"종류": "식", "수량": 1}]}, "종류"),
|
||||
):
|
||||
with pytest.raises(sd.DocError, match=why):
|
||||
sd.clean(_doc(**bad), ROW)
|
||||
# 원값 · 바탕판 = 서버 몫 — 브라우저 값 버림
|
||||
got = sd.clean(_doc(덮개={"labor:LB000033#0": {"qty": 0.5, "원값": 9}}), ROW)
|
||||
assert got["덮개"] == {"labor:LB000033#0": {"qty": 0.5}}
|
||||
|
||||
|
||||
def test_open_inputs_only_ratio():
|
||||
with pytest.raises(sd.DocError, match="배율만"):
|
||||
sd.clean(_doc(열린입력=["돌"], 덮개={"labor:LB000033#0": {"qty": 1}}), ROW)
|
||||
got = sd.clean(_doc(열린입력=["돌"], 덮개={"labor:LB000033#0": {"배율": 1.1}}), ROW)
|
||||
assert got["덮개"]["labor:LB000033#0"] == {"배율": 1.1}
|
||||
|
||||
|
||||
def test_patched_cover_qty_price_ratio():
|
||||
doc = sd.clean(
|
||||
_doc(
|
||||
덮개={
|
||||
"labor:LB000033#1": {"qty": 0.25, "labor": 200000},
|
||||
"labor:LB000033#0": {"배율": 1.5},
|
||||
"logic:GC000276#0": {"expense": 1000},
|
||||
"pct:공구손료#0": {"qty": 5},
|
||||
}
|
||||
),
|
||||
ROW,
|
||||
)
|
||||
got = sd.patched(ROW, doc)
|
||||
rows = got["줄"]["호표"]
|
||||
assert rows[0]["수량"] == "(높이 * 0.1) * 1.5"
|
||||
assert rows[2]["수량"] == "0.25" and rows[2]["단가"] == Decimal(200000)
|
||||
assert rows[4]["값묶음"] == {"경비": Decimal(1000)}
|
||||
assert got["줄"]["덧줄"][0]["식"] == "(석공 * LB000033) * 5 / 100"
|
||||
assert got["짝잃음"] == [] and ROW["호표"][2]["수량"] == "0.2" # 바탕은 안 바뀜
|
||||
|
||||
|
||||
def test_drop_keeps_pick_on_its_line():
|
||||
"""뺀줄 뒤 고름 제자리 — 자갈(바탕 3째) 고름이 뺀줄 뒤 새 2째로 옮겨 감 · 모래 줄에 안 붙음."""
|
||||
doc = sd.clean(_doc(뺀줄=["material:모래#0"], 고름={"material:자갈#0": "MT000010"}), ROW)
|
||||
got = sd.patched(ROW, doc)
|
||||
names = [x["이름"] for x in got["줄"]["호표"]]
|
||||
assert names == ["석공", "석공", "자갈", "굴착기"]
|
||||
assert got["고름"] == {"2": "MT000010"}
|
||||
assert got["정체"][:4] == [
|
||||
"labor:LB000033#0",
|
||||
"labor:LB000033#1",
|
||||
"material:자갈#0",
|
||||
"logic:GC000276#0",
|
||||
]
|
||||
|
||||
|
||||
def test_added_lines_before_extras():
|
||||
doc = sd.clean(
|
||||
_doc(
|
||||
더한줄=[
|
||||
{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": 0.1},
|
||||
{
|
||||
"종류": "로직",
|
||||
"로직": "GC000001",
|
||||
"입력값": {"규격": "가", "두께": 2},
|
||||
"수량": 1,
|
||||
},
|
||||
{"참조": {"층": "system", "주인": None, "키": "SK-000002"}, "수량": 2},
|
||||
]
|
||||
),
|
||||
ROW,
|
||||
)
|
||||
got = sd.patched(ROW, doc)
|
||||
added = got["줄"]["호표"][5:]
|
||||
assert added[0]["비목"] == "노무비" and added[0]["수량"] == "0.1"
|
||||
assert added[1]["요소"] == "로직(GC000001, 규격='가', 두께=2)"
|
||||
assert added[2]["참조"]["키"] == "SK-000002" and added[2]["값묶음"] is None
|
||||
assert got["정체"][5:] == ["add:#0", "add:#1", "add:#2", "pct:공구손료#0"]
|
||||
|
||||
|
||||
def test_lost_covers_kept_and_listed():
|
||||
"""짝 잃은 덮개 = 문서에 남김 + 목록(지우지 않음) · 비목 다른 단가 · 재료 아닌 고름."""
|
||||
doc = sd.clean(
|
||||
_doc(
|
||||
덮개={"labor:LB999999#0": {"qty": 1}, "labor:LB000033#0": {"material": 5}},
|
||||
고름={"labor:LB000033#1": "MT000010"},
|
||||
뺀줄=["material:없음#0"],
|
||||
),
|
||||
ROW,
|
||||
)
|
||||
got = sd.patched(ROW, doc)
|
||||
why = {x["cid"]: x["까닭"] for x in got["짝잃음"]}
|
||||
assert why["labor:LB999999#0"].startswith("덮개") and "비목 다름" in why["labor:LB000033#0"]
|
||||
assert why["labor:LB000033#1"].startswith("고름") and why["material:없음#0"].startswith("뺀줄")
|
||||
assert "labor:LB999999#0" in doc["덮개"]
|
||||
|
||||
|
||||
def test_chain_moves_base_picks():
|
||||
"""바탕 SK 사슬 — 바탕 쪽 차례 고름이 아래 SK 뺀줄 뒤에도 그 줄에."""
|
||||
first = sd.patched(ROW, sd.clean(_doc(고름={"material:자갈#0": "MT000010"}), ROW))
|
||||
child = sd.clean({**_doc(뺀줄=["labor:LB000033#0"]), "바탕": None}, first["줄"])
|
||||
got = sd.patched(first["줄"], child, first["고름"])
|
||||
assert got["고름"] == {"2": "MT000010"} and got["줄"]["호표"][2]["이름"] == "자갈"
|
||||
|
||||
|
||||
def test_empty_base():
|
||||
row = sd.empty_row({"키": "SK-000003", "이름": "자체", "단위": "식"})
|
||||
doc = sd.clean(
|
||||
{"키": "SK-000003", "이름": "자체", "단위": "식", "바탕": None,
|
||||
"더한줄": [{"종류": "재료", "요소": "MT000010", "수량": 3, "단가": 1200}]},
|
||||
row,
|
||||
) # fmt: skip
|
||||
got = sd.patched(row, doc)
|
||||
assert got["줄"]["호표"][0]["단가"] == Decimal(1200) and got["줄"]["결과단위"] == "원/식"
|
||||
Reference in New Issue
Block a user