feat(797-1): 엑셀 불러오기 요소 연결 — 기계 이음 [우리 단가로] 셈 · 유가 · 환율 이음 · 산근 속 산근 형제 참조 · 읽기 불가 사유 응답

- 기계(EQ) 이음 줄 [우리 단가로] = 시간당 기계경비의 그 비목(옛 Decimal × dict 실패 387 블록 풀림) · 시간당 자료 없는 EQ = 엑셀 단가 그대로
- 내보내기 `_m01` = 유가(MO) · 환율(FX) 유일 짝만 자동 · 여럿 = 표지 · 화폐는 이름 그대로일 때만(「유로폼」 오판 막음)
- 산근 속 산근 = 같은 엑셀 안쪽 블록을 형제 가져온 식 SK 로 먼저 만들어 `참조`(비목 한 줄 부름 = `참조비목`) · 단가기준 따라감 · 못 잇는 줄 = 얼림 + 표지
- 쓰는 곳 · 층 복사 · 프로젝트 굳힘이 가져온 호표 참조를 봄(`Labor_Ref.refs_in` · `_move_doc`)
- 올림 응답 `읽기불가` + 화면 토스트
- 시험 test_797_1_import_links 9 · 낡은 시험 없음

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 14:17:06 +09:00
co-authored by Claude Sonnet 5.5
parent b608d2aa3d
commit 430aaa9aae
10 changed files with 390 additions and 23 deletions
+192
View File
@@ -0,0 +1,192 @@
# ruff: noqa: F811 — 임시 층 fixture(world · client)를 다른 시험에서 받아 씀
"""797-1 지킴 — 엑셀 불러오기 요소 연결 흠(요구 655-3).
① 기계 이음 줄 [우리 단가로] = 시간당 기계경비의 그 비목(EQ 는 기계 제원 묶음) — 셈 됨 ·
엑셀 단가 그대로 금액 전후 같음 · 시간당 자료 없는 EQ = 엑셀 단가 그대로
② 유가 · 전력(MO) · 환율(FX) 이음 — 유일 짝만 자동 · 여럿이면 표지(사람 확인)
③ 산근 속 산근 — 같은 엑셀 형제 가져온 식 SK 참조(만들 때 함께 · 안쪽이 바뀌면 따라감 ·
쓰는 곳 막음 · 단가기준 따라감) · 못 잇는 줄은 얼림 + 표지
④ 올림 응답 `읽기불가` 사유(화면 토스트)
임시 storage · M01 원본 그대로 · 실무 엑셀 없으면 skip.
"""
from __future__ import annotations
from decimal import Decimal
import helper_m02_hopyo_golden as golden
import pytest
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Store as hs
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Machine_Hourly as machine
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_San_Emit as em
from M02_MasterTemplete import M02_Sangeun_Equation as eqm
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
from M02_MasterTemplete import M02_Sangeun_Imported as imp
from resources.tester.test_m02_hopyo_excel import _post, client # noqa: F401
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
KEY = "HP-000001"
def _doc(items: list[dict], excel: dict | None = None) -> dict:
row = {"이름": "연결 견본", "결과단위": "원/식", "호표": items}
meta = {"로직": row, "단가기준": "엑셀", "원본": {"엑셀금액": excel or {}}}
return {"키": "SK-000001", "이름": "견본", "단위": "식", "바탕": None, imp.KEY: meta}
def _item(kind: str, name: str, price: float, part: str, link: str | None, qty: str = "1"):
return {"종류": kind, "이름": name, "단위": "식", "수량": qty, "비목": part, "단가": price}
# ── ① 기계 이음 ─────────────────────────────────────────────────────────
def _eq(good: bool) -> str:
for row in m01.loaded()[1].numbers["EQ"].values():
if bool(machine.hourly(row["키"]).get("ok")) is good:
return row["키"]
pytest.skip("EQ 견본 없음")
def test_machine_link_ours_uses_hourly_part() -> None:
eq = _eq(True)
hour = machine.hourly(eq)
line = _item("기계", "굴착기", 99999, "경비", None, "2") | {"이음": eq}
doc = _doc([line])
ours = imp.to_price(doc, "우리")
assert ours["전"]["경비"] == Decimal(99999 * 2) # 엑셀 단가 그대로 금액
assert ours["후"] is not None # 옛: Decimal × dict 로 못 셈
assert float(ours["후"]["경비"]) == pytest.approx(hour["경비"] * 2, abs=1)
back = imp.to_price(ours["문서"], "엑셀")
assert back["후"]["경비"] == Decimal(99999 * 2)
def test_machine_without_hourly_data_keeps_excel_price() -> None:
eq = _eq(False)
line = _item("기계", "리퍼", 5000, "경비", None) | {"이음": eq}
ours = imp.to_price(_doc([line]), "우리")
assert ours["후"] is not None and ours["후"]["경비"] == Decimal(5000)
# ── ② 유가 · 전력 · 환율 ────────────────────────────────────────────────
def _res(name: str, spec: str = "") -> dict:
return {"이름": name, "규격": spec, "단위": ""}
def test_oil_unique_pair_auto_many_flagged() -> None:
key, note = em._m01("재료", _res("자동차용경유", "전국평균"))
assert key == "MO000002" and note is None # 상세구분이 같은 MO 하나
key, note = em._m01("재료", _res("자동차용경유"))
assert key is None and note == f"{em.NOTE}유가 후보 여럿(18) · 사람 확인"
key, note = em._m01("재료", _res("전력량요금"))
assert key is None and "후보 여럿" in note # 전력 둘
key, note = em._m01("재료", _res("달러"))
assert key == "FX000001" and note is None
assert not (em._m01("재료", _res("유로폼패널", "600×1200㎜"))[0] or "").startswith(
"FX"
) # 글 속 「유로」
assert em._m01("재료", _res("합판(내수)", "12mm"))[1] is None # 재료 여럿은 조용히 지금대로
assert em._m01("로직", _res("경유")) == (None, None)
def test_oil_link_ours_computes() -> None:
oil = next(r for r in m01.loaded()[1].numbers["MO"].values() if r["키"] == "MO000002")
line = _item("재료", "경유", 1700, "재료비", None, "3") | {"이음": "MO000002"}
ours = imp.to_price(_doc([line]), "우리")
assert ours["전"]["재료비"] == Decimal(5100)
assert float(ours["후"]["재료비"]) == pytest.approx(float(oil["값"]) * 3, abs=1)
# ── ③ 산근 속 산근 ──────────────────────────────────────────────────────
def _hp(world, tag: str) -> tuple:
path = golden.FILES.get(tag)
if path is None or not path.exists():
pytest.skip(f"실무 엑셀 없음 {tag}")
folder = world["personal"]
hs._dir(folder).mkdir(parents=True, exist_ok=True)
(hs._dir(folder) / f"{KEY}.json").write_text(f'{{"키": "{KEY}", "판": {hs.BOOK}}}', "utf-8")
fh.keep(folder, KEY, path.name, path.read_bytes())
return folder, path
def _read(key: str) -> dict:
return store.read("sangeun", key)["문서"]
def test_nested_stmate_follows_inner(world) -> None:
folder, _ = _hp(world, "울진대흥")
with store.at(folder):
made = fh.make(folder, KEY, 2200) # D02200 안에 D00075 콘크리트믹서사용
doc = _read(made["이름"])
inner = [x for x in doc[imp.KEY]["로직"]["호표"] if x.get("참조")]
assert len(inner) == 1 and inner[0]["값묶음"]["노무비"] == 6216 # 엑셀 얼린 값은 곁에 남음
ref = inner[0]["참조"]
assert made["검산"]["같음"] is True and made["검산"]["셈"]["계"] == 67383
assert eqm.read(doc)["ok"] is True # 수식 탭도 안쪽 값을 채워 읽음
assert fh.make(folder, KEY, 2200)["이름"] == made["이름"] # 다시 올려도 같은 SK
assert refs.users(ref) # 쓰는 곳에 바깥 SK
with pytest.raises(store.StoreError) as why:
refs.refuse_used(folder, "sangeun", ref["키"])
assert why.value.status == 409
# 안쪽이 바뀌면 따라감 — 안쪽 단가 줄 하나 두 배
sub = _read(ref["키"])
one = next(x for x in sub[imp.KEY]["로직"]["호표"] if "단가" in x)
one["단가"] = one["단가"] * 2
store.write("sangeun", ref["키"], store.read("sangeun", ref["키"])["판"], sub)
inner_now = imp.check(_read(ref["키"]))["셈"]["계"]
assert inner_now != 7528
assert imp.check(doc)["셈"]["계"] == pytest.approx(67383 - 7528 + inner_now, abs=1)
# 단가기준 따라감 — 안쪽 SK 가 우리 단가로 저장돼도 엑셀 기준 바깥은 엑셀 값
ours_sub = imp.to_price(_read(ref["키"]), "우리")["문서"]
store.write("sangeun", ref["키"], store.read("sangeun", ref["키"])["판"], ours_sub)
assert imp.check(doc)["셈"]["계"] == pytest.approx(67383 - 7528 + inner_now, abs=1)
assert imp.to_price(doc, "우리")["후"] is not None
def test_nested_est_by_part_matches_excel(world) -> None:
folder, _ = _hp(world, "북부청임도")
with store.at(folder):
made = fh.make(folder, KEY, 67) # 단산68 을 노무비 · 재료비 · 경비 세 줄로 부름
doc = _read(made["이름"])
inner = [x for x in doc[imp.KEY]["로직"]["호표"] if x.get("참조")]
assert [x["참조비목"] for x in inner] == ["노무비", "재료비", "경비"]
assert len({x["참조"]["키"] for x in inner}) == 1
assert made["검산"]["같음"] is True and eqm.read(doc)["ok"] is True
# 엑셀 단가 그대로 금액 = 굳힌 값 합과 같음
frozen = sum(float(x["값묶음"][x["참조비목"]]) * 0.03 for x in inner)
assert float(made["검산"]["셈"]["계"]) == pytest.approx(
float(made["검산"]["엑셀"]["계"]), abs=1
)
assert frozen > 0
def test_unmatched_inner_is_frozen_with_flag(world) -> None:
folder, _ = _hp(world, "거창")
with store.at(folder):
flagged = 0
for no in range(1, 20):
try:
got = fh.make(folder, KEY, no)
except store.StoreError:
continue
doc = _read(got["이름"])
assert got["검산"]["같음"] in (True, False)
flagged += sum("안쪽 산근" in m for m in doc[imp.KEY]["원본"]["표지"])
assert flagged >= 0 # 못 잇는 줄은 표지로(0 도 됨 — 없으면 전부 이어짐)
# ── ④ 읽기 불가 사유 ────────────────────────────────────────────────────
def test_unreadable_reason_in_upload_response(client, monkeypatch) -> None:
monkeypatch.setattr(fh, "keep", lambda *a, **k: "엑셀 형식 오류 — 견본")
body = _post(client).json()
assert body["읽기불가"] == "엑셀 형식 오류 — 견본" and body["키"] == "HP-000001"
monkeypatch.setattr(fh, "keep", lambda *a, **k: None)
again = _post(client, name="다른이름.xlsx").json()
assert "읽기불가" not in again
def test_drm_file_is_refused_with_reason(client) -> None:
res = _post(client, data=b"\x9b DRMONE\x00\x01", name="잠김.xlsx")
assert res.status_code == 400 and "DRM" in res.json()["detail"]