From 13e59807320ea369da37885f9eee55e0c0850f7d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 13:01:54 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(M02=20=EA=B8=B0=EC=A4=80=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D):=20=EB=8C=80=ED=91=9C=20=EA=B7=9C=EA=B2=A9=20?= =?UTF-8?q?=ED=8E=BC=EC=B9=9C=20=EA=B8=B0=EC=A4=80=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=C2=B7=20=EA=B3=84=EC=82=B0=EC=84=9C=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EA=B8=B8=20=E2=80=94=20=EA=B8=88=EC=95=A1=20=3D=20M01=20?= =?UTF-8?q?=EC=97=94=EC=A7=84=20=C2=B7=20=ED=8C=8C=EC=83=9D=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EB=B3=B8=20(PLAN=20704-1=20=C2=B7=20704-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `M02_Logic_Standard` — 로직 하나를 대표 규격(고르기 값 전부 · 표 찾기 조건 수 = 표 줄마다 · 지역 = 자동값)으로 펼친 줄 · 품명 · 규격 · 단위 · 금액(엔진 계) · 출처 · 공종 · 실무 낱말 별칭(석축 → 돌쌓기) - 목록 = 서버가 뒤에서 한 번 셈(프로세스 4 · 약 90 s) → storage `_derived/m02_logic_standard/` · 열쇠 = 마스터 내용 지문 · 셈 도중 빈 줄 + 준비 false - 계산서 = `master_text.lines` 줄 · 비목 소계 · 계 · 맨 위 출처 · 노임 판 · 규격 축 아닌 입력 400 - 길 `/api/m02/logic-lib/standard` · `/logic-lib/sheet` — 로그인만(일반 사용자도) - 시험 `test_704_logic_standard.py` — 세 항목 금액 = M01 시험 계산 · 계산서 계 = 엔진 값 · 석축 찾기 · 저장본 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Logic_Standard.py | 350 ++++++++++++++++++ .../M02_MasterTemplete_Router_Combos.py | 26 ++ resources/tester/test_704_logic_standard.py | 128 +++++++ 3 files changed, 504 insertions(+) create mode 100644 M02_MasterTemplete/M02_Logic_Standard.py create mode 100644 resources/tester/test_704_logic_standard.py diff --git a/M02_MasterTemplete/M02_Logic_Standard.py b/M02_MasterTemplete/M02_Logic_Standard.py new file mode 100644 index 000000000..71eaac2cb --- /dev/null +++ b/M02_MasterTemplete/M02_Logic_Standard.py @@ -0,0 +1,350 @@ +"""M02 기준 목록 · 계산서 보기(PLAN 704-1 · 704-2 · 요구서 4장 8) — 로직 고르기 창이 읽음. + +기준 목록 = 단가산출 로직 하나를 대표 규격으로 펼친 줄(품명 · 규격 · 단위 · 금액 · 출처 · 공종). +대표 규격 = 고르기 입력 값 전부 · 표 찾기 조건으로 쓰이는 수 입력은 그 표 줄마다 값 하나(범위 줄은 +위 끝) · 그 밖 연속 값 입력은 견본 자동값(`master_auto.values`) 한 줄 · 지역 = 자동값(전국평균). +펼침 = 바탕(자동값 · 규격 축은 첫 대표 값) 한 줄 + 규격 축마다 값 하나씩 바꾼 줄(곱하지 않음 · +바꾼 줄이 0 원이면 표에 없는 조합으로 보고 뺌). +금액 = M01 엔진(`master_formula.run`) 「계」 — M01 시험 계산과 같은 셈 · 최신 판 노임(마스터 판). +목록은 서버가 뒤에서 한 번 셈(프로세스 `WORKERS` 개 · 수 분) → 프로세스 메모리 + 파생 저장본 +(storage `_derived/m02_logic_standard/`) · 열쇠 = 마스터 내용 지문 + `SHAPE` · 같으면 안 셈 · +셈 도중 목록 = 빈 줄 `준비: false`(화면은 로직 줄 그대로 · 금액 빈칸). +실무 낱말(`WORDS`) = 별칭 칸에 덧붙여 찾기(공용 `matchLogic`)가 「석축」 → 돌쌓기를 찾음. +계산서 = `master_text.lines`(줄 「석공 | 268,908 * 0.1 = 26,891」 · 비목 소계 · 계 = 엔진 값). +M01 은 읽기만 · 권한은 부르는 쪽 몫(로그인). +""" + +from __future__ import annotations + +import hashlib +import os +import sys +import threading +from concurrent.futures import ProcessPoolExecutor +from decimal import Decimal +from typing import Any + +from common_util.common_util_derived_store import derived_key, read_derived, write_derived +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs +from M02_MasterTemplete import M02_Template_Layers as layers + +SHAPE = 2 # 줄 꼴이 바뀌면 올림 — 옛 저장본을 안 씀 +KIND = "m02_logic_standard" +REGION = "지역" # 지역 = 자동값 한 벌(정함 8) +QUIET = ("해당 없음", "없음") # 규격 글에서 뺄 값 +#: 실무 낱말 → 로직 이름에 든 품셈 낱말(띄어쓰기 무시) — 맞으면 별칭으로 붙음 +WORDS: dict[str, tuple[str, ...]] = { + "석축": ("돌쌓기", "돌붙임", "찰쌓기", "메쌓기"), + "기슭막이": ("돌쌓기", "돌붙임"), + "골막이": ("돌쌓기",), + "흄관": ("원심력",), + "배수관": ("흄관", "파형강관", "원심력", "관부설"), + "땅파기": ("터파기", "굴착"), + "흙메우기": ("되메",), + "콘크리트치기": ("타설",), + "레미콘": ("타설",), + "비탈녹화": ("녹화", "파종", "식생"), +} +#: 공종 — 차례 = 거름 차례(임도 · 사방 자주 쓰는 것 먼저) · (이름 낱말, 장 낱말) · 이름이 먼저 +KINDS: tuple[tuple[str, tuple[str, ...], tuple[str, ...]], ...] = ( + ("돌공", ("돌쌓기", "돌붙임", "석축", "찰쌓기", "메쌓기", "전석", "사석", "돌망태", "개비온"), + ("돌공사",)), + ("관", ("흄관", "강관", "파형", "관부설", "배수관", "PE관", "관접합"), ("관부설",)), + ("콘크리트", ("콘크리트", "거푸집", "철근", "타설", "레미콘", "모르타르"), ("철근콘크리트",)), + ("토공", ("터파기", "되메", "흙", "성토", "절토", "굴착", "땅깎기", "사토", "다짐", "잔토"), + ("토공",)), + ("운반", ("운반", "집재", "운재"), ("운반", "집재")), + ("식재 · 녹화", ("식재", "녹화", "파종", "식생", "묘목"), ("식재", "생육")), + ("벌채", ("벌채", "벌목"), ("나무베기",)), + ("가설", (), ("가설",)), +) # fmt: skip +OTHER = "기타" + +WORKERS = max(1, min(4, (os.cpu_count() or 2) - 1)) +_LOCK = threading.Lock() +_MEMO: dict[str, Any] = {} # whole(색인) → 열쇠 · 목록 · 셈 중 + + +def _sq(text: Any) -> str: + return "".join(str(text or "").split()).lower() + + +def kind_of(row: dict) -> str: + name, chapter = _sq(row.get("이름")), _sq(row.get("상세구분")) + for kind, words, _ in KINDS: + if any(_sq(w) in name for w in words): + return kind + for kind, _, chapters in KINDS: + if any(_sq(w) in chapter for w in chapters): + return kind + return OTHER + + +def words_of(row: dict) -> list[str]: + name = _sq(row.get("이름")) + return [w for w, targets in WORDS.items() if any(_sq(t) in name for t in targets)] + + +def _range_label(table: dict, low: Any, high: Any, unit: str) -> str: + f = m01.mtx.fmt + if low is None: + return f"{f(high)}{unit} 이하" + if high is None: + rule = str(table.get("범위규칙") or "초과") + return f"{f(low)}{unit} {'이상' if rule.startswith('이상') else '초과'}" + return f"{f(low)}~{f(high)}{unit}" + + +def _table_reps(whole: Any, ref: str, cond: str, unit: str) -> list[tuple[Any, str]]: + """표 `ref` 조건 칸 `cond` 줄마다 (값, 글) — 범위 줄은 위 끝(없으면 아래 끝 · 드는 값).""" + try: + table = whole.get(ref) + except m01.cm.mf.FormulaError: + return [] + ranged = table.get("조건", {}).get(cond) == "범위" + out: dict[Any, str] = {} + for r in table.get("줄", []): + have = r.get(cond) + if have is None: + continue + if not ranged: + if isinstance(have, Decimal): + out.setdefault(have, f"{m01.mtx.fmt(have)}{unit}") + continue + low, high = have + tries = [v for v in (high, low, (low or 0) + 1) if isinstance(v, Decimal)] + got = next((v for v in tries if m01.cm.mf._match(table, r, cond, v)), None) + if got is not None: + out.setdefault(got, _range_label(table, low, high, unit)) + return sorted(out.items()) + + +def axes(whole: Any, logic: dict) -> tuple[dict, list[tuple[str, list[tuple[Any, str]]]]]: + """(바탕 입력, [(입력 이름, [(값, 글) …]) …]) — 규격 축 = 지역 뺀 고르기 · 표 찾기 수 입력.""" + base = m01.mau.values(whole, logic) + finds, _ = m01.mau._uses(logic) + out = [] + for spec in logic.get("입력") or []: + name = spec["이름"] + if name == REGION: + continue + unit = str(spec.get("단위") or "") + if spec.get("고르기"): + labels = labor_inputs._labels(whole, spec) + reps = [(v, str(labels.get(v, m01.mtx.fmt(v) if isinstance(v, Decimal) else v))) + for v in spec["고르기"]] # fmt: skip + elif name in finds: + seen: dict[Any, str] = {} + for ref, cond in finds[name]: + for v, text in _table_reps(whole, ref, cond, unit): + seen.setdefault(v, text) + reps = sorted(seen.items()) + else: + continue + if not reps: + continue + if base.get(name) not in [v for v, _ in reps]: + base[name] = reps[0][0] + out.append((name, reps)) + return base, out + + +def _spec(given: dict, ax: list) -> str: + parts = [] + for name, reps in ax: + text = dict(reps).get(given.get(name), "") + if text and text not in QUIET: + parts.append(text if text.startswith(name) else f"{name} {text}") + return " · ".join(parts) + + +def _unit(logic: dict) -> str: + unit = str(logic.get("결과단위") or "") + return unit[2:] if unit.startswith("원/") else ("" if unit == "원" else unit) + + +def _money(whole: Any, logic: dict, given: dict) -> Decimal | None: + if not str(logic.get("결과단위") or "").startswith("원"): + return None + try: + got = m01.cm.mf.run(whole, str(logic["키"]), m01.cm.mf.fill_empty(logic, given)) + except (m01.cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): + return None + return got.get("계", got.get("결과")) + + +def _rows_of(whole: Any, logic: dict, blocked: bool) -> list[dict]: + base, ax = axes(whole, logic) + changes: list[dict] = [{}] + for name, reps in ax: + changes += [{name: v} for v, _ in reps if v != base.get(name)] + head = { + "키": logic.get("키"), + "이름": logic.get("이름") or "", + "단위": _unit(logic), + "출처": logic.get("출처") or "", + "공종": kind_of(logic), + "별칭": ", ".join(x for x in [logic.get("별칭") or "", *words_of(logic)] if x), + "구분": logic.get("구분") or "", + "상세구분": logic.get("상세구분") or "", + "막힘": blocked, + } + out = [] + for change in changes: + given = {**base, **change} + money = _money(whole, logic, given) + if change and money == 0: # 표에 없는 규격 조합(견치돌 · 뒷길이 25 따위) — 0 원 줄은 뺌 + continue + out.append( + { + **head, + "규격": _spec(given, ax), + "금액": None if money is None else round(float(money), 2), + "바꿈": labor_inputs._num(change), + } + ) + return out + + +def edition(files: dict) -> str: + """노임 판 글 — 「2026 상반기 노임」(인력 머리 첫 판) · 없으면 빈 글.""" + for head in ((files.get("인력.json") or {}).get("구분") or {}).values(): + day = str(head.get("판") or "") + if len(day) >= 7: + return f"{day[:4]} {'상반기' if int(day[5:7]) < 7 else '하반기'} 노임" + return "" + + +def _stamp() -> str: + """마스터 파일 내용 지문 — 워크트리마다 시각이 달라도 같은 내용이면 같은 열쇠.""" + sha = hashlib.sha1() + for path in sorted(m01.FOLDER.glob("*.json")): + if not path.name.startswith("_"): + sha.update(path.name.encode()) + sha.update(hashlib.sha1(path.read_bytes()).digest()) + return sha.hexdigest() + + +def _chunk(keys: list[str], blocked: set[str]) -> list[dict]: + """일꾼 프로세스 몫 — 로직 키들의 줄(마스터는 그 프로세스가 읽음).""" + whole = m01.loaded()[1] + out = [] + for key in keys: + out += _rows_of(whole, whole.logic(key), key in blocked) + return out + + +def build(files: dict, workers: int = 1) -> dict: + """기준 목록 통째 — 장 차례 · `workers` > 1 이면 로직을 나눠 프로세스 여럿이 셈(차례 그대로).""" + why_of = m01.blocks() + blocked = {k for k, why in why_of.items() if why} + keys = [ + str(logic.get("키")) + for _, data in sorted(m01._logic_files(files), key=lambda f: f[1].get("차례") or 0) + for logic in data.get("줄") or [] + ] + size = max(1, len(keys) // (workers * 8)) + parts = [keys[i : i + size] for i in range(0, len(keys), size)] + if workers > 1: + with ProcessPoolExecutor(workers) as pool: + got = list(pool.map(_chunk, parts, [blocked] * len(parts))) + else: + got = [_chunk(part, blocked) for part in parts] + kinds = [k for k, _, _ in KINDS] + [OTHER] + return {"판": edition(files), "공종": kinds, "줄": [row for part in got for row in part]} + + +def _make(files: dict, whole: Any, key: str) -> None: + try: + value = build(files, WORKERS) + write_derived(layers.storage_root(), KIND, key, value) + with _LOCK: + if _MEMO.get("whole") is whole: + _MEMO.update(value=value) + except Exception as e: # noqa: BLE001 — 다음 요청이 다시 셈 + print(f"[M02 기준 목록] 셈 실패: {e}", file=sys.stderr) + finally: + with _LOCK: + if _MEMO.get("whole") is whole: + _MEMO["job"] = None + + +def listing() -> dict: + """기준 목록 `{판, 공종, 줄, 준비}` — 메모리 → 파생 저장본 → 뒤에서 셈(그동안 빈 줄).""" + files, whole = m01.loaded() + with _LOCK: + if _MEMO.get("whole") is not whole: + _MEMO.clear() + _MEMO.update(whole=whole, key=derived_key(KIND, SHAPE, _stamp()), value=None, job=None) + if _MEMO["value"] is None and _MEMO["job"] is None: + got = read_derived(layers.storage_root(), KIND, _MEMO["key"]) + if got: + _MEMO["value"] = got["value"] + else: + _MEMO["job"] = threading.Thread( + target=_make, args=(files, whole, _MEMO["key"]), daemon=True + ) + _MEMO["job"].start() + if _MEMO["value"] is not None: + return {**_MEMO["value"], "준비": True} + kinds = [k for k, _, _ in KINDS] + [OTHER] + return {"판": edition(files), "공종": kinds, "줄": [], "준비": False} + + +def warm() -> None: + """서버가 뜰 때 뒤에서 — 저장본이 있으면 읽기만 · 없으면 셈 시작.""" + threading.Thread(target=lambda: _quiet(listing), daemon=True).start() + + +def _quiet(fn) -> None: + try: + fn() + except Exception as e: # noqa: BLE001 — 데움 실패는 첫 요청이 다시 셈 + print(f"[M02 기준 목록] 데움 실패: {e}", file=sys.stderr) + + +def _given(whole: Any, logic: dict, change: dict) -> tuple[dict, list]: + base, ax = axes(whole, logic) + known = {name: dict(reps) for name, reps in ax} + for name, value in (change or {}).items(): + if name not in known: + raise m01.StoreError(400, f"규격 축이 아닌 입력 「{name}」") + want = Decimal(str(value)) if isinstance(value, (int, float)) else value + if want not in known[name]: + raise m01.StoreError(400, f"「{name}」 대표 값이 아님: {value}") + base[name] = want + return base, ax + + +def sheet(key: str, change: dict) -> dict: + """계산서 한 장 — 머리(품명 · 규격 · 단위 · 출처 · 판) + 비목 묶음 줄 · 소계 · 계(엔진 값).""" + files, whole = m01.loaded() + try: + logic = whole.logic(key) + except m01.cm.mf.FormulaError as e: + raise m01.StoreError(404, str(e)) from e + given, ax = _given(whole, logic, change) + got = m01.mtx.lines(files, key, m01.cm.mf.fill_empty(logic, given)) + groups = [ + { + "비목": g["비목"], + "소계": g.get("소계"), + "끝수": g.get("끝수") or "", + "줄": [{k: line.get(k) for k in ("이름", "글", "금액")} for line in g["줄"]], + } + for g in got.get("groups") or [] + ] + return labor_inputs._num( + { + "키": key, + "이름": logic.get("이름") or "", + "규격": _spec(given, ax), + "단위": _unit(logic), + "출처": labor_inputs.sources(logic) or "", + "판": edition(files), + "입력": given, + "묶음": groups, + "계": got.get("계"), + } + ) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index d31f40c65..011163f40 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -9,12 +9,17 @@ 39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만(`_door` · 담을 로직 읽기 뺌). 85-4 — 원문 패널 읽기(`/logic-lib/source` · M01 길과 같은 몸) · 원문 pdf 통째 (`/logic-lib/source-pdf` · 책 이름만 받음 · 경로 안 받음)도 로그인만. +704 — 기준 목록(`/logic-lib/standard`) · 계산서(`/logic-lib/sheet`) 로그인만(`M02_Logic_Standard`). """ from __future__ import annotations +import sys +from typing import Any + from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import Response +from pydantic import BaseModel from common_util.common_util_auth import verify_session from common_util.common_util_http_cache import cached_file_response @@ -22,6 +27,7 @@ from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Make as make from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Layer_Access as access +from M02_MasterTemplete import M02_Logic_Standard as standard from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root @@ -36,6 +42,9 @@ router = APIRouter( prefix="/api/m02", tags=["M02 MasterTemplete Combos"], dependencies=[Depends(_door)] ) +if "pytest" not in sys.modules: # 기준 목록을 뒤에서 한 번 셈(저장본이 있으면 읽기만) + standard.warm() + def _where(key: str) -> tuple[str, str | None]: got = combos.where(key) @@ -107,6 +116,23 @@ def get_logic_inputs(keys: str = "") -> dict: return _read(labor_inputs.logic_inputs, keys.split(",")) +@router.get("/logic-lib/standard", dependencies=[Depends(verify_session)]) +def get_standard() -> dict: + """기준 목록 `{판, 공종, 줄}` — 대표 규격 펼침 · 금액 = M01 엔진(704-1).""" + return _read(standard.listing) + + +class SheetBody(BaseModel): + key: str + 바꿈: dict[str, Any] = {} + + +@router.post("/logic-lib/sheet", dependencies=[Depends(verify_session)]) +def post_sheet(body: SheetBody) -> dict: + """계산서 한 장 — 노임 × 품 = 금액 줄 · 비목 소계 · 계(704-2 · 저장 안 함).""" + return _read(standard.sheet, body.key, body.바꿈) + + #: 원문 pdf(85-4) — 책 이름 → 파일(쪽 자르기 없음) SOURCE_PDF = { "건설": make.BODY / "건설공사_표준품셈" / "2026년_건설공사_표준품셈.pdf", diff --git a/resources/tester/test_704_logic_standard.py b/resources/tester/test_704_logic_standard.py new file mode 100644 index 000000000..2cd394958 --- /dev/null +++ b/resources/tester/test_704_logic_standard.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +"""PLAN 704-1 · 704-2 — M02 기준 목록(대표 규격 펼침 · 금액 = M01 엔진) · 계산서 보기. + +① 세 항목(돌쌓기 메쌓기 · 파형강관 · 구조물터파기) 줄 금액 = M01 시험 계산(`store.calc`) +② 대표 규격 — 관경 고르기 11 · 뒷길이 표 줄 3 · 지역 = 자동값 한 벌 +③ 실무 낱말 — 「석축」 찾으면 돌쌓기 · 찰쌓기(서버 찾기 맞춤 `hit` = 화면 `matchLogic` 거울 · 208-1) +④ 계산서 — 계 = 엔진 값 · 비목 소계 합 = 계 · 규격 축 아닌 입력은 400 +⑤ 목록 저장본 — 임시 storage `_derived/` · 같은 지문이면 다시 안 셈 +⑥ 화면 — 고르기 창이 기준 목록 표 · 계산서 [담기] = 지금 담기 동작(`done(logic)`) +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M01_MasterData.M01_MasterData_Store_Find import hit +from M02_MasterTemplete import M02_Logic_Standard as st + +ROOT = Path(__file__).resolve().parents[2] +THREE = ("GF000220", "GC000404", "GF000101") # 돌쌓기 메쌓기(장비) · 파형강관 부설 · 구조물터파기 + + +def _rows(key: str) -> list[dict]: + whole = m01.loaded()[1] + return st._rows_of(whole, whole.logic(key), False) + + +def _given(key: str, change: dict) -> dict: + whole = m01.loaded()[1] + return st._given(whole, whole.logic(key), change)[0] + + +@pytest.mark.parametrize("key", THREE) +def test_줄_금액은_M01_시험_계산(key: str) -> None: + rows = _rows(key) + assert rows + for row in rows: + got = m01.calc(key, _given(key, row["바꿈"])) + assert got["ok"], got + assert row["금액"] == pytest.approx(float(got["sums"]["계"]), abs=0.01), row["규격"] + + +def test_대표_규격_펼침() -> None: + pipe = _rows("GC000404") + assert len({r["규격"] for r in pipe}) == len(pipe) # 줄마다 규격이 다름 + assert sum(1 for r in pipe if not r["바꿈"] or "관경" in r["바꿈"]) == 11 + assert pipe[0]["규격"].startswith("관경 250") and pipe[0]["단위"] == "본" + stone = _rows("GF000220") + backs = [r for r in stone if not r["바꿈"] or "뒷길이" in r["바꿈"]] + assert [r["규격"].split(" · ")[0] for r in backs] == [ + "뒷길이 35㎝ 이하", + "뒷길이 55㎝ 이하", + "뒷길이 75㎝ 이하", + ] + assert all("지역" not in r["바꿈"] for r in stone) # 지역 = 자동값 한 벌 + hand = _rows("GF000219") # 메쌓기(인력) — 견치돌 · 뒷길이 25 같은 표 밖 조합은 0 원 → 뺌 + assert all(r["금액"] != 0 for r in hand[1:]) + assert stone[0]["공종"] == "돌공" and stone[0]["단위"] == "㎡" + assert _rows("GF000101")[0]["공종"] == "토공" + + +def test_석축_찾으면_쌓기() -> None: + hits = [ + r["이름"] + for _, data in m01._logic_files(m01.loaded()[0]) + for logic in data.get("줄") or [] + for r in [{**logic, "별칭": ", ".join(st.words_of(logic))}] + if hit(r, "석축") + ] + assert "돌쌓기 메쌓기(장비)" in hits + assert any("찰쌓기" in name for name in hits) + assert "석축" in _rows("GF000220")[0]["별칭"] + + +@pytest.mark.parametrize("key", THREE) +def test_계산서_계는_엔진_값(key: str) -> None: + row = _rows(key)[-1] + got = st.sheet(key, row["바꿈"]) + assert got["계"] == pytest.approx(row["금액"], abs=0.01) + assert sum(g["소계"] for g in got["묶음"]) == pytest.approx(got["계"], abs=0.01) + assert got["판"].endswith("노임") and got["출처"] + assert all(line["글"] for g in got["묶음"] for line in g["줄"]) + + +def test_계산서_규격_축_아닌_입력은_거절() -> None: + with pytest.raises(m01.StoreError) as e: + st.sheet("GF000220", {"지역": "서울"}) + assert e.value.status == 400 + with pytest.raises(m01.StoreError): + st.sheet("GF000220", {"뒷길이": 40}) # 대표 값 아님 + + +def test_목록_저장본_같은_지문이면_안_셈(tmp_path, monkeypatch) -> None: + calls = [] + + def fake(files, workers=1): + calls.append(workers) + return {"판": "2026 상반기 노임", "공종": ["돌공"], "줄": [{"키": "GF000220"}]} + + monkeypatch.setattr(st.layers, "storage_root", lambda: tmp_path) + monkeypatch.setattr(st, "build", fake) + st._MEMO.clear() + first = st.listing() + assert first["준비"] is False and first["줄"] == [] # 셈 도중 = 빈 줄 + st._MEMO["job"].join() + assert st.listing()["줄"] == [{"키": "GF000220"}] + st._MEMO.clear() # 서버 다시 켬 — 저장본에서 읽음 + assert st.listing()["준비"] is True and len(calls) == 1 + assert list((tmp_path / "_derived" / st.KIND).glob("*.json")) + st._MEMO.clear() + + +def test_화면_기준_목록_계산서_담기() -> None: + pick = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts").read_text("utf-8") + assert 'from "@ui/ui_template_data_table"' in pick and "fetchStandard()" in pick + assert "openStandardSheet(row, () => {\n close();\n done(logic);" in pick + assert "(!work.select.value || x.공종 === work.select.value)" in pick + sheet = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts").read_text("utf-8") + assert 'from "@ui/ui_template_calc_row"' in sheet and "createSourceView(" in sheet + for name in ("Labor_Pick", "Labor_Sheet"): + text = (ROOT / f"M02_MasterTemplete/M02_MasterTemplete_{name}.ts").read_text("utf-8") + assert len(text.splitlines()) <= 700 + router = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py").read_text("utf-8") + assert '@router.get("/logic-lib/standard", dependencies=[Depends(verify_session)])' in router + assert '@router.post("/logic-lib/sheet", dependencies=[Depends(verify_session)])' in router From 508e1ec34d5182763cd5c226c9de9a796d8e0f02 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 13:02:07 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(M02=20=EA=B8=B0=EC=A4=80=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D):=20=EB=A1=9C=EC=A7=81=20=EA=B3=A0=EB=A5=B4=EA=B8=B0?= =?UTF-8?q?=20=EC=B0=BD=20=3D=20=EA=B8=B0=EC=A4=80=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=ED=91=9C=20=C2=B7=20=EC=A4=84=20=EB=88=84=EB=A6=84=20=3D=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=EC=84=9C=20=EB=B3=B4=EA=B8=B0=20=E2=86=92=20?= =?UTF-8?q?[=EB=8B=B4=EA=B8=B0]=20(PLAN=20704-1=20=C2=B7=20704-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 고르기 창 단가산출 줄 → 공용 표(품명 · 규격 · 단위 · 금액 · 출처) · 공종 거름 · 찾기 = 공용 matchLogic(규격 · 실무 낱말 별칭까지) · GF 번호 숨김 · 창 폭 960 - 기준 목록이 오기 전 · 서버 셈 도중 = 로직 줄(금액 빈칸) · 서고 · 덧줄 목록은 그대로 - 계산서 창 `M02_MasterTemplete_Labor_Sheet` — 맨 위 출처 · 노임 판 · 「이름 | 노임 × 품 | 금액」 줄 · 비목 소계 · 계(계산 줄 부품) · 곁에 원문 띠 · [담기] = 지금 담기 동작 그대로 Co-Authored-By: Claude Opus 5.5 --- .../M02_MasterTemplete_Labor.css | 27 ++++++ .../M02_MasterTemplete_Labor_Api.ts | 45 +++++++++ .../M02_MasterTemplete_Labor_Pick.ts | 95 +++++++++++++++---- .../M02_MasterTemplete_Labor_Sheet.ts | 76 +++++++++++++++ .../M02_MasterTemplete_Labor_Text.ts | 11 +++ 5 files changed, 236 insertions(+), 18 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 1c32aadc0..dd3399808 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -481,3 +481,30 @@ font-weight: 600; font-variant-numeric: tabular-nums; } + +/* 기준 목록(704-1) — 찾기 · 거름 위 · 기준 목록 표 아래(표 높이 = 창 몸에 맞춤) */ +.m02-std-pick { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-height: 0; +} + +.m02-std-pick .ui-find__list { + max-height: 10rem; +} + +/* 계산서 보기(704-2) — 계산서 | 원문 띠 */ +.m02-std-sheet { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); + gap: var(--spacing-16); + min-height: 0; +} + +.m02-std-sheet__body { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 10e55f950..82d64ec84 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -115,6 +115,51 @@ export const fetchLibLogics = (): Promise => export const fetchLibLogicSubs = (): Promise => request<{ subs: SubBrief[] }>("/logic-lib/subs").then((d) => d.subs); +/* --- 기준 목록 · 계산서 보기(704 · 서버 M01 엔진이 셈 · `M02_Logic_Standard`) --- */ +/** 기준 목록 한 줄 — 로직 하나를 대표 규격 하나로 펼친 줄 · `바꿈` = 바탕에서 바꾼 입력 */ +export interface StandardRow { + 키: string; + 이름: string; + 규격: string; + 단위: string; + 금액: number | null; + 출처: string; + 공종: string; + 별칭: string; + 구분: string; + 상세구분: string; + 막힘: boolean; + 바꿈: Record; +} +export interface StandardList { + 판: string; + 공종: string[]; + 줄: StandardRow[]; +} +export interface StandardSheet { + 이름: string; + 규격: string; + 단위: string; + 출처: string; + 판: string; + 묶음: { + 비목: string; + 소계: number | null; + 끝수: string; + 줄: { 이름: string; 글: string; 금액: number | null }[]; + }[]; + 계: number | null; +} +let standard: Promise | null = null; // 페이지에 한 벌(서버 저장본 · 판 바뀌면 새로 고침) +export const fetchStandard = (): Promise => + (standard ??= request("/logic-lib/standard").catch((error) => { + standard = null; + throw error; + })); + +export const fetchStandardSheet = (row: StandardRow): Promise => + request("/logic-lib/sheet", "POST", { key: row.키, 바꿈: row.바꿈 }); + /* --- 일위대가 문서(39 · 구조물 열마다 `labor/<열>.json` · 틀 = sub7 39-1a) --- */ /** 손 입력 수량 — `null` 이면 면적산출 알약 연결(칸)을 따름 · 손이 칸보다 앞섬 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index d03b5c56a..a92505ab3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -6,9 +6,13 @@ * 단가연결(251 · B08 거리이정표 · B09 설계내역서) = 종류 고르기(단가산출 · 사전조합 · 덧붙인 줄 종류) + * 덧붙일 줄(구조물 조합 · B09 호표) — `lib.extra` · `lib.kindFilter` · 늦게 받는 덧줄 `lib.more` * (324-4 실무 호표 · `withHopyo`) + * 704-1 단가산출 줄 = 기준 목록 표(품명 · 규격 · 단위 · 금액 · 출처 · 대표 규격 펼침 · 공종 거름 · + * 실무 낱말 = 서버가 별칭에 덧붙임) · 금액 = 서버 M01 엔진(`/logic-lib/standard`) · 오기 전엔 로직 줄만 · + * 줄 누름 = 계산서 보기(704-2 · `Labor_Sheet`) → [담기] = 지금 담기 동작(`done(로직)`) * ========================================================================== */ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; +import { createDataTable } from "@ui/ui_template_data_table"; import { createFindList } from "@ui/ui_template_find_list"; import { aliasNote, matchLogic } from "@ui/ui_template_logic_find"; import { t as L } from "@ui/ui_template_locale"; @@ -19,8 +23,11 @@ import { fetchLibLogics as fetchLogics, fetchLibLogicSubs as fetchLogicSubs, fetchLibrary, + fetchStandard, type LibraryRow, + type StandardRow, } from "./M02_MasterTemplete_Labor_Api"; +import { openStandardSheet } from "./M02_MasterTemplete_Labor_Sheet"; import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; import { ownOwner } from "./M02_MasterTemplete_Layer"; import { tc } from "./M02_MasterTemplete_Labor_Text"; @@ -29,6 +36,7 @@ import { fetchStructureDef } from "./StructOptions/M02_SO_Api"; import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀 import "./M02_MasterTemplete_Labor.css"; import { openModal } from "@ui/ui_template_modal"; +import { createText } from "@ui/ui_template_text"; export { withHopyo } from "./M02_MasterTemplete_Hopyo_Pick"; @@ -90,6 +98,9 @@ export function openLogicPicker( const subs: SubBrief[] = []; let classes: ReturnType = []; // 사전조합 새 분류(149-2) — 서고 줄이 있을 때만 let all: LogicSummary[] = []; + let std: StandardRow[] | null = null; // 기준 목록(서버 셈) — 오기 전 = 로직 줄 그대로 + let listed: StandardRow[] = []; + const work = createSelectField({ options: opts([]), compact: true, label: tc("Std_Kind") }); const sub = createSelectField({ options: opts([]), compact: true, label: tc("Sub") }); const detail = createSelectField({ options: opts([]), compact: true, label: tc("Detail") }); detail.select.disabled = true; @@ -115,19 +126,56 @@ export function openLogicPicker( /* 공용 찾기 목록 — 구분 · 상세구분 2열 · 이름 왼쪽 맞춤 · ↑ ↓ Enter */ const finder = createFindList({ placeholder: tc("Pick_Search"), - filters: [...(kindSel ? [kindSel.root] : []), sub.root, detail.root], + filters: [...(kindSel ? [kindSel.root] : []), work.root, sub.root, detail.root], extra: [layerBar], }); + const table = createDataTable({ + caption: tc("Pick_Title"), + columns: [ + { key: "품명", label: tc("Std_Name"), width: 220 }, + { key: "규격", label: tc("Std_Spec"), width: 220 }, + { key: "단위", label: tc("Std_Unit"), width: 56 }, + { key: "금액", label: tc("Std_Money"), kind: "number", width: 96 }, + { key: "출처", label: tc("Std_Source"), width: 150 }, + ], + rows: [], + onRowClick: (i) => { + const row = listed[i]; + const logic = all.find((x) => x.키 === row?.키); + if (row && logic) + openStandardSheet(row, () => { + close(); + done(logic); + }); + }, + }); + /** 기준 목록이 오기 전 · 못 받으면 — 로직 하나 = 줄 하나(금액 빈칸) */ + const plain = (x: LogicSummary): StandardRow => ({ + 키: x.키, + 이름: x.이름, + 규격: "", + 단위: x.결과단위.replace(/^원\/?/, ""), + 금액: null, + 출처: x.출처 ?? "", + 공종: "", + 별칭: x.별칭 ?? "", + 구분: x.구분, + 상세구분: x.상세구분, + 막힘: x.blocked, + 바꿈: {}, + }); if (pre?.query) finder.search.value = pre.query; const find = (): void => { const q = finder.search.value.trim().toLowerCase(); - const found = all.filter( + const rows = std ?? all.map(plain); + const found = rows.filter( (x) => kindOk("logic") && + (!work.select.value || x.공종 === work.select.value) && (!sub.select.value || x.구분 === sub.select.value) && (!detail.select.value || x.상세구분 === detail.select.value) && - matchLogic(finder.search.value, x).ok, // 이름 · 키 · 별칭 …(공용 · 208-1) + matchLogic(finder.search.value, { ...x, 이름: `${x.이름} ${x.규격}` }).ok, // 공용 · 208-1 ); const libFound = libRows.filter( (x) => @@ -142,27 +190,25 @@ export function openLogicPicker( !sub.select.value && (!q || `${x.tag} ${x.name} ${x.sub}`.toLowerCase().includes(q)), ); - const total = all.length + libRows.length + (lib?.extra?.length ?? 0) + later.length; + const total = rows.length + libRows.length + (lib?.extra?.length ?? 0) + later.length; const shown = found.length + libFound.length + extra.length; finder.hint.textContent = loading ? tc("Loading") : shown ? `${shown} / ${total}` : tc("Pick_None"); - finder.setRows([ - ...found.slice(0, 100).map((x) => ({ - key: x.키, - tag: tc("Kind_Logic"), - name: x.이름, - sub: [`${x.키} · ${x.구분} ${x.상세구분}`.trim(), aliasNote(matchLogic(q, x).alias)] - .filter(Boolean) - .join(" · "), - unit: `${x.결과단위}${x.blocked ? ` · ${tc("Pick_Blocked")}` : ""}`, - onPick: () => { - close(); - done(x); - }, + listed = found; + table.root.hidden = !!lib?.libOnly; + table.setRows( + found.map((x) => ({ + 품명: [x.이름, aliasNote(matchLogic(q, x).alias)].filter(Boolean).join(" · "), + 규격: x.규격, + 단위: `${x.단위}${x.막힘 ? ` · ${tc("Pick_Blocked")}` : ""}`, + 금액: x.금액, + 출처: x.출처, })), + ); + finder.setRows([ ...libFound.map((x) => { const at = scope as Scope; return { @@ -246,6 +292,7 @@ export function openLogicPicker( find(); }); detail.select.addEventListener("change", find); + work.select.addEventListener("change", find); kindSel?.select.addEventListener("change", find); finder.search.addEventListener("input", find); @@ -272,8 +319,15 @@ export function openLogicPicker( title: lib?.title ?? tc("Pick_Title"), closeLabel: tc("Pick_Close"), dialogClass: "m01-logic__pick m01-combo", + width: "960px", // 기준 목록 표 다섯 칸(704-1) backdropClass: "m01-logic__backdrop", - mount: (body) => body.append(finder.root), + mount: (body) => + body.append( + el("div", { + className: "m02-std-pick", + children: [finder.root, createText(tc("Std_Hint")), table.root], + }), + ), })); finder.search.focus(); @@ -288,6 +342,11 @@ export function openLogicPicker( all = l; drawSubs(); find(); + return fetchStandard().then((got) => { + std = got.줄.length ? got.줄 : null; // 빈 줄 = 서버가 아직 셈 — 로직 줄 그대로 + work.setOptions(opts(got.공종), ""); + find(); + }); }) .catch((error) => showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts new file mode 100644 index 000000000..1c22a7caa --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts @@ -0,0 +1,76 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Sheet.ts + * 계산서 보기(704-2) — 기준 목록 줄을 누르면 담기 전에 한 장: 맨 위 「출처 · 판」 · 비목마다 + * 「이름 | 노임 × 품 | 금액」 줄 · 소계 · 계(서버 M01 엔진 값 · `/logic-lib/sheet`) · 곁에 원문 띠 · + * [담기] = 부르는 쪽 담기 동작 그대로(`put`). + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { createCalcFormula, createCalcFormulaRow } from "@ui/ui_template_calc_row"; +import { openModal } from "@ui/ui_template_modal"; +import { createText } from "@ui/ui_template_text"; +import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; +import { fetchStandardSheet, type StandardRow } from "./M02_MasterTemplete_Labor_Api"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; + +const won = (v: number | null, digits = 0): string => + v === null ? "-" : v.toLocaleString("ko-KR", { maximumFractionDigits: digits }); + +const out = (v: number | null, key = false, digits = 0): HTMLElement => + el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: won(v, digits) }); + +export function openStandardSheet(row: StandardRow, put: () => void): void { + const sheet = el("div", { className: "m02-std-sheet__body", attrs: { "data-std-sheet": "" } }); + sheet.append(createText(tc("Loading"))); + const source = createSourceView({ + path: "/m02/logic-lib/source", + pdf: "/m02/logic-lib/source-pdf", + }); + let close = (): void => {}; + const add = createButton({ + label: tc("Std_Put"), + variant: "filled", + onClick: () => { + close(); + put(); + }, + }); + ({ close } = openModal({ + title: `${tc("Std_Sheet")} — ${row.이름}`, + closeLabel: tc("Pick_Close"), + width: "1100px", + wide: true, + actions: [add], + mount: (body) => + body.append(el("div", { className: "m02-std-sheet", children: [sheet, source.root] })), + })); + void fetchStandardSheet(row).then( + (got) => { + const head = [got.이름, got.규격, got.단위].filter(Boolean).join(" · "); + const lines: HTMLElement[] = [ + createText(head, "muted", 14), + createText([got.출처, got.판].filter(Boolean).join(" · "), "hint"), + ]; + for (const g of got.묶음) { + for (const line of g.줄) { + const text = line.글.replace(/ = [^=]*$/, "").replaceAll(" * ", " × "); + lines.push( + createCalcFormulaRow(line.이름, createCalcFormula(text), out(line.금액, false, 1)), + ); + } + lines.push( + createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true)), + ); + } + lines.push( + createCalcFormulaRow(`${tc("Std_Sum")} (원/${got.단위 || "식"})`, null, out(got.계, true)), + ); + sheet.replaceChildren(...lines); + source.show(got.출처); + }, + (error) => { + sheet.replaceChildren(createText(tc("Load_Failed"), "error")); + showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); + }, + ); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index e5b3b3ce6..b379755f3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -57,6 +57,17 @@ const TEXT = { Pick_None: ["찾은 로직 없음", "No logics found"], Pick_Close: ["닫기", "Close"], Pick_Blocked: ["막힘", "Blocked"], + // 기준 목록 · 계산서 보기(704) + Std_Name: ["품명", "Item"], + Std_Spec: ["규격", "Spec"], + Std_Unit: ["단위", "Unit"], + Std_Money: ["금액", "Amount"], + Std_Source: ["출처", "Source"], + Std_Kind: ["공종", "Work type"], + Std_Sheet: ["계산서", "Cost sheet"], + Std_Put: ["담기", "Add"], + Std_Sum: ["계", "Total"], + Std_Hint: ["줄을 누르면 계산서 · [담기]", "Click a row for its sheet · [Add]"], Prev_Labor: ["노무비", "Labor"], Prev_Material: ["재료비", "Material"], Prev_Expense: ["경비", "Expense"],