feat(793-2): 가져온 식 단가산출 SK 담기 — 바탕 갈래 · 원본 메타 · 검산 · 우리 단가로 · 이음 고름 · 우리 기초로 바꾸기 · 만들기 길

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 17:44:31 +09:00
co-authored by Claude Opus 5.5
parent c0604b917f
commit e393d67d98
6 changed files with 864 additions and 26 deletions
@@ -0,0 +1,347 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""793-2 가져온 식 단가산출(SK 바탕 갈래) — 경쟁사 산근 블록 = 엔진 로직 줄을 SK 가 품고 다시 셈.
- 견본 둘 = 791-1 손 옮김 견본(봉화 D00102 합판거푸집 STmate 번지 · 율 / 북부청임도 18 문양거푸집
EST 줄 자리) · 단가 = 엑셀 목록표 값 → 셈 = 엑셀 금액 · 줄마다 검산
- 덮개 · 상수 덮개 · 식 칸 · 변수 줄이 가져온 줄에도 먹음
- 셈 답 `원래` = 엑셀 금액 · `가져온` 원본 글
- [우리 단가로] · [엑셀 단가로] · 이음 고름(덮개 열쇠 옮김) · [우리 기초로 바꾸기]
- 만들기(임시 층) · 같은 내용 409 · 옛 SK 지문 그대로 · 펼침 막음 · B09 굳힘 · 단가 전파
"""
from __future__ import annotations
import copy
import json
from decimal import Decimal
import pytest
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Item_Sangeun as sk
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Library_Flat as flat
from M02_MasterTemplete import M02_Library_Same as same
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Calc as calc
from M02_MasterTemplete import M02_Sangeun_Const as const
from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_Imported as imp
from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층
D = Decimal
MAN, CARP = "LB000002", "LB000007" # M01 보통인부 · 형틀목공
FORM = "GC000211" # 합판거푸집 기초
F211 = {"자재지역": "포항", "유형": "보통", "사용횟수": "6회", "높이감률": 0, "지붕슬래브": "아님"}
F211 |= {"폼타이": "아님", "폼타이규격": "12.7㎜", "측압": "3t/㎡", "구멍땜": 0}
def _whole():
return m01.loaded()[1]
def _man(qty, name, price, **more):
line = {"종류": "인력", "이름": name, "단위": "인", "수량": qty, "비목": "노무비"}
return {**line, "단가": price, **more}
def _mat(qty, name, unit, price, **more):
line = {"종류": "재료", "이름": name, "단위": unit, "수량": qty, "비목": "재료비"}
return {**line, "단가": price, **more}
def _bonghwa() -> tuple[dict, dict]:
"""봉화 단가산출근거 2909~2943 행(791-1 견본) — 보통인부만 M01 에 이음."""
row = {
"이름": "합판거푸집(봉화 D00102)",
"결과단위": "원/㎡",
"호표": [
_mat("1.03 * 0.327", "합판(내수)", "㎡", 11468, 번지="A01"),
_mat("0.038 * 0.327", "각재", "㎥", 500661, 번지="A01"),
_man("5 / 50", "형틀목공", 274978, 번지="A02"),
_man("2 / 50", "보통인부", 165545, 번지="A02", 이음=MAN),
],
"덧줄": [
{"이름": "소모자재(박리재 등)", "식": "번지.A01.재료비 * 11 / 100", "비목": "재료비"},
{"이름": "공구손료", "식": "번지.A02.노무비 * 1 / 100", "비목": "경비"},
],
"단계": [
{"이름": "자재비 소계", "단": 1, "줄": ["합판(내수)", "각재", "소모자재(박리재 등)"]},
{"이름": "설치 소계", "단": 1, "줄": ["형틀목공", "보통인부", "공구손료"]},
{"이름": "계", "단": 2},
],
}
src = {
"파일": "봉화.xlsx",
"시트": "단가산출근거",
"행": [2909, 2943],
"갈래": "STmate",
"엑셀금액": {"노무비": 34119, "재료비": 11192, "경비": 341, "계": 45652},
"글줄": [
{"행": 2909, "글": "합판거푸집 ㎡ 당"},
{"행": 2910, "글": "합판(내수) &M00081& * 1.03 * 0.327", "자리": PLY, "값": 3862.5},
{"행": 2912, "글": "형틀목공 5 / 50", "자리": "labor:형틀목공#0", "값": 27497.8},
{"행": 2913, "글": "보통인부 2 / 50", "자리": f"labor:{MAN}#0", "값": 6621.8},
{"행": 2915, "글": "소모자재 &PA01M:M& * 11'%'", "자리": BURN, "값": 1109.2},
],
}
return row, src
PLY, BURN = "material:합판(내수)#0", "pct:소모자재(박리재 등)#0"
def _bukbu() -> tuple[dict, dict]:
"""북부청임도 단가산출 18 번(791-1 견본) — 잡재료 줄만 반올림 0 · 이음 없음."""
row = {
"이름": "문양거푸집(북부청 18)",
"결과단위": "원/㎡",
"호표": [
_mat("1 * (1 / 0.72)", "문양거푸집(ABS코팅폼)", "㎡", 4166),
_man("0.07", "형틀목공", 275790),
_man("0.03", "보통인부", 172068),
],
"덧줄": [
{
"이름": "잡재료비",
"식": "재료비 * 2 / 100",
"비목": "재료비",
"자리": {"방법": "반올림", "자리": 0},
}
],
"단계": [{"이름": "인부소계", "단": 1, "줄": ["형틀목공", "보통인부"]}],
}
src = {"파일": "북부청임도.xlsx", "시트": "단가산출", "행": [410, 420], "갈래": "EST"}
src |= {"엑셀금액": {"노무비": 24467, "재료비": 5902, "경비": 0, "계": 30369}}
src |= {"글줄": [{"행": 415, "글": "5,786.1 × 2% = 116", "자리": "pct:잡재료비#0", "값": 116}]}
return row, src
def _doc(sample=_bonghwa, **more):
row, src = sample()
meta = {"로직": row, "단가기준": "엑셀", "원본": src}
return {"키": "SK-000001", "이름": "가져온", "단위": "㎡", "바탕": None, imp.KEY: meta, **more}
def _run(doc, whole=None):
whole = whole or _whole()
return calc.run(sd.clean(doc, None, whole), whole)
def _sum(doc, whole=None) -> dict:
return {k: float(v) for k, v in _run(doc, whole)["1단위"].items()}
def _total(doc, whole=None) -> float:
return _sum(doc, whole)["계"]
# ── 셈 = 엑셀 ─────────────────────────────────────────────────────────
def test_봉화_셈_엑셀_검산():
assert _sum(_doc()) == {"노무비": 34119, "재료비": 11192, "경비": 341, "계": 45652}
got = imp.check(sd.clean(_doc(), None, _whole()))
assert got["같음"] is True and len(got["줄"]) == 4 and all(x["같음"] for x in got["줄"])
bad = _doc()
bad[imp.KEY]["원본"]["엑셀금액"]["계"] = 45653
bad[imp.KEY]["원본"]["글줄"][1]["값"] = 3870
got = imp.check(sd.clean(bad, None, _whole()))
assert got["같음"] is False and got["까닭"] == "금액 다름"
assert got["줄"][0]["까닭"] == "셈 3862.5 · 엑셀 3870"
def test_북부청_18_줄_자리_셈_엑셀():
assert _sum(_doc(_bukbu)) == {"노무비": 24467, "재료비": 5902, "경비": 0, "계": 30369}
assert imp.check(sd.clean(_doc(_bukbu), None, _whole()))["같음"] is True
# ── 덮개 · 식 칸 · 셈 답 ─────────────────────────────────────────────
def test_덮개_상수_식칸_가져온_줄에_먹음():
base = _total(_doc())
covered = _doc(덮개={"labor:형틀목공#0": {"qty": 0.2}})
assert _total(covered) > base # 형틀목공 5/50 → 0.2 인
assert (
_total(_doc(상수덮개={BURN: {"0": 12}})) == 45652 + 101
) # 소모자재 11 → 12 %(A01 재료 10,083.7 × 1 %)
assert _total(_doc(식덮개={"labor:형틀목공#0": "6 / 50"})) > base
var = [{"이름": "할증", "식": "1.2", "자리": ""}]
assert _total(_doc(변수줄=var, 식덮개={"labor:형틀목공#0": "5 / 50 * 할증"})) > base
assert _total(_doc(뺀줄=[PLY])) < base
adds = [{"종류": "인력", "요소": MAN, "수량": 0.01}]
assert _total(_doc(더한줄=adds)) > base
def test_셈_답_원래_엑셀_원본_글(world):
doc = _doc(덮개={"labor:형틀목공#0": {"qty": 0.2}}, 까닭={"labor:형틀목공#0": "현장 품"})
with store.at(world["personal"]):
got = calc.calc_doc(doc)
assert got["원래"] == {"노무비": 34119, "재료비": 11192, "경비": 341, "계": 45652}
assert got["기준"]["계"] > 45652
spot = got["시트"]["자리"]["labor:형틀목공#0"]
assert spot["덮개"] == ["품"] and spot["까닭"] == "현장 품"
view = got[imp.KEY]
assert view["단가기준"] == "엑셀" and view["파일"] == "봉화.xlsx" and view["행"] == [2909, 2943]
lines = view["글줄"]
assert lines[0] == {"행": 2909, "글": "합판거푸집 ㎡ 당", "자리": None}
assert lines[1]["같음"] is True and lines[2]["같음"] is False # 덮은 줄 = 엑셀과 다름
assert [x["이름"] for x in view["이음없음"]] == ["합판(내수)", "각재", "형틀목공"]
# ── 단가 기준 · 이음 ──────────────────────────────────────────────────
def test_우리_단가로_엑셀_단가로():
got = imp.to_price(_doc(), "우리")
assert got["문서"][imp.KEY]["단가기준"] == "우리" and float(got["전"]["계"]) == 45652
price = float(_whole().get(MAN)["값"])
assert float(got["후"]["노무비"]) == pytest.approx(34119 + (price - 165545) * 2 / 50, abs=1)
back = imp.to_price(got["문서"], "엑셀")
assert float(back["후"]["계"]) == 45652 and back["전"] == got["후"]
assert len(got["이음없음"]) == 3
with pytest.raises(sd.DocError):
imp.to_price(_doc(), "남의")
with pytest.raises(sd.DocError):
imp.to_price({"키": "SK-000001", "바탕": {"로직": FORM}}, "우리")
def test_이음_고름_덮개_열쇠_옮김():
doc = _doc(덮개={"labor:형틀목공#0": {"qty": 0.2}}, 까닭={"labor:형틀목공#0": "현장"})
doc["글줄"] = [{"자리": "labor:형틀목공#0", "글": "※ 현장 조정"}]
got = imp.link(doc, "labor:형틀목공#0", CARP)["문서"]
new = f"labor:{CARP}#0"
assert got["덮개"] == {new: {"qty": 0.2}} and got["까닭"] == {new: "현장"}
assert got["글줄"][0]["자리"] == new
assert got[imp.KEY]["원본"]["글줄"][2]["자리"] == new
assert _total(got) == _total(doc) # 엑셀 단가 그대로 = 금액 그대로
ours = imp.to_price(got, "우리")
assert len(ours["이음없음"]) == 2 and ours["후"]["계"] != ours["전"]["계"]
with pytest.raises(sd.DocError):
imp.link(doc, "labor:형틀목공#0", "LB999999")
with pytest.raises(sd.DocError):
imp.link(doc, "labor:없는줄#0", CARP)
def test_우리_기초로_바꾸기():
doc = _doc(덮개={"labor:형틀목공#0": {"qty": 0.2}})
got = imp.to_base(doc, FORM, F211)
new = got["문서"]
assert new["바탕"] == {"로직": FORM} and not imp.is_imported(new) and new["덮개"] == {}
assert float(got["후"]["계"]) == 51419 and float(got["전"]["계"]) > 45652
assert got["차이"]["계"] == got["후"]["계"] - got["전"]["계"]
meta = new[imp.KEY]
assert meta["원본"]["파일"] == "봉화.xlsx" and meta["바꿈"]["로직"]["이름"].startswith("합판")
assert _total(new) == 51419 and same.finger("sangeun", new) == same.finger(
"sangeun", {k: v for k, v in new.items() if k != imp.KEY}
)
with pytest.raises(sd.DocError):
imp.to_base(doc, "GC999999", {})
# ── 꼴 검사 ───────────────────────────────────────────────────────────
@pytest.mark.parametrize(
("change", "why"),
[
(lambda r: r.update(결과단위="㎥/시간"), "결과단위"),
(lambda r: r["호표"][0].update(수량="1.03 * (0.327"), "수량"),
(lambda r: r["호표"][0].update(비목="잡비"), "비목"),
(lambda r: r["호표"][0].pop("단가"), "둘 다 없음"),
(lambda r: r["호표"][0].update(이음="MT999999"), "없는 이음"),
(lambda r: r["덧줄"][0].update(식="번지.A01.재료비 * "), "덧줄"),
(lambda r: r.update(호표=[]), "호표"),
],
)
def test_틀린_가져온_로직(change, why):
doc = _doc()
change(doc[imp.KEY]["로직"])
with pytest.raises(sd.DocError, match=why):
sd.clean(doc, None, _whole())
def test_바탕과_같이_못_둠_펼침_막음():
with pytest.raises(sd.DocError, match="바탕 없음"):
sd.clean(_doc(바탕={"로직": FORM}), None, _whole())
with pytest.raises(sd.DocError, match="펼치지 않음"):
const.no_flat(_doc())
# ── 만들기 · 저장 · 같은 내용 ────────────────────────────────────────
def test_만들기_저장_같은_내용(world):
mine = world["personal"]
row, src = _bonghwa()
with store.at(mine):
made = imp.create(row, src, "합판거푸집 가져옴")
doc = store.read("sangeun", made["이름"])["문서"]
with pytest.raises(store.StoreError) as e: # 같은 내용 = 409
imp.create(row, {**src, "파일": "다른.xlsx"}, "또")
assert e.value.status == 409
again = imp.create(row, src, "또", new=True)["이름"]
ours = imp.create(row, src, "우리", new=True)["이름"]
assert made["검산"]["같음"] is True and made["단가기준"] == "엑셀"
meta = doc[imp.KEY]
assert doc["바탕"] is None and doc["단위"] == "㎡" and meta["원본"] == src
assert meta["검산"]["같음"] is True and meta["로직"]["호표"][3]["이음"] == MAN
assert again != made["이름"] != ours
plain = {"키": "SK-000009", "이름": "x", "단위": "식", "바탕": {"로직": FORM}, "입력값": F211}
one = same.finger("sangeun", doc)
assert one and one != same.finger("sangeun", imp.to_price(doc, "우리")["문서"])
assert same.finger("sangeun", {**doc, imp.KEY: {**doc[imp.KEY], "원본": {}}}) == one
assert same.finger("sangeun", {k: v for k, v in plain.items()}) == same.finger(
"sangeun", {**plain, imp.KEY: {}}
)
with store.at(mine): # 저장 = 서버가 검산 다시(브라우저 값 안 받음)
lie = {**doc, imp.KEY: {**doc[imp.KEY], "검산": {"같음": False}}}
store.write("sangeun", made["이름"], store.read("sangeun", made["이름"])["판"], lie)
assert store.read("sangeun", made["이름"])["문서"][imp.KEY]["검산"]["같음"] is True
def test_옛_SK_그대로():
plain = {"키": "SK-000001", "이름": "x", "단위": "식", "바탕": {"로직": FORM}, "입력값": F211}
got = sd.clean(plain, None, _whole())
assert imp.KEY not in got and _total(plain) == 51419
empty = {"키": "SK-000001", "이름": "x", "단위": "식", "바탕": None}
assert sd.empty_row(empty)["호표"] == [] and same.finger("sangeun", empty) is None
# ── B09 굳힘 · 단가 전파 ───────────────────────────────────────────────
class _Priced:
"""M01 색인 위 노임 하나만 바꿈(단가 전파 흉내)."""
def __init__(self, whole, key, value):
self._whole, self._key, self._value = whole, key, value
def value(self, ref):
got = self._whole.value(ref)
return (D(self._value), got[1]) if ref == self._key else got
def __getattr__(self, name):
return getattr(self._whole, name)
def _frozen_total(doc, whole):
rid = "personal|7/42|SK-000001"
frozen = sk.freeze({rid: sd.clean(doc, None, _whole())})[rid]
key, row = frozen["바탕"]["로직"], json.loads(json.dumps(frozen[sk.ROW]))
assert key.startswith("GX9") and frozen["원본"][imp.KEY]["로직"]
over = calc._Over(whole, key, row)
return float(calc.run(sd.clean(frozen, None, over), over)["1단위"]["계"])
def test_B09_굳힘_단가_전파():
whole = _whole()
excel, ours = _doc(), imp.to_price(_doc(), "우리")["문서"]
assert _frozen_total(excel, whole) == 45652
live = _total(ours)
assert _frozen_total(ours, whole) == live
up = _Priced(whole, MAN, float(whole.get(MAN)["값"]) + 50000)
assert _frozen_total(excel, up) == 45652 # 엑셀 단가 그대로 = 안 바뀜
assert _frozen_total(ours, up) == pytest.approx(live + 50000 * 2 / 50 * 1.01, abs=2)
covered = _doc(덮개={"labor:형틀목공#0": {"qty": 0.2}})
assert _frozen_total(covered, whole) == _total(covered)
def test_원본_보존_사본():
doc = _doc()
keep = copy.deepcopy(doc)
imp.to_price(doc, "우리")
imp.link(doc, "labor:형틀목공#0", CARP)
imp.to_base(doc, FORM, F211)
assert doc == keep # 받은 문서 안 고침(화면 캐시 = 답 문서로 바꿈)
assert refs.kind_of("SK-000001") == "sangeun" and MINE["층"] == "personal"
assert flat.flat_doc("sangeun", doc) == doc # 참조 없음 = 그대로