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:
2026-10-31 13:01:54 +09:00
co-authored by Claude Opus 5.5
parent 935bd7d04e
commit 13e5980732
3 changed files with 504 additions and 0 deletions
+350
View File
@@ -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` · 담을 로직 읽기 뺌). 39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만(`_door` · 담을 로직 읽기 뺌).
85-4 — 원문 패널 읽기(`/logic-lib/source` · M01 길과 같은 몸) · 원문 pdf 통째 85-4 — 원문 패널 읽기(`/logic-lib/source` · M01 길과 같은 몸) · 원문 pdf 통째
(`/logic-lib/source-pdf` · 책 이름만 받음 · 경로 안 받음)도 로그인만. (`/logic-lib/source-pdf` · 책 이름만 받음 · 경로 안 받음)도 로그인만.
704 — 기준 목록(`/logic-lib/standard`) · 계산서(`/logic-lib/sheet`) 로그인만(`M02_Logic_Standard`).
""" """
from __future__ import annotations from __future__ import annotations
import sys
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import Response from fastapi.responses import Response
from pydantic import BaseModel
from common_util.common_util_auth import verify_session from common_util.common_util_auth import verify_session
from common_util.common_util_http_cache import cached_file_response 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 M01_MasterData import M01_MasterData_Store_Make as make
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
from M02_MasterTemplete import M02_Layer_Access as access 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 import M02_MasterTemplete_Combo_Store as combos
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root 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)] 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]: def _where(key: str) -> tuple[str, str | None]:
got = combos.where(key) got = combos.where(key)
@@ -107,6 +116,23 @@ def get_logic_inputs(keys: str = "") -> dict:
return _read(labor_inputs.logic_inputs, keys.split(",")) 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) — 책 이름 → 파일(쪽 자르기 없음) #: 원문 pdf(85-4) — 책 이름 → 파일(쪽 자르기 없음)
SOURCE_PDF = { SOURCE_PDF = {
"건설": make.BODY / "건설공사_표준품셈" / "2026년_건설공사_표준품셈.pdf", "건설": make.BODY / "건설공사_표준품셈" / "2026년_건설공사_표준품셈.pdf",
+128
View File
@@ -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