"""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 # 743-1 — 붙여넣기 실무자료 = 만든 뒤 지움(엑셀 보관 안 함) assert client.get("/api/m02/layers/personal/hopyo").json()["줄"] == [] assert client.get(f"/api/m02/layers/personal/hopyo/{got['키']}").status_code == 404 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) from M02_MasterTemplete import M02_Hopyo_Build as hb from M02_MasterTemplete import M02_MasterTemplete_Store as mstore from M02_MasterTemplete import M02_Template_Layers as mlayers mine = mlayers.personal_dir(7, 42) # 모두 고정(막지 않음) — 길은 남은 줄 호표 실패(729-2) with mstore.at(mine): made = hb.build_all(mine, mine, "HP-000001") 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_구조물_조합으로_같은_내용_409_씀_새로(client): """733-2 — 이 층 같은 내용 항목 = 409 `{같은것}` → `씀` = 새 항목 0 · `새로` = 새 항목 1.""" key = _upload(client, "대흥") url, body = "/api/m02/layers/personal/hopyo-item", {"층": "personal", "키": key, "번": 17} first = client.post(url, json=body).json() count = len(list((layers_dir() / "labor").glob("*.json"))) res = client.post(url, json=body) assert res.status_code == 409 and res.json()["detail"]["같은것"][0]["키"] == first["열"] used = client.post(url, json={**body, "씀": first["열"]}).json() assert used["열"] == first["열"] and used["씀"] is True assert len(list((layers_dir() / "labor").glob("*.json"))) == count made = client.post(url, json={**body, "새로": True}).json() assert made["열"] != first["열"] assert len(list((layers_dir() / "labor").glob("*.json"))) == count + 1 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