merge(M02): origin/sub_laptop_2 받음 — Api_Fetch Kind 설명 충돌 풂(labor 한 번)

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 18:28:00 +09:00
co-authored by Claude Opus 5.5
8 changed files with 302 additions and 86 deletions
@@ -123,3 +123,16 @@
gap: var(--spacing-8);
margin-top: var(--spacing-12);
}
/* 보기 갈래(면적산출 오른쪽 패널 · 일위대가 카드) */
.m02-labview {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m02-labview__title {
margin: 0;
font-size: var(--text-body-sm);
}
@@ -12,6 +12,7 @@ import {
type LogicSummary,
type SubBrief,
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { LaborCalc } from "./M02_MasterTemplete_Labor_Calc";
import {
getActiveLayer,
getActiveOwner,
@@ -215,39 +216,64 @@ export interface LaborDoc {
자재: LaborMat[];
계산값?: unknown;
}
export interface LaborCost {
노무비: number;
재료비: number;
경비: number;
계: number;
/** 받은 문서를 화면 모양으로 — 빈 칸 · 옛 모양도 받음(빈 일위대가) */
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,
})),
};
}
/** 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>;
/** 면적산출 문서의 알약 → {칸, 값}(식 칸 = 서버 `계산값` · 그 밖 = 칸 값 · 수만) — 서버 `area_values` 와 같은 규칙 */
export function areaLinks(area: unknown): Map<string, { 칸: string; 값: number | null }> {
const out = new Map<string, { 칸: string; 값: number | null }>();
const doc = (area && typeof area === "object" ? area : {}) as {
시트?: {
id?: string;
칸?: Record<string, { 값?: unknown }>;
연결?: { 키?: string; 칸?: string }[];
}[];
계산값?: Record<string, Record<string, { 수?: unknown }>>;
};
for (const sheet of doc.시트 ?? []) {
const solved = doc.계산값?.[sheet.id ?? ""] ?? {};
for (const link of sheet.연결 ?? []) {
if (!link.키 || !link.칸) continue;
const raw = solved[link.칸]?.수 ?? sheet.칸?.[link.칸]?.값;
const n =
raw === null || raw === undefined || raw === ""
? NaN
: Number(String(raw).replace(/,/g, ""));
out.set(link.키, { 칸: link.칸, 값: Number.isFinite(n) ? n : null });
}
}
return out;
}
/** calc 답 모양 · 수량 · 반영 짝 셈 = `M02_MasterTemplete_Labor_Calc.ts`(sub7 · ↔ `M02_Labor_Doc.py`) */
export type {
CalcLine as LaborCalcLine,
CalcMaterial as LaborCalcMat,
LaborCalc,
Money as LaborCost,
} from "./M02_MasterTemplete_Labor_Calc";
/** 지금 층이면 `owner` 를 붙인 층 길(`/layers/{층}/labor/{열}…`) */
const laborAt = (layer: Layer, column: string, tail: string): string => {
const owner = layer === getActiveLayer() ? getActiveOwner() : null;
@@ -255,13 +281,13 @@ const laborAt = (layer: Layer, column: string, tail: string): string => {
return `/layers/${layer}/labor/${enc(column)}${tail}${q}`;
};
/** 계산(서버 · 저장 안 함) — `doc` = 화면 캐시(없으면 저장본) · `수량` = 칸 값 바꿔 끼우기 */
/** 계산(서버 · 저장 안 함) — `doc` = 화면 캐시(없으면 저장본) · `수량` = {알약 키: 칸 값} 바꿔 끼우기 */
export const calcLabor = (
layer: Layer,
column: string,
doc: LaborDoc | null,
수량?: Record<string, number>,
): Promise<LaborCalc> =>
): Promise<LaborCalc & { 알약: unknown[] }> =>
request(laborAt(layer, column, "/calc"), "POST", {
...(doc ? { doc } : {}),
...(수량 ? { 수량 } : {}),
@@ -11,10 +11,13 @@ import { createButton, createInputField, el, showToast } from "@ui/ui_template_e
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 { readTemplate, type ItemLogic, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import { settle } from "./M02_MasterTemplete_Labor_Calc";
import {
areaLinks,
calcLabor,
fetchLogicInputs,
normalizeLabor,
type LaborCalc,
type LaborDoc,
type LaborRow,
@@ -26,7 +29,7 @@ import {
} 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 { logicTable, matTable } 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";
@@ -35,30 +38,6 @@ 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;
@@ -88,6 +67,11 @@ export function mountLaborDetail(
let seq = 0;
let timer = 0;
let alive = true;
/** 그 열 면적산출의 알약 → 칸 · 값(저장본 · 서버 calc 와 같은 것) */
let links = new Map<string, { 칸: string; 값: number | null }>();
const cells = (): Map<string, string> => new Map([...links].map(([k, v]) => [k, v.칸]));
const given = (): Record<string, number> =>
Object.fromEntries([...links].flatMap(([k, v]) => (v.값 === null ? [] : [[k, v.값]])));
const logicHost = el("div");
const matHost = el("div");
@@ -126,14 +110,14 @@ export function mountLaborDetail(
const draw = (): void => {
logicHost.replaceChildren(
logicTable(
{ rows: cur.담은로직, logics, calc, fail },
{ rows: cur.담은로직, logics, calc, fail, cells: cells() },
// 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠)
edit ? { open: openRow, move, remove: removeRow } : { open: openRow },
),
);
matHost.replaceChildren(
matTable(
{ mats: cur.자재, calc, fail },
{ mats: cur.자재, calc, fail, cells: cells() },
edit ? { open: openMat, remove: removeMat } : { open: openMat },
),
);
@@ -160,9 +144,20 @@ export function mountLaborDetail(
}, 150);
};
const changed = (): void => {
/** `qtyOnly` = 수량만 바뀜 — 기준은 그대로라 짝 셈(`Labor_Calc.settle`)으로 바로 곱함(서버 안 부름) */
const changed = (qtyOnly = false): void => {
opt.onChanged(JSON.stringify(cur) !== saved);
recalc();
if (qtyOnly && calc) {
calc = settle(calc, cur, given());
draw();
} else recalc();
};
/** 이은 칸 글 「B6 = 12.5 ㎡」 — 알약이 칸에 안 이어졌으면 null */
const linkedText = (pill: string | null | undefined, unit: string): string | null => {
const at = pill ? links.get(pill) : undefined;
if (!at) return null;
return `${at.칸} = ${at.값 === null ? "—" : at.값.toLocaleString("ko-KR")}${unit ? ` ${unit}` : ""}`;
};
/** 아직 안 받은 로직 입력 목록만 받음 */
@@ -203,16 +198,18 @@ export function mountLaborDetail(
memo: row.메모,
seat: {
qty: row.수량,
linked: line && line.수량출처 !== "손" ? qtyText(line, line.분모 ?? "") || null : null,
linked: linkedText(line?.알약, line?.분모 ?? ""),
unit: line?.분모 ?? "",
},
readOnly: !edit,
goArea: opt.goArea,
done: (next) => {
row.입력값 = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값;
const values = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값;
const qtyOnly = JSON.stringify(values) === JSON.stringify(row.입력값);
row.입력값 = values;
row.메모 = next.메모;
row.수량 = next.수량;
changed();
changed(qtyOnly);
},
});
}
@@ -224,17 +221,14 @@ export function mountLaborDetail(
`${mat.코드} ${mat.이름 ?? ""}`.trim(),
{
qty: mat.수량,
linked:
line && line.수량출처 && line.수량출처 !== "손" && line.수량 !== null
? `${line.수량출처} = ${line.수량}`
: null,
linked: linkedText(line?.알약, mat.단위 ?? ""),
unit: mat.단위 ?? "",
},
!edit,
opt.goArea,
(qty) => {
mat.수량 = qty;
changed();
changed(true);
},
);
}
@@ -248,7 +242,7 @@ export function mountLaborDetail(
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);
void loadLogics().then(() => changed());
}),
});
const addMat = createButton({
@@ -308,7 +302,14 @@ export function mountLaborDetail(
}),
);
draw();
void loadLogics().then(() => {
// 로직 입력 목록 · 면적산출 이은 칸(없으면 빈 것 — 새 구조물)을 받은 뒤 첫 계산
void Promise.all([
loadLogics(),
readTemplate(opt.layer, "area", opt.column, null).then(
(got) => (links = areaLinks(got.문서)),
() => undefined,
),
]).then(() => {
if (alive) recalc();
});
@@ -105,11 +105,11 @@ export interface LaborDiagramSource {
줄: {
로직: string;
기준: Money | null;
반영: Money | null;
결과?: number | null;
까닭: string | null;
반영?: Money | null;
결과?: unknown;
까닭?: string | null;
}[];
자재: { 기준단가: number | null; 반영: number | null }[];
자재: { 기준단가: number | null; 반영?: number | null }[];
} | null;
/** 담은 자재 수 — 0 이면 자재 카드 없음 */
mats: number;
@@ -157,7 +157,7 @@ export function buildLaborDiagram(src: LaborDiagramSource): DiagramData {
unit: "",
inputs: [],
out: mats ? { 노무비: 0, 재료비: sum, 경비: 0, 계: sum } : null,
...(mats ? { basis: mats.every((m) => m.반영 !== null) ? "반영" : "기준" } : {}),
...(mats ? { basis: mats.every((m) => typeof m.반영 === "number") ? "반영" : "기준" } : {}),
material: true,
});
}
@@ -137,7 +137,12 @@ export function openLaborInput(opt: LaborInputOptions): void {
if (isBad) bad.add(input.이름);
else bad.delete(input.이름);
if (value === null) delete draft[input.이름];
else draft[input.이름] = value;
// 고르기 · 범위 없는 칸도 수로 읽히면 수로 둠(M01 넣을 값과 같게)
else
draft[input.이름] =
typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))
? Number(value)
: value;
});
field.control.disabled = opt.readOnly;
field.control.setAttribute("data-labor-input", input.이름);
@@ -33,16 +33,17 @@ const HEAD: Record<(typeof COSTS)[number], Parameters<typeof tc>[0]> = {
const num = (v: number): string =>
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」 · 없으면 빈 글 */
/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) · 없으면 빈 글 */
export function qtyText(
line: { 수량: number | null; 수량출처: string | null } | undefined,
line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined,
unit: string,
cells: ReadonlyMap<string, string> = new Map(),
): 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}`;
if (line.수량출처 === "손") return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`;
const cell = (line.알약 && cells.get(line.알약)) || tc("In_QtyCell");
return `${tc("Labor_Qty")} ${cell} = ${v}`;
}
/** 금액 칸 하나 — 위 기준(작게) · 아래 반영 */
@@ -100,6 +101,8 @@ export interface LogicTableData {
/** calc 답 — null = 계산 중 · 못 셈은 `fail` */
calc: LaborCalc | null;
fail?: string | null;
/** 알약 키 → 이은 면적산출 칸(「B6」) */
cells?: ReadonlyMap<string, string>;
}
/** 담은 로직 표 — 보기 갈래는 `on` 없이 */
@@ -114,7 +117,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
const line = lineOf(row.로직);
const unit = logic?.결과단위 ?? "";
const values = valueLine(logic?.입력 ?? [], row.입력값);
const qty = qtyText(line, line?.분모 ?? "");
const qty = qtyText(line, line?.분모 ?? "", data.cells);
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 =>
@@ -210,6 +213,7 @@ export interface MatTableData {
mats: LaborMat[];
calc: LaborCalc | null;
fail?: string | null;
cells?: ReadonlyMap<string, string>;
}
/** 자재목록 표 — 보기 갈래는 `on` 없이 */
@@ -240,7 +244,7 @@ export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HT
el("td", {
className: "m02-labrow__num",
text: qty,
attrs: { title: line?.수량출처 ?? "" },
attrs: { title: qtyText(line, mat.단위 ?? "", data.cells) },
}),
el("td", { className: "m02-labrow__num", text: show(line?.기준단가) }),
el("td", { className: "m02-labrow__num", text: show(line?.반영) }),
@@ -271,7 +275,7 @@ export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HT
tc("Labor_Code"),
tc("Col_Name"),
tc("Labor_Spec"),
tc("Col_Unit"),
tc("Labor_Unit"),
tc("Labor_Qty"),
tc("Labor_BasePrice"),
tc("Labor_AppliedSum"),
@@ -0,0 +1,152 @@
/* =============================================================================
* M02_MasterTemplete_Labor_View.ts
* 일위대가 보기 갈래(39-3 · 고치기 없음) — 그 열 일위대가의 담은 로직 표 + 자재목록 표(기준 · 반영).
* 면적산출 상세 오른쪽 패널(sub3) · 구조물 조합 일위대가 카드(sub1)가 씀. 표 모양은 상세와 한 벌(`Labor_Rows`).
* · `show(층, 열, 수량?)` = 문서 · 로직 입력 목록 · calc 를 받아 그림(최신 응답만)
* · `setQty({알약 키: 칸 값})` = 면적 칸 값이 바뀌면 서버 없이 짝 셈(`Labor_Calc.settle`)으로 바로 곱함
* (그 열 면적 알약 값 전부를 줌 — 빠진 키는 「수량 없음」)
* · `onRow` = 줄 누름(알약 키 · 이름 · 단위 — 칸 지정 모달 등) · 없으면 누름 없음
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { readTemplate, type ItemLogic, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import {
areaLinks,
calcLabor,
fetchLogicInputs,
normalizeLabor,
type LaborCalc,
type LaborDoc,
} from "./M02_MasterTemplete_Labor_Api";
import { settle } from "./M02_MasterTemplete_Labor_Calc";
import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import "./M02_MasterTemplete_Labor.css";
export interface LaborViewRow {
kind: "logic" | "material";
/** 로직 키 · 자재 코드 */
key: string;
/** 면적산출 알약 키(로직 「이름|단위」 · 자재 「자재|MT키」) — calc 전이면 null */
pill: string | null;
name: string;
unit: string;
}
export interface LaborViewHandle {
root: HTMLElement;
/** 그 열 일위대가를 읽어 그림 — `수량` = 저장 안 한 면적 칸 값(없으면 저장된 면적산출) */
show: (layer: Layer, column: string | null, 수량?: Record<string, number>) => Promise<void>;
/** 면적 칸 값이 바뀜(알약 값 전부) — 받은 기준에 바로 곱함(서버 안 부름) */
setQty: (수량: Record<string, number>) => void;
/** 지금 문서(없으면 null) */
doc: () => LaborDoc | null;
destroy: () => void;
}
export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewHandle {
const root = el("div", { className: "m02-labview" });
const logics = new Map<string, ItemLogic>();
let doc: LaborDoc | null = null;
let calc: LaborCalc | null = null;
let fail: string | null = null;
let cells = new Map<string, string>();
let seq = 0;
const paint = (): void => {
if (!doc) return;
const d = doc;
const pick = (row: LaborViewRow): (() => void) | undefined =>
onRow ? () => onRow(row) : undefined;
const logicOpen = (i: number): void => {
const row = d.담은로직[i];
const line = calc?.줄.find((l) => l.로직 === row.로직);
pick({
kind: "logic",
key: row.로직,
pill: line?.알약 ?? null,
name: logics.get(row.로직)?.이름 ?? row.로직,
unit: line?.분모 ?? "",
})?.();
};
const matOpen = (i: number): void => {
const mat = d.자재[i];
const line = calc?.자재.find((m) => m.코드 === mat.코드);
pick({
kind: "material",
key: mat.코드,
pill: line?.알약 ?? null,
name: mat.이름 ?? mat.코드,
unit: mat.단위 ?? "",
})?.();
};
root.replaceChildren(
el("h4", { className: "m02-labview__title", text: tc("Logics_Title") }),
logicTable(
{ rows: d.담은로직, logics, calc, fail, cells },
onRow ? { open: logicOpen } : undefined,
),
el("h4", { className: "m02-labview__title", text: tc("Labor_Mats") }),
matTable({ mats: d.자재, calc, fail, cells }, onRow ? { open: matOpen } : undefined),
);
};
const show = async (
layer: Layer,
column: string | null,
수량?: Record<string, number>,
): Promise<void> => {
const mine = ++seq;
doc = null;
calc = null;
fail = null;
if (!column) return void root.replaceChildren();
root.replaceChildren(el("p", { className: "m02-labrow__muted", text: tc("Loading") }));
try {
const got = await readTemplate(layer, "labor", column, null);
if (mine !== seq) return;
doc = normalizeLabor(got.문서, column);
const want = doc.담은로직.map((r) => r.로직).filter((k) => !logics.has(k));
const [inputs, area] = await Promise.all([
fetchLogicInputs(want),
readTemplate(layer, "area", column, null).then(
(a) => a.문서,
() => null,
),
]);
if (mine !== seq) return;
for (const l of inputs.로직) logics.set(l.로직, l);
cells = new Map([...areaLinks(area)].map(([k, v]) => [k, v.칸]));
paint(); // 금액 「…」
calc = await calcLabor(layer, column, null, 수량);
} catch (error) {
if (mine !== seq) return;
if (!doc) {
root.replaceChildren(
el("p", {
className: "m02-labrow__empty",
text: error instanceof Error ? error.message : tc("Load_Failed"),
}),
);
return;
}
fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail");
}
if (mine === seq) paint();
};
return {
root,
show,
setQty: (수량) => {
if (!doc || !calc) return;
calc = settle(calc, doc, 수량);
paint();
},
doc: () => doc,
destroy: () => {
seq += 1;
root.remove();
},
};
}
@@ -68,6 +68,7 @@ const NUMBER_FIELD: Partial<Record<Kind, string>> = {
structure: "도번",
area: "면적번",
material: "재료번",
labor: "일위대가번",
};
// 표 부품(sub3) · 도면 부품(sub2) — 내보낸 이름으로 찾음
// 표는 진입 파일만 — 폴더 통째(`*.ts`)면 분수 모듈이 따로 늦게 받는 입구가 되어 번들러 도우미를
@@ -451,6 +452,20 @@ export function createMain(
editor = { getDoc: () => comboMerge(loaded, shown.getDoc()), destroy: shown.destroy };
return;
}
if (at.kind === "labor") {
// 일위대가 상세(39-3) — 구조물 열마다 하나 · 고침은 이 화면 [저장]으로
const { mountLaborDetail } = await import("./M02_MasterTemplete_Labor_Detail");
editor = mountLaborDetail(host, doc, {
layer: at.layer,
column: at.name,
readOnly,
onChanged: (d) => (dirty = d && !readOnly),
goArea: async () => {
if (await confirmLeave()) void open({ ...at, kind: "area" });
},
});
return;
}
if (at.kind === "area" || at.kind === "material") {
// 면적산출 · 재료산출 = 구조물집계표 열마다 한 스프레드시트 — 페이지 번들에 안 섞이게 이 화면을
// 열 때만 `import()` 로 받음(계약 `spreadsheet_types.ts` 머리)