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` · 담을 로직 읽기 뺌).
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",
+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