feat(M02 기준 목록): 대표 규격 펼친 기준 목록 · 계산서 서버 길 — 금액 = M01 엔진 · 파생 저장본 (PLAN 704-1 · 704-2)
- `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 <noreply@anthropic.com>
This commit is contained in:
@@ -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("계"),
|
||||
}
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user