Files
Aislo/resources/tester/test_705_2_hopyo_paste.py
T
eomsangdonandClaude Opus 5.5 86f40dedbd fix(705-2 붙여넣기 · 다시 잇기): 화면 검증에서 나온 흠 — 첫 줄 머리 짐작 · 열 짝 한 칸 밀림 · 저절로 고름 · 칸 폭 · B09 최신 캐시
- 첫 줄이 수량 없음이어도 금액 없으면(레미콘 별도계산) 줄 — 머리는 금액 있을 때만
- 기억한 열 짝을 다른 꼴(엑셀꼴 호표 칸 · STmate 꼴)에 붙이면 서버가 한 칸 밀어 맞춤
- 다시 잇기 저절로 고름 = 후보 하나 · 붙인 단가 있음만(별도계상 0 원 · 후보 여럿은 사람이 고름) · 빈 칸 글 없앰 · 고름 칸 앞으로 · 칸 폭 1100 안
- B09 알림에서 연 다시 잇기가 붙이기 전 캐시를 읽던 흠 — 붙인 뒤 캐시를 들고 감

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:42:28 +09:00

387 lines
18 KiB
Python

"""705-2 엑셀 붙여넣기 — 붙인 글(탭 · 줄)을 서버가 읽어 고정 줄 · 호표마다 사전조합(요구서 3장 10 · 5-A ⑤-2).
- 실무 xlsx 넷(거창 · 영월 · 울진 대흥 · 소광) 일위대가 시트를 엑셀 보이는 글(쉼표 · 0 빈칸)로 복사한 꼴
→ 시트 통째 = 파일 올리기와 호표 · 줄 · 합계 같음 · 만듦 = 엑셀 합계 = 우리 합계
- 호표 하나 · 머리 글 없음(열 짝 짐작 + 물음) · 기억한 열 짝 · 줄만(열린 문서) · 합계 · 근거 줄 거름 · % 줄
층은 임시 폴더(공용 storage 안 씀).
"""
import json
import warnings
from decimal import Decimal
from pathlib import Path
import openpyxl
import pytest
from M02_MasterTemplete import M02_Hopyo_Excel as hx
from M02_MasterTemplete import M02_Hopyo_Paste as hp
# ruff: noqa: F811
from resources.tester.test_m02_hopyo_excel import client, master_hash # noqa: F401
URL = "/api/m02/layers/personal/hopyo-paste"
DOCS = Path(__file__).resolve().parents[2] / "resources" / "knowledge" / "original" / "실무문서"
FILES = { # 이름 · 엑셀 합계 = 우리 합계 수(영월 23 — 붙인 글엔 하위 호표 코드가 없어 엑셀 값 그대로 · 파일은 22)
"거창": (
"01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx",
14,
),
"영월": ("05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx", 23),
"대흥": (
"2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).xlsx",
34,
),
"소광": (
"25년 산불진화임도(기번8)(울진소광)/"
"2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx",
25,
),
}
def _shown(v):
"""엑셀이 클립보드에 내는 글 — 천 단위 쉼표 · 0 = 빈칸(STmate 표시꼴) · 줄바꿈 = 띄움."""
if v is None or v == 0:
return ""
if isinstance(v, (int, float)):
return f"{int(v):,}" if float(v).is_integer() else f"{v:,.6f}".rstrip("0").rstrip(".")
return str(v).replace("\n", " ")
def _rows(tag):
with warnings.catch_warnings():
warnings.simplefilter("ignore")
book = openpyxl.load_workbook(DOCS / FILES[tag][0], read_only=True, data_only=True)
sheet = "일위대가" if "일위대가" in book.sheetnames else "일위대가표"
first = 1 if sheet == "일위대가" else 0 # 엑셀꼴 = B 열(호표)부터 · STmate = A 열부터
width = 14 if first else 13
return [
[_shown(v) for v in list(r)[first : first + width]]
for r in book[sheet].iter_rows(values_only=True)
]
def _text(rows):
return "\n".join("\t".join(r) for r in rows)
def _paste(client, text, **body):
res = client.post(URL, json={"글": text, **body})
assert res.status_code == 200, res.text
return res.json()
@pytest.mark.parametrize("tag", list(FILES))
def test_시트_통째_붙여넣기_파일과_같음(tag):
got = hp.read_text(_text(_rows(tag)))
book = hx.read_book(FILES[tag][0], (DOCS / FILES[tag][0]).read_bytes())
assert got["물음"] is False # 머리 글이 붙어 옴 = 열 짝 저절로
key = lambda h: (h["번"], h["이름"], h["규격"], h["단위"], h["금액"].get("계"), len(h["줄"])) # noqa: E731
assert [key(h) for h in got["호표"]] == [key(h) for h in book["호표"]]
zero = lambda m: {k: round(v or 0, 4) for k, v in m.items()} # noqa: E731 · 엑셀 0 = 빈칸으로 옴
line = lambda x: (x["이름"], x["수량"] or 0, x["단위"], zero(x["단가"]), zero(x["금액"])) # noqa: E731
assert [line(x) for h in got["호표"] for x in h["줄"]] == [
line(x) for h in book["호표"] for x in h["줄"]
]
@pytest.mark.parametrize("tag", list(FILES))
def test_실무_xlsx_넷_붙여넣기_만듦_합계_같음(client, tag):
before = master_hash()
got = _paste(client, _text(_rows(tag)))
assert got["갈래"] == "만듦" and got["빠짐"] == 0 and got["실패"] == []
assert got["같음"] == FILES[tag][1] and all(abs(x["차이"]) <= 1 for x in got["다름"])
assert len(got["만듦"]) == got["호표"] and got["고정"] + got["호표참조"] == got["줄"]
assert master_hash() == before
rows = client.get("/api/m02/layers/personal/hopyo").json()["줄"]
assert rows[0]["파일"].startswith("붙여넣기") and rows[0]["시트"] == "붙여넣기"
def _block(rows, no):
"""호표 하나 줄 — 「제N호표」 줄부터 다음 호표 앞까지(머리 글 없이)."""
at = next(i for i, r in enumerate(rows) if any(hx._HOPYO.fullmatch(c.replace(" ", "")) and int(hx._HOPYO.fullmatch(c.replace(" ", "")).group(1)) == no for c in r)) # fmt: skip
end = next((i for i in range(at + 1, len(rows)) if any(hx._HOPYO.fullmatch(c.replace(" ", "")) for c in rows[i])), len(rows)) # fmt: skip
return rows[at:end]
def test_호표_하나_머리_없음_물음_뒤_기억한_열_짝(client):
rows = _block(_rows("대흥"), 17) # 돌기슭막이(찰쌓기) H=1.5
ask = _paste(client, _text(rows))
assert ask["갈래"] == "물음" and ask["짐작"] is True and ask["보기"]
cols = ask["열"]
assert (cols["품명"], cols["수량"], cols["단위"], cols["노"]) == (0, 2, 3, 6)
got = _paste(client, _text(rows), 열=cols) # 화면이 한 번 묻고 기억한 열 짝
assert got["갈래"] == "만듦" and got["호표"] == 1 and got["같음"] == 1
assert got["만듦"][0]["이름"].startswith("돌기슭막이")
def test_엑셀꼴_호표_여럿_머리_없음(client):
rows = _rows("거창")
some = _block(rows, 2) + _block(rows, 3)
cols = hp.read_text(_text(some))["열"]
assert cols["품명"] == 1 # 「제N호표」 칸이 따로 = 그 다음 칸이 품명
got = _paste(client, _text(some), 열=cols)
assert (got["호표"], got["같음"]) == (2, 2)
def test_줄만_고정_줄_합계_근거_거름():
text = "\n".join(
[
"석공\t\t0.08\t인\t268,908\t21,512.6\t268,908\t21,512.6\t\t\t\t\t",
"보통인부\t\t0.04\t인\t169,804\t6,792.1\t169,804\t6,792.1\t\t\t\t\t",
"공구손료\t노무비의\t3\t%\t28,304.7\t849.1\t\t\t\t\t849.1\t849.1\t",
"건설표준품셈\t6-7-2\t\t\t\t\t\t\t\t\t\t\t",
"합 계\t\t\t\t\t29,153\t\t28,304\t\t\t\t849\t",
]
)
cols = {"품명": 0, "규격": 1, "수량": 2, "단위": 3, "금액": 5, "노": 6, "재": 8, "경": 10}
parsed = hp.read_text(text, cols)
(one,) = parsed["호표"]
assert [x["이름"] for x in one["줄"]] == ["석공", "보통인부", "공구손료"] # 근거 · 합계 줄 뺌
assert one["금액"]["계"] == 29153 and one["줄"][0]["수량"] == 0.08
out = hp.fixed_lines(parsed, {"고정|석공||인"})
names = [x["고정"]["이름"] for x in out["줄"]]
assert names == ["석공 (2)", "보통인부", "공구손료"] # 있는 줄 가름 피함
assert out["줄"][2]["고정"]["비율"] == {"기준": "노무비", "비목": "경비"}
assert out["줄"][0]["고정"]["단가"]["노무비"] == 268908 and out["엑셀"] == 29153
def test_줄만_길_저장_없음(client):
text = "석공\t\t0.08\t인\t268,908\t21,512.6\t268,908\t21,512.6"
cols = {"품명": 0, "규격": 1, "수량": 2, "단위": 3, "금액": 5, "노": 6, "재": 8, "경": 10}
got = _paste(client, text, 열=cols, 줄만=True)
assert got["갈래"] == "줄" and len(got["줄"]) == 1 and got["줄"][0]["수량"] == {"손": 0.08}
assert client.get("/api/m02/layers/personal/hopyo").json()["줄"] == []
@pytest.mark.parametrize(
("text", "cols", "word"),
[
("", None, "빔"),
("가\t나", {"품명": 0, "수량": 0, "단위": 1}, "같은 칸"),
("가\t나", {"품명": 0, "규격": 1}, "꼭"),
("합계\t\t\t\t1,000", {"품명": 0, "수량": 2, "단위": 3, "금액": 4}, "줄이 없음"),
],
)
def test_틀린_글_400(client, text, cols, word):
res = client.post(URL, json={"글": text, **({"열": cols} if cols else {})})
assert res.status_code == 400 and word in res.json()["detail"]
def test_쉼표_빈칸_수():
assert hx._num("1,415,169") == Decimal("1415169") and hx._num("") is None
# ── 이 공사 단가로 다시 잇기 ──────────────────────────────────────────────────
RELINK = "/api/m02/layers/personal/hopyo-relink"
def test_다시_잇기_후보_바꿈_되돌리기(client):
from resources.tester.test_m02_hopyo_excel import _post
assert _post(client).status_code == 200 # 북부청 — 이음 13 · 이음 후보 31(705-3)
made = client.post(
"/api/m02/layers/personal/hopyo-build-all", json={"층": "personal", "키": "HP-000001"}
).json()
keys = [x["키"] for x in made["만듦"]]
assert len(keys) == 68
rows = client.post(f"{RELINK}/rows", json={"키": keys}).json()["줄"]
near = [r for r in rows if r["후보"] and not any(r["이음"].values())]
assert near, "단가 다른 이음 후보 줄"
linked = [r for r in rows if any(r["이음"].values())]
assert len(linked) >= 13 and all(r["지금"] for r in linked)
one = near[0]
pick = one["후보"][0]
change = {"키": one["키"], "줄": one["줄"], pick["비목"]: pick["키"]}
got = client.post(RELINK, json={"바꿈": [change]})
assert got.status_code == 200, got.text
done = got.json()
(doc,) = done["문서"]
assert done["바뀜"] == 1 and doc["전"] != doc["후"] # 붙인 단가 → 단가 책 값
again = client.post(f"{RELINK}/rows", json={"키": [one["키"]]}).json()["줄"]
assert next(r for r in again if r["줄"] == one["줄"])["이음"][pick["비목"]] == pick["키"]
back = client.post(RELINK, json={"바꿈": done["되돌림"]}).json() # 되돌리기
assert back["문서"][0]["후"] == doc["전"]
bad = {"키": one["키"], "줄": one["줄"], "노임": "LB999999"}
assert client.post(RELINK, json={"바꿈": [bad]}).status_code == 400
assert client.post(RELINK, json={"바꿈": [{**change, "줄": "고정|없음||"}]}).status_code == 404
num = client.post(RELINK, json={"바꿈": [change], "금액": 1})
assert num.status_code == 422 # 숫자 칸 안 받음
def test_다시_잇기_열린_문서_후보만(client):
fx = {"이름": "보통인부", "규격": "", "단위": "인", "단가": {"노무비": 1}, "이음": {}}
doc = {"담은로직": [{"고정": fx, "수량": {"손": 0.5}}]}
(row,) = client.post(f"{RELINK}/rows", json={"문서": doc}).json()["줄"]
assert row["키"] is None and row["후보"][0]["비목"] == "노임" and row["후보"][0]["단가"] > 1
# ── B09 펼친 일위대가 붙여넣기(덮개 @add) ─────────────────────────────────────
def _b09():
from resources.tester import test_145_2_b09_labor_calc as b09
return b09
def _added(user):
from B09_Estimation import B09_Estimation_Daega_Paste as dp
text = "\n".join(
[
"석공\t\t0.08\t인\t258,935\t20,714.8\t258,935\t20,714.8\t\t\t\t\t",
"잡재료\t\t1\t식\t1,000\t1,000\t\t\t1,000\t1,000\t\t\t",
"공구손료\t노무비의\t3\t%\t20,714.8\t621.4\t\t\t\t\t621.4\t621.4\t",
]
)
cols = {"품명": 0, "규격": 1, "수량": 2, "단위": 3, "금액": 5, "노": 6, "재": 8, "경": 10}
return dp.paste(user, "C:DG9", text, cols)
def test_B09_붙인_줄_거울_정리_수요():
from B09_Estimation.B09_Estimation_Calc_Labor import ADD, added_keys, compute_labor
from B09_Estimation.B09_Estimation_Store import clean_user
b09 = _b09()
got = _added({})
assert got["갈래"] == "줄" and got["줄"] == 3 and got["엑셀"] == 22335
cover = got["user"]["daega_cover"]
lines = cover["C:DG9"][ADD]
assert [ln["kind"] for ln in lines] == ["fixed", "fixed", "pct"]
assert all(ln["cid"].startswith("@add|고정|") for ln in lines)
again = _added(got["user"]) # 같은 글 두 번 = 이름 「 (2)」 가름 · 줄 더함
names = [ln["name"] for ln in again["user"]["daega_cover"]["C:DG9"][ADD]]
assert names[3:] == ["석공 (2)", "잡재료 (2)", "공구손료 (2)"]
assert clean_user(again["user"]) == again["user"] # 저장 정리 그대로
bad = clean_user(
{"daega_cover": {"C:x": {ADD: [{"cid": "x", "kind": "fixed"}, {"kind": "?"}]}}}
)
assert "daega_cover" not in bad # cid 머리 틀림 · 모르는 갈래 버림
linked = {**lines[0], "link": {"labor": "L00005", "material": None, "machine": None}}
cover = {"C:DG9": {ADD: [linked, *lines[1:]], linked["cid"]: {"qty": 0.1}}}
assert added_keys(cover) == ["L00005"]
blocks = json.loads(json.dumps(b09.EXCEL))
for b in blocks["blocks"]:
b["cover"] = f"C:{b['id']}"
for n, ln in enumerate(b["lines"]):
ln["cid"] = f"{ln['kind']}:{ln.get('key')}#{n}"
py = json.loads(json.dumps(compute_labor(blocks, b09.SHEETS, b09.SANGEUN, cover)))
ts = b09._ts(blocks, b09.SHEETS, b09.SANGEUN, cover)
assert b09._same(py, ts["out"])
dg9 = b09._block(py, "DG9")
stone = next(ln for ln in dg9["lines"] if ln["cid"] == linked["cid"])
assert stone["prices"]["labor"] == 258935 and stone["qty"] == 0.1 # 이은 단가 · 덮개
pct = next(ln for ln in dg9["lines"] if ln["kind"] == "pct" and ln["cid"].startswith(ADD))
assert pct["amounts"]["expense"] > 0 and py["daega_orphans"] == []
# ── 704-4 뒷부분 — [구조물 조합으로] · 규격 묶음 ─────────────────────────────────
def _upload(client, tag):
from resources.tester.test_m02_hopyo_excel import _post
path = DOCS / FILES[tag][0]
res = _post(client, data=path.read_bytes(), name=path.name)
assert res.status_code == 200, res.text
return res.json()["키"]
def _item_doc(column):
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
with store.at(layers.personal_dir(7, 42)):
table = store.read("table", store.STRUCTURE_TABLE)["문서"]
col = next(c for c in table["열"] if c.get("id") == column)
return store.read("labor", column)["문서"], col
def _total(doc, col=None):
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Spec as spec
doc = {**doc, "규격": {**doc["규격"], "기본": col}} if col else doc # 셈 = 기본 열
return labor.calc(labor.clean(spec.at(doc, col)), {})["반영합"]["계"]
def layers_dir():
from M02_MasterTemplete import M02_Template_Layers as layers
return layers.personal_dir(7, 42)
def test_구조물_조합으로_규격_묶음_울진_돌기슭막이(client):
before = master_hash()
key = _upload(client, "대흥")
sib = client.get(f"/api/m02/layers/personal/hopyo/{key}/17/siblings").json()
assert sib["이름"] == "H" and [(r["번"], r["열"]) for r in sib["묶음"]] == [
(17, "1.5"), (24, "2.0"), (25, "2.5")
] # fmt: skip
body = {"층": "personal", "키": key, "번": 17, "묶음": [24, 25]}
res = client.post("/api/m02/layers/personal/hopyo-item", json=body)
assert res.status_code == 200, res.text
got = res.json()
assert got["열수"] == 3 and got["이름"].endswith("돌기슭막이(찰쌓기,기초무,구입)")
doc, col = _item_doc(got["열"])
assert col["경로"] == ["실무자료", "돌기슭막이(찰쌓기,기초무,구입)"] and doc["규격"]["열"] == [
"1.5",
"2.0",
"2.5",
]
assert doc["규격"]["기본"] == "1.5" and all("고정" in x for x in doc["담은로직"])
excel = {
h["번"]: h["금액"]["계"]
for h in client.get(f"/api/m02/layers/personal/hopyo/{key}").json()["호표"]
if h["번"] in (17, 24, 25)
}
for no, c in ((17, "1.5"), (24, "2.0"), (25, "2.5")):
assert (
abs(_total(doc, c) - excel[no]) <= excel[no] * 1e-4
) # 열마다 = 그 호표 엑셀 합계(끝수 차)
one = client.post("/api/m02/layers/personal/hopyo-item", json={**body, "묶음": []}).json()
single, _ = _item_doc(one["열"])
assert not single.get("규격") and one["이름"].endswith("H=1.5 L3=45cm")
bad = client.post("/api/m02/layers/personal/hopyo-item", json={**body, "묶음": [3]})
assert bad.status_code == 400 # 줄 구성 다른 호표
precombo = layers_dir() / "precombo"
assert not list(precombo.glob("SC-*.json")) # 사전조합 문서 안 만듦
assert master_hash() == before
def test_구조물_조합으로_남의_층_403(client):
key = _upload(client, "대흥")
body = {"층": "personal", "키": key, "번": 17}
assert client.post("/api/m02/layers/company/hopyo-item", json=body).status_code == 403
def test_규격_열_이름():
from M02_MasterTemplete import M02_Hopyo_Item as item
assert item.columns(["H=1.5 L3=45cm", "H=2.0 L3=45cm"]) == ("H", ["1.5", "2.0"])
assert item.columns(["일반형", "ㄷ형"]) == ("규격", ["일반형", "ㄷ형"])
assert item.columns(["D800", "D1000"]) == ("D", ["800", "1000"])
def test_줄만_첫_줄_수량_없음_별도계산은_줄():
rows = _block(_rows("거창"), 2)[
1:
] # 「제2호표」 머리 줄 빼고 — 첫 줄 레미콘(별도계산 · 수량 없음)
got = hp.read_text(
_text(rows),
{"품명": 1, "규격": 2, "수량": 3, "단위": 4, "금액": 6, "노": 7, "재": 9, "경": 11},
)
(one,) = got["호표"]
assert one["번"] == 0 and one["이름"] == "붙여넣기" and one["줄"][0]["이름"] == "레미콘"
assert len(one["줄"]) == 5
def test_기억한_열_짝_한_칸_밀림_맞춤():
stm = {"품명": 0, "규격": 1, "수량": 2, "단위": 3, "금액": 5, "노": 6, "재": 8, "경": 10}
rows = _block(_rows("거창"), 2)[1:] # 엑셀꼴(앞 호표 칸) 줄만 — STmate 꼴로 기억한 짝
got = hp.read_text(_text(rows), stm)
assert got["열"]["품명"] == 1 and [x["이름"] for x in got["호표"][0]["줄"]][:2] == [
"레미콘",
"콘크리트공",
]
same = hp.read_text(_text(_block(_rows("대흥"), 17)), stm)
assert same["열"] == stm