feat(m02): 일위대가 자재목록 로직 자재 기본 줄 · [바꾸기] 고름 · 사용자 추가분만 삭제(103-1)

- 문서 담은로직[].고름 {호표 차례: MT키} 정리 · 검사(재료 줄 · M01 자재품목만)
- run_line 을 엔진 choosing(고름)으로 감쌈 · 로직 자재 줄(차례 · 품목 · 단가 · 금액 · 가격 · 걸림) 내보냄
- 로직 자재 금액은 합 · 알약에 안 더함 · 사용자 자재와 같은 품목은 저장 때 400
- 단가승격 · 사전조합 안 로직도 고름 따름(없으면 지금 값)
- 프로젝트 사본 고른자재 씨앗 = 문서 고름 · 단가 묶음에 고른 품목 줄
- 화면: 자재목록 표 앞에 로직 자재 줄(삭제 없음 · [바꾸기] · 바꾼 줄 [기본]) · 입력 바뀌면 걸린 고름 풂 · 로직 자재와 같은 품목 추가 막음
- 시험 test_m02_labor_logic_mats.py(서버 · Node 짝)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
2026-10-08 16:18:21 +09:00
co-authored by Claude Opus 5.5
parent 331883043e
commit f79078f9c9
15 changed files with 566 additions and 22 deletions
+14 -3
View File
@@ -109,11 +109,22 @@ def seed_values(labor: Any) -> dict[str, Any]:
return out
def seed_picks(labor: Any) -> dict[str, Any]:
"""일위대가 문서 고름 → 프로젝트 고른자재 첫 값 `{로직: {호표 차례: MT키}}`(103-1)."""
doc = labor.get("줄") if isinstance(labor, dict) else None
return {
str(line["로직"]): dict(line["고름"])
for line in (doc or {}).get("담은로직") or []
if isinstance(line, dict) and line.get("로직") and isinstance(line.get("고름"), dict)
}
def build_snapshot(item: dict[str, Any]) -> dict[str, Any]:
"""원본 내용 + 프로젝트 값(입력값 첫 값 · 빈 고른자재) — `{파일 이름: 내용}` · SS 줄이 없으면
LookupError."""
"""원본 내용 + 프로젝트 값(입력값 · 고른자재 첫 값 = 일위대가 문서 것) — `{파일 이름: 내용}` ·
SS 줄이 없으면 LookupError."""
files = build_source(item)
return {**files, VALUES_FILE: seed_values(files.get(LABOR_FILE)), PICKS_FILE: {}}
labor = files.get(LABOR_FILE)
return {**files, VALUES_FILE: seed_values(labor), PICKS_FILE: seed_picks(labor)}
def source_files(files: dict[str, Any]) -> dict[str, Any]:
+5 -1
View File
@@ -150,7 +150,11 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]:
logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])]
logic_keys += refs.frozen_logics((labor or {}).get("참조사본") or {}) # 굳힌 단가승격 로직
materials = [str(m.get("코드")) for m in ((labor or {}).get("줄") or {}).get("자재") or []]
doc = (labor or {}).get("줄") or {}
materials = [str(m.get("코드")) for m in doc.get("자재") or []]
materials += [ # 고른 로직 자재(103-1) 품목 줄도 — 복사본 계산이 그 단가를 씀
str(key) for one in doc.get("담은로직") or [] for key in (one.get("고름") or {}).values()
]
wanted = logic_keys + [code for code in materials if code]
files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {}
return files
+85 -10
View File
@@ -1,7 +1,7 @@
"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장).
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?}
| {참조: {층, 주인, 키}, 별칭, 메모, 수량}],
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬,
담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?, 고름?} | {참조: {층, 주인, 키}, 별칭, 메모, 수량}],
자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠).
수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}`
(손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값).
@@ -14,11 +14,15 @@
분모 = 그 문서 단위 · 배 1 · 알약 키 = `ref_id` · 같은 참조 두 번 금지 · 위 층만.
짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` —
거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
로직 자재(103-1) = 담은 로직 맨 위 층 호표 재료 줄(`logic_mats` · 줄마다 `재료`) ·
고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와
같은 품목 두 번은 [저장] 때 400.
"""
from __future__ import annotations
import json
import re
from decimal import Decimal, InvalidOperation
from typing import Any
@@ -34,6 +38,7 @@ COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비
MATERIAL_HEAD = "자재|"
_MAT_COLS = ("코드", "이름", "규격", "단위")
ALIAS_MAX = 40 # 별칭(44-6) 글자 수 상한 — 화면 칸과 같음
_BRACE = re.compile(r"\{([^{}]+)\}") # 품목 조건의 {입력}
def _bad(text: str) -> store.StoreError:
@@ -109,6 +114,7 @@ def clean(doc: Any) -> dict[str, Any]:
raise _bad(f"{where} · 입력값이 묶음 아님")
memo, alias = one.get("메모"), _alias(one.get("별칭"), where)
rate = _rate(one.get("켜쌓기율"), code, where)
picks = _picks(one.get("고름"), logic, whole, where)
lines.append(
{
"로직": code,
@@ -117,6 +123,7 @@ def clean(doc: Any) -> dict[str, Any]:
"입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")},
"수량": _qty(one.get("수량"), where),
**({"켜쌓기율": rate} if rate is not None else {}),
**({"고름": picks} if picks else {}),
}
)
mats, codes = [], set()
@@ -162,6 +169,28 @@ def _alias(alias: Any, where: str) -> str | None:
return alias or None
def _picks(value: Any, logic: dict, whole: Any, where: str) -> dict[str, str] | None:
"""고름(103-1) = `{호표 차례: MT키}` — 맨 위 층 재료 줄 · M01 자재품목 키만 · 빈 것 = None."""
if not value:
return None
if not isinstance(value, dict):
raise _bad(f"{where} · 고름은 {{호표 차례: MT키}}")
rows = logic.get("호표") or []
out = {}
for at, key in value.items():
n = int(at) if str(at).isdigit() else -1
if not 0 <= n < len(rows) or rows[n].get("종류") != "재료":
raise _bad(f"{where} · 고름 {at}째는 재료 줄 아님")
try:
ok = str(key)[:2] == mm.MARKET and whole.get(str(key)) is not None
except mf.FormulaError:
ok = False
if not ok:
raise _bad(f"{where} · 고름 「{key}」 M01 자재품목에 없음")
out[str(n)] = str(key)
return out
def _rate(value: Any, code: str, where: str) -> int | float | None:
"""켜쌓기율(85-2) — 0 초과 200 이하 · 소수 한 자리 · 100 · 대상 밖 로직은 None(안 둠)."""
if value is None or value == "":
@@ -372,7 +401,8 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
"분모": pill.get("단위"),
"배": pill.get("배") or 1,
}
lines.append({**line, **run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"))})
got = run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름"))
lines.append({**line, **got})
mats = [
{
"코드": one["코드"],
@@ -385,19 +415,58 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
return settle(_json({"줄": lines, "자재": mats}), doc, have)
def run_line(whole: Any, code: str, given: Any, rate: Any = None) -> dict[str, Any]:
"""로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값 · × 켜쌓기율) — `{기준, 까닭}` ·
돈 아닌 로직은 `{결과, 기준: None}`(반영 없음)."""
def run_line(
whole: Any, code: str, given: Any, rate: Any = None, picks: Any = None
) -> dict[str, Any]:
"""로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값 · × 켜쌓기율 · 고른 재료 = 엔진
`choosing`) — `{기준, 줄, 재료, 까닭}` · 돈 아닌 로직은 `{결과, 기준: None}`(반영 없음)."""
try:
logic = whole.logic(code)
auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None}
got = layered(mf.run(whole, code, {**auto, **m01._dec(given or {})}), rate)
with mf.mp.choosing(picks):
got = layered(mf.run(whole, code, {**auto, **m01._dec(given or {})}), rate)
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
return {"기준": None, "까닭": str(e) or type(e).__name__}
if "결과" in got:
return {"결과": got["결과"], "기준": None, "까닭": None}
# 「줄」 = 그 로직 재료 · 노무 · 경비 줄(줄 누름 상세 · 50-4)
return {"기준": {k: got[k] for k in (*COSTS, "계")}, "줄": got["줄"], "까닭": None}
money = {k: got[k] for k in (*COSTS, "계")}
mats = logic_mats(whole, logic, got["줄"], picks)
return {"기준": money, "줄": got["줄"], "재료": mats, "까닭": None}
def logic_mats(whole: Any, logic: dict, lines: list[dict], picks: Any) -> list[dict[str, Any]]:
"""로직 자재(103-1) — 맨 위 층 호표 재료 줄마다 엔진 답 줄(같은 차례) 그대로 · 금액은 로직
재료비 안(합에 안 더함) · `걸림` = 품목 조건에 든 입력 이름(중간값이면 입력 전부 — 그 입력이
바뀌면 화면이 고름을 풂 · 짝 `M02_Labor_LogicMats_Model.ts` `releaseTied`)."""
if isinstance(logic.get("흐름"), dict):
return [] # 흐름 로직 재료 자리는 입력 이름 — 이번엔 안 보임
names = {str(spec.get("이름")) for spec in _specs(logic)}
out = []
for n, item in enumerate(logic.get("호표") or []):
if item.get("종류") != "재료" or n >= len(lines):
continue
line, goods = lines[n], lines[n].get("품목")
try:
row = (whole.get(goods["키"]) if goods else None) or {}
except mf.FormulaError:
row = {}
cond = item.get("요소")
texts = cond.values() if isinstance(cond, dict) else [cond]
tied = {w for t in texts if isinstance(t, str) for w in _BRACE.findall(t)}
out.append(
{
"차례": n,
"이름": item.get("이름") or "",
"단위": item.get("단위") or "",
**{k: line.get(k) for k in ("수량", "단가", "금액", "까닭")},
"품목": goods,
"가격": {slot: row.get(slot) for slot in mm.SLOTS},
"고름": str(n) in (picks or {}),
"걸림": sorted(tied if tied <= names else names) if tied else [],
}
)
return out
def ref_line(ref: dict, got: dict[str, Any]) -> dict[str, Any]:
@@ -421,6 +490,12 @@ def _json(value: Any) -> Any:
def server(doc: Any) -> dict[str, Any]:
"""[저장] · 복사 때 서버 정본 — 정리 + 지금 면적 값으로 `계산값`(브라우저 값 버림)."""
"""[저장] · 복사 때 서버 정본 — 정리 + 지금 면적 값으로 `계산값`(브라우저 값 버림) ·
사용자 자재가 로직 자재 품목과 같으면 400(103-1 · 같은 품목 두 번 셈)."""
got = clean(doc)
return {**got, "계산값": calc(got)}
out = calc(got)
inside = {x["품목"]["키"] for one in out["줄"] for x in one.get("재료") or [] if x.get("품목")}
twice = sorted({m["코드"] for m in got["자재"]} & inside)
if twice:
raise _bad(f"자재 「{', '.join(twice)}」 는 로직 자재에 있음 — 같은 품목 두 번 담음")
return {**got, "계산값": out}
@@ -0,0 +1,53 @@
/* =============================================================================
* M02_Labor_LogicMats_Model.ts
* 로직 자재(103-1) 자료 · 규칙(화면 없음) — 서버 calc `줄[].재료`(짝 `M02_Labor_Doc.logic_mats`) ·
* 입력이 바뀌면 그 입력에 걸린 고름을 풂(`releaseTied`) · 사용자 자재와 같은 품목 막기(`insideKeys`).
* 시험 `resources/tester/test_m02_labor_logic_mats.py`(Node 헬퍼).
* ========================================================================== */
/** 잡힌 품목 — 엔진 답 `품목`(값칸 = 단가가 나온 값 열) */
export interface LogicMatItem {
키: string;
이름?: string | null;
규격?: string | null;
단위?: string | null;
값칸?: string | null;
}
/** 로직 자재 한 줄 — 맨 위 층 호표 재료 줄(금액은 로직 재료비 안 · 합에 안 더함) */
export interface LogicMat {
차례: number;
이름: string;
단위: string;
수량: number | null;
단가: number | null;
금액: number | null;
까닭?: string | null;
품목: LogicMatItem | null;
/** M01 자재품목 값 열 다섯(빈 값 null) */
가격?: Record<string, number | null>;
고름: boolean;
/** 품목 조건에 든 입력 이름 — 그 입력이 바뀌면 고름을 풂 */
걸림: string[];
}
/** 입력값이 `before` 에서 바뀐 이름에 걸린 고름을 지움(다 풀리면 `고름` 칸째) — `row` 를 고쳐 돌려줌 */
export function releaseTied<
T extends { 입력값: Record<string, unknown>; 고름?: Record<string, string> },
>(row: T, before: Record<string, unknown>, mats: readonly LogicMat[]): T {
if (!row.고름) return row;
const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
const keys = new Set([...Object.keys(before), ...Object.keys(row.입력값)]);
const changed = [...keys].filter((k) => text(before[k]) !== text(row.입력값[k]));
for (const mat of mats)
if (mat.걸림.some((n) => changed.includes(n))) delete row.고름[String(mat.차례)];
if (!Object.keys(row.고름).length) delete row.고름;
return row;
}
/** calc 답의 로직 자재 품목 키 전부 — 사용자 자재 추가 막기 */
export function insideKeys(lines: readonly { 재료?: LogicMat[] }[] | undefined): Set<string> {
return new Set(
(lines ?? []).flatMap((l) => (l.재료 ?? []).flatMap((m) => (m.품목 ? [m.품목.키] : []))),
);
}
+6 -2
View File
@@ -370,7 +370,9 @@ class Expand:
line = (doc.get("담은로직") or [{}])[0]
code = str(line.get("로직") or "")
got = labor.run_line(self.whole, code, line.get("입력값"), line.get("켜쌓기율"))
got = labor.run_line(
self.whole, code, line.get("입력값"), line.get("켜쌓기율"), line.get("고름")
)
times = labor._number(doc.get("배")) or Decimal(1)
base = got.get("기준")
why = got.get("까닭") or (None if base else "돈 아닌 로직")
@@ -397,7 +399,9 @@ class Expand:
from M02_MasterTemplete import M02_Labor_Doc as labor
pill = pill or {}
got = labor.run_line(self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"))
got = labor.run_line(
self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름")
)
times = labor._number(pill.get("배")) or Decimal(1)
base = got.get("기준")
return {
@@ -53,6 +53,16 @@
font-size: var(--text-caption);
}
/* 로직 자재 줄(103-1) — 금액은 로직 단가 안(합에 안 더함 · 흐리게) · 바꾼 줄 = 소속 글 강조 */
.m02-labrow__logic-mat .m02-labrow__money {
color: var(--color-text-secondary);
}
.m02-labrow__logic-mat.is-picked-mat .m02-labrow__key {
color: var(--color-accent);
font-weight: 600;
}
.m02-labrow__money,
.m02-labrow__num {
text-align: right;
@@ -131,6 +131,8 @@ export interface LaborRow {
수량: LaborQty;
/** 켜쌓기 적용율 %(85-2 · 대상 로직만 · 없음 = 100) */
켜쌓기율?: number | null;
/** 고른 로직 자재(103-1) — {호표 차례: MT키} · 없으면 엔진 기본 */
고름?: Record<string, string>;
}
export interface LaborMat {
코드: string;
@@ -167,6 +169,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc {
수량: r.수량 ?? null,
...(r.참조 ? { 참조: r.참조 } : {}),
...(r.켜쌓기율 != null ? { 켜쌓기율: r.켜쌓기율 } : {}),
...(r.고름 && Object.keys(r.고름).length ? { 고름: { ...r.고름 } } : {}),
})),
자재: (d.자재 ?? []).map((m) => ({
코드: m.코드,
@@ -7,6 +7,7 @@
* 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`). */
import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { LogicMat } from "./M02_Labor_LogicMats_Model";
export const MONEY_KEYS = ["노무비", "재료비", "경비", "계"] as const;
export type Money = Record<(typeof MONEY_KEYS)[number], number>;
@@ -36,6 +37,8 @@ export interface CalcLine {
결과?: unknown;
/** 그 로직 재료 · 노무 · 경비 줄(M01 시험계산 표와 같은 모양 · 50-4) */
줄?: LogicLine[];
/** 로직 자재(103-1) — 맨 위 층 재료 줄 · 금액은 `기준` 안(여기서 안 더함) */
재료?: LogicMat[];
/** 참조 줄 — 가리킨 문서 이름 · 단가승격의 원 로직 키 · 사전조합 안 줄(한 층 · 50-3 ④ ·
* 참조 줄 = `참조` · 로직 줄(51-11) = `로직`) */
이름?: string | null;
@@ -7,6 +7,8 @@
* [확인] = 화면 캐시만 · 저장은 [저장]
* · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈
* · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨
* · 자재목록 = 로직 자재 줄(103-1 · `Labor_LogicMats` · [바꾸기] → `담은로직[].고름` · 입력이 바뀌면 걸린 고름 풂) +
* 사용자 자재 줄([삭제]) · 로직 자재와 같은 품목은 사용자 자재로 못 담음
* · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문
* · 서고 모드(50-3 · `mode`) = 단가승격 · 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) ·
* 정보 칸 구분 · 상세구분(51-12 · 승격 = 읽기만 + 연결 · 사전조합 = 고름) ·
@@ -58,8 +60,10 @@ import { openLaborInput, type GoArea } from "./M02_MasterTemplete_Labor_Input";
import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal";
import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats";
import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { insideKeys, releaseTied } from "./M02_Labor_LogicMats_Model";
import { keepKnown } from "./M02_Labor_Values_Model";
import { pickMaterial } from "./M02_Material_Pick";
import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick";
@@ -103,6 +107,8 @@ export function mountLaborDetail(
const promote = lib === "promote";
const logics = new Map<string, ItemLogic>();
let calc: LaborCalc | null = null;
/** 마지막으로 받은 calc — 다시 세는 동안 로직 자재 줄을 그대로 둠(줄이 사라졌다 나타나지 않게) */
let last: LaborCalc | null = null;
let fail: string | null = null;
let seq = 0;
let timer = 0;
@@ -202,7 +208,18 @@ export function mountLaborDetail(
drawLogic();
matHost.replaceChildren(
matTable(
{ mats: cur.자재, calc, fail, cells: cells() },
{
mats: cur.자재,
calc,
fail,
cells: cells(),
lead: logicMatRows(
cur.담은로직,
calc ?? last,
!calc && !fail,
edit ? { swap: swapMat, reset: resetMat } : undefined,
),
},
edit ? { open: openMat, remove: removeMat } : { open: openMat },
),
);
@@ -222,7 +239,7 @@ export function mountLaborDetail(
// 저장 안 한 칸 연결이 있으면 그 칸 값으로(없으면 서버가 저장된 면적산출을 읽음)
const got = await calcLabor(opt.layer, opt.column, cur, areaDirty ? given() : undefined);
if (!alive || mine !== seq) return;
calc = got;
calc = last = got;
pills = new Map(
(got.알약 as { 키: string; 이름?: string; 단위?: string }[]).map((p) => [p.키, p]),
);
@@ -343,6 +360,25 @@ export function mountLaborDetail(
cur.자재.splice(i, 1);
changed();
}
/** 로직 자재 [바꾸기](103-1) — 사용자 자재와 같은 품목은 막음 */
function swapMat(i: number, at: number, key: string): void {
const own = cur.자재.find((m) => m.코드 === key);
if (own)
return showToast(
tc("Labor_AlreadyMat").replace("{value}", `${key} ${own.이름 ?? ""}`),
"error",
);
const row = cur.담은로직[i];
row.고름 = { ...row.고름, [String(at)]: key };
changed();
}
function resetMat(i: number, at: number): void {
const row = cur.담은로직[i];
if (!row.고름) return;
delete row.고름[String(at)];
if (!Object.keys(row.고름).length) delete row.고름;
changed();
}
function openRow(i: number): void {
const row: LaborRow = cur.담은로직[i];
@@ -359,7 +395,9 @@ export function mountLaborDetail(
const same =
JSON.stringify(values) === JSON.stringify(row.입력값) &&
next.켜쌓기율 === (row.켜쌓기율 ?? null);
const before = row.입력값;
row.입력값 = values;
releaseTied(row, before, lineOf(row.로직)?.재료 ?? []); // 바뀐 입력에 걸린 고름 = 기본(103-1)
row.메모 = next.메모;
if (next.켜쌓기율 === null) delete row.켜쌓기율;
else row.켜쌓기율 = next.켜쌓기율;
@@ -434,6 +472,11 @@ export function mountLaborDetail(
variant: "filled",
onClick: () =>
pickMaterial((m) => {
if (insideKeys((calc ?? last)?.줄).has(m.코드))
return showToast(
tc("Labor_InLogicMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`),
"error",
);
if (cur.자재.some((x) => x.코드 === m.코드))
return showToast(
tc("Labor_AlreadyMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`),
@@ -0,0 +1,144 @@
/* =============================================================================
* M02_MasterTemplete_Labor_LogicMats.ts
* 자재목록 표의 로직 자재 줄(103-1) — 담은 로직 맨 위 층 재료 줄(서버 calc `줄[].재료`) · 사용자 자재 줄과 같은 칸
* (코드 · 이름 · 규격 · 수량 · 단위 · 가격 다섯 · 단가 / 금액) · 사용자 자재 앞.
* · 삭제 없음 · [바꾸기] = M01 자재품목 표에서 고름(문서 `담은로직[].고름` · 엔진 `choosing`) · 바꾼 줄만 [기본](고름 풂)
* · 금액은 로직 단가 안이라 합에 다시 안 더함(보기만) · 수량은 로직 것(누름 · 알약 없음)
* · 하위 로직 안 재료는 안 보임(엔진이 맨 위 층만 고름)
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
import { openPickTable } from "../M01_MasterData/M01_MasterData_UI_Logic_PickTable";
import { headWord } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Modal";
import type { LaborCalc, LaborRow } from "./M02_MasterTemplete_Labor_Api";
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import type { LogicMat } from "./M02_Labor_LogicMats_Model";
export interface LogicMatHandlers {
/** [바꾸기]로 고른 품목 키 — `row` = 담은 로직 차례 · `at` = 호표 차례 */
swap: (row: number, at: number, key: string) => void;
/** [기본] — 그 줄 고름 풂 */
reset: (row: number, at: number) => void;
}
const num = (v: number | null): string =>
v === null
? "—"
: Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
/** 담은 로직 차례대로 그 줄 로직 자재 — calc 답이 없으면 빈 것(답이 오면 다시 그림) */
export function logicMatRows(
rows: LaborRow[],
calc: LaborCalc | null,
wait: boolean,
on?: LogicMatHandlers,
): HTMLElement[] {
return rows.flatMap((row, r) => {
const line = row.참조 ? undefined : calc?.줄.find((l) => lineId(l) === lineId(row));
return (line?.재료 ?? []).map((mat) => matRow(row, r, mat, wait, on));
});
}
function matRow(
row: LaborRow,
r: number,
mat: LogicMat,
wait: boolean,
on?: LogicMatHandlers,
): HTMLElement {
const item = mat.품목;
const name = item?.이름 ?? mat.이름;
const owner = `${row.별칭 ?? row.로직} · ${mat.이름}`;
const priceCell = (slot: (typeof PRICE_SLOTS)[number]): HTMLElement => {
const base = !!item?.값칸 && item.값칸 === slot;
const v = mat.가격?.[slot];
return el("td", {
className: `m02-labrow__num m02-labrow__price${base ? " is-base-price" : ""}`,
text: wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v),
attrs: { "data-label": slot, "data-price-slot": slot },
});
};
const money = moneyCell(mat.단가, mat.금액, wait, tc("Labor_AppliedSum"));
money.title = tc("LogicMat_Inside");
const tools = on
? [
el("td", {
className: "m02-labrow__move",
children: [
el("div", {
className: "ui-btn-group",
children: [
createButton({
label: tc("LogicMat_Swap"),
variant: "ghost",
onClick: (e) => {
e.stopPropagation();
openPickTable({
kind: "재료",
title: `${tc("LogicMat_PickTitle")} · ${mat.이름}`,
seed: headWord(name),
alt: [headWord(mat.이름)],
isCurrent: (x) => String(x["키"]) === item?.키,
onPick: (x) => on.swap(r, mat.차례, String(x["키"])),
});
},
}),
...(mat.고름
? [
createButton({
label: tc("LogicMat_Reset"),
variant: "ghost",
onClick: (e) => {
e.stopPropagation();
on.reset(r, mat.차례);
},
}),
]
: []),
],
}),
],
}),
]
: [];
return el("tr", {
className: `m02-labrow__row m02-labrow__logic-mat${mat.고름 ? " is-picked-mat" : ""}`,
attrs: { "data-labor-logic-mat": `${row.로직}|${mat.차례}` },
children: [
el("td", { className: "m02-labrow__code", text: item?.키 ?? "—" }),
el("td", {
className: "m02-labrow__name",
children: [
el("strong", { text: name, attrs: { title: name } }),
el("small", {
className: "m02-labrow__key",
text: mat.고름 ? `${owner} · ${tc("LogicMat_Picked")}` : owner,
attrs: { title: owner },
}),
...(mat.까닭 ? [el("small", { className: "m02-labrow__empty", text: mat.까닭 })] : []),
],
}),
el("td", {
className: "m02-labrow__spec",
text: item?.규격 ?? "",
attrs: { title: item?.규격 ?? "" },
}),
el("td", {
className: "m02-labrow__qty",
text: wait ? "…" : num(mat.수량),
attrs: { "data-label": tc("Labor_QtyCol") },
}),
el("td", {
className: "m02-labrow__unit",
text: mat.단위,
attrs: { "data-label": tc("Labor_Unit") },
}),
...PRICE_SLOTS.map(priceCell),
money,
...tools,
],
});
}
@@ -121,7 +121,7 @@ function qtyCell(
}
/** 금액 칸 하나 — 위 기준(작게) · 아래 반영 · `label` = 좁은 칸에서 칸 이름(머리 줄 대신) */
function moneyCell(
export function moneyCell(
base: number | null | undefined,
applied: number | null | undefined,
wait: boolean,
@@ -516,6 +516,8 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
export interface MatTableData {
mats: LaborMat[];
/** 로직 자재 줄(103-1 · `Labor_LogicMats`) — 사용자 자재 앞 · 같은 칸 */
lead?: HTMLElement[];
calc: LaborCalc | null;
fail?: string | null;
cells?: ReadonlyMap<string, string>;
@@ -609,7 +611,8 @@ export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HT
`${tc("Labor_BasePrice")} · ${tc("Labor_AppliedSum")}`,
...(remove ? [tc("Col_Move")] : []),
];
return data.mats.length
? table(heads, trs, [])
const lead = data.lead ?? [];
return data.mats.length || lead.length
? table(heads, [...lead, ...trs], [])
: el("p", { className: "m02-labrow__muted", text: tc("Labor_NoMat") });
}
@@ -110,6 +110,16 @@ const TEXT = {
Labor_Spec: ["규격", "Spec"],
Labor_Already: ["이미 담은 단가산출 로직 — {value}", "Already added — {value}"],
Labor_AlreadyMat: ["이미 담은 자재 — {value}", "Already added — {value}"],
// 로직 자재(103-1)
Labor_InLogicMat: ["로직 자재에 있는 품목 — {value}", "Already a logic material — {value}"],
LogicMat_Swap: ["바꾸기", "Change"],
LogicMat_Reset: ["기본", "Default"],
LogicMat_PickTitle: ["로직 자재 바꾸기", "Change logic material"],
LogicMat_Inside: [
"로직 단가 안 금액 — 합계에 다시 안 더함",
"Inside the logic price — not added again",
],
LogicMat_Picked: ["바꾼 품목", "Changed item"],
In_Title: ["넣을 값 — {value}", "Inputs — {value}"],
In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"],
In_Memo: ["메모", "Memo"],
@@ -20,6 +20,7 @@ import {
type LaborDoc,
} from "./M02_MasterTemplete_Labor_Api";
import { lineId, settle } from "./M02_MasterTemplete_Labor_Calc";
import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats";
import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import "./M02_MasterTemplete_Labor.css";
@@ -92,7 +93,10 @@ export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewH
onRow ? { open: logicOpen } : undefined,
),
el("h4", { className: "m02-labview__title", text: tc("Labor_Mats") }),
matTable({ mats: d.자재, calc, fail, cells }, onRow ? { open: matOpen } : undefined),
matTable(
{ mats: d.자재, calc, fail, cells, lead: logicMatRows(d.담은로직, calc, !calc && !fail) },
onRow ? { open: matOpen } : undefined,
),
);
};
@@ -0,0 +1,15 @@
/* M02 일위대가 로직 자재(`M02_Labor_LogicMats_Model.ts` `releaseTied`) 헬퍼 —
* 그 자리에서 트랜스파일해 Node 로 돌림 · stdin = {row, before, mats} · stdout = 고친 row JSON. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const file = path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_Labor_LogicMats_Model.ts");
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
const input = JSON.parse(fs.readFileSync(0, "utf8"));
process.stdout.write(JSON.stringify(box.exports.releaseTied(input.row, input.before, input.mats)));
@@ -0,0 +1,162 @@
"""일위대가 로직 자재(PLAN 103-1) — 담은 로직 맨 위 층 재료 줄 · 고름 · 합 이중 계산 없음.
- 로직 줄 계산값 `재료` = 호표 재료 줄(차례 · 품목 · 단가 · 금액 · 걸림) · 재료 없는 로직은 빈 목록
- 고름 `{호표 차례: MT키}` → 그 줄 품목 · 단가 · 로직 재료비가 바뀜(엔진 `choosing`) ·
고름 없으면 지금 값
- 합 · 알약에 로직 자재 금액을 다시 안 더함 · 저장 뒤 고름 남음 · 틀린 고름 400
- 사용자 자재와 같은 품목 = [저장] 400 · 입력에 걸린 고름은 입력이 바뀌면 풂(화면 짝 · Node)
- 프로젝트 사본 씨앗 = 문서 고름
"""
import json
import os
import subprocess
import pytest
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Item_Snapshot as snap
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from resources.tester.test_m02_structure import write_sample_table
HERE = os.path.dirname(os.path.abspath(__file__))
NET = "GC000337" # 낙석방지망 — 재료 줄 3 · 4 · 5(철망 MT006697 · 결속선 MT000194 · 에폭시)
BORING = "GC000431" # 보링 천공 — 재료 줄 4~8 조건에 {규격} · 9 = MT000008(입력 안 걸림)
BARE = "GC000994" # 재료 줄 없는 돈 로직
WIRE = "MT000194"
NET_ITEM = "MT006697"
def _whole():
return m01.loaded()[1]
def _doc(**extra):
return {"열": "rv", "담은로직": [], "자재": [], **extra}
def test_로직_자재는_맨_위_층_재료_줄_재료_없는_로직은_빈_목록():
got = labor.run_line(_whole(), NET, {})
assert [(m["차례"], m["품목"]["키"]) for m in got["재료"]] == [
(3, NET_ITEM),
(4, WIRE),
(5, "MT007705"),
]
assert all(m["고름"] is False and m["걸림"] == [] for m in got["재료"])
assert labor.run_line(_whole(), BARE, {})["재료"] == []
def test_조건에_든_입력은_걸림():
mats = {m["차례"]: m for m in labor.run_line(_whole(), BORING, {})["재료"]}
assert all(mats[n]["걸림"] == ["규격"] for n in range(4, 9))
assert mats[9]["걸림"] == []
def test_고름이_단가와_로직_재료비를_바꿈_고름_없으면_지금_값():
plain = labor.run_line(_whole(), NET, {})
again = labor.run_line(_whole(), NET, {}, None, None)
assert again["기준"] == plain["기준"] # 고름 없음 = 지금 길 그대로(회귀)
picked = labor.run_line(_whole(), NET, {}, None, {"3": WIRE})
row = picked["재료"][0]
assert (row["품목"]["키"], row["고름"]) == (WIRE, True)
assert row["단가"] == picked["재료"][1]["단가"] != plain["재료"][0]["단가"]
gap = (plain["재료"][0]["단가"] - row["단가"]) * row["수량"]
assert plain["기준"]["재료비"] - picked["기준"]["재료비"] == pytest.approx(gap)
assert picked["기준"]["노무비"] == plain["기준"]["노무비"]
def test_합_알약에_로직_자재_금액_다시_안_더함():
doc = labor.clean(_doc(담은로직=[{"로직": NET, "수량": {"손": 2}}]))
got = labor.calc(doc, {})
line = got["줄"][0]
assert line["재료"] and got["자재"] == []
assert got["기준합"]["계"] == pytest.approx(line["기준"]["계"])
assert got["반영합"]["계"] == pytest.approx(line["기준"]["계"] * 2)
assert not [p for p in labor.pills(doc) if p["키"].startswith(labor.MATERIAL_HEAD)]
def test_고름_정리_틀리면_400():
got = labor.clean(_doc(담은로직=[{"로직": NET, "고름": {3: WIRE}}, {"로직": BARE, "고름": {}}]))
assert got["담은로직"][0]["고름"] == {"3": WIRE} and "고름" not in got["담은로직"][1]
for bad in ({"0": WIRE}, {"3": "MT999999"}, {"3": "LB000001"}, {"가": WIRE}, ["3"]):
with pytest.raises(store.StoreError) as e:
labor.clean(_doc(담은로직=[{"로직": NET, "고름": bad}]))
assert e.value.status == 400
def test_사용자_자재가_로직_자재와_같으면_저장_400():
with pytest.raises(store.StoreError) as e:
labor.server(_doc(담은로직=[{"로직": NET}], 자재=[{"코드": WIRE}]))
assert e.value.status == 400 and WIRE in str(e.value.detail)
# 고른 품목도 같음 — 결속선 줄을 다른 품목으로 바꾸면 사용자 자재로 담을 수 있음
with pytest.raises(store.StoreError):
labor.server(
_doc(담은로직=[{"로직": NET, "고름": {"3": "MT000008"}}], 자재=[{"코드": "MT000008"}])
)
ok = labor.server(
_doc(담은로직=[{"로직": NET, "고름": {"4": NET_ITEM}}], 자재=[{"코드": WIRE}])
)
assert ok["자재"][0]["코드"] == WIRE
@pytest.fixture()
def system(tmp_path, monkeypatch):
write_sample_table(tmp_path)
monkeypatch.setattr(store, "FOLDER", tmp_path)
store.create_structure("rv")
return tmp_path
def test_저장_뒤_고름_남고_계산값에_반영(system):
have = store.read("labor", "rv")
sent = {**have["문서"], "담은로직": [{"로직": NET, "고름": {"3": WIRE}, "수량": None}]}
store.write("labor", "rv", have["판"], sent)
doc = store.read("labor", "rv")["문서"]
assert doc["담은로직"][0]["고름"] == {"3": WIRE}
assert doc["계산값"]["줄"][0]["재료"][0]["품목"]["키"] == WIRE
def test_단가승격도_고름을_따름_없으면_지금_값():
expand = refs.Expand(_whole())
plain = expand._promote({"담은로직": [{"로직": NET}], "배": 1})
picked = expand._promote({"담은로직": [{"로직": NET, "고름": {"3": WIRE}}], "배": 1})
assert plain["기준"] == labor.run_line(_whole(), NET, {})["기준"]
assert picked["기준"]["재료비"] < plain["기준"]["재료비"]
def test_사본_씨앗은_문서_고름():
labor_file = {
"줄": {
"담은로직": [
{"로직": NET, "고름": {"3": WIRE}},
{"로직": BARE},
{"참조": {"층": "system", "주인": None, "키": "SP-000001"}, "로직": ""},
]
}
}
assert snap.seed_picks(labor_file) == {NET: {"3": WIRE}}
assert snap.seed_picks(None) == {}
def _release(row: dict, before: dict, mats: list) -> dict:
proc = subprocess.run(
["node", os.path.join(HERE, "helper_m02_labor_logic_mats.cjs")],
input=json.dumps({"row": row, "before": before, "mats": mats}, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(proc.stdout)
def test_입력이_바뀌면_걸린_고름만_풂_화면_짝():
mats = labor._json(labor.run_line(_whole(), BORING, {})["재료"])
picks = {"4": "MT007895", "9": WIRE}
row = {"로직": BORING, "입력값": {"규격": "NX"}, "고름": picks}
assert _release(row, {"규격": "NX"}, mats)["고름"] == picks # 그대로
assert _release(row, {"규격": "BX"}, mats)["고름"] == {"9": WIRE} # 규격에 걸린 줄만
row = {**row, "고름": {"4": "MT007895"}}
assert "고름" not in _release(row, {}, mats) # 다 풀리면 칸째 지움