실무 여덟 파일 455 블록 검산 같음 · 상수 자리 찾기 「(11)」 겹침 고침(M01 식엔 그 꼴 없음 = 옛 상수덮개 차례 그대로) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
392 lines
19 KiB
Python
392 lines
19 KiB
Python
# 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 # 참조 없음 = 그대로
|
||
|
||
|
||
# ── 실무 블록(793-2a 내보내기 → 가져온 식 SK) ─────────────────────────────
|
||
REAL = [("봉화", "D00102"), ("동부청영월", "D00003"), ("울진소광", "D01489"), ("북부청임도", 18)]
|
||
RULE = {"봉화": "비목버림", "동부청영월": "합버림_큰나머지", "울진소광": "비목버림"}
|
||
|
||
|
||
def _emitted(tag, code):
|
||
import helper_m02_hopyo_golden as golden
|
||
|
||
from M02_MasterTemplete import M02_San_Book as book
|
||
from M02_MasterTemplete import M02_San_Emit as em
|
||
from M02_MasterTemplete import M02_San_Est as est
|
||
from M02_MasterTemplete import M02_San_Stmate as stm
|
||
|
||
if not golden.FILES[tag].exists():
|
||
pytest.skip(f"실무 엑셀 없음 {tag}")
|
||
data = book.load(golden.FILES[tag])
|
||
if tag in RULE:
|
||
blk = next(b for b in data["블록"] if b["코드"] == code)
|
||
return em.stmate(blk, stm.parse_block(blk["몸"]), data["단가"], RULE[tag], True, tag)
|
||
blk = next(b for b in data["블록"] if b["번"] == code)
|
||
want = data["총괄"][blk["번"]]
|
||
return em.est(blk, est.parse_block(blk["글"], data["단가"]), want, True, tag)
|
||
|
||
|
||
@pytest.mark.parametrize(("tag", "code"), REAL)
|
||
def test_실무_블록_가져온_식(tag, code, world):
|
||
out = _emitted(tag, code)
|
||
with store.at(world["personal"]):
|
||
made = imp.create(out["로직"], out["원본"], f"{tag} {code}")
|
||
doc = store.read("sangeun", made["이름"])["문서"]
|
||
ans = calc.calc_doc(doc)
|
||
check = made["검산"]
|
||
excel = {k: round(v) for k, v in out["원본"]["엑셀금액"].items()}
|
||
assert check["같음"] is True and check["셈"] == excel and len(check["줄"]) >= 3
|
||
assert all(":" in x["자리"] for x in check["줄"]) # 줄 이름 → cid · mid: · 단계:
|
||
assert ans["원래"] == out["원본"]["엑셀금액"] and round(ans["기준"]["계"]) == excel["계"]
|
||
shown = [x for x in ans[imp.KEY]["글줄"] if "같음" in x]
|
||
assert shown and all(x["같음"] for x in shown)
|
||
assert _frozen_total(doc, _whole()) == excel["계"]
|
||
ours = imp.to_price(doc, "우리")
|
||
assert {k: round(float(v)) for k, v in ours["전"].items()} == excel
|
||
assert len(ours["이음없음"]) < len(doc[imp.KEY]["로직"]["호표"]) # 1차 줄 판정 이음
|