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
+9 -4
View File
@@ -271,11 +271,12 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
mark = hopyo_store.fingerprint(data)
hopyo_forget.sweep(store.root()) # 하루 지난 작업 중 HP 끝냄(743-1)
if same := next((r for r in hopyo_store.rows(store.root()) if r.get("지문") == mark), None):
cannot = None
if not from_hopyo.has(store.root(), same["키"]): # 793-3b 전에 올린 HP
from_hopyo.keep(store.root(), same["키"], name, data)
cannot = from_hopyo.keep(store.root(), same["키"], name, data)
hopyo_judge.ensure(store.root(), same["키"])
san_basis.ensure(store.root(), same["키"])
return {**same, "이어서": True}
return {**same, "이어서": True, **({"읽기불가": cannot} if cannot else {})}
try:
parsed = hopyo_excel.read_book(name, data)
except hopyo_excel.ExcelError as error:
@@ -288,10 +289,14 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
parsed["사전"] = hopyo_row.lexicon(sheets) # 엑셀 사전(785-3 · 1차 줄 판정)
parsed["숨은식"] = san_basis.hidden(parsed, sheets) # STmate 숨은 G 식(793-3a 근거 제안)
row = hopyo_store.save(store.root(), name, parsed, who, grid, mark)
from_hopyo.keep(store.root(), row["키"], name, data) # 산근 시트(793-3b 가져온 식 열기)
cannot = from_hopyo.keep(
store.root(), row["키"], name, data
) # 산근 시트(793-3b 가져온 식 열기)
hopyo_judge.ensure(store.root(), row["키"]) # 블록 풀이 뒤 셈(파생 저장본 · 화면 안 기다림)
san_basis.ensure(store.root(), row["키"]) # 근거 제안 뒤 셈(793-3a)
return row
return (
{**row, "읽기불가": cannot} if cannot else row
) # 797-1 가져온 식 못 씀 사유 → 화면 토스트
@router.get("/layers/{layer}/hopyo")
+12 -6
View File
@@ -54,24 +54,30 @@ class Expand:
return got
return labor._number(qty.get(refs.SAVED)) if self.hands else hand
def __call__(self, ref: Ref, trail: tuple[str, ...] = ()) -> dict[str, Any]:
def __call__(
self, ref: Ref, trail: tuple[str, ...] = (), basis: str | None = None
) -> dict[str, Any]:
"""`basis` = 가져온 식 SK(797-1 산근 속 산근)를 부르는 쪽 단가기준(엑셀 | 우리)으로 셈."""
rid = refs.ref_id(ref)
if rid in trail:
keys = [t.rsplit("|", 1)[-1] for t in (*trail, rid)]
return self._stop(ref, "순환 — " + " → ".join(keys))
if len(trail) >= refs.DEPTH:
return self._stop(ref, f"참조가 {refs.DEPTH}단을 넘음")
if rid not in self.memo:
memo = f"{rid}|{basis}" if basis else rid
if memo not in self.memo:
doc = self.read(ref)
if not isinstance(doc, dict):
return self._stop(ref, f"없는 참조 「{ref['키']}」")
if refs.kind_of(ref["키"]) == "promote":
self.memo[rid] = self._promote(doc)
self.memo[memo] = self._promote(doc)
elif refs.kind_of(ref["키"]) == "sangeun": # 단가산출(715-2) = 고친 줄 셈 1 단위
self.memo[rid] = calc.unit(doc, self.whole, self, (*trail, rid))
if basis and isinstance((doc.get("가져온") or {}).get("로직"), dict):
doc = {**doc, "가져온": {**doc["가져온"], "단가기준": basis}}
self.memo[memo] = calc.unit(doc, self.whole, self, (*trail, rid))
else: # 사전조합 = 그 SC 로직 사본(795-2)
self.memo[rid] = lc.combo(self, doc, (*trail, rid))
return self.memo[rid]
self.memo[memo] = lc.combo(self, doc, (*trail, rid))
return self.memo[memo]
@staticmethod
def _stop(ref: Ref, why: str) -> dict[str, Any]:
+8
View File
@@ -60,6 +60,8 @@ def refs_in(doc: Any) -> list[Ref]:
return []
base = doc.get("바탕")
lines = [*(doc.get("담은로직") or []), *(doc.get("더한줄") or []), base]
own = ((doc.get("가져온") or {}).get("로직") or {}).get("호표") # 가져온 식 산근 속 산근(797-1)
lines += own if isinstance(own, list) else []
return [
one["참조"] for one in lines if isinstance(one, dict) and isinstance(one.get("참조"), dict)
]
@@ -500,6 +502,12 @@ def _move_doc(doc: dict[str, Any], move: Callable[[Ref, int], Ref], depth: int)
out = {**doc, "더한줄": _moved(doc.get("더한줄"), move, depth)}
if isinstance(base, dict) and isinstance(base.get("참조"), dict):
out["바탕"] = {"참조": move(base["참조"], depth)}
own = (doc.get("가져온") or {}).get("로직")
if isinstance(own, dict): # 가져온 식 산근 속 산근(797-1)
out["가져온"] = {
**doc["가져온"],
"로직": {**own, "호표": _moved(own.get("호표"), move, depth)},
}
return out
@@ -30,6 +30,8 @@ export interface HopyoFile {
시트: string;
호표수: number;
어긋남: number;
/** 올림 응답만 — 산근 시트를 못 읽은 사유(빈 파일 · DRM 암호 · 형식 오류 · 797-1) */
읽기불가?: string;
}
export type Money = Record<"계" | "노무비" | "재료비" | "경비", number | null>;
@@ -148,6 +148,7 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
.then((got: HopyoFile & { 이어서?: boolean }) => {
if (got.이어서) showToast(`같은 엑셀 작업 중 — ${got.키} 이어서`, "info");
else showToast(`올림 — 호표 ${got.호표수}`, got.어긋남 ? "warning" : "success");
if (got.읽기불가) showToast(`가져온 식으로 열 수 없음 — ${got.읽기불가}`, "warning");
return show(pick, got, true);
})
.catch((error) => showToast(why(error), "error"))
+36 -7
View File
@@ -176,15 +176,40 @@ def reprice(logic: dict, where: dict, price: dict[str, float]) -> int:
return n
def _m01(kind: str, src: dict) -> str | None:
"""1차 줄 판정 유일 짝 M01 키 — 없거나 여럿 = None."""
def _m01(kind: str, src: dict) -> tuple[str | None, str | None]:
"""1차 줄 판정 유일 짝 M01 키 → (키, 표지) — 없음 = (None, None) · 여럿 = (None, 표지 · 유가 ·
환율만 — 재료 · 인력 · 기계는 지금처럼 조용히 이음 없음). 재료 줄 이름이 M01 유가전력(MO)이면
유가 · 화폐 이름 그대로(달러 · USD …)면 환율(FX)로 판정(797-1 · 「유로폼」 같은 글 속 낱말은 아님)."""
if kind not in ("인력", "재료", "기계"):
return None
return None, None
from M02_MasterTemplete import M02_Hopyo_Elem as elem
from M02_MasterTemplete import M02_Hopyo_Row as hrow
got = hrow.m01_picks(kind, {k: src.get(k) or "" for k in ("이름", "규격", "단위")})
line = {k: src.get(k) or "" for k in ("이름", "규격", "단위")}
if kind == "재료":
if elem.oil_rows(line["이름"]):
kind = "유가"
elif line["이름"].strip().upper() in {x.upper() for pair in hrow.CURRENCY for x in pair}:
kind = "환율"
if kind == "유가": # MO 전부 → 줄 규격이 상세구분(전국 · 지역)이면 그것만
got = [
{"키": r["키"], "규격": str(r.get("상세구분") or "")}
for r in elem.oil_rows(line["이름"])
]
spec = line["규격"].replace(" ", "")
got = (
[x for x in got if x["규격"] == spec]
or [x for x in got if spec and spec in x["규격"]]
or got
)
else:
got = hrow.m01_picks(kind, line)
keys = {x.get("키") for x in got if x.get("키")}
return keys.pop() if len(keys) == 1 else None
if len(keys) == 1:
return keys.pop(), None
if len(keys) > 1 and kind in ("유가", "환율"):
return None, f"{NOTE}{kind} 후보 여럿({len(keys)}) · 사람 확인"
return None, None
# ── 내보낼 로직 틀 ─────────────────────────────────────────────────────
@@ -371,9 +396,11 @@ class _Stm:
if not one_ and len(sole) == 1: # 비목 꼬리 없어도 값 든 비목 하나면 단가 줄(셈 같음)
one_, to = sole[0], None
res |= {"비목": one_, "옮김": to} if one_ else {}
link = _m01(res["갈래"], res) if self.keys else None
link, many = _m01(res["갈래"], res) if self.keys else (None, None)
one = resource(o.names.item(name), pr.get("단위"), cf, res, link)
self.line(one, node, True, src)
if many:
o.glyph(src["행"], src["글"], mark=many)
def rate(self, node: dict, src: dict, cf: str) -> None:
rate, o = node["율"], self.o
@@ -558,11 +585,13 @@ def est(block: dict, lines: list[dict], want: dict, keys: bool = False, file: st
i, role = next((i, r) for i, r in L["역할"].items() if r["갈래"] == "단가")
price = next(n[1] for n in ex.numbers(L["식"]) if n[2] == i)
res = _est_res(role, price, comp)
link = _m01(res["갈래"], res) if keys else None
link, many = _m01(res["갈래"], res) if keys else (None, None)
qty = text(L["식"], {}, {i: "1"})
one = resource(o.names.item(L.get("라벨") or ""), None, qty, res, link)
one |= {**head, **({"자리": place} if place else {})}
o.add_line(one, True, at, glyph, L["값"])
if many:
o.glyph(at, glyph, mark=many)
if role.get("모호"): # 임의 선택 아님 — 후보를 사람이 고름
o.glyph(at, glyph, mark=f"{NOTE}단가 후보 여럿({role['남은']}) · 사람 확인")
elif role.get("갈림") in ("문맥", "기계 세 줄"):
+15 -1
View File
@@ -125,6 +125,11 @@ def _base_of(doc: dict, whole: Any, read: Any, trail: tuple[str, ...]) -> dict[s
row = skc.over(doc, whole).logic(base["로직"]) if base else sd.empty_row(doc)
except Exception as e: # noqa: BLE001 — 엔진 FormulaError
raise sd.DocError(f"바탕 로직 · {e}") from e
if any(x.get("참조") for x in row.get("호표") or []): # 가져온 식 안쪽 산근(797-1) 값 채움
from M02_MasterTemplete.M02_Labor_Expand import Expand
row = {**row, "호표": [dict(x) for x in row["호표"]]}
_bundles(row, whole, Expand(whole, read), trail)
row, lost = eqm.on(row, doc, skc.over(doc, whole))
return {**empty, "줄": row, "짝잃음": lost}
ref = base["참조"]
@@ -323,7 +328,12 @@ def _bundles(row: dict, whole: Any, expand: Any, trail: tuple) -> None:
for at, item in enumerate(row["호표"]):
if item.get("값묶음") is not None or not ("참조" in item or "고정" in item):
continue
sub = expand(item["참조"], trail) if "참조" in item else fixed.line(whole, item["고정"])
if "참조" in item and item.get(
"기준"
): # 가져온 식 산근 속 산근(797-1) — 부르는 쪽 단가기준
sub = expand(item["참조"], trail, item["기준"])
else:
sub = expand(item["참조"], trail) if "참조" in item else fixed.line(whole, item["고정"])
item["이름"] = item.get("이름") or sub.get("이름") or ""
item["단위"] = item.get("단위") or sub.get("단위") or ""
if sub.get("기준") is None:
@@ -331,6 +341,10 @@ def _bundles(row: dict, whole: Any, expand: Any, trail: tuple) -> None:
row["호표"][at] = _gap(item, NO_QTY_WHY if NO_QTY in why else why)
continue
item["값묶음"] = {k: Decimal(str(sub["기준"][k])) for k in COSTS}
if item.get("참조비목") in COSTS: # 안쪽 산근의 그 비목만(엑셀이 비목마다 한 줄로 부른 때)
item["값묶음"] = {
k: v if k == item["참조비목"] else Decimal(0) for k, v in item["값묶음"].items()
}
def run(
+63 -2
View File
@@ -14,16 +14,19 @@ STmate 블록 끝전(비목버림 | 합버림_큰나머지)은 파일 설정 —
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_San_Emit as em
DIRNAME = "산근"
RELOAD = "가져온 식 자료 없음 — 엑셀을 다시 올림"
RULES = ("비목버림", "합버림_큰나머지")
MAX_INNER = 6 # 안쪽 산근 깊이 상한
def _path(folder: str | Path, key: str) -> Path:
@@ -105,12 +108,70 @@ def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]:
return em.est(blk, est.parse_block(blk["글"], prices), total[blk["번"]], True, name)
def make(source: Path, key: str, no: int, name: str = "") -> dict[str, Any]:
"""지금 층(만들 층)에 가져온 식 SK — 같은 내용이 있으면 그 SK(`다시씀`)."""
def _inner(source: Path, key: str, logic: dict) -> list[tuple[dict, int | None, str | None]]:
"""안쪽 산근 줄 `[(호표 줄, 같은 엑셀 안쪽 블록 번, 못 잇는 까닭)]` — STmate = 줄 코드가 블록 코드 ·
EST = 줄 이름 「단산N」(N 은 총괄표에 있는 블록). 비목 하나만 부른 줄은 그 비목이 그대로(옮김 없음)일 때만."""
from M02_MasterTemplete import M02_San_Book as book
sheets = json.loads(_path(source, key).read_bytes())["시트"]
stmate = book.kind_of(sheets) == "STmate"
have = {b["코드"]: int(b["코드"][1:]) for b in book.stmate_blocks(sheets)} if stmate else {}
total = set() if stmate else set(book.est_prices(sheets)[1])
out = []
for line in logic["호표"]:
res = line.get("자원") or {}
if res.get("갈래") != "단가산출":
continue
if stmate:
n = have.get(str(res.get("코드")))
else:
hit = re.fullmatch(r"단산(\d+)", str(res.get("이름")))
n = int(hit.group(1)) if hit and int(hit.group(1)) in total else None
if n is None:
out.append((line, None, "같은 엑셀에 안쪽 블록 없음"))
elif res.get("옮김") or (res.get("꼴") == "EST" and res.get("열쇠비목") not in em.COMP):
out.append((line, n, "비목이 바뀐 부름"))
else:
out.append((line, n, None))
return out
def _link_inner(source: Path, key: str, no: int, out: dict, trail: tuple) -> None:
"""산근 속 산근(797-1) — 안쪽 블록을 먼저 같은 길로 만들어 `참조` 로 이음(안쪽이 바뀌면 따라감) ·
못 잇는 줄 = 엑셀 값묶음 얼림 + 표지."""
from M02_MasterTemplete import M02_Labor_Ref as refs
made: dict[int, dict | None] = {}
for line, n, why in _inner(source, key, out["로직"]):
if n is not None and not why and (n == no or n in trail or len(trail) >= MAX_INNER):
why = "안쪽 블록이 자기 자신을 부름"
if n is not None and not why and n not in made:
try:
made[n] = make(source, key, n, "", (*trail, no))
except Exception as e: # noqa: BLE001 — 안쪽을 못 만듦 = 얼림(바깥은 만듦)
made[n], why = None, f"안쪽 블록을 못 만듦 — {str(getattr(e, 'detail', e))[:60]}"
if n is not None and not why and made[n] is None:
why = "안쪽 블록을 못 만듦"
if why:
out["원본"]["표지"].append(
f"{em.NOTE}안쪽 산근 「{line['이름']}」 이음 못 함({why}) · 엑셀 값 얼림"
)
continue
layer, owner = refs.here()
line["참조"] = {"층": layer, "주인": owner, "키": made[n]["이름"]}
res = line["자원"]
if res.get("비목"):
line["참조비목"] = em.COMP[res["비목"]]
def make(source: Path, key: str, no: int, name: str = "", _trail: tuple = ()) -> dict[str, Any]:
"""지금 층(만들 층)에 가져온 식 SK — 같은 내용이 있으면 그 SK(`다시씀`) ·
안쪽 산근 블록은 형제 SK 를 먼저 만들어 참조(797-1)."""
from M02_MasterTemplete import M02_San_Basis_Store as san_basis
from M02_MasterTemplete import M02_Sangeun_Imported as imp
out = emit(source, key, no)
_link_inner(source, key, no, out, _trail)
title = str(name or "").strip() or str(out["로직"].get("이름") or f"단산{no}")
try:
basis = san_basis.block(source, key, no).get("근거후보") or None
+52 -3
View File
@@ -51,6 +51,7 @@ KINDS = ("인력", "재료", "기계", "로직")
ROW = ("이름", "결과단위", "입력", "중간", "호표", "덧줄", "단계", "끝전", "조정", "끝수")
LINE = ("종류", "이름", "규격", "단위", "수량", "비목", "단가", "값묶음", "이음", "번지", "합밖")
LINE += ("자리", "출처", "비고", "자원") # 자원 = 경쟁사 코드 · 엑셀 단가 원본(793-2a · 셈 안 씀)
LINE += ("참조", "참조비목") # 산근 속 산근 = 같은 엑셀의 형제 가져온 식 SK(797-1 · 로직 줄만)
EXTRA = ("이름", "식", "비목", "번지", "합밖", "자리", "출처", "비고")
META = ("원본", "검산", "근거후보", "바꿈") # 검산 = 저장 때 서버가 다시 적음(`stamp`)
MANY = 300 # 호표 · 덧줄 · 중간 줄 수
@@ -75,6 +76,29 @@ def is_imported(doc: Any) -> bool:
# ── 바탕 줄 ───────────────────────────────────────────────────────────
_HOURLY: dict[str, Any] = {"whole": None, "rows": {}}
def hourly_of(key: str) -> dict:
"""이음 기계(EQ) 시간당 노 · 재 · 경 — M01 판이 바뀌면 다시(캐시 = M01 묶음 하나에 매임)."""
from M02_MasterTemplete import M02_Machine_Hourly as machine
whole = _whole()
if _HOURLY["whole"] is not whole:
_HOURLY.update(whole=whole, rows={})
if key not in _HOURLY["rows"]:
_HOURLY["rows"][key] = machine.hourly(key)
return _HOURLY["rows"][key]
def machine_price(link: str, part: Any, excel: Any) -> Any:
"""[우리 단가로] 기계 이음 줄 값 — 시간당 경비의 그 비목 · 시간당 자료 없으면 엑셀 단가 그대로."""
got = hourly_of(link)
if got.get("ok") and part in COSTS:
return sd.plain(Decimal(str(got[part])))
return excel
def row_of(doc: Any) -> dict | None:
"""가져온 식 바탕 줄(엔진 로직 줄) — 단가기준 우리 = 이음 있는 줄의 단가 · 값묶음 뗌 · 요소 =
이음 | {이름}(로직 줄 = 이음 | None) · 가져온 식 아님 = None."""
@@ -87,9 +111,17 @@ def row_of(doc: Any) -> dict | None:
link = one.get("이음")
item = {k: v for k, v in one.items() if k != "이음"}
item["요소"] = link or (None if one.get("종류") == "로직" else {"이름": one.get("이름")})
if one.get(
"참조"
): # 안쪽 산근 = 형제 SK 1 단위 값(셈이 채움 · 엑셀 값묶음은 얼린 사본으로 둠)
item |= {"요소": None, "값묶음": None, "기준": "우리" if ours else "엑셀"}
if ours and link:
item.pop("단가", None)
item.pop("값묶음", None)
if str(link).startswith(
"EQ"
): # EQ = 기계 제원 묶음 — 줄 값은 시간당 기계경비의 그 비목
item["단가"] = machine_price(link, one.get("비목"), one.get("단가"))
items.append(item)
row = {"입력": [], "중간": [], "덧줄": [], **meta["로직"]}
return {**row, "키": doc.get("키"), "호표": items}
@@ -150,6 +182,17 @@ def _line(raw: Any, where: str, whole: Any) -> dict:
out["단위"] = str(raw.get("단위") or "")
out["수량"] = _formula(raw.get("수량"), f"{where} 수량")
out["이음"] = _link(raw.get("이음"), kind, where, whole)
if "참조" in out:
ref = out["참조"]
if kind != "로직" or not isinstance(ref, dict):
raise sd.DocError(f"{where} · 참조는 로직 줄의 {{층, 주인, 키}}")
if ref.get("층") not in ("system", "company", "personal") or not re.fullmatch(
r"SK-\d{6}", str(ref.get("키") or "")
):
raise sd.DocError(f"{where} · 참조는 단가산출(SK)만")
out["참조"] = {k: ref.get(k) for k in ("층", "주인", "키")}
if out.get("참조비목") not in (*COSTS, None):
raise sd.DocError(f"{where} · 참조비목은 노무비 · 재료비 · 경비")
if kind != "로직" and out.get("비목") not in sd.PARTS.values():
raise sd.DocError(f"{where} · 비목은 노무비 · 재료비 · 경비")
if "단가" in out:
@@ -159,7 +202,7 @@ def _line(raw: Any, where: str, whole: Any) -> dict:
out["값묶음"] = {k: sd.plain(sd._num(pack.get(k, 0), f"{where} {k}")) for k in COSTS}
if kind == "로직" and "단가" in out:
raise sd.DocError(f"{where} · 로직 줄 엑셀 값은 값묶음")
if not any(k in out for k in ("단가", "값묶음")) and not out["이음"]:
if not any(k in out for k in ("단가", "값묶음", "참조")) and not out["이음"]:
raise sd.DocError(f"{where} · 엑셀 단가 · 이음 둘 다 없음")
return out
@@ -261,7 +304,7 @@ def unlinked(doc: dict) -> list[dict]:
return [
{"자리": ids[n], "이름": one.get("이름") or ""}
for n, one in enumerate(doc[KEY]["로직"].get("호표") or [])
if not one.get("이음")
if not one.get("이음") and not one.get("참조")
]
@@ -321,7 +364,13 @@ def _inner(doc: dict, whole: Any) -> dict:
from M02_MasterTemplete import M02_Sangeun_Calc as calc
m01, key = calc.labor.m01, str(doc.get("키") or "SK-000000")
over = calc._Over(whole, key, {**row_of(doc), "키": key})
row = {**row_of(doc), "키": key}
if any(x.get("참조") for x in row["호표"]): # 안쪽 산근 값 채움(797-1 · 셈 길과 같음)
from M02_MasterTemplete.M02_Labor_Expand import Expand
row["호표"] = [dict(x) for x in row["호표"]]
calc._bundles(row, whole, Expand(whole), ())
over = calc._Over(whole, key, row)
try:
auto = {k: v for k, v in m01.mau.values(over, over.logic(key)).items() if v is not None}
given = {**auto, **m01._dec(doc.get("입력값") or {})}
+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"]