Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_5

This commit is contained in:
2026-10-31 13:27:37 +09:00
8 changed files with 740 additions and 18 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("계"),
}
)
@@ -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",
+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