From d6fd3c179630a3c9845843e900ec40df9ff9f487 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 15:56:49 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(b08):=20STmate=20=EC=B6=9C=EB=A0=A5=20?= =?UTF-8?q?=EC=97=91=EC=85=80=20=E3=80=8C=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=ED=91=9C=E3=80=8D=20=EC=9D=BD=EA=B0=9C=20+=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=ED=98=95=20=ED=95=AD=EB=AA=A9=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EA=B8=B0=20=E2=80=94=20=EC=8B=A4=EB=AC=B4=206?= =?UTF-8?q?=EA=B1=B4=20=ED=98=B8=ED=91=9C=20170=20=EC=A0=84=EB=B6=80=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=200(=EC=BD=94=EB=8D=B1=EC=8A=A4=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC=EC=99=80=20=EA=B0=99=EC=9D=8C)=20=C2=B7=20=EB=B4=89?= =?UTF-8?q?=ED=99=94=2045=20=C2=B7=20=EA=B8=B0=EC=8A=AD=EB=A7=89=EC=9D=B4?= =?UTF-8?q?=20H2.0=20=EA=B5=AC=EC=84=B1=209=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 머리 칸이 다르거나 수량이 수가 아니면 칸 주소를 짚은 사유로 그 호표를 통째로 뺌(추측 안 함) · 갈 곳은 구성 = unit_price · 별산자재 = material · % 가산 행 = reference + 항목 사유 · 계약단가·낙찰율 줄 안 뽑고 항목 사유 · 품셈 근거 줄은 formula_text 보존 · 원문 호표·자원 코드와 공사명은 출처로만(판정 Ⓑ~Ⓗ · ㉮㉯㉰) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B08_Quantity_Engine_StmateRecipe.py | 202 ++++++++++++++++++ .../tester/test_b08_stmate_recipe_read.py | 129 +++++++++++ 2 files changed, 331 insertions(+) create mode 100644 B08_Quantity/B08_Quantity_Engine_StmateRecipe.py create mode 100644 resources/tester/test_b08_stmate_recipe_read.py diff --git a/B08_Quantity/B08_Quantity_Engine_StmateRecipe.py b/B08_Quantity/B08_Quantity_Engine_StmateRecipe.py new file mode 100644 index 00000000..5b4eb0ce --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_StmateRecipe.py @@ -0,0 +1,202 @@ +"""STmate 출력 엑셀 「일위대가표」 읽개 → **고정형** 라이브러리 항목 (PLAN 4장 · 2026-09-14 브레인 판정). + +왜 엑셀인가 — STC 는 호표(B) 구성이 난독화된 BDQTY 에만 있어 못 읽음(7파일 전수) · 경쟁사 보호 장치는 +안 풂. 출력 엑셀은 평문이고 모양은 코덱스 `recipe_extract.py`(실무 6건 누락 0)가 본 그대로. + +⚠ **모양이 다르면 억지로 읽지 않음** — 머리 칸·수량 칸이 어긋나면 「어느 칸이 안 맞는지」 사유로 + 돌려보내고 그 호표는 통째로 뺌. 추측해서 맞추면 수량이 조용히 틀린 채 라이브러리에 들어가 계속 쓰임. +⚠ 뽑는 것은 **수량만**(판정 Ⓔ) — 원문 단가·금액은 안 실음. 수량도 그 시점 품셈값이라 사유를 붙임. +⚠ 한 단만(판정 Ⓕ) — 구성 줄이 하위 호표면 그 이름·수량만. 하위 전개는 우리 일위대가 몫. +⚠ 원문 호표·자원 코드(B#####·M#####)는 파일 안 카운터라 **출처로 기록만**(판정 Ⓓ). +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +SHEET = "일위대가표" +HEADER_ROW = 3 +HEADER = ("명칭", "규격", "수량", "단위") +COLUMNS = "ABCD" +HOPYO = re.compile(r"^제(\d+)호표$") +ROW_CODE = re.compile(r"(? str: + return "" if value is None else str(value).strip() + + +def read_recipes(path: str | Path) -> dict[str, Any]: + """`{"project": 공사명, "hopyo": [호표…], "problems": [사유…]}` — 못 읽은 호표는 목록에 없고 사유만.""" + import openpyxl + + problems: list[str] = [] + try: + book = openpyxl.load_workbook(path, data_only=True, read_only=True) + except Exception as error: # noqa: BLE001 — 어떤 파일이든 사유로 돌려보냄 + return {"project": "", "hopyo": [], "problems": [f"엑셀을 못 엶 — {error}"]} + if SHEET not in book.sheetnames: + return { + "project": "", + "hopyo": [], + "problems": [f"시트 「{SHEET}」가 없음 — STmate 출력 엑셀이 아님"], + } + rows = [list(r) + [None] * 14 for r in book[SHEET].iter_rows(max_col=14, values_only=True)] + head = rows[HEADER_ROW - 1] if len(rows) >= HEADER_ROW else [None] * 14 + for column, want, got in zip(COLUMNS, HEADER, head): + if _text(got).replace(" ", "") != want: + problems.append( + f"{column}{HEADER_ROW} 칸이 「{want}」이 아니라 「{_text(got)}」 — 이 모양은 못 읽음" + ) + if problems: + return {"project": "", "hopyo": [], "problems": problems} + project = _text(rows[1][0]).split(":", 1)[-1].strip() if len(rows) > 1 else "" + + hopyo: list[dict[str, Any]] = [] + current: dict[str, Any] | None = None + + def close() -> None: + if current and not current["broken"]: + if current["name"]: + hopyo.append({k: v for k, v in current.items() if k != "broken"}) + else: + problems.append(f"제{current['no']}호표 제목 줄이 없음 — 안 읽음") + + for index, row in enumerate(rows[HEADER_ROW:], start=HEADER_ROW + 1): + name, spec, qty, unit = _text(row[0]), _text(row[1]), row[2], _text(row[3]) + key = name.replace(" ", "") + found = HOPYO.match(key) + if found: + close() + code = HOPYO_CODE.search(_text(row[13])) + current = { + "no": int(found.group(1)), + "source_code": code.group(1) if code else "", + "name": "", + "spec": "", + "unit": "", + "rows": [], + "basis": [], + "contract_rows": 0, + "broken": False, + } + continue + if key.startswith("합계"): + close() + current = None + continue + if current is None or current["broken"] or not any(_text(c) for c in row[:4]): + continue + where = f"제{current['no']}호표" + if not current["name"]: + if not name or qty not in (None, "") or not unit: + problems.append( + f"A{index}: {where} 제목 줄(명칭·단위 · 수량 빈칸) 모양이 아님 — 안 읽음" + ) + current["broken"] = True + else: + current.update(name=name, spec=spec, unit=unit) + continue + if not unit and (key == "계" or key.startswith("소계")): + continue # 착공·변경 판 소계 줄 — 구성 아님 + if not unit and key.startswith(CONTRACT_ROWS): + current["contract_rows"] += 1 + continue + if not unit and qty == 0 and not isinstance(qty, bool): + current["basis"].append(f"{name} {spec}".strip()) # 품셈 근거 줄 — 수량 0 · 단위 없음 + continue + if isinstance(qty, bool) or not isinstance(qty, (int, float)): + problems.append( + f"C{index}: {where} 「{name}」 수량 「{_text(qty)}」이 수가 아님 — 안 읽음" + ) + current["broken"] = True + continue + if not name or not unit: + empty = "명칭" if not name else "단위" + problems.append( + f"{'A' if not name else 'D'}{index}: {where} 「{name}」 {empty}이 빔 — 이 모양은 못 읽음" + ) + current["broken"] = True + continue + code = ROW_CODE.search(_text(row[13])) + current["rows"].append( + { + "name": name, + "spec": spec, + "amount": qty, + "unit": unit, + "remark": _text(row[12]), + "source_code": code.group(1) if code else "", + } + ) + close() + return {"project": project, "hopyo": hopyo, "problems": problems} + + +def recipe_item( + hopyo: dict[str, Any], *, type_id: str, file_name: str, project: str +) -> dict[str, Any]: + """읽은 호표 하나 → 고정형 라이브러리 항목(명세 13장 칸 계약 · `formula` 빈칸 = 고정형). 코드는 저장이 발급.""" + basis = " · ".join(hopyo.get("basis") or []) + rows = [] + percent = [] + for seq, source in enumerate(hopyo["rows"], start=1): + is_percent = source["unit"] == "%" + if is_percent: + percent.append(source["name"]) + separate = any(mark in source["remark"].replace(" ", "") for mark in SEPARATE_MATERIAL) + rows.append( + { + "seq": seq, + "name": source["name"], + "spec": source["spec"], + "formula": "", + "formula_text": " · ".join(part for part in ("STmate 원문 수량", basis) if part), + "amount": str(source["amount"]), + "unit": source["unit"], + # 원문에서 구성 줄은 그 호표 단가 안으로 들어감(호표 = 일위대가) · 별도계상만 자재총괄(판정 ㉮) + "destination": "reference" + if is_percent + else "material" + if separate + else "unit_price", + "rounding": {"mode": "none", "digits": 0}, + "source": "library", + "reason": "가산 행 — 수량 계산에 안 씀" if is_percent else NOTE_SOURCE_QTY, + } + ) + notes = [NOTE_SOURCE_QTY] + if percent: + notes.append(NOTE_PERCENT.format(count=len(percent), names=" · ".join(percent))) + if hopyo.get("contract_rows"): + notes.append(NOTE_CONTRACT.format(count=hopyo["contract_rows"])) + return { + "schema_version": 1, + "item_kind": "fixed", + "type_id": type_id, + "name": f"{hopyo['name']} {hopyo['spec']}".strip(), + "unit": hopyo["unit"], + "note": " · ".join(notes), + "vars": {}, + "tables": {}, + "rows": rows, + # 출처 — 원문 공사명·파일명이 들어감. 공유 만들 때 뺄지·가릴지 판정(PLAN 4장). + "origin": { + "kind": "stmate_xlsx", + "file": file_name, + "project": project, + "hopyo_no": hopyo["no"], + "hopyo_code": hopyo["source_code"], + }, + } diff --git a/resources/tester/test_b08_stmate_recipe_read.py b/resources/tester/test_b08_stmate_recipe_read.py new file mode 100644 index 00000000..bb80f8bf --- /dev/null +++ b/resources/tester/test_b08_stmate_recipe_read.py @@ -0,0 +1,129 @@ +"""STmate 출력 엑셀 「일위대가표」 읽개 — 고정형 라이브러리 항목의 둘째 길(PLAN 4장 · 2026-09-14 브레인 판정 ①). + +왜 엑셀인가 — STC 는 호표(B) 구성이 난독화된 BDQTY 에만 있어 못 읽음(7파일 전수 확인) · +경쟁사 보호 장치는 풀지 않음. 출력 엑셀은 평문이고 코덱스 `recipe_extract.py` 가 실무 6건 누락 0 으로 뽑은 모양. +⚠ **모양이 다르면 억지로 읽지 않음** — 「어느 칸이 안 맞는지」 사유로 돌려보냄(추측해서 맞추면 + 수량이 조용히 틀린 채 라이브러리에 들어가 계속 쓰임). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import openpyxl +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_StmateRecipe import read_recipes # noqa: E402 + +PRACTICE = ROOT / "resources" / "knowledge" / "original" / "실무문서" +BONGHWA = next( + (p for p in PRACTICE.rglob("*.xlsx") if "기번41" in p.name and not p.name.startswith("~$")), + None, +) + + +def _book(tmp_path: Path, rows: list[list[object]], sheet: str = "일위대가표") -> Path: + book = openpyxl.Workbook() + ws = book.active + ws.title = sheet + for row in rows: + ws.append(row) + path = tmp_path / "book.xlsx" + book.save(path) + return path + + +HEAD = [ + ["일 위 대 가 표"], + ["공사명 : 시험"], + ["명 칭", "규 격", "수 량", "단위", "합 계"], + [None], +] + + +@pytest.mark.skipif(BONGHWA is None, reason="실무 엑셀(지식DB 원문) 없음") +def test_봉화_호표_45_기슭막이_H2_구성_9줄() -> None: + read = read_recipes(BONGHWA) + assert read["problems"] == [] + assert len(read["hopyo"]) == 45 + target = next( + h + for h in read["hopyo"] + if h["name"] == "기슭막이(깬잡석,찰쌓기 L3=45m)" and h["spec"] == "H=2.0m, 채집" + ) + assert target["unit"] == "M" and target["source_code"] == "B00010" + rows = target["rows"] + assert len(rows) == 9 + assert (rows[0]["name"], rows[0]["spec"], rows[0]["amount"], rows[0]["unit"]) == ( + "깬잡석채집", + "L=45cm 내외", + 2.09, + "M2", + ) + assert rows[1]["source_code"] == "B00004" # 하위 호표 참조(한 단만 — 판정 Ⓕ) + assert rows[-1]["name"] == "콘크리트믹서사용" and rows[-1]["amount"] == 0.42 + + +def test_머리_칸이_다르면_안_읽고_칸을_짚는다(tmp_path: Path) -> None: + head = [row[:] for row in HEAD] + head[2][2] = "수량합" + read = read_recipes(_book(tmp_path, [*head, [" 제 1 호표"], ["벽", "H=1", None, "m"]])) + assert read["hopyo"] == [] + assert any("C3" in p and "수량" in p for p in read["problems"]) + + +def test_수량이_수가_아니면_그_호표를_안_읽는다(tmp_path: Path) -> None: + rows = [ + *HEAD, + [" 제 1 호표"], + ["벽", "H=1", None, "m"], + ["돌", "", "약 2", "㎡"], + ["합 계"], + [" 제 2 호표"], + ["담", "H=2", None, "m"], + ["돌", "", 3, "㎡"], + ["합 계"], + ] + read = read_recipes(_book(tmp_path, rows)) + assert [h["name"] for h in read["hopyo"]] == ["담"] # 틀린 호표는 통째로 뺌 + assert any("C7" in p for p in read["problems"]) + + +def test_착공판_계약단가_줄은_안_뽑고_항목에_적는다(tmp_path: Path) -> None: + """판정 ㉮㉯㉰ — 별산은 자재총괄 · % 가산 행은 참고 + 항목 사유 · 계약단가(낙찰률)는 안 뽑음 · 근거 줄 보존.""" + rows = [ + *HEAD, + [" 제 1 호표"], + ["기슭막이", "H=2.0", None, "m"], + ["건설표준품셈", "7-1-1(메쌓기)", 0, None], + ["파쇄암", "별도계상", 2.09, "㎡", *[0] * 8, "별산자재 25 "], + ["메쌓기", "L3=55cm이하", 2.09, "m2", *[0] * 8, "대가 11호표"], + ["공구손료", "노무비의 %", 2, "%"], + ["계"], + ["계약단가", None, 88.5], + ] + from B08_Quantity.B08_Quantity_Engine_StmateRecipe import recipe_item + + read = read_recipes(_book(tmp_path, rows)) + assert read["problems"] == [] and read["project"] == "시험" + item = recipe_item(read["hopyo"][0], type_id="masonry_dry", file_name="a.xlsx", project="시험") + assert [(r["name"], r["destination"]) for r in item["rows"]] == [ + ("파쇄암", "material"), + ("메쌓기", "unit_price"), + ("공구손료", "reference"), + ] + assert all(r["formula"] == "" and "7-1-1" in r["formula_text"] for r in item["rows"]) + assert item["rows"][1]["amount"] == "2.09" + assert "가산 행 1줄" in item["note"] and "공구손료" in item["note"] + assert "계약단가·낙찰율 줄 1줄" in item["note"] and "현행 품셈 대조 전" in item["note"] + assert item["item_kind"] == "fixed" and item["origin"]["project"] == "시험" + + +def test_시트가_없으면_사유(tmp_path: Path) -> None: + read = read_recipes(_book(tmp_path, HEAD, sheet="다른표")) + assert read["hopyo"] == [] and any("일위대가표" in p for p in read["problems"]) From 38a5e785f2485b67a1679eeca8f7299940d692e2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 15:59:11 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(b08):=20STmate=20=EC=97=91=EC=85=80=20?= =?UTF-8?q?=EB=BD=91=EA=B8=B0=20=EC=B0=BD=EA=B5=AC=20=EB=91=90=20=EA=B1=B8?= =?UTF-8?q?=EC=9D=8C=20=E2=80=94=20[=EC=9D=BD=EA=B8=B0]=EB=8A=94=20?= =?UTF-8?q?=ED=98=B8=ED=91=9C=20=EC=88=98=C2=B7=EA=B5=AC=EC=84=B1=20?= =?UTF-8?q?=EC=A4=84=20=EC=88=98=C2=B7=EB=AA=BB=20=EC=9D=BD=EC=9D=80=20?= =?UTF-8?q?=EC=82=AC=EC=9C=A0=EB=A7=8C(=EC=95=88=20=EC=94=80)=20=C2=B7=20[?= =?UTF-8?q?=EB=84=A3=EA=B8=B0]=EB=8A=94=20=EC=84=9C=EB=B2=84=EA=B0=80=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=9D=84=20=EB=8B=A4=EC=8B=9C=20=EC=9D=BD?= =?UTF-8?q?=EC=96=B4=20=EA=B3=A0=EB=A5=B8=20=ED=98=B8=ED=91=9C=EB=A5=BC=20?= =?UTF-8?q?=EA=B0=9C=EC=9D=B8=20=EB=8B=A8=EC=97=90=20=EA=B3=A0=EC=A0=95?= =?UTF-8?q?=ED=98=95=EC=9C=BC=EB=A1=9C(=ED=9A=8C=EC=82=AC=20=EC=97=86?= =?UTF-8?q?=EC=9D=8C=20403=20=C2=B7=20=EB=AA=BB=20=EC=9D=BD=EC=9D=80=20?= =?UTF-8?q?=ED=98=B8=ED=91=9C=20404=20=C2=B7=20=EB=AA=A8=EB=A5=B4=EB=8A=94?= =?UTF-8?q?=20=EC=A2=85=EB=A5=98=20400)=20=C2=B7=20main.py=20=EC=97=90=20?= =?UTF-8?q?=EC=B0=BD=EA=B5=AC=20=ED=95=9C=20=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B08_Quantity_Engine_StmateRecipe.py | 4 +- .../B08_Quantity_Engine_StructureLibrary.py | 17 ++- .../B08_Quantity_Router_StmateLibrary.py | 112 ++++++++++++++++++ main.py | 2 + .../tester/test_b08_stmate_library_router.py | 108 +++++++++++++++++ 5 files changed, 238 insertions(+), 5 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Router_StmateLibrary.py create mode 100644 resources/tester/test_b08_stmate_library_router.py diff --git a/B08_Quantity/B08_Quantity_Engine_StmateRecipe.py b/B08_Quantity/B08_Quantity_Engine_StmateRecipe.py index 5b4eb0ce..427174ae 100644 --- a/B08_Quantity/B08_Quantity_Engine_StmateRecipe.py +++ b/B08_Quantity/B08_Quantity_Engine_StmateRecipe.py @@ -14,7 +14,7 @@ from __future__ import annotations import re from pathlib import Path -from typing import Any +from typing import Any, BinaryIO SHEET = "일위대가표" HEADER_ROW = 3 @@ -37,7 +37,7 @@ def _text(value: Any) -> str: return "" if value is None else str(value).strip() -def read_recipes(path: str | Path) -> dict[str, Any]: +def read_recipes(path: str | Path | BinaryIO) -> dict[str, Any]: """`{"project": 공사명, "hopyo": [호표…], "problems": [사유…]}` — 못 읽은 호표는 목록에 없고 사유만.""" import openpyxl diff --git a/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py b/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py index bdfaaef5..4d6b8a65 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py @@ -127,6 +127,19 @@ def _write(folder: Path, item: dict[str, Any]) -> None: (folder / f"{item['code']}.json").write_text(text, encoding="utf-8") +def _personal_code(folder: Path, type_id: Any) -> str: + """개인 단 코드 — 같은 종류가 있으면 그 코드(덮어씀 · 판정 Ⓐ 종류당 하나), 없으면 새로.""" + same = [item for item in _items(folder) if item.get("type_id") == type_id] + return str(same[0]["code"]) if same else f"AX-ST-{secrets.token_hex(4)}" + + +def save_item_personal(folder: Path, item: dict[str, Any]) -> str: + """이미 만든 항목(STmate 에서 뽑은 고정형 등)을 개인 단에 씀. 코드.""" + code = _personal_code(folder, item.get("type_id")) + _write(folder, {**item, "code": code, "library_tier": "personal"}) + return code + + def save_personal( folder: Path, template: dict[str, Any], @@ -141,9 +154,7 @@ def save_personal( """ from B08_Quantity.B08_Quantity_Engine_StructureTemplate import overridden_rows - type_id = template.get("type_id") - same = [item for item in _items(folder) if item.get("type_id") == type_id] - code = str(same[0]["code"]) if same else f"AX-ST-{secrets.token_hex(4)}" + code = _personal_code(folder, template.get("type_id")) # 고친 식·반올림이 곧 이 항목의 값 — 「사용자」 표시와 되돌릴 자리는 떼어 냄. dropped = {"default_formula", "default_rounding"} rows = [ diff --git a/B08_Quantity/B08_Quantity_Router_StmateLibrary.py b/B08_Quantity/B08_Quantity_Router_StmateLibrary.py new file mode 100644 index 00000000..096407cd --- /dev/null +++ b/B08_Quantity/B08_Quantity_Router_StmateLibrary.py @@ -0,0 +1,112 @@ +"""B08 라이브러리 — STmate 출력 엑셀에서 **고정형 항목 뽑아 넣기** (PLAN 4장 · 2026-09-14 브레인 판정). + +두 걸음: + ① [읽기] 파일을 올리면 읽은 호표 수·구성 줄 수·못 읽은 사유를 돌려줌 — **아무것도 안 씀**. + 사용자가 수를 보고 넣을지 정함(판정 「읽은 뒤 수를 보이고 묻는 걸음」). + ② [넣기] 같은 파일 + 고른 호표 차례 + 우리 구조물 종류 → 서버가 **파일을 다시 읽어** 개인 단에 씀. +⚠ 브라우저가 보낸 줄을 받아 적지 않음(CLAUDE.md 5장) · 개인 단만(판정 Ⓗ) · 종류당 하나라 같은 종류 내 것은 + 덮어씀(판정 Ⓐ) · 종류는 사용자가 고름 — 이름으로 자동으로 안 붙임(판정 Ⓒ). +""" + +import asyncio +import io +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, File, Form, UploadFile +from fastapi.responses import JSONResponse + +from common_util.common_util_auth import verify_session + +router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"]) +BASE = "/{project_id}/quantity/structure-sheets/library/stmate" +#: 출력 엑셀은 수백 KB — 넉넉히 두되 끝없이 받지 않음. +MAX_BYTES = 20 * 1024 * 1024 + + +def _error(status: int, message: str, **extra: Any) -> JSONResponse: + return JSONResponse( + status_code=status, content={"status": "error", "message": message, **extra} + ) + + +async def _read(file: UploadFile) -> dict[str, Any] | JSONResponse: + from B08_Quantity.B08_Quantity_Engine_StmateRecipe import read_recipes + + data = await file.read(MAX_BYTES + 1) + if len(data) > MAX_BYTES: + return _error(413, "파일이 너무 큼(20MB 넘음)") + return await asyncio.to_thread(read_recipes, io.BytesIO(data)) + + +@router.post(BASE + "/read") +async def read_stmate_recipes( + project_id: UUID, + file: UploadFile = File(...), + session: dict[str, Any] = Depends(verify_session), +) -> JSONResponse: + """① 읽기만 — 호표 목록(차례·명칭·규격·단위·구성 줄 수)과 못 읽은 사유.""" + read = await _read(file) + if isinstance(read, JSONResponse): + return read + hopyo = [ + { + "no": h["no"], + "name": h["name"], + "spec": h["spec"], + "unit": h["unit"], + "rows": len(h["rows"]), + "percent_rows": sum(1 for row in h["rows"] if row["unit"] == "%"), + "contract_rows": h["contract_rows"], + } + for h in read["hopyo"] + ] + return JSONResponse( + content={ + "status": "success", + "file_name": file.filename or "", + "project": read["project"], + "hopyo": hopyo, + "counts": {"hopyo": len(hopyo), "rows": sum(h["rows"] for h in hopyo)}, + "problems": read["problems"], + } + ) + + +@router.post(BASE + "/save") +async def save_stmate_recipe( + project_id: UUID, + file: UploadFile = File(...), + hopyo_no: int = Form(...), + type_id: str = Form(..., min_length=1, max_length=100), + session: dict[str, Any] = Depends(verify_session), +) -> JSONResponse: + """② 넣기 — 서버가 다시 읽은 호표 하나를 고정형 항목으로 **로그인한 사람 개인 단**에 씀.""" + from B05_Profile.B05_Profile_Structures_Schema import structure_type_map + from B08_Quantity.B08_Quantity_Engine_StmateRecipe import recipe_item + from B08_Quantity.B08_Quantity_Engine_StructureLibrary import save_item_personal, tier_dirs + + folder = tier_dirs(session.get("company_id"), session.get("user_id")).get("personal") + if folder is None: + return _error(403, "개인 라이브러리는 회사에 속한 사용자만 씁니다.") + if type_id not in structure_type_map(): + return _error(400, f"모르는 구조물 종류: {type_id}") + read = await _read(file) + if isinstance(read, JSONResponse): + return read + hopyo = next((h for h in read["hopyo"] if h["no"] == hopyo_no), None) + if hopyo is None: + return _error(404, f"제{hopyo_no}호표를 읽지 못함", problems=read["problems"]) + item = recipe_item( + hopyo, type_id=type_id, file_name=file.filename or "", project=read["project"] + ) + code = await asyncio.to_thread(save_item_personal, folder, item) + return JSONResponse( + content={ + "status": "success", + "code": code, + "name": item["name"], + "rows": len(item["rows"]), + "note": item["note"], + } + ) diff --git a/main.py b/main.py index ad281e75..45f4e487 100644 --- a/main.py +++ b/main.py @@ -64,6 +64,7 @@ from B08_Quantity.B08_Quantity_Router import router as b08_quantity_router from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_router from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router from B08_Quantity.B08_Quantity_Router_StructureSheet import router as b08_structure_sheet_router +from B08_Quantity.B08_Quantity_Router_StmateLibrary import router as b08_stmate_library_router from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router from B09_Estimation.B09_Estimation_Router_Contract import router as b09_contract_router from B09_Estimation.B09_Estimation_Router_Execution import router as b09_execution_router @@ -644,6 +645,7 @@ app.include_router(b08_quantity_router, dependencies=protected_with_company) app.include_router(b08_earthwork_router, dependencies=protected_with_company) app.include_router(b08_material_router, dependencies=protected_with_company) app.include_router(b08_structure_sheet_router, dependencies=protected_with_company) +app.include_router(b08_stmate_library_router, dependencies=protected_with_company) app.include_router(b09_estimation_router, dependencies=protected_with_company) app.include_router(b09_cost_sheet_router, dependencies=protected_with_company) app.include_router(b09_contract_router, dependencies=protected_with_company) diff --git a/resources/tester/test_b08_stmate_library_router.py b/resources/tester/test_b08_stmate_library_router.py new file mode 100644 index 00000000..1df6376d --- /dev/null +++ b/resources/tester/test_b08_stmate_library_router.py @@ -0,0 +1,108 @@ +"""라이브러리 — STmate 출력 엑셀에서 고정형 항목 뽑아 넣기 창구(PLAN 4장 · 2026-09-14 브레인 판정). + +두 걸음: [읽기]는 호표 수·구성 줄 수·못 읽은 사유만 돌려주고 아무것도 안 씀 → [넣기]는 같은 파일을 +서버가 **다시 읽어** 고른 호표를 개인 단에 씀(브라우저가 보낸 줄을 받아 적지 않음 · 개인 단만 · 판정 Ⓗ). +""" + +from __future__ import annotations + +import io +import json +import sys +from pathlib import Path + +import openpyxl +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import B08_Quantity.B08_Quantity_Engine_StructureLibrary as library_module # noqa: E402 +import B08_Quantity.B08_Quantity_Router_StmateLibrary as router_module # noqa: E402 +from common_util.common_util_auth import verify_session # noqa: E402 + +PROJECT_ID = "33333333-3333-3333-3333-333333333333" +BASE = f"/api/projects/{PROJECT_ID}/quantity/structure-sheets/library/stmate" + + +def _xlsx() -> bytes: + book = openpyxl.Workbook() + ws = book.active + ws.title = "일위대가표" + for row in [ + ["일 위 대 가 표"], + ["공사명 : 시험 공사"], + ["명 칭", "규 격", "수 량", "단위"], + [None], + [" 제 1 호표"], + ["돌기슭막이(메쌓기)", "H=2.0", None, "m"], + ["메쌓기", "L3=55cm이하", 2.09, "m2"], + ["고임돌채집", "기계", 0.31, "m3"], + ["합 계"], + [" 제 2 호표"], + ["규준틀설치", "종단", None, "개소"], + ["각재", "외송", "약간", "M3"], + ["합 계"], + ]: + ws.append(row) + buffer = io.BytesIO() + book.save(buffer) + return buffer.getvalue() + + +@pytest.fixture() +def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: + monkeypatch.setattr(library_module, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + app = FastAPI() + app.include_router(router_module.router) + app.dependency_overrides[verify_session] = lambda: {"company_id": 7, "user_id": 42} + return TestClient(app) + + +def _files() -> dict: + return {"file": ("견본.xlsx", _xlsx(), "application/octet-stream")} + + +def test_읽기는_수와_사유만_주고_안_쓴다(client: TestClient, tmp_path: Path) -> None: + body = client.post(f"{BASE}/read", files=_files()).json() + assert body["counts"] == {"hopyo": 1, "rows": 2} + assert body["hopyo"][0]["name"] == "돌기슭막이(메쌓기)" and body["hopyo"][0]["rows"] == 2 + assert any("C12" in p for p in body["problems"]) # 수량이 수가 아닌 2호표는 사유로 + assert not (tmp_path / "storage").exists() + + +def test_넣기는_서버가_다시_읽어_개인_단에_고정형으로(client: TestClient, tmp_path: Path) -> None: + response = client.post( + f"{BASE}/save", files=_files(), data={"hopyo_no": "1", "type_id": "masonry_dry"} + ) + assert response.status_code == 200, response.text + code = response.json()["code"] + saved = json.loads( + (tmp_path / "storage" / "7" / "42" / "library" / f"{code}.json").read_text(encoding="utf-8") + ) + assert saved["library_tier"] == "personal" and saved["origin"]["project"] == "시험 공사" + assert [r["amount"] for r in saved["rows"]] == ["2.09", "0.31"] + dirs = library_module.tier_dirs(7, 42) + assert [i["kind"] for i in library_module.list_items(dirs, "masonry_dry")] == ["fixed"] + + +def test_회사_없으면_403_없는_호표는_404_모르는_종류는_400(client: TestClient) -> None: + bad = client.post( + f"{BASE}/save", files=_files(), data={"hopyo_no": "2", "type_id": "masonry_dry"} + ) + assert bad.status_code == 404 # 못 읽은 호표는 넣을 수 없음 + unknown = client.post(f"{BASE}/save", files=_files(), data={"hopyo_no": "1", "type_id": "없음"}) + assert unknown.status_code == 400 + client.app.dependency_overrides[verify_session] = lambda: {"company_id": None, "user_id": 1} + denied = client.post( + f"{BASE}/save", files=_files(), data={"hopyo_no": "1", "type_id": "masonry_dry"} + ) + assert denied.status_code == 403 + + +def test_앱에_창구가_걸린다() -> None: + main = (ROOT / "main.py").read_text(encoding="utf-8") + assert "B08_Quantity_Router_StmateLibrary" in main and "b08_stmate_library_router" in main From 91317295f508a9bea09583dbbfe5db75efa62152 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 16:01:50 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(b08):=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=9F=AC=EB=A6=AC=20=EC=B9=B8=20[STmate=20=EC=97=91=EC=85=80?= =?UTF-8?q?=20=EC=9D=BD=EA=B8=B0]=20=E2=86=92=20=ED=98=B8=ED=91=9C=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=E2=86=92=20[=EB=82=B4=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=EC=97=90=20?= =?UTF-8?q?=EB=84=A3=EA=B8=B0]=20=E2=80=94=20=EC=9D=BD=EC=9D=80=20?= =?UTF-8?q?=ED=98=B8=ED=91=9C=20=EC=88=98=C2=B7=EA=B5=AC=EC=84=B1=20?= =?UTF-8?q?=EC=A4=84=20=EC=88=98=EB=A5=BC=20=EB=B3=B4=EC=9D=B4=EA=B3=A0=20?= =?UTF-8?q?=EB=84=A3=EA=B8=B0=20=EC=A0=84=EC=97=90=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EB=AC=BC=EC=9D=8C(=EB=8D=AE=EC=96=B4=EC=94=80=C2=B7=EC=9B=90?= =?UTF-8?q?=EB=AC=B8=20=EC=8B=9C=EC=A0=90=20=EC=88=98=EB=9F=89=C2=B7?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=20=EC=95=88=20=EA=B0=80=EC=A0=B8=EC=98=B4)?= =?UTF-8?q?=20=C2=B7=20=EB=84=A3=EA=B8=B0=EB=8A=94=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EC=9D=84=20=EB=8B=A4=EC=8B=9C=20=EB=B3=B4=EB=83=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배정 프로젝트 화면: 돌쌓기(찰) 장에서 봉화 엑셀 읽기 → 「호표 45개 · 구성 줄 209줄」 → 제6호표 기슭막이(깬잡석,찰쌓기) H=2.0m 구성 9줄 넣기 → 목록 「개인 · [고정형] …」 · [내 것 지우기]로 되돌림(목록 기본 한 줄) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B08_Quantity_UI_StructureSheet_Library.ts | 11 ++ .../B08_Quantity_UI_StructureSheet_Stmate.ts | 144 ++++++++++++++++++ .../tester/test_b08_stmate_library_ui.py | 26 ++++ 3 files changed, 181 insertions(+) create mode 100644 B08_Quantity/B08_Quantity_UI_StructureSheet_Stmate.ts create mode 100644 resources/tester/test_b08_stmate_library_ui.py diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts index 196a177a..bc2a91a0 100644 --- a/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts @@ -8,6 +8,7 @@ * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; +import { buildStmatePanel } from "./B08_Quantity_UI_StructureSheet_Stmate"; const TIER_LABELS: Record = { personal: "개인", company: "회사", program: "기본" }; /** 항목 종류 배지 — 양식형 = 제원을 바꾸면 수량이 다시 남 · 고정형 = 박힌 수량(명세 13장). */ @@ -191,5 +192,15 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement { mine.append(save, remove); panel.append(title, scope, load, list, take, mine, status); + // 고정형 항목을 만드는 둘째 길 — STmate 출력 엑셀에서 호표 하나를 뽑아 개인 단에(PLAN 4장). + panel.append( + buildStmatePanel({ + projectId, + typeId, + onSaved: () => { + if (!list.hidden) load.click(); + }, + }), + ); return panel; } diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet_Stmate.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet_Stmate.ts new file mode 100644 index 00000000..0b1aa968 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet_Stmate.ts @@ -0,0 +1,144 @@ +/* ============================================================================= + * B08_Quantity_UI_StructureSheet_Stmate.ts + * 라이브러리 칸 — [STmate 엑셀에서 뽑기] (PLAN 4장 · 2026-09-14 브레인 판정). + * + * 두 걸음: [읽기] → 호표 수·구성 줄 수·못 읽은 사유를 보임(아무것도 안 씀) → 호표를 고르고 + * [내 라이브러리에 넣기] → 수를 다시 묻고 **파일을 다시 보냄**(서버가 다시 읽어 씀 — 화면은 줄을 안 보냄). + * ⚠ 종류는 지금 보고 있는 장의 종류 — 이름으로 자동으로 안 붙임(판정 Ⓒ). + * ⚠ 모양은 옆 제원 칸(`b08-spec*`) 클래스를 그대로 씀. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; + +interface ReadHopyo { + no: number; + name: string; + spec: string; + unit: string; + rows: number; + percent_rows: number; + contract_rows: number; +} + +interface ReadResult { + project: string; + hopyo: ReadHopyo[]; + counts: { hopyo: number; rows: number }; + problems: string[]; +} + +async function postForm(url: string, form: FormData): Promise { + const response = await fetch(url, { method: "POST", credentials: "include", body: form }); + const payload = (await response.json().catch(() => ({}))) as T & { message?: string }; + if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); + return payload; +} + +export function buildStmatePanel(options: { + projectId: string; + typeId: string; + /** 넣은 뒤 — 목록을 다시 받게 함. */ + onSaved: () => void; +}): HTMLElement { + const { projectId, typeId, onSaved } = options; + const base = `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/library`; + const box = document.createElement("div"); + box.className = "b08-sheet__actions"; + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".xlsx"; + input.className = "b08-spec__input"; + input.title = "STmate 가 내보낸 내역 엑셀(일위대가표 시트)"; + const read = document.createElement("button"); + read.type = "button"; + read.className = "b08-quantity__tab"; + read.textContent = "STmate 엑셀 읽기"; + const list = document.createElement("select"); + list.className = "b08-spec__input"; + list.hidden = true; + const put = document.createElement("button"); + put.type = "button"; + put.className = "b08-spec__save"; + put.textContent = "내 라이브러리에 넣기"; + put.hidden = true; + const status = document.createElement("p"); + status.className = "b08-spec__scope"; + + let result: ReadResult | null = null; + input.addEventListener("change", () => { + result = null; + list.hidden = put.hidden = true; + status.textContent = ""; + }); + + read.addEventListener("click", () => { + const file = input.files?.[0]; + if (!file) { + status.textContent = "엑셀 파일을 먼저 고르세요"; + return; + } + void (async () => { + read.disabled = true; + status.textContent = "읽는 중…"; + try { + const form = new FormData(); + form.append("file", file); + result = await postForm(`${base}/stmate/read`, form); + list.replaceChildren( + ...result.hopyo.map((h) => { + const option = document.createElement("option"); + option.value = String(h.no); + const extra = h.percent_rows ? ` · 가산 행 ${h.percent_rows}` : ""; + option.textContent = `제${h.no}호표 ${h.name} ${h.spec} / ${h.unit} · 구성 ${h.rows}줄${extra}`; + return option; + }), + ); + list.hidden = put.hidden = result.hopyo.length === 0; + const missed = result.problems.length + ? ` · 못 읽은 것 ${result.problems.length}건 — ${result.problems.slice(0, 3).join(" / ")}` + : ""; + status.textContent = `「${result.project}」 호표 ${result.counts.hopyo}개 · 구성 줄 ${result.counts.rows}줄 읽음${missed}`; + } catch (error) { + status.textContent = error instanceof Error ? error.message : "읽지 못함"; + } finally { + read.disabled = false; + } + })(); + }); + + put.addEventListener("click", () => { + const file = input.files?.[0]; + const picked = result?.hopyo.find((h) => String(h.no) === list.value); + if (!file || !result || !picked) return; + const question = + `읽은 것: 호표 ${result.counts.hopyo}개 · 구성 줄 ${result.counts.rows}줄\n` + + `넣을 것: 「${picked.name} ${picked.spec}」 구성 ${picked.rows}줄 → 이 장 종류로 내 라이브러리\n` + + "· 같은 종류 내 것이 있으면 덮어씀\n· 수량은 원문 시점값(현행 품셈 대조 전) · 단가는 안 가져옴"; + if (!window.confirm(question)) return; + void (async () => { + put.disabled = true; + status.textContent = "넣는 중…"; + try { + const form = new FormData(); + form.append("file", file); + form.append("hopyo_no", String(picked.no)); + form.append("type_id", typeId); + const saved = await postForm<{ code: string; name: string; note: string }>( + `${base}/stmate/save`, + form, + ); + status.textContent = `내 라이브러리에 넣음 — 「${saved.name}」 [고정형] · ${saved.note}`; + onSaved(); + } catch (error) { + status.textContent = error instanceof Error ? error.message : "넣지 못함"; + } finally { + put.disabled = false; + } + })(); + }); + + const wrap = document.createElement("div"); + box.append(read, put); + wrap.append(input, box, list, status); + return wrap; +} diff --git a/resources/tester/test_b08_stmate_library_ui.py b/resources/tester/test_b08_stmate_library_ui.py new file mode 100644 index 00000000..5bed025d --- /dev/null +++ b/resources/tester/test_b08_stmate_library_ui.py @@ -0,0 +1,26 @@ +"""라이브러리 칸의 [STmate 엑셀에서 뽑기] — 읽은 수를 보이고 묻고 넣는 두 걸음(2026-09-14 브레인 판정). + +⚠ 읽기 전에 넣기가 열리면 안 됨 · 넣기 전에 호표 수·구성 줄 수·덮어씀을 묻는 확인이 있어야 함 · + 넣기는 **파일을 다시 보냄**(서버가 다시 읽음 — 화면이 읽은 줄을 보내지 않음). +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +UI = ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Stmate.ts" +PANEL = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text( + encoding="utf-8" +) + + +def test_라이브러리_칸에_뽑기_칸이_붙는다() -> None: + assert "buildStmatePanel(" in PANEL + + +def test_읽고_수를_보이고_묻고_파일을_다시_보내_넣는다() -> None: + ui = UI.read_text(encoding="utf-8") + assert "/stmate/read" in ui and "/stmate/save" in ui + assert 'form.append("file"' in ui and 'form.append("hopyo_no"' in ui + assert "window.confirm(" in ui and "counts.hopyo" in ui and "counts.rows" in ui + assert "put.hidden = true" in ui # 읽기 전에는 넣기 단추가 숨음 + assert "rows:" not in ui.split("/stmate/save")[1][:400] # 줄을 보내지 않음