feat(M02): 일위대가 상세 부품 — 담은 로직 표 · 입력 모달 · 자재목록 표 · 다이어그램 calc 값 · 미리 보기 지움(39-3 C3 · C4 · C6)

- 새 Labor_Detail.ts(mountLaborDetail · 차례 = 정보 → 담은 로직 → 자재목록 → 다이어그램 · 고칠 때마다 calc 를 뒤에서 · 최신 답만 · 같은 로직 · 자재 두 번 막음)
- 새 Labor_Input.ts(담은 로직 줄 입력 모달 · 빈 칸 = 자동값 · 메모 · 수량 = 이은 칸 / 손 입력 / 면적산출에서 잇기 · 사라진 입력값 알림 · 자재 수량 모달)
- 새 Labor_Rows.ts(작은 글 「지역 서울 · 수량 B6 = 12.5㎡」 · 빈 값 n 붉게 · 금액 칸 넷 두 줄(위 기준 · 아래 반영) · 합계 줄 · 보기 갈래는 손잡이 없이)
- Labor_Api.ts: labor 문서 · calc 답 모양 · calcLabor · fetchLogicInputs · previewCombo 지움
- Labor_Diagram.ts: buildDiagramData → buildLaborDiagram(줄 입력값 + calc · 수량 있으면 반영 · 없으면 기준 표기 · 자재목록 카드 하나 → 재료비 · 알약 = 입력 모달)
- 미리 보기 지움: Labor_Preview.ts · M02 preview 길 · Combo_Store.preview · 옛 조합 상세의 미리 보기 · 다이어그램 칸
- 시험: test_m02_labor_diagram 새 모양 · test_m01_combo 미리 보기 시험은 같은 계산(combo_preview)을 바로 부름

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
This commit is contained in:
2026-10-04 17:42:59 +09:00
co-authored by Claude Opus 5.5
parent 43a59a6893
commit d847f724cf
14 changed files with 1266 additions and 426 deletions
@@ -190,12 +190,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">;
@@ -227,13 +210,6 @@ export const pasteCombos = (
판,
});
/** 보기만 — 수량(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);
@@ -248,3 +224,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,328 @@
/* =============================================================================
* 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 LaborMat,
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 "../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;
/** 자재 하나 고르기(`M02_Material_Pick`) — 없으면 [자재 추가] 없음 */
pickMaterial?: (done: (m: Omit<LaborMat, "수량">) => void) => void;
}
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 pick = opt.pickMaterial;
const addMat = pick
? createButton({
label: tc("Labor_AddMat"),
variant: "filled",
size: "sm",
onClick: () =>
pick((m) => {
if (cur.자재.some((x) => x.코드 === m.코드))
return showToast(
tc("Labor_AlreadyMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`),
"error",
);
cur.자재.push({ ...m, 수량: null });
changed();
}),
})
: null;
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 길과 같음.
"""
@@ -62,13 +62,6 @@ class PasteBody(BaseModel):
판: str = ""
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, 판, 폴더, 줄}]` · 회사 없음 빈 층."""
@@ -193,17 +186,6 @@ def post_paste(
return _call(folder, combo_copy.paste, source, body.model_dump())
@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:
+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"])