merge(M02): origin/dev 받음 — 일위대가 조합 길 충돌 풂(39장)

- Labor_Api.ts · Router_Combos.py = 나무 길(sub5) · 미리 보기 길(sub2) 둘 다 지운 꼴 · 시스템 관리자 문 _door(sub4) 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
This commit is contained in:
2026-10-04 17:52:17 +09:00
co-authored by Claude Opus 5.5
21 changed files with 1947 additions and 426 deletions
+22
View File
@@ -125,6 +125,28 @@ def clean(doc: Any) -> dict[str, Any]:
return out
def from_combo(key: str) -> dict[str, Any]:
"""옛 일위대가 조합(마스터 UA = 실무문서 · 회사 UC · 개인 UP) → 일위대가 문서 몸 —
담은로직 · 단위 복사 · 입력값 {} · 수량 null · 자재 [] · `본뜬` = 그 조합(층 · 주인 · 키).
없는 조합은 404."""
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
row = combos.find(key)
if row is None:
raise store.StoreError(404, f"없는 일위대가 조합 「{key}」")
layer, owner = combos.where(key) or (None, None)
return {
"단위": row.get("단위"),
"본뜬": {"층": layer, "주인": owner, "키": key},
"담은로직": [
{"로직": one.get("로직"), "메모": one.get("메모"), "입력값": {}, "수량": None}
for one in row.get("담은로직") or []
if isinstance(one, dict)
],
"자재": [],
}
# ── 알약 · 면적 값 ────────────────────────────────────────────────────
def material_key(code: str) -> str:
return f"{MATERIAL_HEAD}{code}"
+90
View File
@@ -0,0 +1,90 @@
"""옛 일위대가 조합 → 구조물 열마다 일위대가 문서 — 한 번 돌리는 도구(PLAN 39-1b · 설계_정함 1장).
층마다 구조물 열마다 `labor/<열id>.json`:
- 열 `일위대가` 키가 있으면 그 조합의 담은로직 · 단위 복사(`본뜬.키` = 옛 키 · 입력값 {} ·
수량 null · 자재 []) — 여러 열이 쓰던 조합도 열마다 따로 복사
- 키 없는 열 = 빈 일위대가 · 번호 SU 는 저장소가 줌(도번과 같은 뒷자리)
- 몇 번 돌려도 같음 — 이미 그 키를 본뜬 문서 · 사람이 채운 문서(담은로직 · 자재가 있음)는 그대로
옛 조합 파일 · 집계표 열 `일위대가` 칸은 손대지 않음(실무문서 · 은퇴는 39-1b B9).
./venv/Scripts/python.exe M02_MasterTemplete/M02_Labor_Migrate.py # 시스템 층
./venv/Scripts/python.exe M02_MasterTemplete/M02_Labor_Migrate.py --storage # 회사 · 개인 층
[--dry-run]
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from M02_MasterTemplete import M02_Labor_Doc as labor # noqa: E402
from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402
from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402
def _filled(doc: dict) -> bool:
return bool(doc.get("담은로직") or doc.get("자재"))
def migrate(folder: Path, dry: bool = False) -> dict[str, list[str]]:
"""`folder` 층 하나 — `{만듦, 옮김, 그대로, 못함}`(열 id · 못함은 까닭 붙임)."""
out: dict[str, list[str]] = {"만듦": [], "옮김": [], "그대로": [], "못함": []}
with store.at(folder):
for col in store._structure_columns():
cid, key = col["id"], str(col.get("일위대가") or "")
path = store._path("labor", cid)
have = json.loads(path.read_bytes()) if path.is_file() else None
done = have is not None and (have.get("본뜬") or {}).get("키") == key
if have is not None and (_filled(have) or not key or done):
out["그대로"].append(cid)
continue
if not key:
out["만듦"].append(cid)
if not dry:
store._ensure("labor", cid)
continue
try:
body = labor.from_combo(key)
except store.StoreError as e:
out["못함"].append(f"{cid} · {e.detail}")
continue
out["옮김"].append(f"{cid} ← {key}")
if dry:
continue
store._ensure("labor", cid) # 번호(SU · 같은 뒷자리)
got = store.read("labor", cid)
store.write("labor", cid, got["판"], {**got["문서"], **body})
return out
def layer_folders(storage: bool) -> list[Path]:
"""시스템 층 하나 · 또는 storage 회사 · 개인 층(구조물집계표가 있는 것 · 프로젝트 빼고)."""
if not storage:
return [layers.system_dir()]
root = layers.storage_root()
table = Path("table") / f"{store.STRUCTURE_TABLE}.json"
found = [
*root.glob(f"*/{layers.TEMPLATES_DIRNAME}"),
*root.glob(f"*/*/{layers.TEMPLATES_DIRNAME}"),
]
return sorted(p for p in found if (p / table).is_file())
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--storage", action="store_true", help="회사 · 개인 층(storage)")
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
report: dict[str, Any] = {}
for folder in layer_folders(args.storage):
report[str(folder)] = migrate(folder, args.dry_run)
print(json.dumps(report, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -184,12 +184,6 @@ def pills(key: str) -> dict[str, Any]:
return combo_store.combo_pills(_find(_load()[0], key))
def preview(combo: dict | str, inputs: dict | None = None) -> dict[str, Any]:
"""저장 안 함 — `combo` 는 조합 줄 통째 또는 지금 층 키."""
row = _find(_load()[0], combo) if isinstance(combo, str) else combo
return _m01(combo_store.combo_preview, row, inputs)
# ── 쓰기(회사 · 개인) ──────────────────────────────────────────────────
def _clean(row: Any, key: str, was: dict | None = None) -> dict[str, Any]:
"""서버가 잡는 칸 — 키 · 열 한 벌 · 소유 · 본뜬키는 있는 다른 조합(어느 층이든)."""
@@ -21,3 +21,105 @@
.m02-labor__list tbody tr:hover {
background: var(--color-mist-violet);
}
/* 일위대가 상세(39-3) — 담은 로직 · 자재목록 표 · 금액 칸 두 줄(위 기준 작게 · 아래 반영) */
.m02-labrow {
width: 100%;
}
.m02-labrow__row.is-open {
cursor: pointer;
}
.m02-labrow__row.is-open:hover {
background: var(--color-mist-violet);
}
.m02-labrow__name {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 12rem;
}
.m02-labrow__key,
.m02-labrow__note,
.m02-labrow__muted {
color: var(--color-text-secondary);
font-size: var(--text-caption);
overflow-wrap: anywhere;
}
.m02-labrow__empty {
color: var(--color-danger);
font-size: var(--text-caption);
}
.m02-labrow__money,
.m02-labrow__num {
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.m02-labrow__money {
vertical-align: top;
}
.m02-labrow__base {
display: block;
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.m02-labrow__applied {
display: block;
}
.m02-labrow__sum td {
font-weight: 600;
border-top: 2px solid var(--color-border);
}
.m02-labrow__move {
white-space: nowrap;
}
/* 입력 모달 */
.m02-labin {
width: min(40rem, calc(100vw - 32px));
}
.m02-labin__qty {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding-top: var(--spacing-8);
border-top: 1px solid var(--color-border);
}
.m02-labin__qty-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
font-size: var(--text-body-sm);
}
.m02-labin__muted {
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.m02-labin__lost {
margin: 0;
color: var(--color-danger);
font-size: var(--text-caption);
}
.m02-labin__foot {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
margin-top: var(--spacing-12);
}
@@ -12,7 +12,12 @@ import {
type LogicSummary,
type SubBrief,
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
import {
getActiveLayer,
getActiveOwner,
type ItemLogic,
type Layer,
} from "./M02_MasterTemplete_Api_Fetch";
/** 담은 로직 한 줄 — 로직 키와 메모뿐(수량·계산은 마스터에 두지 않음 · 계약 9장) */
export interface ComboLogic {
@@ -56,28 +61,6 @@ export interface ComboOne {
combo: Combo;
줄: ComboLine[];
}
export interface PreviewLine {
차례: number;
로직: string;
이름: string;
ok: boolean;
까닭?: string;
갈래?: string;
결과?: number;
노무비?: number;
재료비?: number;
경비?: number;
계?: number;
}
export interface PreviewAnswer {
줄: PreviewLine[];
노무비: number;
재료비: number;
경비: number;
계: number;
멈춤: string[];
}
/** 조합 층 — 층 + 주인(회사 `7` · 개인 `7/42` · 없으면 내 것) */
export interface LaborScope {
layer: Exclude<Layer, "project">;
@@ -188,13 +171,6 @@ export const deleteCombo = (
scope: LaborScope = SYSTEM_SCOPE,
): Promise<unknown> => request(at(scope, `/${enc(key)}`, { 판: version }), "DELETE");
/** 보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 */
export const previewCombo = (
combo: Combo | string,
inputs: Record<string, Record<string, unknown>>,
scope: LaborScope = SYSTEM_SCOPE,
): Promise<PreviewAnswer> => request(at(scope, "/preview"), "POST", { combo, inputs });
/* --- 담을 로직(읽기만 · 로그인) — M01 로직 길과 같은 모양 --- */
export const fetchLibLogics = (): Promise<LogicSummary[]> =>
request<{ logics: LogicSummary[] }>("/logic-lib/logics").then((d) => d.logics);
@@ -209,3 +185,92 @@ export const fetchLibAuto = (key: string): Promise<Record<string, unknown>> =>
request<{ 값?: Record<string, unknown> }>(`/logic-lib/auto?key=${enc(key)}`)
.then((d) => d.값 ?? {})
.catch(() => ({}));
/* --- 일위대가 문서(39 · 구조물 열마다 `labor/<열>.json` · 틀 = sub7 39-1a) --- */
/** 손 입력 수량 — `null` 이면 면적산출 알약 연결(칸)을 따름 · 손이 칸보다 앞섬 */
export type LaborQty = { 손: number } | null;
/** 담은 로직 한 줄 — 입력값 = 그 로직 입력 이름만(빈 값 = 자동값) */
export interface LaborRow {
로직: string;
메모: string | null;
입력값: Record<string, string | number>;
수량: LaborQty;
}
export interface LaborMat {
코드: string;
이름: string | null;
규격: string | null;
단위: string | null;
수량: LaborQty;
}
export interface LaborDoc {
종류: "일위대가";
판?: number;
열: string;
일위대가번?: string;
단위: string | null;
본뜬?: { 층: string; 주인: string | null; 키: string } | null;
담은로직: LaborRow[];
자재: LaborMat[];
계산값?: unknown;
}
export interface LaborCost {
노무비: number;
재료비: number;
경비: number;
계: number;
}
/** calc 줄 — 기준 = 결과단위 그대로 · 반영 = 기준 × 수량 ÷ 배(수량 없거나 돈 아닌 로직은 null) */
export interface LaborCalcLine {
로직: string;
분모: string | null;
배: number | null;
수량: number | null;
수량출처: string | null;
기준: LaborCost | null;
반영: LaborCost | null;
결과?: number | null;
까닭: string | null;
}
export interface LaborCalcMat {
코드: string;
기준단가: number | null;
수량: number | null;
수량출처: string | null;
반영: number | null;
까닭: string | null;
}
export interface LaborCalc {
줄: LaborCalcLine[];
자재: LaborCalcMat[];
기준합: Partial<LaborCost>;
반영합: Partial<LaborCost>;
}
/** 지금 층이면 `owner` 를 붙인 층 길(`/layers/{층}/labor/{열}…`) */
const laborAt = (layer: Layer, column: string, tail: string): string => {
const owner = layer === getActiveLayer() ? getActiveOwner() : null;
const q = owner && layer !== "system" && layer !== "project" ? `?owner=${enc(owner)}` : "";
return `/layers/${layer}/labor/${enc(column)}${tail}${q}`;
};
/** 계산(서버 · 저장 안 함) — `doc` = 화면 캐시(없으면 저장본) · `수량` = 칸 값 바꿔 끼우기 */
export const calcLabor = (
layer: Layer,
column: string,
doc: LaborDoc | null,
수량?: Record<string, number>,
): Promise<LaborCalc> =>
request(laborAt(layer, column, "/calc"), "POST", {
...(doc ? { doc } : {}),
...(수량 ? { 수량 } : {}),
});
/** 담은 로직 입력 목록 + 자동값(입력 모달 · `M02_Labor_Inputs`) */
export const fetchLogicInputs = (
keys: string[],
): Promise<{ 로직: ItemLogic[]; 빠진로직: string[] }> =>
keys.length
? request(`/logic-lib/inputs?keys=${enc(keys.join(","))}`)
: Promise.resolve({ 로직: [], 빠진로직: [] });
@@ -0,0 +1,323 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Detail.ts
* 일위대가 상세(39-3) — 구조물 열마다 하나(`labor/<열>.json`) · UI_Main 종류 `labor` 가 이 부품을 붙임
* (양식 길 읽기 · [저장] · [다시 읽기]는 UI_Main). 차례 = 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램.
* · 담은 로직 줄 누름 = 입력 모달(`Labor_Input`) · [확인] = 화면 캐시만 · 저장은 [저장]
* · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈
* · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨
* ========================================================================== */
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { ROUTES } from "@config/config_frontend";
import { navigateTo } from "../A00_Common/router";
import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side";
import type { ItemLogic, Layer } from "./M02_MasterTemplete_Api_Fetch";
import {
calcLabor,
fetchLogicInputs,
type LaborCalc,
type LaborDoc,
type LaborRow,
} from "./M02_MasterTemplete_Labor_Api";
import {
buildLaborDiagram,
diagramTitle,
renderComboDiagram,
} from "./M02_MasterTemplete_Labor_Diagram";
import { openLaborInput, openQtyModal } from "./M02_MasterTemplete_Labor_Input";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import { logicTable, matTable, qtyText } from "./M02_MasterTemplete_Labor_Rows";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { keepKnown } from "./M02_Labor_Values_Model";
import { pickMaterial } from "./M02_Material_Pick";
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css";
import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css";
import "@ui/ui_template_general_layout.css";
import "./M02_MasterTemplete_Labor.css";
/** 받은 문서를 화면 모양으로 — 빈 칸 · 옛 모양도 받음(빈 일위대가) */
export function normalizeLabor(doc: unknown, column: string): LaborDoc {
const d = (doc && typeof doc === "object" ? doc : {}) as Partial<LaborDoc>;
return {
...d,
종류: "일위대가",
열: d.열 ?? column,
단위: d.단위 ?? null,
담은로직: (d.담은로직 ?? []).map((r) => ({
로직: r.로직,
메모: r.메모 ?? null,
입력값: r.입력값 ?? {},
수량: r.수량 ?? null,
})),
자재: (d.자재 ?? []).map((m) => ({
코드: m.코드,
이름: m.이름 ?? null,
규격: m.규격 ?? null,
단위: m.단위 ?? null,
수량: m.수량 ?? null,
})),
};
}
export interface LaborDetailOptions {
layer: Layer;
column: string;
readOnly: boolean;
/** 저장 안 한 고침이 생기거나 사라짐 */
onChanged: (dirty: boolean) => void;
/** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(없으면 단추 없음) */
goArea: (() => void) | null;
}
export interface LaborDetailHandle {
getDoc: () => LaborDoc;
destroy: () => void;
}
export function mountLaborDetail(
host: HTMLElement,
doc: unknown,
opt: LaborDetailOptions,
): LaborDetailHandle {
const cur = normalizeLabor(doc, opt.column);
const saved = JSON.stringify(cur);
const edit = !opt.readOnly;
const logics = new Map<string, ItemLogic>();
let calc: LaborCalc | null = null;
let fail: string | null = null;
let seq = 0;
let timer = 0;
let alive = true;
const logicHost = el("div");
const matHost = el("div");
const dgHost = el("div");
const dgBox = el("details", {
className: "m01lab__box m01-logic__section m02-labor__dg",
attrs: { open: "" },
children: [el("summary", { text: diagramTitle() }), dgHost],
});
const lineOf = (key: string) => calc?.줄.find((l) => l.로직 === key);
const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code);
const drawDg = (): void => {
if (!dgBox.open) return;
const data = buildLaborDiagram({
rows: cur.담은로직,
logics: [...logics.values()],
calc,
mats: cur.자재.length,
});
renderComboDiagram(dgHost, data, {
focusInput: (key) => {
const i = cur.담은로직.findIndex((r) => r.로직 === key);
if (i >= 0) openRow(i);
},
// 로직 카드 → 단가산출 로직 화면(떠날 때 물음은 UI_Main 의 leave guard)
openLogic: (key) => {
setPendingLogicKey(key);
navigateTo(ROUTES.M01_MASTER_DATA);
},
});
};
dgBox.addEventListener("toggle", drawDg);
const draw = (): void => {
logicHost.replaceChildren(
logicTable(
{ rows: cur.담은로직, logics, calc, fail },
// 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠)
edit ? { open: openRow, move, remove: removeRow } : { open: openRow },
),
);
matHost.replaceChildren(
matTable(
{ mats: cur.자재, calc, fail },
edit ? { open: openMat, remove: removeMat } : { open: openMat },
),
);
drawDg();
};
/** 서버 다시 셈 — 늦게 온 답은 버림(최신 응답만) */
const recalc = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(async () => {
const mine = ++seq;
calc = null;
fail = null;
draw();
try {
const got = await calcLabor(opt.layer, opt.column, cur);
if (!alive || mine !== seq) return;
calc = got;
} catch (error) {
if (!alive || mine !== seq) return;
fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail");
}
draw();
}, 150);
};
const changed = (): void => {
opt.onChanged(JSON.stringify(cur) !== saved);
recalc();
};
/** 아직 안 받은 로직 입력 목록만 받음 */
const loadLogics = async (): Promise<void> => {
const want = cur.담은로직.map((r) => r.로직).filter((k) => !logics.has(k));
if (!want.length) return;
try {
const got = await fetchLogicInputs(want);
for (const l of got.로직) logics.set(l.로직, l);
} catch (error) {
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
}
};
function move(i: number, d: number): void {
const j = i + d;
if (j < 0 || j >= cur.담은로직.length) return;
[cur.담은로직[i], cur.담은로직[j]] = [cur.담은로직[j], cur.담은로직[i]];
changed();
}
function removeRow(i: number): void {
cur.담은로직.splice(i, 1);
changed();
}
function removeMat(i: number): void {
cur.자재.splice(i, 1);
changed();
}
function openRow(i: number): void {
const row: LaborRow = cur.담은로직[i];
const logic = logics.get(row.로직) ?? null;
const line = lineOf(row.로직);
openLaborInput({
title: `${row.로직} ${logic?.이름 ?? ""}`.trim(),
logic,
values: row.입력값,
memo: row.메모,
seat: {
qty: row.수량,
linked: line && line.수량출처 !== "손" ? qtyText(line, line.분모 ?? "") || null : null,
unit: line?.분모 ?? "",
},
readOnly: !edit,
goArea: opt.goArea,
done: (next) => {
row.입력값 = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값;
row.메모 = next.메모;
row.수량 = next.수량;
changed();
},
});
}
function openMat(i: number): void {
const mat = cur.자재[i];
const line = matOf(mat.코드);
openQtyModal(
`${mat.코드} ${mat.이름 ?? ""}`.trim(),
{
qty: mat.수량,
linked:
line && line.수량출처 && line.수량출처 !== "손" && line.수량 !== null
? `${line.수량출처} = ${line.수량}`
: null,
unit: mat.단위 ?? "",
},
!edit,
opt.goArea,
(qty) => {
mat.수량 = qty;
changed();
},
);
}
const addLogic = createButton({
label: tc("AddLogic"),
variant: "filled",
size: "sm",
onClick: () =>
openLogicPicker((x) => {
if (cur.담은로직.some((r) => r.로직 === x.키))
return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error");
cur.담은로직.push({ 로직: x.키, 메모: null, 입력값: {}, 수량: null });
void loadLogics().then(changed);
}),
});
const addMat = createButton({
label: tc("Labor_AddMat"),
variant: "filled",
size: "sm",
onClick: () =>
pickMaterial((m) => {
if (cur.자재.some((x) => x.코드 === m.코드))
return showToast(
tc("Labor_AlreadyMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`),
"error",
);
cur.자재.push({ 코드: m.코드, 이름: m.이름, 규격: m.규격, 단위: m.단위, 수량: null });
changed();
}),
});
const unit = createInputField({ type: "text", label: tc("Labor_Unit") });
unit.input.value = cur.단위 ?? "";
unit.input.readOnly = !edit;
unit.input.maxLength = 20;
unit.input.setAttribute("data-labor-field", "단위");
unit.input.addEventListener("input", () => {
cur.단위 = unit.input.value.trim() || null;
opt.onChanged(JSON.stringify(cur) !== saved);
});
const origin = createInputField({ type: "text", label: tc("Labor_Origin") });
origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self");
origin.input.readOnly = true;
const section = (title: string, button: HTMLElement | null, body: HTMLElement): HTMLElement =>
el("section", {
className: "m01lab__box m01-logic__section",
children: [
el("div", {
className: "m01-logic__section-head",
children: [el("h3", { text: title }), ...(edit && button ? [button] : [])],
}),
body,
],
});
host.replaceChildren(
el("div", {
className: "m01-logic__editor m01-combo m02-labor__edit",
attrs: { "data-labor": opt.column },
children: [
el("section", {
className: "m01lab__box m01-logic__section",
children: [el("div", { className: "m01lab__info", children: [unit.root, origin.root] })],
}),
section(tc("Logics_Title"), addLogic, logicHost),
section(tc("Labor_Mats"), addMat, matHost),
dgBox,
],
}),
);
draw();
void loadLogics().then(() => {
if (alive) recalc();
});
return {
getDoc: () => cur,
destroy: () => {
alive = false;
window.clearTimeout(timer);
host.replaceChildren();
},
};
}
@@ -309,3 +309,13 @@
cursor: pointer;
font-weight: var(--font-weight-bold);
}
/* 39-3 — 자동값 알약(넣은 값 없음 · 기울임) · 자재목록 카드(누름 없음) */
.m02-labordg__pill.is-auto .m02-labordg__pill-value {
font-style: italic;
color: var(--color-text-secondary);
}
.m02-labordg__card.is-material {
cursor: default;
}
@@ -1,8 +1,9 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Diagram.ts
* 일위대가 조합 다이어그램(PLAN 37) — 왼쪽 로직별 넣을 값 묶음 → 가운데 로직 카드 → 오른쪽 비용 구분 칸(노무비 · 재료비 · 경비 · 값)
* 조합은 계산하지 않음 — 미리 보기 결과를 로직마다 · 비용 구분마다 보여 주기만(고치기 없음 · 흐름도와 다른 모양 · 색)
* 값은 미리 보기와 한 벌(`buildDiagramData` 로 미리 보기가 넘김) · 글 [한, 영] 짝은 아래 TEXT
* 다이어그램은 계산하지 않음 — 일위대가 calc 답을 로직마다 · 비용 구분마다 보여 주기만(고치기 없음 · 흐름도와 다른 모양 · 색)
* 값 = 저장된 줄 입력값 + calc 답(수량 있으면 반영 · 없으면 기준 · 카드에 표기 · `buildLaborDiagram` · 39-3) ·
* 자재목록은 카드 하나로 재료비 칸에 이음 · 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
@@ -29,7 +30,9 @@ const TEXT = {
"누르면 이 로직을 단가산출 로직 화면에서 엶",
"Open this logic in the unit-price logic screen",
],
Goto: ["미리 보기 입력 칸으로 감", "Go to this input in the preview"],
Goto: ["누르면 이 로직 입력 모달을 엶", "Open this logic's inputs"],
Mats: ["자재목록", "Materials"],
Auto: ["자동값", "auto"],
Same: ["같은 이름의 넣을 값", "Same input name"],
Won: ["원", "KRW"],
Title: ["다이어그램", "Diagram"],
@@ -46,8 +49,10 @@ type Cost = (typeof COST_BINS)[number];
export interface DiagramInput {
name: string;
unit: string;
/** 미리 보기에 넣은 값 — 빈 글 = 값 없음 */
/** 넣은 값 — 빈 글 = 값 없음 */
value: string;
/** 넣은 값이 없어 자동값을 보임 */
auto?: boolean;
}
/** 로직 하나의 미리 보기 결과 — 돈이면 비용 구분별 · 값이면 값 하나 · 멈추면 까닭 · 아직이면 null */
@@ -64,6 +69,10 @@ export interface DiagramLogic {
unit: string;
inputs: DiagramInput[];
out: DiagramOut;
/** 금액이 어느 값인지 — 「기준」 · 「반영」(없으면 안 보임) */
basis?: string;
/** 자재목록 카드 — 누름 없음 */
material?: boolean;
}
export interface DiagramData {
@@ -73,53 +82,86 @@ export interface DiagramData {
export interface DiagramHandlers {
/** 로직 카드 누름 — 단가산출 로직 화면에서 엶 */
openLogic(key: string): void;
/** 넣을 값 알약 누름 — 미리 보기 그 입력 칸으로 */
/** 넣을 값 알약 누름 — 그 로직 입력 모달 */
focusInput(key: string, name: string): void;
}
/* ── 값 만들기 (미리 보기가 부름 · DOM 없음) ──────────────────────────────── */
/* ── 값 만들기 (일위대가 상세가 부름 · DOM 없음 · 39-3) ───────────────────── */
interface PreviewLineLike {
ok: boolean;
까닭?: string;
갈래?: string;
결과?: number;
노무비?: number;
재료비?: number;
경비?: number;
계?: number;
}
type Money = { 노무비: number; 재료비: number; 경비: number; 계: number };
export interface DiagramSource {
export interface LaborDiagramSource {
/** 담은 차례대로 */
rows: { key: string; name: string; unit: string; inputs: { name: string; unit: string }[] }[];
/** 로직 키 → 입력 이름 → 넣은 값 */
values: Record<string, Record<string, string>>;
/** 서버 미리 보기 줄(담은 차례) · 아직 안 셌으면 null */
lines: PreviewLineLike[] | null;
rows: { 로직: string; 입력값: Record<string, string | number> }[];
/** 로직 입력 목록(`/logic-lib/inputs`) */
logics: {
로직: string;
이름: string;
결과단위?: string;
입력: { 이름: string; 단위?: string; 자동값?: unknown }[];
}[];
/** calc 답 · 아직이면 null */
calc: {
줄: {
로직: string;
기준: Money | null;
반영: Money | null;
결과?: number | null;
까닭: string | null;
}[];
자재: { 기준단가: number | null; 반영: number | null }[];
} | null;
/** 담은 자재 수 — 0 이면 자재 카드 없음 */
mats: number;
}
const outOf = (l: PreviewLineLike | undefined): DiagramOut => {
if (!l) return null;
if (!l.ok) return { 멈춤: l.까닭 ?? "" };
if (l.갈래 === "결과") return { 값: l.결과 ?? 0 };
return { 노무비: l.노무비 ?? 0, 재료비: l.재료비 ?? 0, 경비: l.경비 ?? 0, 계: l.계 ?? 0 };
};
const autoOf = (v: unknown): string =>
typeof v === "string" || typeof v === "number" ? String(v) : "";
export function buildDiagramData(src: DiagramSource): DiagramData {
return {
logics: src.rows.map((r, i) => ({
key: r.key,
name: r.name,
unit: r.unit,
inputs: r.inputs.map((x) => ({
name: x.name,
unit: x.unit,
value: src.values[r.key]?.[x.name] ?? "",
})),
out: outOf(src.lines?.[i]),
})),
};
export function buildLaborDiagram(src: LaborDiagramSource): DiagramData {
const logics: DiagramLogic[] = src.rows.map((r) => {
const def = src.logics.find((l) => l.로직 === r.로직);
const line = src.calc?.줄.find((l) => l.로직 === r.로직);
let out: DiagramOut = null;
let basis: string | undefined;
if (line?.까닭) out = { 멈춤: line.까닭 };
else if (line && !line.기준 && typeof line.결과 === "number") out = { 값: line.결과 };
else if (line && (line.반영 || line.기준)) {
out = { ...((line.반영 ?? line.기준) as Money) };
basis = line.반영 ? "반영" : "기준";
}
return {
key: r.로직,
name: def?.이름 ?? r.로직,
unit: def?.결과단위 ?? "",
inputs: (def?.입력 ?? []).map((x) => {
const mine = r.입력값[x.이름];
const has = mine !== undefined && mine !== "";
return {
name: x.이름,
unit: x.단위 ?? "",
value: has ? String(mine) : autoOf(x.자동값),
...(has || !autoOf(x.자동값) ? {} : { auto: true }),
};
}),
out,
...(basis ? { basis } : {}),
};
});
if (src.mats > 0) {
const mats = src.calc?.자재 ?? null;
const sum = mats?.reduce((t, m) => t + (m.반영 ?? m.기준단가 ?? 0), 0) ?? 0;
logics.push({
key: "",
name: `${td("Mats")} ${src.mats}`,
unit: "",
inputs: [],
out: mats ? { 노무비: 0, 재료비: sum, 경비: 0, 계: sum } : null,
...(mats ? { basis: mats.every((m) => m.반영 !== null) ? "반영" : "기준" } : {}),
material: true,
});
}
return { logics };
}
/* ── 글 ─────────────────────────────────────────────────────────────────── */
@@ -134,7 +176,7 @@ const isStop = (o: DiagramOut): o is { 멈춤: string } => !!o && "멈춤" in o;
const amountOf = (o: DiagramOut, c: Cost): number => (isMoney(o) ? o[c] : 0);
function summary(l: DiagramLogic): string {
if (isMoney(l.out)) return `${td("Sum")} ${formatMoney(l.out.계)} ${td("Won")}`;
if (isMoney(l.out)) return `${l.basis ?? td("Sum")} ${formatMoney(l.out.계)} ${td("Won")}`;
if (isValue(l.out)) return `${num(l.out.값)} ${l.unit}`.trim();
if (isStop(l.out)) return `${td("Stop")}: ${l.out.멈춤}`;
return td("Wait");
@@ -360,8 +402,12 @@ export function renderComboDiagram(
? l.inputs.map((x) => {
const empty = x.value.trim() === "";
const pill = el("button", {
className: `m02-labordg__pill${empty ? " is-empty" : ""}`,
attrs: { type: "button", "data-name": x.name, title: td("Goto") },
className: `m02-labordg__pill${empty ? " is-empty" : ""}${x.auto ? " is-auto" : ""}`,
attrs: {
type: "button",
"data-name": x.name,
title: x.auto ? `${td("Auto")} · ${td("Goto")}` : td("Goto"),
},
children: [
el("span", { className: "m02-labordg__pill-name", text: x.name }),
el("span", {
@@ -376,8 +422,12 @@ export function renderComboDiagram(
: [],
});
const card = el("button", {
className: `m02-labordg__card${isStop(l.out) ? " is-stop" : ""}`,
attrs: { type: "button", style: `grid-column:3;grid-row:${row}`, title: td("Open") },
className: `m02-labordg__card${isStop(l.out) ? " is-stop" : ""}${l.material ? " is-material" : ""}`,
attrs: {
type: "button",
style: `grid-column:3;grid-row:${row}`,
...(l.material ? {} : { title: td("Open") }),
},
children: [
el("span", { className: "m02-labordg__card-key", text: l.key }),
el("strong", { className: "m02-labordg__card-name", text: l.name }),
@@ -385,7 +435,7 @@ export function renderComboDiagram(
el("span", { className: "m02-labordg__card-out", text: summary(l) }),
],
});
card.addEventListener("click", () => on.openLogic(l.key));
if (!l.material) card.addEventListener("click", () => on.openLogic(l.key));
const mine = bins.map((b, c) => {
const amount = b === "값" ? (isValue(l.out) ? l.out.값 : 0) : amountOf(l.out, b);
sums.set(b, (sums.get(b) ?? 0) + (b === "값" ? 0 : amount));
@@ -1,7 +1,8 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Edit.ts
* 일위대가 라이브러리 오른쪽 — 조합 목록(찾기) / 상세(이름 · 구분 · 상세구분 · 단위 · 비고 + 담은 로직 표
* (차례 · 키 · 이름 · 결과 단위 · 메모 · 순서) + 미리 보기). 옛 M01 조합 화면을 옮겨 씀(PLAN 27-2b).
* (차례 · 키 · 이름 · 결과 단위 · 메모 · 순서)). 옛 M01 조합 화면을 옮겨 씀(PLAN 27-2b) · 미리 보기 ·
* 다이어그램은 39-3 에 구조물 열 일위대가 상세(`Labor_Detail`)로 감.
* 저장 · 지우기 = 고른 층 조합 표(마스터 UA · 회사 UC · 개인 UP · `Labor_Api`) · 읽기만 층은 단추 없음.
* ========================================================================== */
@@ -12,10 +13,7 @@ import {
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { ROUTES } from "@config/config_frontend";
import { navigateTo } from "../A00_Common/router";
import { ApiError, type LogicSummary } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side";
import {
deleteCombo,
editCombo,
@@ -26,9 +24,7 @@ import {
type ComboSummary,
type LaborScope,
} from "./M02_MasterTemplete_Labor_Api";
import { diagramTitle, renderComboDiagram } from "./M02_MasterTemplete_Labor_Diagram";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import { buildPreview } from "./M02_MasterTemplete_Labor_Preview";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { matchText } from "./M02_MasterTemplete_TreePanel_Model";
import { loadingError, showLoading } from "@ui/ui_template_loading";
@@ -215,31 +211,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
});
return f.root;
};
const preview = buildPreview(() => combo);
// 다이어그램 — 미리 보기와 값 한 벌(넣은 값 · 결과) · 보기만 · 로직 카드 누르면 단가산출 로직 화면
const dgHost = el("div");
const dgBox = el("details", {
className: "m01lab__box m01-logic__section m02-labor__dg",
attrs: { open: "" },
children: [el("summary", { text: diagramTitle() }), dgHost],
});
const drawDg = (): void => {
if (dgBox.open)
renderComboDiagram(dgHost, preview.data(), {
focusInput: preview.focusInput,
openLogic: async (logic) => {
if (dirty() && !(await showConfirmDialog(tc("LeaveAsk")))) return;
setPendingLogicKey(logic);
navigateTo(ROUTES.M01_MASTER_DATA);
},
});
};
let dgTimer = 0;
preview.onUpdate(() => {
window.clearTimeout(dgTimer);
dgTimer = window.setTimeout(drawDg, 120);
});
dgBox.addEventListener("toggle", drawDg);
const table = el("div", { className: "m01-master__grid-wrap" });
const drawTable = (): void => {
const move = (i: number, d: number): void => {
@@ -306,7 +277,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
}),
...(trs.length ? [] : [el("p", { className: "m01-logic__muted", text: tc("NoLogic") })]),
);
void preview.redraw();
};
const add = createButton({
@@ -409,8 +379,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
table,
],
}),
dgBox,
preview.root,
);
drawTable();
};
@@ -0,0 +1,241 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Input.ts
* 일위대가 입력 모달(39-3) — 담은 로직 줄을 누르면 그 로직 입력 전부(빈 칸 = 자동값) · 메모 · 수량
* (면적산출 이은 칸 · 값 보임 / 손 입력 / 「면적산출에서 잇기」). 자재 줄은 수량만(`openQtyModal`).
* [확인] = 줄 값만 바꿈(화면 캐시) · 저장은 상세 [저장]. 값 칸 규칙 = `M02_Labor_Values`(한 벌).
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import type { ItemInput, ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
import type { LaborQty } from "./M02_MasterTemplete_Labor_Api";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { createValueControl } from "./M02_Labor_Values";
import { keepKnown, valueHint, type LaborValues } from "./M02_Labor_Values_Model";
import "./M02_Labor_Values.css";
import "./M02_MasterTemplete_Labor.css";
const fill = (text: string, value: string): string => text.replace("{value}", value);
export interface QtySeat {
/** 지금 수량 */
qty: LaborQty;
/** 면적산출 이은 칸 글(「B6 = 12.5 ㎡」) — 없으면 null */
linked: string | null;
/** 손 입력 단위 표기(분모 · 자재 단위) */
unit: string;
}
interface QtyPart {
root: HTMLElement;
/** 확인 때 값 — 손 입력이 수가 아니면 undefined(막음) */
read: () => LaborQty | undefined;
}
/** 수량 칸 — 「면적산출 칸」 / 「손 입력」 · 손이 칸보다 앞섬 */
function qtyPart(seat: QtySeat, readOnly: boolean, goArea: (() => void) | null): QtyPart {
const name = `m02-labin-qty-${Math.random().toString(36).slice(2)}`;
const radio = (value: "cell" | "hand"): HTMLInputElement => {
const input = el("input", { attrs: { type: "radio", name, value } }) as HTMLInputElement;
input.disabled = readOnly;
return input;
};
const cell = radio("cell");
const hand = radio("hand");
const number = el("input", {
className: "ui-input m02-inputval__control",
attrs: { type: "number", step: "any", "data-labor-qty": "hand" },
}) as HTMLInputElement;
number.readOnly = readOnly;
if (seat.qty) number.value = String(seat.qty.손);
(seat.qty ? hand : cell).checked = true;
number.disabled = !hand.checked;
for (const r of [cell, hand])
r.addEventListener("change", () => {
number.disabled = !hand.checked;
if (hand.checked) number.focus();
});
const go = goArea
? createButton({ label: tc("In_QtyGoArea"), variant: "ghost", size: "sm", onClick: goArea })
: null;
const line = (input: HTMLInputElement, label: string, ...rest: HTMLElement[]): HTMLElement =>
el("label", {
className: "m02-labin__qty-line",
children: [input, el("span", { text: label }), ...rest],
});
const root = el("section", {
className: "m02-labin__qty",
children: [
el("h4", { className: "m02-inputval__logic-name", text: tc("In_Qty") }),
line(
cell,
tc("In_QtyCell"),
el("small", {
className: "m02-labin__muted",
text: seat.linked ? fill(tc("In_QtyLinked"), seat.linked) : tc("In_QtyNoLink"),
}),
...(go ? [go] : []),
),
line(hand, tc("In_QtyHand"), number, el("small", { text: seat.unit })),
],
});
return {
root,
read: () => {
if (!hand.checked) return null;
const v = Number(number.value);
return number.value.trim() !== "" && Number.isFinite(v) ? { 손: v } : undefined;
},
};
}
function footer(readOnly: boolean, ok: () => void, close: () => void): HTMLElement {
return el("div", {
className: "m02-labin__foot",
children: readOnly
? [createButton({ label: tc("In_Close"), variant: "ghost", onClick: close })]
: [
createButton({ label: tc("In_Cancel"), variant: "ghost", onClick: close }),
createButton({ label: tc("In_Ok"), variant: "filled", onClick: ok }),
],
});
}
export interface LaborInputResult {
입력값: LaborValues;
메모: string | null;
수량: LaborQty;
}
export interface LaborInputOptions {
/** 「GF000219 메쌓기」 */
title: string;
/** 로직 입력 목록(없으면 = 못 읽음 · 넣을 값 없이 수량만) */
logic: ItemLogic | null;
values: LaborValues;
memo: string | null;
seat: QtySeat;
readOnly: boolean;
/** 「면적산출에서 잇기」 — 모달을 닫고 면적산출 상세로(없으면 단추 없음) */
goArea: (() => void) | null;
done: (next: LaborInputResult) => void;
}
/** 담은 로직 줄 입력 모달 */
export function openLaborInput(opt: LaborInputOptions): void {
const inputs: ItemInput[] = opt.logic?.입력 ?? [];
const { kept, lost } = keepKnown(inputs, opt.values);
const draft: LaborValues = { ...kept };
const bad = new Set<string>();
openModal({
title: fill(tc("In_Title"), opt.title),
closeLabel: tc("In_Close"),
dialogClass: "m02-modal m02-labin",
mount: (body, close) => {
const rows = inputs.map((input) => {
const field = createValueControl(input, draft[input.이름] ?? null, (value, isBad) => {
if (isBad) bad.add(input.이름);
else bad.delete(input.이름);
if (value === null) delete draft[input.이름];
else draft[input.이름] = value;
});
field.control.disabled = opt.readOnly;
field.control.setAttribute("data-labor-input", input.이름);
const hint = valueHint(input);
return el("div", {
className: "m02-inputval__row",
children: [
el("span", {
className: "m02-inputval__name",
text: input.단위 ? `${input.이름} (${input.단위})` : input.이름,
attrs: { title: [input.설명, hint].filter(Boolean).join(" · ") },
}),
el("div", { className: "m02-inputval__cell", children: [field.control, field.error] }),
],
});
});
const memo = el("input", {
className: "ui-input m02-inputval__control",
attrs: { type: "text", maxlength: "200", "data-labor-memo": "" },
}) as HTMLInputElement;
memo.value = opt.memo ?? "";
memo.readOnly = opt.readOnly;
const qty = qtyPart(
opt.seat,
opt.readOnly,
opt.goArea &&
(() => {
close();
opt.goArea?.();
}),
);
const ok = (): void => {
if (bad.size) return showToast(tc("In_Bad"), "error");
const 수량 = qty.read();
if (수량 === undefined) return showToast(tc("In_QtyNeed"), "error");
opt.done({ 입력값: { ...draft }, 메모: memo.value.trim() || null, 수량 });
close();
};
body.append(
el("div", {
className: "m02-inputval",
children: [
...(lost.length
? [
el("p", {
className: "m02-labin__lost",
text: fill(tc("Labor_Lost"), lost.join(", ")),
}),
]
: []),
...(rows.length
? rows
: [el("p", { className: "m02-labin__muted", text: tc("In_None") })]),
el("div", {
className: "m02-inputval__row",
children: [
el("span", { className: "m02-inputval__name", text: tc("In_Memo") }),
memo,
],
}),
qty.root,
],
}),
footer(opt.readOnly, ok, close),
);
},
});
}
/** 자재 줄 수량 모달 */
export function openQtyModal(
title: string,
seat: QtySeat,
readOnly: boolean,
goArea: (() => void) | null,
done: (qty: LaborQty) => void,
): void {
openModal({
title,
closeLabel: tc("In_Close"),
dialogClass: "m02-modal m02-labin",
mount: (body, close) => {
const qty = qtyPart(
seat,
readOnly,
goArea &&
(() => {
close();
goArea();
}),
);
const ok = (): void => {
const 수량 = qty.read();
if (수량 === undefined) return showToast(tc("In_QtyNeed"), "error");
done(수량);
close();
};
body.append(qty.root, footer(readOnly, ok, close));
},
});
}
@@ -1,244 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Preview.ts
* 조합 미리 보기 — 담은 로직 저마다 받은 입력값을 서버 미리 보기(`POST …/combos/preview`)에 실어 비목별 합계를 받음
* 입력값(수량)은 이 화면 안에만 두고 조합에 저장하지 않음(계약 9장)
* ========================================================================== */
import { createInputField, createSelectField, el } from "@ui/ui_template_elements";
import type { LogicInput } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
import {
fetchLibAuto as fetchAuto,
fetchLibLogic as fetchLogic,
previewCombo,
type Combo,
} from "./M02_MasterTemplete_Labor_Api";
import { buildDiagramData, type DiagramData } from "./M02_MasterTemplete_Labor_Diagram";
import { tc, type ComboTextKey } from "./M02_MasterTemplete_Labor_Text";
const COSTS = ["노무비", "재료비", "경비"] as const;
const LABEL: Record<(typeof COSTS)[number], ComboTextKey> = {
노무비: "Prev_Labor",
재료비: "Prev_Material",
경비: "Prev_Expense",
};
const num = (v: string): number | string => (v.trim() !== "" && !isNaN(Number(v)) ? Number(v) : v);
/** 조합 미리 보기는 모두 돈 자리(노무비·재료비·경비·계) */
const fmt = formatMoney;
/** 로직 입력 정의 → 처음 값(고르기 첫째 · 범위 아래끝) */
const initial = (i: LogicInput): string => String(i.고르기?.[0] ?? i.범위?.[0] ?? "");
/** 입력 칸 — 바꾼 값은 `values`(이 화면 메모)에만 적음 */
function inputCell(
input: LogicInput,
key: string,
values: Record<string, Record<string, string>>,
onEdit: () => void,
onInput: () => void,
): HTMLElement {
const mine = (values[key] ??= {});
const value = mine[input.이름] ?? initial(input);
mine[input.이름] = value;
const choices = input.고르기?.map((v) => ({ value: String(v), text: String(v) }));
const field = choices
? createSelectField({ options: choices, value, compact: true, label: input.이름 })
: createInputField({ type: "text", label: input.이름, compact: true });
const control = "select" in field ? field.select : field.input;
control.value = value;
control.setAttribute("data-input", `${key}|${input.이름}`);
const edit = (): void => {
mine[input.이름] = control.value;
onInput();
};
control.addEventListener("input", edit);
control.addEventListener("change", () => {
edit();
onEdit(); // 바꾸면 다시 미리 보기 계산
});
return field.root;
}
export interface PreviewHandle {
root: HTMLElement;
/** 담은 로직이 바뀐 뒤 입력 칸을 다시 그림 */
redraw: () => Promise<void>;
/** 다이어그램이 쓰는 값 한 벌 — 넣은 값 · 로직 이름 · 결과(저장 안 함) */
data: () => DiagramData;
/** 입력 칸이 바뀌거나 결과가 나올 때마다 부름(돌려받은 함수 = 그만 받기) */
onUpdate: (fn: () => void) => () => void;
/** 그 입력 칸으로 가서 깜빡임 */
focusInput: (key: string, name: string) => void;
}
export function buildPreview(combo: () => Combo): PreviewHandle {
const values: Record<string, Record<string, string>> = {};
/** 서버가 받을 꼴 — 원래 숫자·고르기 값으로 되돌림 */
const inputs = new Map<string, LogicInput[]>();
/** 로직마다 자동값은 한 번만 받음 */
const asked = new Set<string>();
/** 다이어그램에 넘길 것 — 로직 이름 · 결과 단위 · 마지막 미리 보기 줄 */
const meta = new Map<string, { name: string; unit: string }>();
let lines: Parameters<typeof buildDiagramData>[0]["lines"] = null;
const listeners = new Set<() => void>();
const notify = (): void => listeners.forEach((f) => f());
const form = el("div", { className: "m01-logic__stack" });
const out = el("div", {
className: "m01-master__grid-wrap",
attrs: { "data-combo": "preview" },
});
const rowOf = (cells: string[], note = "", bold = false): HTMLElement =>
el("tr", {
className: bold ? "m01lab__subtotal" : "",
children: cells.map((t, i) => {
const text = i === 0 && note ? `${t} — ${note}` : t;
return el("td", { text, attrs: { title: text } });
}),
});
const run = async (): Promise<void> => {
out.replaceChildren(el("p", { className: "m01-logic__muted", text: "…" }));
lines = null;
notify();
const send: Record<string, Record<string, unknown>> = {};
for (const [key, defs] of inputs) {
send[key] = {};
for (const i of defs) {
const raw = values[key]?.[i.이름] ?? initial(i);
if (raw.trim() === "") continue; // 빈 칸은 안 보냄(로직 화면과 같음)
send[key][i.이름] = i.고르기?.find((o) => String(o) === raw) ?? num(raw);
}
}
let got;
try {
got = await previewCombo(combo(), send);
lines = got.줄;
notify();
} catch (error) {
out.replaceChildren(
el("p", { text: `${tc("Prev_Fail")}: ${error instanceof Error ? error.message : ""}` }),
);
return;
}
const trs = got.줄.map((l) =>
rowOf(
[
`${l.로직} ${l.이름}`,
fmt(l.노무비 ?? 0),
fmt(l.재료비 ?? 0),
fmt(l.경비 ?? 0),
fmt(l.계 ?? 0),
],
l.ok ? "" : `${tc("Prev_Fail")}: ${l.까닭 ?? ""}`,
),
);
trs.push(
rowOf(
[tc("Prev_Sum"), fmt(got.노무비), fmt(got.재료비), fmt(got.경비), fmt(got.계)],
"",
true,
),
);
const heads = [tc("Prev_Row"), ...COSTS.map((c) => tc(LABEL[c])), tc("Prev_Sum")];
out.replaceChildren(
el("table", {
className: "m01-master__grid",
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
el("tbody", { children: trs }),
],
}),
);
};
const button = el("button", {
className: "ui-btn ui-btn--filled",
text: tc("Prev_Run"),
attrs: { type: "button", "data-combo": "run" },
});
button.addEventListener("click", () => void run());
const redraw = async (): Promise<void> => {
const blocks: HTMLElement[] = [];
inputs.clear();
for (const row of combo().담은로직) {
try {
const [one, auto] = await Promise.all([
fetchLogic(row.로직),
asked.has(row.로직) ? Promise.resolve({}) : fetchAuto(row.로직),
]);
asked.add(row.로직);
const mine = (values[row.로직] ??= {});
for (const i of one.logic.입력) {
const got = (auto as Record<string, unknown>)[i.이름];
if (got === undefined || got === null || (mine[i.이름] ?? "").trim() !== "") continue;
mine[i.이름] = String(got);
}
inputs.set(row.로직, one.logic.입력);
meta.set(row.로직, { name: one.logic.이름, unit: one.logic.결과단위 ?? "" });
blocks.push(
el("div", {
className: "m01lab__info",
children: [
el("strong", {
className: "m01lab__cell--wide",
text: `${row.로직} ${one.logic.이름}`,
}),
...one.logic.입력.map((i) =>
inputCell(i, row.로직, values, () => void run(), notify),
),
],
}),
);
} catch {
/* 못 읽은 로직은 미리 보기에서 그 줄만 까닭이 뜸 */
}
}
form.replaceChildren(...blocks);
notify();
if (blocks.length)
await run(); // 열자마자 한 번 미리 보기 계산
else out.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Prev_Wait") }));
};
const root = el("section", {
className: "m01lab__box m01-logic__section",
children: [
el("div", {
className: "m01-logic__section-head",
children: [el("h3", { text: tc("Prev_Title") })],
}),
el("h4", { text: tc("Prev_Inputs") }),
form,
button,
out,
],
});
const data = (): DiagramData =>
buildDiagramData({
rows: combo().담은로직.map((r) => ({
key: r.로직,
name: meta.get(r.로직)?.name ?? r.로직,
unit: meta.get(r.로직)?.unit ?? "",
inputs: (inputs.get(r.로직) ?? []).map((i) => ({ name: i.이름, unit: i.단위 ?? "" })),
})),
values,
lines,
});
const focusInput = (key: string, name: string): void => {
const box = root.querySelector<HTMLElement>(`[data-input="${CSS.escape(`${key}|${name}`)}"]`);
if (!box) return;
box.scrollIntoView({ block: "center", behavior: "smooth" });
box.focus({ preventScroll: true });
box.classList.add("m02-labor__flash");
window.setTimeout(() => box.classList.remove("m02-labor__flash"), 1600);
};
const onUpdate = (fn: () => void): (() => void) => {
listeners.add(fn);
return () => listeners.delete(fn);
};
return { root, redraw, data, onUpdate, focusInput };
}
@@ -0,0 +1,283 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Rows.ts
* 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀.
* 담은 로직 = 차례 · 로직(키 · 이름 + 작은 글 「지역 서울 · 규격 25mm · 수량 B6 = 12.5㎡」 · 「빈 값 n」 붉게) ·
* 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) · 맨 아래 합계 줄.
* 자재목록 = 코드 · 이름 · 규격 · 단위 · 수량 · 기준 단가 · 반영 금액. 금액은 calc 답(없으면 「…」).
* 줄 누름 · 순서 단추는 `on` 이 있을 때만(보기 갈래 = 없음).
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
import type {
LaborCalc,
LaborCalcLine,
LaborCalcMat,
LaborCost,
LaborMat,
LaborRow,
} from "./M02_MasterTemplete_Labor_Api";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import "./M02_MasterTemplete_Labor.css";
import { valueLine } from "./M02_Labor_Values_Model";
const COSTS = ["노무비", "재료비", "경비", "계"] as const;
const HEAD: Record<(typeof COSTS)[number], Parameters<typeof tc>[0]> = {
노무비: "Prev_Labor",
재료비: "Prev_Material",
경비: "Prev_Expense",
계: "Prev_Sum",
};
const num = (v: number): string =>
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」 · 없으면 빈 글 */
export function qtyText(
line: { 수량: number | null; 수량출처: string | null } | undefined,
unit: string,
): string {
if (!line || line.수량 === null || line.수량 === undefined) return "";
const v = `${num(line.수량)}${unit ? ` ${unit}` : ""}`;
if (!line.수량출처 || line.수량출처 === "손")
return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`;
return `${tc("Labor_Qty")} ${line.수량출처} = ${v}`;
}
/** 금액 칸 하나 — 위 기준(작게) · 아래 반영 */
function moneyCell(
base: number | null | undefined,
applied: number | null | undefined,
wait: boolean,
): HTMLElement {
const show = (v: number | null | undefined): string =>
wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v);
return el("td", {
className: "m02-labrow__money",
children: [
el("small", {
className: "m02-labrow__base",
text: show(base),
attrs: { title: tc("Labor_Base") },
}),
el("span", {
className: "m02-labrow__applied",
text: show(applied),
attrs: { title: tc("Labor_Applied") },
}),
],
});
}
const table = (heads: string[], rows: HTMLElement[], foot: HTMLElement[]): HTMLElement =>
el("div", {
className: "ui-general-block__table-wrap",
children: [
el("table", {
className: "ui-general-block__table m02-labrow",
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
el("tbody", { children: rows }),
...(foot.length ? [el("tfoot", { children: foot })] : []),
],
}),
],
});
/** 줄 누름 · 순서 · 빼기 — 순서 · 빼기가 없으면 그 칸 없음 */
export interface RowHandlers {
open: (i: number) => void;
move?: (i: number, d: number) => void;
remove?: (i: number) => void;
}
export interface LogicTableData {
rows: LaborRow[];
logics: Map<string, ItemLogic>;
/** calc 답 — null = 계산 중 · 못 셈은 `fail` */
calc: LaborCalc | null;
fail?: string | null;
}
/** 담은 로직 표 — 보기 갈래는 `on` 없이 */
export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement {
const wait = !data.calc && !data.fail;
const { move, remove } = on ?? {};
const tools = !!(move && remove);
const lineOf = (key: string): LaborCalcLine | undefined =>
data.calc?.줄.find((l) => l.로직 === key);
const trs = data.rows.map((row, i) => {
const logic = data.logics.get(row.로직);
const line = lineOf(row.로직);
const unit = logic?.결과단위 ?? "";
const values = valueLine(logic?.입력 ?? [], row.입력값);
const qty = qtyText(line, line?.분모 ?? "");
const note = [values.text, qty].filter(Boolean).join(" · ");
const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null);
const small = (label: string, run: () => void): HTMLElement =>
createButton({
label,
variant: "ghost",
size: "sm",
onClick: (e) => {
e.stopPropagation(); // 줄 누름(입력 모달)과 따로
run();
},
});
const tr = el("tr", {
className: on ? "m02-labrow__row is-open" : "m02-labrow__row",
attrs: { "data-labor-logic": row.로직 },
children: [
el("td", { text: String(i + 1) }),
el("td", {
className: "m02-labrow__name",
children: [
el("span", { className: "m02-labrow__key", text: row.로직 }),
el("strong", { text: logic?.이름 ?? row.로직 }),
...(note ? [el("small", { className: "m02-labrow__note", text: note })] : []),
...(values.empty
? [
el("small", {
className: "m02-labrow__empty",
text: tc("Labor_Empty").replace("{n}", String(values.empty)),
}),
]
: []),
...(row.메모 ? [el("small", { className: "m02-labrow__note", text: row.메모 })] : []),
...(why
? [
el("small", {
className: "m02-labrow__empty",
text: `${tc("Labor_Calc_Fail")}: ${why}`,
}),
]
: []),
],
}),
el("td", { text: unit }),
...COSTS.map((c) => moneyCell(line?.기준?.[c], line?.반영?.[c], wait)),
...(move && remove
? [
el("td", {
className: "m02-labrow__move",
children: [
small(tc("Up"), () => move(i, -1)),
small(tc("Down"), () => move(i, 1)),
small(tc("Remove"), () => remove(i)),
],
}),
]
: []),
],
});
if (on) tr.addEventListener("click", () => on.open(i));
return tr;
});
const sum = (part: Partial<LaborCost> | undefined, c: (typeof COSTS)[number]): number | null =>
part?.[c] ?? null;
const foot = data.rows.length
? [
el("tr", {
className: "m02-labrow__sum",
children: [
el("td", { text: "" }),
el("td", { text: tc("Prev_Sum") }),
el("td", { text: "" }),
...COSTS.map((c) =>
moneyCell(sum(data.calc?.기준합, c), sum(data.calc?.반영합, c), wait),
),
...(tools ? [el("td", { text: "" })] : []),
],
}),
]
: [];
const heads = [
tc("Col_Order"),
tc("Col_Name"),
tc("Col_Unit"),
...COSTS.map((c) => tc(HEAD[c])),
...(tools ? [tc("Col_Move")] : []),
];
return data.rows.length
? table(heads, trs, foot)
: el("p", { className: "m02-labrow__muted", text: tc("NoLogic") });
}
export interface MatTableData {
mats: LaborMat[];
calc: LaborCalc | null;
fail?: string | null;
}
/** 자재목록 표 — 보기 갈래는 `on` 없이 */
export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HTMLElement {
const wait = !data.calc && !data.fail;
const remove = on?.remove;
const show = (v: number | null | undefined): string =>
wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v);
const trs = data.mats.map((mat, i) => {
const line: LaborCalcMat | undefined = data.calc?.자재.find((m) => m.코드 === mat.코드);
const qty = line?.수량 === null || line?.수량 === undefined ? "" : num(line.수량);
const tr = el("tr", {
className: on ? "m02-labrow__row is-open" : "m02-labrow__row",
attrs: { "data-labor-mat": mat.코드 },
children: [
el("td", { text: mat.코드 }),
el("td", {
className: "m02-labrow__name",
children: [
el("strong", { text: mat.이름 ?? "" }),
...(line?.까닭
? [el("small", { className: "m02-labrow__empty", text: line.까닭 })]
: []),
],
}),
el("td", { text: mat.규격 ?? "" }),
el("td", { text: mat.단위 ?? "" }),
el("td", {
className: "m02-labrow__num",
text: qty,
attrs: { title: line?.수량출처 ?? "" },
}),
el("td", { className: "m02-labrow__num", text: show(line?.기준단가) }),
el("td", { className: "m02-labrow__num", text: show(line?.반영) }),
...(remove
? [
el("td", {
className: "m02-labrow__move",
children: [
createButton({
label: tc("Remove"),
variant: "ghost",
size: "sm",
onClick: (e) => {
e.stopPropagation();
remove(i);
},
}),
],
}),
]
: []),
],
});
if (on) tr.addEventListener("click", () => on.open(i));
return tr;
});
const heads = [
tc("Labor_Code"),
tc("Col_Name"),
tc("Labor_Spec"),
tc("Col_Unit"),
tc("Labor_Qty"),
tc("Labor_BasePrice"),
tc("Labor_AppliedSum"),
...(remove ? [tc("Col_Move")] : []),
];
return data.mats.length
? table(heads, trs, [])
: el("p", { className: "m02-labrow__muted", text: tc("Labor_NoMat") });
}
@@ -65,16 +65,48 @@ const TEXT = {
Pick_None: ["찾은 로직 없음", "No logics found"],
Pick_Close: ["닫기", "Close"],
Pick_Blocked: ["막힘", "Blocked"],
Prev_Title: ["미리 보기 (보기만 · 저장 안 함)", "Preview (view only)"],
Prev_Inputs: ["넣을 값", "Inputs"],
Prev_Run: ["미리 보기 계산", "Run preview"],
Prev_Labor: ["노무비", "Labor"],
Prev_Material: ["재료비", "Material"],
Prev_Expense: ["경비", "Expense"],
Prev_Sum: ["합계", "Total"],
Prev_Row: ["로직", "Logic"],
Prev_Fail: ["못 셈", "Failed"],
Prev_Wait: ["미리 보기 계산을 누르면 값이 나옴", "Press the button to see values"],
// 일위대가 상세(39-3) — 구조물 열마다 하나
Labor_Title: ["일위대가", "Unit price"],
Labor_Unit: ["단위", "Unit"],
Labor_Origin: ["본뜬 곳", "Copied from"],
Labor_Mats: ["자재목록", "Materials"],
Labor_NoMat: ["담은 자재 없음", "No materials yet"],
Labor_AddMat: ["자재 추가", "Add material"],
Labor_Qty: ["수량", "Quantity"],
Labor_Base: ["기준", "Base"],
Labor_Applied: ["반영", "Applied"],
Labor_BasePrice: ["기준 단가", "Base price"],
Labor_AppliedSum: ["반영 금액", "Applied amount"],
Labor_Calc_Wait: ["계산 중…", "Calculating…"],
Labor_Calc_Fail: ["계산 못 함", "Calculation failed"],
Labor_Empty: ["빈 값 {n}", "{n} empty"],
Labor_Lost: [
"로직 입력이 바뀌어 저장 때 빠지는 값 — {value}",
"Inputs no longer in the logic (dropped on save) — {value}",
],
Labor_Code: ["코드", "Code"],
Labor_Spec: ["규격", "Spec"],
Labor_Already: ["이미 담은 로직 — {value}", "Already added — {value}"],
Labor_AlreadyMat: ["이미 담은 자재 — {value}", "Already added — {value}"],
In_Title: ["넣을 값 — {value}", "Inputs — {value}"],
In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"],
In_Memo: ["메모", "Memo"],
In_Qty: ["수량", "Quantity"],
In_QtyCell: ["면적산출 칸", "Area sheet cell"],
In_QtyHand: ["손 입력", "Manual"],
In_QtyLinked: ["이은 칸 {value}", "Linked cell {value}"],
In_QtyNoLink: ["이은 칸 없음 — 면적산출에서 알약을 칸에 이음", "No linked cell"],
In_QtyGoArea: ["면적산출에서 잇기", "Link in area sheet"],
In_QtyNeed: ["손 입력 수량을 수로 적음", "Enter a number"],
In_Bad: ["범위 밖 값이 있음 — 고친 뒤 확인", "Out-of-range values — fix first"],
In_Ok: ["확인", "OK"],
In_Cancel: ["취소", "Cancel"],
In_Close: ["닫기", "Close"],
In_Loading: ["입력 목록 받는 중…", "Loading inputs…"],
} as const satisfies Record<string, readonly [string, string]>;
export type ComboTextKey = keyof typeof TEXT;
@@ -5,7 +5,7 @@
company 읽기 = 같은 회사 · 쓰기 = 회사 관리자(키 UC{회사}-######)
personal 읽기 · 쓰기 = 본인(키 UP{사용자}-######)
저장소 = `M02_MasterTemplete_Combo_Store`.
담을 로직 고르기 · 미리 보기가 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은
담을 로직 고르기 · 일위대가 입력 모달이 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은
`/api/m02/logic-lib/…` 읽기만(로그인) — 모양은 M01 길과 같음.
39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만(`_door` · 담을 로직 읽기 뺌).
"""
@@ -41,13 +41,6 @@ class ComboBody(BaseModel):
판: str | None = None # 새로 만들 때 빈 글 = 지금 판 · 고칠 때 어긋나면 409
class PreviewBody(BaseModel):
"""저장 전 미리 보기 — `combo` 는 조합 줄 통째 또는 그 층 키 · `inputs` 는 로직마다."""
combo: dict[str, Any] | str
inputs: dict[str, dict[str, Any]] = {}
@router.get("/combos/all")
def get_all(session: Session = Depends(verify_session)) -> list[dict]:
"""고르기(use-combo · 갈라 내기) — 세 층 `[{layer, owner, 판, 폴더, 줄}]` · 회사 없음 빈 층."""
@@ -146,17 +139,6 @@ def delete_practice(판: str = "", session: Session = Depends(verify_session)) -
return _call(_root(session, "system", None, write=True), combos.drop_practice, 판)
@router.post("/layers/{layer}/combos/preview")
def post_preview(
layer: Layer,
body: PreviewBody,
owner: str | None = None,
session: Session = Depends(verify_session),
):
"""보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 · 읽기 권한."""
return _call(_root(session, layer, owner), combos.preview, body.combo, body.inputs)
# ── 담을 로직(읽기만) — M01 `/logics` · `/subs?group=로직` · `/logic` · `/logic/auto` 모양 ──
def _read(fn, *args):
try:
@@ -252,3 +252,9 @@
.m02-struct__sheet.is-pills-off .ss-links {
display: none;
}
/* [알약] 켬 — 눌린 단추를 강조색으로(밝음 · 어두움 같은 토큰) */
.m02-struct__card-tools .ui-btn[aria-pressed="true"] {
border-color: var(--color-accent);
color: var(--color-accent);
}
+340
View File
@@ -0,0 +1,340 @@
{
"종류": "일위대가",
"판": 1,
"열": "item1",
"단위": "개소",
"본뜬": {
"층": "system",
"주인": null,
"키": "UA000008"
},
"담은로직": [
{
"로직": "GC000198",
"메모": "레미콘타설(장비) 무근,소형",
"입력값": {},
"수량": null
},
{
"로직": "GC000214",
"메모": "유로폼 설치 및 해체 보통",
"입력값": {},
"수량": null
},
{
"로직": "GC000206",
"메모": "철근가공 및 조립 Type-Ⅰ-1,현장가공조립",
"입력값": {},
"수량": null
},
{
"로직": "GC000207",
"메모": "철근가공 및 조립 Type-Ⅰ-1,현장가공조립",
"입력값": {},
"수량": null
},
{
"로직": "GF000218",
"메모": "기초다짐 및 뒤채움 소형 장비사용 시공 (실무 수는 옛 판 값)",
"입력값": {},
"수량": null
},
{
"로직": "GF000225",
"메모": "깬잡석찰붙임 L3=30cm내외",
"입력값": {},
"수량": null
},
{
"로직": "GF000156",
"메모": "콘크리트(기계비빔) 무근구조물 (실무 수는 옛 판 값)",
"입력값": {},
"수량": null
},
{
"로직": "GF000226",
"메모": "큰돌메쌓기 뒷길이 0.6~0.8m",
"입력값": {},
"수량": null
},
{
"로직": "GF000214",
"메모": "야면석채집 L3=45cm내외 (실무 수는 옛 판 값)",
"입력값": {},
"수량": null
},
{
"로직": "GF000222",
"메모": "찰쌓기(켜쌓기) L3=55cm이하 (실무 수는 옛 판 값)",
"입력값": {},
"수량": null
},
{
"로직": "GC000677",
"메모": "모르터바르기 1:3",
"입력값": {},
"수량": null
},
{
"로직": "GF000221",
"메모": "돌쌓기 찰(켜)쌓기,L3=55cm이하 (실무 수는 옛 판 값)",
"입력값": {},
"수량": null
},
{
"로직": "GC000088",
"메모": "구조물터파기(토사) 굴착기0.6m3",
"입력값": {},
"수량": null
},
{
"로직": "GF000113",
"메모": "구조물되메우기 굴착기0.6m3 (실무 수는 옛 판 값)",
"입력값": {},
"수량": null
}
],
"자재": [],
"일위대가번": "SU-000001",
"계산값": {
"줄": [
{
"로직": "GC000198",
"알약": "레디믹스트콘크리트 타설|㎥",
"분모": "㎥",
"배": 1,
"기준": {
"노무비": 58122.782608695656,
"재료비": 0,
"경비": 1162.455652173913,
"계": 59285.23826086956
},
"까닭": null,
"수량": null,
"수량출처": "없음",
"반영": null
},
{
"로직": "GC000214",
"알약": "유로폼 설치 및 해체|㎡",
"분모": "㎡",
"배": 1,
"기준": {
"노무비": 51524.36363636364,
"재료비": 0,
"경비": 1545.730909090909,
"계": 53070.09454545454
},
"까닭": null,
"수량": null,
"수량출처": "없음",
"반영": null
},
{
"로직": "GC000206",
"알약": "철근 현장가공|ton",
"분모": "ton",
"배": 1,
"기준": {
"노무비": 217028.66666666666,
"재료비": 0,
"경비": 19532.58,
"계": 236561.24666666667
},
"까닭": null,
"수량": null,
"수량출처": "없음",
"반영": null
},
{
"로직": "GC000207",
"알약": "철근 현장조립|ton",
"분모": "ton",
"배": 1,
"기준": {
"노무비": 574487.6470588235,
"재료비": 11765,
"경비": 11489.75294117647,
"계": 597742.4
},
"까닭": null,
"수량": null,
"수량출처": "없음",
"반영": null
},
{
"로직": "GF000218",
"알약": "기초다짐 및 뒷채움|㎥",
"분모": "㎥",
"배": 1,
"기준": {
"노무비": 12869.246375,
"재료비": 1530.2328689,
"경비": 1315.382,
"계": 15714.8612439
},
"까닭": null,
"수량": null,
"수량출처": "없음",
"반영": null
},
{
"로직": "GF000225",
"알약": "돌붙임 찰붙임|㎡",
"분모": "㎡",
"배": 1,
"기준": {
"노무비": 49447.04583333333,
"재료비": 4806.2270256,
"경비": 6425.98,
"계": 60679.25285893333
},
"까닭": null,
"수량": null,
"수량출처": "없음",
"반영": null
},
{
"로직": "GF000156",
"알약": "기계비빔타설|㎥",
"분모": "㎥",
"배": 1,
"기준": {
"노무비": 144348.25222222222,
"재료비": 1647.113,
"경비": 378,
"계": 146373.36522222223
},
"까닭": null,
"수량": null,
"수량출처": "없음",
"반영": null
},
{
"로직": "GF000226",
"알약": "큰돌쌓기 메쌓기|㎡",
"분모": "㎡",
"배": 10,
"기준": {
"노무비": 819958.01,
"재료비": 132344.5084416,
"경비": 111948.7001,
"계": 1064251.2185416
},
"까닭": null,
"수량": null,
"수량출처": "없음",
"반영": null
},
{
"로직": "GF000214",
"알약": "야면석 채집|㎡ · ㎥",
"분모": "㎡ · ㎥",
"배": 1,
"기준": {
"노무비": 18927.48,
"재료비": 0,
"경비": 0,
"계": 18927.48
},
"까닭": null,
"수량": null,
"수량출처": "없음",
"반영": null
},
{
"로직": "GF000222",
"알약": "돌쌓기 찰쌓기|㎡",
"분모": "㎡",
"배": 1,
"기준": {
"노무비": 51101.38458333333,
"재료비": 6772.4108088,
"경비": 9054.79,
"계": 66928.58539213333
},
"까닭": null,
"수량": null,
"수량출처": "없음",
"반영": null
},
{
"로직": "GC000677",
"알약": "모르타르 배합|㎥",
"분모": "㎥",
"배": 1,
"기준": {
"노무비": 113564.88,
"재료비": 166136,
"경비": 0,
"계": 279700.88
},
"까닭": null,
"수량": null,
"수량출처": "없음",
"반영": null
},
{
"로직": "GF000221",
"알약": "돌쌓기 찰쌓기|㎡",
"분모": "㎡",
"배": 1,
"기준": {
"노무비": 52917.12,
"재료비": 1469.88,
"경비": 0,
"계": 54387
},
"까닭": null,
"수량": null,
"수량출처": "없음",
"반영": null
},
{
"로직": "GC000088",
"알약": "터파기(기계) 보통토사|㎥",
"분모": "㎥",
"배": 1,
"기준": {
"노무비": 843.1458333333334,
"재료비": 627.50883,
"경비": 423.15714285714284,
"계": 1893.811806190476
},
"까닭": null,
"수량": null,
"수량출처": "없음",
"반영": null
},
{
"로직": "GF000113",
"알약": "되메우기|㎥",
"분모": "㎥",
"배": 1,
"기준": {
"노무비": 2660.6419100706057,
"재료비": 416.1508405797101,
"경비": 391.04953017996496,
"계": 3467.8422808302807
},
"까닭": null,
"수량": null,
"수량출처": "없음",
"반영": null
}
],
"자재": [],
"기준합": {
"노무비": 2167800.6667278423,
"재료비": 327515.0318154797,
"경비": 163667.5782754784,
"계": 2658983.2768188003
},
"반영합": {
"노무비": 0,
"재료비": 0,
"경비": 0,
"계": 0
}
}
}
@@ -0,0 +1,11 @@
{
"종류": "일위대가",
"판": 1,
"열": "item2",
"단위": null,
"본뜬": null,
"담은로직": [],
"자재": [],
"계산값": {},
"일위대가번": "SU-000002"
}
@@ -0,0 +1,98 @@
{
"종류": "일위대가",
"판": 1,
"열": "item3",
"단위": "식",
"본뜬": {
"층": "system",
"주인": null,
"키": "UA000001"
},
"담은로직": [
{
"로직": "GF000219",
"메모": "메쌓기 — 돌쌓기 인력",
"입력값": {},
"수량": null
},
{
"로직": "GF000119",
"메모": "노체포설 — 굴착기 0.7㎥",
"입력값": {},
"수량": null
},
{
"로직": "GF000137",
"메모": "운반 — 중기운반 트레일러(20ton)",
"입력값": {},
"수량": null
}
],
"자재": [],
"일위대가번": "SU-000003",
"계산값": {
"줄": [
{
"로직": "GF000219",
"알약": "돌쌓기 메쌓기|㎡",
"분모": "㎡",
"배": 1,
"기준": {
"노무비": 79279.668,
"재료비": 0,
"경비": 0,
"계": 79279.668
},
"까닭": null,
"수량": null,
"수량출처": "없음",
"반영": null
},
{
"로직": "GF000119",
"알약": "노체포설|㎥",
"분모": "㎥",
"배": 1,
"기준": {
"노무비": 2433.321308321308,
"재료비": 1077.3082366522367,
"경비": 1012.3273551844981,
"계": 4522.956900158043
},
"까닭": null,
"수량": null,
"수량출처": "없음",
"반영": null
},
{
"로직": "GF000137",
"알약": "중기운반 트레일러|회",
"분모": "회",
"배": 1,
"기준": {
"노무비": 38181.82530864197,
"재료비": 32481.682622277778,
"경비": 13280.479259259258,
"계": 83943.98719017902
},
"까닭": null,
"수량": null,
"수량출처": "없음",
"반영": null
}
],
"자재": [],
"기준합": {
"노무비": 119894.81461696328,
"재료비": 33558.99085893002,
"경비": 14292.806614443756,
"계": 167746.61209033706
},
"반영합": {
"노무비": 0,
"재료비": 0,
"경비": 0,
"계": 0
}
}
}
+11 -4
View File
@@ -25,6 +25,7 @@ from fastapi.testclient import TestClient
from common_util.common_util_auth import verify_session
from M01_MasterData import M01_MasterData_Router as router_module
from M01_MasterData import M01_MasterData_Store as store
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete.M02_MasterTemplete_Router_Combos import router as combos_router
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "master_data/scripts"))
@@ -285,6 +286,12 @@ def test_메모가_글자_아니면_막는다(client: TestClient) -> None:
# ── 미리 보기 ─────────────────────────────────────────────────────────
# M02 미리 보기 길은 39-3 에 은퇴(일위대가 상세 = calc 길) — 같은 계산(`combo_preview`)을 바로 부름
def _preview(body: dict) -> dict:
got = combo_store.combo_preview(body["combo"], body["inputs"])
return json.loads(json.dumps(got, default=str, ensure_ascii=False))
def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별로_같다(client: TestClient) -> None:
key = _make(client)["key"]
each = {k: _inputs(k) for k in SAMPLE}
@@ -293,7 +300,7 @@ def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별
for name in ("노무비", "재료비", "경비", "계")
}
got = _post(client, f"{SYS}/preview", {"combo": key, "inputs": each})
got = _preview({"combo": key, "inputs": each})
assert got["멈춤"] == [], got["멈춤"]
assert [one["로직"] for one in got["줄"]] == list(SAMPLE)
for name in ("노무비", "재료비", "경비", "계"):
@@ -308,7 +315,7 @@ def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별
def test_미리보기는_저장_전_조합_줄도_받고_아무것도_저장하지_않는다(client: TestClient) -> None:
before = _list(client)
row = {"이름": "저장 안 한 것", "담은로직": [{"로직": STACK, "메모": None}]}
got = _post(client, f"{SYS}/preview", {"combo": row, "inputs": {STACK: _inputs(STACK)}})
got = _preview({"combo": row, "inputs": {STACK: _inputs(STACK)}})
assert got["멈춤"] == [] and Decimal(str(got["계"])) > 0
assert _list(client) == before # 아무것도 안 생김
@@ -316,7 +323,7 @@ def test_미리보기는_저장_전_조합_줄도_받고_아무것도_저장하
def test_못_도는_줄은_까닭만_적고_나머지_합계는_낸다(client: TestClient) -> None:
key = _make(client, keys=(STACK, LAYER))["key"]
only = {STACK: _inputs(STACK)} # LAYER 입력은 안 줌 — 그 줄만 멈춤
got = _post(client, f"{SYS}/preview", {"combo": key, "inputs": only})
got = _preview({"combo": key, "inputs": only})
stuck = [one for one in got["줄"] if not one["ok"]]
assert len(stuck) == 1 and stuck[0]["로직"] == LAYER and stuck[0]["까닭"]
assert len(got["멈춤"]) == 1
@@ -327,6 +334,6 @@ def test_못_도는_줄은_까닭만_적고_나머지_합계는_낸다(client: T
def test_정본_로직은_조합을_만들어도_안_바뀐다(client: TestClient) -> None:
before = _get(client, "/api/m01/logic", key=STACK)
key = _make(client)["key"]
_post(client, f"{SYS}/preview", {"combo": key, "inputs": {k: _inputs(k) for k in SAMPLE}})
_preview({"combo": key, "inputs": {k: _inputs(k) for k in SAMPLE}})
after = _get(client, "/api/m01/logic", key=STACK)
assert after["version"] == before["version"] and after["logic"] == before["logic"]
+57 -35
View File
@@ -1,4 +1,4 @@
"""일위대가 조합 다이어그램 값 만들기(`M02_MasterTemplete_Labor_Diagram.ts` 의 `buildDiagramData`) — 미리 보기 줄 → 로직 카드 출력.
"""일위대가 다이어그램 값 만들기(`Labor_Diagram.ts` 의 `buildLaborDiagram` · 39-3) — calc 줄 → 카드 출력.
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS 는 이어 트랜스파일 · 화면 부품(@ui)은 빈 흉내 ·
금액 모양(Logic_Money)도 빈 흉내(이 시험은 값 만들기만 봄). 견본 줄은 이 시험 안에 둠.
@@ -39,7 +39,7 @@ function load(f) {
}
const m = load(file);
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
process.stdout.write(JSON.stringify(jobs.map((j) => m.buildDiagramData(j))));
process.stdout.write(JSON.stringify(jobs.map((j) => m.buildLaborDiagram(j))));
"""
@@ -57,54 +57,76 @@ def run(jobs: list[dict]) -> list:
return json.loads(done.stdout)
ROWS = [
LOGICS = [
{
"key": "GC000001",
"name": "돌쌓기",
"unit": "원/㎡",
"inputs": [{"name": "높이", "unit": "m"}, {"name": "지역", "unit": ""}],
"로직": "GC000001",
"이름": "돌쌓기",
"결과단위": "원/㎡",
"입력": [{"이름": "높이", "단위": "m"}, {"이름": "지역", "자동값": "서울"}],
},
{
"key": "GF000002",
"name": "작업량",
"unit": "㎥/시간",
"inputs": [{"name": "지역", "unit": ""}],
},
{"key": "GC000003", "name": "막힌 로직", "unit": "원/m", "inputs": []},
{"로직": "GF000002", "이름": "작업량", "결과단위": "㎥/시간", "입력": [{"이름": "지역"}]},
{"로직": "GC000003", "이름": "막힌 로직", "결과단위": "원/m", "입력": []},
]
ROWS = [
{"로직": "GC000001", "입력값": {"높이": 4}},
{"로직": "GF000002", "입력값": {"지역": "부산"}},
{"로직": "GC000003", "입력값": {}},
]
MONEY = {"노무비": 100, "재료비": 50, "경비": 0, "계": 150}
APPLIED = {"노무비": 300, "재료비": 150, "경비": 0, "계": 450}
def test_돈_값_멈춤_세_갈래():
lines = [
{"ok": True, "갈래": "돈", "노무비": 100, "재료비": 50, "경비": 0, "계": 150},
{"ok": True, "갈래": "결과", "결과": 8.946},
{"ok": False, "까닭": "표에 맞는 줄이 없음"},
]
(got,) = run([{"rows": ROWS, "values": {"GC000001": {"높이": "4"}}, "lines": lines}])
def _line(key: str, **more) -> dict:
return {"로직": key, "기준": None, "반영": None, "까닭": None, **more}
def test_돈_값_멈춤_세_갈래_반영이_있으면_반영():
calc = {
"줄": [
_line("GC000001", 기준=MONEY, 반영=APPLIED),
_line("GF000002", 결과=8.946),
_line("GC000003", 까닭="표에 맞는 줄이 없음"),
],
"자재": [],
}
(got,) = run([{"rows": ROWS, "logics": LOGICS, "calc": calc, "mats": 0}])
outs = [x["out"] for x in got["logics"]]
assert outs[0] == {"노무비": 100, "재료비": 50, "경비": 0, "계": 150}
assert outs[0] == APPLIED and got["logics"][0]["basis"] == "반영"
assert outs[1] == {"값": 8.946}
assert outs[2] == {"멈춤": "표에 맞는 줄이 없음"}
def test_넣은_값은_로직별_빈_값은_빈_글():
(got,) = run(
[
{
"rows": ROWS,
"values": {"GC000001": {"높이": "4"}, "GF000002": {"지역": "서울"}},
"lines": None,
}
]
)
def test_수량_없으면_기준():
calc = {"줄": [_line("GC000001", 기준=MONEY)], "자재": []}
(got,) = run([{"rows": ROWS[:1], "logics": LOGICS, "calc": calc, "mats": 0}])
assert got["logics"][0]["out"] == MONEY and got["logics"][0]["basis"] == "기준"
def test_넣은_값은_로직별_빈_값은_자동값_또는_빈_글():
(got,) = run([{"rows": ROWS, "logics": LOGICS, "calc": None, "mats": 0}])
first, second, third = got["logics"]
assert [(i["name"], i["value"]) for i in first["inputs"]] == [("높이", "4"), ("지역", "")]
assert [(i["name"], i["value"]) for i in first["inputs"]] == [("높이", "4"), ("지역", "서울")]
assert first["inputs"][1].get("auto") is True and "auto" not in first["inputs"][0]
assert first["inputs"][0]["unit"] == "m"
assert [(i["name"], i["value"]) for i in second["inputs"]] == [("지역", "서울")]
assert [(i["name"], i["value"]) for i in second["inputs"]] == [("지역", "부산")]
assert third["inputs"] == []
def test_아직_안_셌으면_출력_없음():
(got,) = run([{"rows": ROWS, "values": {}, "lines": None}])
(got,) = run([{"rows": ROWS, "logics": LOGICS, "calc": None, "mats": 0}])
assert [x["out"] for x in got["logics"]] == [None, None, None]
assert [x["key"] for x in got["logics"]] == ["GC000001", "GF000002", "GC000003"]
def test_자재목록은_카드_하나_재료비로():
calc = {
"줄": [_line("GC000001", 기준=MONEY)],
"자재": [{"기준단가": 1000, "반영": 5000}, {"기준단가": 200, "반영": None}],
}
(got,) = run([{"rows": ROWS[:1], "logics": LOGICS, "calc": calc, "mats": 2}])
card = got["logics"][-1]
assert card["material"] is True and card["inputs"] == []
assert card["out"] == {"노무비": 0, "재료비": 5200, "경비": 0, "계": 5200}
assert card["basis"] == "기준" # 반영 없는 자재가 있음
(none,) = run([{"rows": ROWS[:1], "logics": LOGICS, "calc": calc, "mats": 0}])
assert not any(x.get("material") for x in none["logics"])
+119
View File
@@ -0,0 +1,119 @@
"""옛 일위대가 조합 → 열마다 일위대가 문서 옮기기(`M02_Labor_Migrate` · PLAN 39-1b).
시스템 층 = 견본 표(임시 폴더) · 조합은 M01 마스터(읽기만) · 개인 층 = 임시 storage.
"""
import json
from pathlib import Path
import pytest
from config import config_system
from M02_MasterTemplete import M02_Labor_Migrate as migrate
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.test_m02_structure import write_sample_table
SHARED = "UP42-000001"
def _point(folder: Path, keys: dict[str, str]) -> None:
path = folder / "table" / f"{store.STRUCTURE_TABLE}.json"
doc = json.loads(path.read_bytes())
for col in doc["열"]:
if col["id"] in keys:
col["일위대가"] = keys[col["id"]]
path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
@pytest.fixture()
def world(tmp_path, monkeypatch):
system = tmp_path / "system"
system.mkdir()
write_sample_table(system)
monkeypatch.setattr(store, "FOLDER", system)
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
storage = tmp_path / "storage"
storage.mkdir()
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
_point(system, {"rv": "UA000008", "c1": "UA000008", "pv_b": "UA999999"})
personal = layers.personal_dir(7, 42)
items_store.ensure(personal)
with store.at(personal):
got = store.read("table", store.STRUCTURE_TABLE)
doc = got["문서"]
doc["열"] = [
{"id": "a", "머리": ["돌", "가"], "꼴": "수", "일위대가": SHARED},
{"id": "b", "머리": ["돌", "나"], "꼴": "수", "일위대가": SHARED},
]
store.write("table", store.STRUCTURE_TABLE, got["판"], doc)
combo = {
"그룹": "일위대가조합",
"다음": 2,
"줄": [
{
"키": SHARED,
"이름": "개인 조합",
"단위": "개소",
"담은로직": [{"로직": "GF000219", "메모": "메쌓기"}],
}
],
}
(personal / combos.FILE).write_text(json.dumps(combo, ensure_ascii=False), encoding="utf-8")
return {"system": system, "personal": personal, "storage": storage}
def _labor(folder: Path, cid: str) -> dict:
return json.loads((folder / "labor" / f"{cid}.json").read_bytes())
def test_시스템_층_열마다_복사_같은_조합도_따로_없는_조합은_못함(world):
system = world["system"]
got = migrate.migrate(system)
assert got["옮김"] == ["rv ← UA000008", "c1 ← UA000008"]
assert got["못함"] == ["pv_b · 없는 일위대가 조합 「UA999999」"]
rv, c1 = _labor(system, "rv"), _labor(system, "c1")
assert rv["본뜬"] == {"층": "system", "주인": None, "키": "UA000008"}
assert rv["담은로직"] == c1["담은로직"] and len(rv["담은로직"]) == 14
assert all(one["입력값"] == {} and one["수량"] is None for one in rv["담은로직"])
assert rv["일위대가번"] != c1["일위대가번"] # 열마다 자기 번호
with store.at(system): # 나중에 만든 도면도 같은 뒷자리
store.create_structure("rv")
doc = json.loads((system / "structure" / "rv.json").read_bytes())
assert doc["도번"][2:] == rv["일위대가번"][2:]
assert rv["계산값"]["줄"] # 서버가 계산값까지
assert _labor(system, "ms")["담은로직"] == [] # 키 없는 열 = 빈 일위대가
def test_개인_층_여러_열이_쓰던_조합은_열마다_본뜬은_그_층(world):
personal = world["personal"]
assert migrate.layer_folders(True) == [personal]
got = migrate.migrate(personal)
assert got["옮김"] == [f"a ← {SHARED}", f"b ← {SHARED}"]
a, b = _labor(personal, "a"), _labor(personal, "b")
assert a["본뜬"] == b["본뜬"] == {"층": "personal", "주인": "7/42", "키": SHARED}
assert (a["단위"], a["담은로직"][0]["메모"]) == ("개소", "메쌓기")
assert a["일위대가번"] != b["일위대가번"]
def test_다시_돌려도_같음_사람이_고친_문서는_그대로(world):
system = world["system"]
migrate.migrate(system)
before = {p.name: p.read_bytes() for p in (system / "labor").glob("*.json")}
assert migrate.migrate(system)["옮김"] == []
after = {p.name: p.read_bytes() for p in (system / "labor").glob("*.json")}
assert before == after
with store.at(system): # 사람이 비운 문서도 다시 안 채움
got = store.read("labor", "rv")
store.write("labor", "rv", got["판"], {**got["문서"], "담은로직": []})
assert "rv" in migrate.migrate(system)["그대로"]
assert _labor(system, "rv")["담은로직"] == []
def test_미리_보기는_파일을_안_씀(world):
system = world["system"]
got = migrate.migrate(system, dry=True)
assert "rv ← UA000008" in got["옮김"]
assert not list((system / "labor").glob("*.json"))