feat(797-2): 엑셀 불러오기 소요량 · 계수 이음 — 값 역색인 후보 + 사람 확인 · 고름 = 표 칸 참조 기록
- 새 `M02_Sangeun_QtyLinks` — 가져온 식 줄(호표 수량 · 덧줄 식)의 수를 M01 소요량(QC/QF) · 계수(CC/CF) 칸과 값 역색인(`San_Basis_Index.lookup`)으로 맞춤 · 같은 값 + 같은 단위 성격 + 공종 문맥(인력 = 직종 · 재료 = 칸 이름 추가) · 일반 수 · 비교 글자 · 중간 변수는 자리 아님 · 유일해도 자동 확정 없음
- 고름 = `가져온.연결` 기록(층 M01 · 표 · 줄 · 칸 · 값 · 꼴) · 엑셀 수 = 칸 값일 때만(다르면 400 까닭) · 버림 · 되돌림 · 기록만(셈 · 금액 · 단가 갱신 안 건드림) · 저장 거름(`clean`) = 자리 · 수 맞는 기록만
- 길 `…/sangeun/imported/{qty|qty-pick|qty-drop}` · 가져온 식 만들 때 표지 「참고 · 소요량 후보 N · 사람 확인」 · 「표 칸 없음」(못 옮김 표지 아님)
- 화면 — 가져온 식 모달 머리 판 한 줄(집계 · [이음 보기]) + 모달(줄마다 후보 · [고르기] · [풀기] · [버리기] · [되돌리기] · 부품 상자만)
- 시험 test_797_2_qty_coef_links 9 · 낡은 시험 없음
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
"""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_Hopyo_Store as hs
|
||||
from M02_MasterTemplete import M02_San_Basis_Index as bi
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
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:
|
||||
sheets = _north(tmp_path)
|
||||
_, total = book.est_prices(sheets)
|
||||
nos = [int(b["번"]) for b in book.est_blocks(sheets) if b["번"] in total]
|
||||
found = None
|
||||
for no in nos:
|
||||
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, "엑셀")["전"]
|
||||
Reference in New Issue
Block a user