Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_5
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("계"),
|
||||
}
|
||||
)
|
||||
@@ -488,3 +488,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;
|
||||
}
|
||||
|
||||
@@ -115,6 +115,51 @@ export const fetchLibLogics = (): Promise<LogicSummary[]> =>
|
||||
export const fetchLibLogicSubs = (): Promise<SubBrief[]> =>
|
||||
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<string, string | number>;
|
||||
}
|
||||
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<StandardList> | null = null; // 페이지에 한 벌(서버 저장본 · 판 바뀌면 새로 고침)
|
||||
export const fetchStandard = (): Promise<StandardList> =>
|
||||
(standard ??= request<StandardList>("/logic-lib/standard").catch((error) => {
|
||||
standard = null;
|
||||
throw error;
|
||||
}));
|
||||
|
||||
export const fetchStandardSheet = (row: StandardRow): Promise<StandardSheet> =>
|
||||
request("/logic-lib/sheet", "POST", { key: row.키, 바꿈: row.바꿈 });
|
||||
|
||||
/* --- 일위대가 문서(39 · 구조물 열마다 `labor/<열>.json` · 틀 = sub7 39-1a) --- */
|
||||
|
||||
/** 손 입력 수량 — `null` 이면 면적산출 알약 연결(칸)을 따름 · 손이 칸보다 앞섬 */
|
||||
|
||||
@@ -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<typeof precomboClasses> = []; // 사전조합 새 분류(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"),
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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