- M02_Doc_NativeEst.triple: 세 줄 수량 식이 같은지 = repr 로 맞댐(자리 규칙 dict 가 set 에 안 들어가 TypeError — 엑셀 줄은 보인 값이 반올림 자리를 타 원파일에 없던 꼴 · 812-3 끝나 브레인이 맡김) - 엑셀 읽기기: 절 소계 덧셈이 뺀 오타 줄 = 비목 없음(블록 금액도 그래야 맞을 때만) · 세 줄 계 금액 칸 비목 하나 = 옮김 · 소계 61 = 금액 칸 비목 하나 · 「.1」 수 · 총괄표 당초 칸 표지 · STmate 엑셀 줄 금액 · 계 꼬리(검산 줄) - 화면: 가져온 식 규격 칸 꼬리표 = 원파일 · 엑셀 갈림(line_stack_sourced) - 시험: 793_1 지움(옛 EST · STmate 엑셀 글 추정 엔진 · 812_1 골든이 대신) · 793_2_san_emit EST 시험 지움(STmate 남김) · 797_1a 옛 표지 ① ② · 줄 단위 합격 · 표본 밖 수 · 버림1 감시 지움(옛 엔진) · 810_1 (c) 엑셀 엔진 대조 지움 · 793_2 실무 블록 · 793_3b 끝전 = 새 길 · 812_1 금액 엇갈림 없음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
294 lines
13 KiB
Python
294 lines
13 KiB
Python
"""797-2 지킴 — 엑셀 불러오기 소요량 · 계수 이음(값 역색인 후보 + 사람 확인 · 요구 655-3).
|
|
|
|
작은 M01(소요량 · 계수 표 몇 칸 · 임시 폴더)로 규칙을, 실무 엑셀(북부청 임도)로 실물을 본다.
|
|
① 후보 유일 · 여럿 · 없음 — 같은 값 + 같은 단위 성격 + 이름 · 공종 문맥 · 단위 다르면 후보 아님
|
|
② 고름 = 칸 참조 기록(층 · 표 · 줄 · 칸) · 금액 그대로 · 값 다르면 못 고름(까닭) · 버림 · 되돌림
|
|
③ 저장 거름(`clean`) — 자리 · 수가 맞는 기록만 · 서버 길 qty · qty-pick · qty-drop
|
|
④ 만들 때 표지(「참고 · 소요량 후보 N · 사람 확인」 · 「표 칸 없음」) — 못 옮김 표지는 안 늘림
|
|
실무 엑셀 없으면 그 시험만 skip. M01 원본은 읽기만.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import helper_m02_hopyo_golden as golden
|
|
import pytest
|
|
|
|
from M01_MasterData import M01_MasterData_Store as m01
|
|
from M02_MasterTemplete import M02_Doc_FromNative as fn
|
|
from M02_MasterTemplete import M02_Hopyo_Store as hs
|
|
from M02_MasterTemplete import M02_San_Basis_Index as bi
|
|
from M02_MasterTemplete import M02_San_Emit as em
|
|
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
|
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
|
|
from M02_MasterTemplete import M02_Sangeun_Imported as imp
|
|
from M02_MasterTemplete import M02_Sangeun_QtyLinks as qty
|
|
from M02_MasterTemplete import M02_Sangeun_Router as sangeun_router
|
|
from resources.tester.test_m02_hopyo_excel import client # noqa: F401
|
|
|
|
API = "/api/m02/layers/personal"
|
|
|
|
|
|
def _put(root: Path, name: str, body: dict) -> None:
|
|
(root / name).write_text(json.dumps(body, ensure_ascii=False), encoding="utf-8")
|
|
|
|
|
|
def _table(key: str, name: str, work: str, cols: dict, rows: list[dict]) -> dict:
|
|
return {
|
|
"키": key,
|
|
"원문번호": key[-1],
|
|
"이름": name,
|
|
"용도": {"공종": work},
|
|
"값칸": cols,
|
|
"줄": rows,
|
|
}
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def idx(tmp_path_factory) -> bi.Index:
|
|
root = tmp_path_factory.mktemp("m01mini")
|
|
work = "견본채집"
|
|
_put(
|
|
root,
|
|
"소요량_견본.json",
|
|
{
|
|
"표": [
|
|
_table(
|
|
"QC900001",
|
|
"견본채집(인력)",
|
|
work,
|
|
{"보통인부": "인", "특별인부": "인"},
|
|
[{"보통인부": 0.17, "특별인부": 0.05}],
|
|
),
|
|
_table("QC900002", "견본채집 보충", work, {"보통인부": "인"}, [{"보통인부": 0.17}]),
|
|
_table("QC900003", "견본채집 시멘트", work, {"시멘트": "kg"}, [{"시멘트": 40}]),
|
|
_table("QC900004", "다른일 품", "다른일", {"보통인부": "인"}, [{"보통인부": 0.09}]),
|
|
]
|
|
},
|
|
)
|
|
_put(
|
|
root,
|
|
"계수_견본.json",
|
|
{
|
|
"표": [
|
|
_table("CC900001", "견본채집 할증", work, {"값": "%"}, [{"항목": "할증", "값": 3}])
|
|
]
|
|
},
|
|
)
|
|
_put(root, "로직_견본.json", {"줄": []})
|
|
_put(root, "기계.json", {"줄": []})
|
|
return bi.Index(root)
|
|
|
|
|
|
def _row(lines: list[dict], extras: list[dict] | None = None) -> dict:
|
|
return {
|
|
"이름": "견본채집(굴삭기)",
|
|
"결과단위": "원/식",
|
|
"호표": lines,
|
|
"덧줄": extras or [],
|
|
}
|
|
|
|
|
|
def _line(kind: str, name: str, qty_: str, unit: str = "", price: float = 1000.0) -> dict:
|
|
part = {"인력": "노무비", "재료": "재료비"}[kind]
|
|
return {"종류": kind, "이름": name, "단위": unit, "수량": qty_, "비목": part, "단가": price}
|
|
|
|
|
|
def _doc(row: dict) -> dict:
|
|
meta = {"로직": row, "단가기준": "엑셀", "원본": {"엑셀금액": {}}}
|
|
return {"키": "SK-000001", "이름": "견본", "단위": "식", "바탕": None, imp.KEY: meta}
|
|
|
|
|
|
ROW = _row(
|
|
[
|
|
_line("인력", "보통인부", "((0.17 * 10) / 100)"), # 칸 둘(QC900001 · QC900002) → 여럿
|
|
_line("인력", "특별인부", "0.05"), # 칸 하나 → 유일
|
|
_line("인력", "보통인부 2", "0.09"), # 값은 있으나 다른 공종 → 없음
|
|
_line("재료", "시멘트", "40", "kg"), # 단위 kg → 유일
|
|
_line("재료", "시멘트 2", "40", "㎥"), # 단위 성격 다름(부피 ≠ 무게) → 없음
|
|
],
|
|
[{"이름": "줄_2", "식": "계 * 0.03", "비목": "노무비"}], # 3 % ↔ 0.03 → 유일(계수)
|
|
)
|
|
|
|
|
|
def _by(scanned: list[dict]) -> dict[str, list]:
|
|
return {s["자리"]: s["후보"] for s in scanned}
|
|
|
|
|
|
# ── ① 후보 ─────────────────────────────────────────────────────────────
|
|
def test_candidates_unique_many_none_and_unit(idx) -> None:
|
|
got = _by(qty.scan(ROW, idx))
|
|
assert sorted(x["표"] for x in got["호표:0:0"]) == ["QC900001", "QC900002"] # 여럿
|
|
assert [x["표"] for x in got["호표:1:0"]] == ["QC900001"] # 유일
|
|
assert got["호표:2:0"] == [] # 다른 공종 칸은 후보 아님
|
|
assert [x["표"] for x in got["호표:3:0"]] == ["QC900003"]
|
|
assert got["호표:4:0"] == [] # 단위 다르면 후보 아님
|
|
ratio = got["덧줄:0:1"] if "덧줄:0:1" in got else got["덧줄:0:0"]
|
|
assert [(x["표"], x["꼴"], x["갈래"]) for x in ratio] == [("CC900001", "pct", "계수")]
|
|
assert {s["자리"] for s in qty.slots(ROW)} >= {"호표:0:0", "호표:1:0", "덧줄:0:0"}
|
|
assert not any(
|
|
s["자리"].startswith("중간")
|
|
for s in qty.slots({**ROW, "중간": [{"이름": "q", "식": "20"}]})
|
|
)
|
|
|
|
|
|
def test_general_numbers_and_compare_are_not_slots() -> None:
|
|
row = _row(
|
|
[_line("인력", "보통인부", "(1 / 100) * 10")], [{"이름": "x", "식": "만약(L > 30, 0.5, 1)"}]
|
|
)
|
|
assert [s["수"] for s in qty.slots(row)] == [0.5] # 1 · 10 · 100 일반 수 · 비교 30 뺌
|
|
|
|
|
|
def test_flags_say_count_and_empty(idx) -> None:
|
|
msgs = qty.flags(ROW, idx)
|
|
assert f"{em.NOTE}소요량 후보 2 · 사람 확인 — 보통인부 0.17" in msgs
|
|
assert f"{em.NOTE}표 칸 없음 — 보통인부 2 0.09" in msgs
|
|
assert all(m.startswith(em.NOTE) for m in msgs) # 못 옮김 표지 아님
|
|
t = qty.tally(qty.scan(ROW, idx))
|
|
assert (t["유일"], t["여럿"], t["없음"]) == (3, 1, 2) and t["자리"] == 6
|
|
|
|
|
|
# ── ② 고름 · 금액 그대로 ───────────────────────────────────────────────
|
|
def _money(doc: dict) -> dict:
|
|
whole = m01.loaded()[1]
|
|
return {"검산": imp.check(doc, whole)["셈"], "엑셀": imp.to_price(doc, "엑셀")["후"]}
|
|
|
|
|
|
def test_pick_records_cell_and_money_unchanged(idx) -> None:
|
|
doc = _doc(ROW)
|
|
cell = {"표": "QC900001", "줄": 0, "칸": "특별인부"}
|
|
got = qty.pick(doc, "호표:1:0", cell, idx)["문서"]
|
|
one = got[imp.KEY]["연결"]["고름"][0]
|
|
assert (one["층"], one["표"], one["줄"], one["칸"], one["꼴"]) == (
|
|
"M01",
|
|
"QC900001",
|
|
0,
|
|
"특별인부",
|
|
"eq",
|
|
)
|
|
assert one["수"] == one["값"] == 0.05
|
|
assert _money(got) == _money(doc) # 기록만 — 금액 그대로
|
|
assert got[imp.KEY]["로직"] == doc[imp.KEY]["로직"] # 엑셀 줄 안 건드림
|
|
view = {x["자리"]: x for x in qty.view(got, idx)["줄"]}
|
|
assert view["호표:1:0"]["상태"] == "고름" and view["호표:1:0"]["고른"]["같음"] is True
|
|
assert view["호표:0:0"]["상태"] == "후보" and view["호표:2:0"]["상태"] == "없음"
|
|
|
|
|
|
def test_pick_pct_form_and_wrong_value_refused(idx) -> None:
|
|
doc = _doc(ROW)
|
|
cell = {"표": "CC900001", "줄": 0, "칸": "값"}
|
|
got = qty.pick(doc, "덧줄:0:0", cell, idx)["문서"]
|
|
assert got[imp.KEY]["연결"]["고름"][0]["꼴"] == "pct"
|
|
with pytest.raises(sd.DocError) as why: # 0.17 인데 칸은 0.05
|
|
qty.pick(doc, "호표:0:0", {"표": "QC900001", "줄": 0, "칸": "특별인부"}, idx)
|
|
assert "0.17" in str(why.value) and "0.05" in str(why.value)
|
|
for bad in (
|
|
{"표": "없는표", "줄": 0, "칸": "값"},
|
|
{"표": "QC900001", "줄": 9, "칸": "특별인부"},
|
|
):
|
|
with pytest.raises(sd.DocError):
|
|
qty.pick(doc, "호표:1:0", bad, idx)
|
|
with pytest.raises(sd.DocError):
|
|
qty.pick(doc, "호표:9:0", {"표": "QC900001", "줄": 0, "칸": "특별인부"}, idx)
|
|
|
|
|
|
def test_pick_twice_replaces_and_drop_undo(idx) -> None:
|
|
doc = _doc(ROW)
|
|
a = qty.pick(doc, "호표:0:0", {"표": "QC900001", "줄": 0, "칸": "보통인부"}, idx)["문서"]
|
|
b = qty.pick(a, "호표:0:0", {"표": "QC900002", "줄": 0, "칸": "보통인부"}, idx)["문서"]
|
|
assert [x["표"] for x in b[imp.KEY]["연결"]["고름"]] == ["QC900002"] # 자리마다 하나
|
|
gone = qty.drop(b, "호표:0:0")["문서"] # 버림 = 고른 것도 풂
|
|
assert "고름" not in gone[imp.KEY]["연결"]
|
|
states = {x["자리"]: x["상태"] for x in qty.view(gone, idx)["줄"]}
|
|
assert states["호표:0:0"] == "버림"
|
|
back = qty.drop(gone, "호표:0:0", True)["문서"]
|
|
assert "연결" not in back[imp.KEY] # 기록 없으면 칸 자체가 없음
|
|
assert {x["자리"]: x["상태"] for x in qty.view(back, idx)["줄"]}["호표:0:0"] == "후보"
|
|
with pytest.raises(sd.DocError):
|
|
qty.drop(doc, "호표:9:0")
|
|
with pytest.raises(sd.DocError):
|
|
qty.view({"키": "SK-000001"}, idx) # 가져온 식 아님
|
|
|
|
|
|
# ── ③ 저장 거름 · 서버 길 ──────────────────────────────────────────────
|
|
def test_clean_keeps_valid_and_drops_stale(idx) -> None:
|
|
whole = m01.loaded()[1]
|
|
got = qty.pick(_doc(ROW), "호표:1:0", {"표": "QC900001", "줄": 0, "칸": "특별인부"}, idx)[
|
|
"문서"
|
|
]
|
|
kept = sd.clean(got, None, whole)[imp.KEY]["연결"]
|
|
assert kept["고름"][0]["표"] == "QC900001"
|
|
got[imp.KEY]["연결"]["고름"][0]["수"] = 0.5 # 로직 수와 안 맞음 → 거름
|
|
got[imp.KEY]["연결"]["버림"] = ["호표:0:0", "호표:99:0", {"x": 1}]
|
|
again = sd.clean(got, None, whole)[imp.KEY]["연결"]
|
|
assert "고름" not in again and again["버림"] == ["호표:0:0"]
|
|
got[imp.KEY]["연결"] = "엉뚱"
|
|
with pytest.raises(sd.DocError):
|
|
sd.clean(got, None, whole)
|
|
|
|
|
|
def test_server_routes(client, monkeypatch, idx) -> None: # noqa: F811
|
|
monkeypatch.setattr(bi, "get", lambda: idx)
|
|
client.app.include_router(sangeun_router.router)
|
|
doc = _doc(ROW)
|
|
seen = client.post(f"{API}/sangeun/imported/qty", json={"문서": doc})
|
|
assert seen.status_code == 200, seen.text
|
|
assert seen.json()["집계"]["유일"] == 3
|
|
cell = {"표": "QC900001", "줄": 0, "칸": "특별인부"}
|
|
done = client.post(
|
|
f"{API}/sangeun/imported/qty-pick", json={"문서": doc, "자리": "호표:1:0", "칸": cell}
|
|
)
|
|
assert done.status_code == 200, done.text
|
|
got = done.json()["문서"]
|
|
assert got[imp.KEY]["연결"]["고름"][0]["칸"] == "특별인부"
|
|
bad = client.post(
|
|
f"{API}/sangeun/imported/qty-pick", json={"문서": doc, "자리": "호표:0:0", "칸": cell}
|
|
)
|
|
assert bad.status_code == 400 and "0.17" in bad.text
|
|
cut = client.post(f"{API}/sangeun/imported/qty-drop", json={"문서": got, "자리": "호표:1:0"})
|
|
assert cut.status_code == 200 and cut.json()["문서"][imp.KEY]["연결"]["버림"] == ["호표:1:0"]
|
|
undo = client.post(
|
|
f"{API}/sangeun/imported/qty-drop",
|
|
json={"문서": cut.json()["문서"], "자리": "호표:1:0", "되돌리기": True},
|
|
)
|
|
assert "연결" not in undo.json()["문서"][imp.KEY]
|
|
|
|
|
|
# ── ④ 실물(북부청 임도) ────────────────────────────────────────────────
|
|
def _north(tmp_path):
|
|
path = golden.FILES["북부청임도"]
|
|
if not path.exists():
|
|
pytest.skip("실무 엑셀 없음")
|
|
hs._dir(tmp_path).mkdir(parents=True, exist_ok=True)
|
|
(hs._dir(tmp_path) / "HP-000001.json").write_text(
|
|
f'{{"키": "HP-000001", "판": {hs.BOOK}}}', "utf-8"
|
|
)
|
|
fh.keep(tmp_path, "HP-000001", path.name, path.read_bytes())
|
|
sheets = json.loads(fh._path(tmp_path, "HP-000001").read_bytes())["시트"]
|
|
return sheets
|
|
|
|
|
|
def test_real_north_block_has_unique_candidate_and_flags(tmp_path) -> None:
|
|
_north(tmp_path)
|
|
excel = json.loads(fh._path(tmp_path, "HP-000001").read_bytes())["엑셀"]
|
|
found = None
|
|
for no in sorted(fn.numbers(excel)):
|
|
out = fh.emit(tmp_path, "HP-000001", no)
|
|
if "야면석채집및운반(계곡내)" in out["로직"]["이름"]:
|
|
found = out
|
|
break
|
|
assert found, "야면석채집및운반(계곡내) 블록"
|
|
row = found["로직"]
|
|
got = _by(qty.scan(row))
|
|
labor = next(i for i, x in enumerate(row["호표"]) if x["이름"] == "보통인부")
|
|
cells = got[f"호표:{labor}:0"]
|
|
assert [x["표"] for x in cells] == ["QF000428"] and cells[0]["값"] == 0.17 # 야면석 채집(인력)
|
|
flags = qty.flags(row)
|
|
assert f"{em.NOTE}소요량 후보 1 · 사람 확인 — 보통인부 0.17" in flags
|
|
assert all(f.startswith(em.NOTE) for f in flags)
|
|
doc = _doc(row)
|
|
kept = qty.pick(doc, f"호표:{labor}:0", {k: cells[0][k] for k in ("표", "줄", "칸")})["문서"]
|
|
assert imp.to_price(kept, "엑셀")["전"] == imp.to_price(doc, "엑셀")["전"]
|