Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1

This commit is contained in:
2026-11-01 23:16:12 +09:00
11 changed files with 530 additions and 779 deletions
+33 -20
View File
@@ -111,6 +111,7 @@ const TEXT = {
Fold: ["누르면 펼침", "Click to expand"],
Per: ["{n} — {v} 원/{u}", "{n} — {v} KRW/{u}"],
Edited: ["시험 고침", "Trial edit"],
EditedM02: ["변경 항목", "Override"],
} as const satisfies Record<string, readonly [string, string]>;
const th = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
const boxName = (b: string): string => (b in TEXT ? th(b as keyof typeof TEXT) : b);
@@ -120,8 +121,14 @@ export interface HoEdit {
covers: Record<number, LineCover>;
set: (idx: number, cover: LineCover | null) => void;
scope?: "m01" | "m02";
/** (M02) 모달 고침 칸 아래 덧 동작(줄 빼기 · 별도계상 등) */
more?: (idx: number, close: () => void) => HTMLElement[];
}
/** 고친 줄 꼬리표 — M01 = 시험 고침 · M02 = 변경 항목 · 고침 없음 = "" */
const editTag = (ctx: HoContext, idx: number): string =>
ctx.edit?.covers[idx] ? th(ctx.edit.scope === "m02" ? "EditedM02" : "Edited") : "";
export interface HoContext {
row: LogicRow;
prices: Record<string, ElementBrief | null>;
@@ -266,22 +273,28 @@ function modalOf(e: Entry, ctx: HoContext, extra: { cond?: PickCond; item?: HoLi
...(item?.종류 === "재료" && ctx.calcHost
? { onPick: (it) => pickMaterial(ctx.calcHost!, ctx.values, e.idx, it) }
: {}),
...(edit && item && !e.extra
// 덧줄(%) = M02 덮개만 율 고침(M01 시험 값은 호표 줄만)
...(edit && (e.extra ? edit.scope === "m02" : item)
? {
edit: (close: () => void) =>
coverForm({
qty: line ? line.수량 : null,
prices: e.logic
? {
labor: perUnit("노무비"),
material: perUnit("재료비"),
expense: perUnit("경비"),
}
: { [e.part]: line ? line.단가 : null },
edit: (close: () => void) => {
const form = coverForm({
qty: line ? pctView(line).qty : null,
prices: e.extra
? {}
: e.logic
? {
labor: perUnit("노무비"),
material: perUnit("재료비"),
expense: perUnit("경비"),
}
: { [e.part]: line ? line.단가 : null },
cover: edit.covers[e.idx],
scope: edit.scope,
onApply: (c) => (close(), edit.set(e.idx, c)),
}),
});
const more = edit.more?.(e.idx, close) ?? [];
return more.length ? el("div", { children: [form, ...more] }) : form;
},
}
: {}),
});
@@ -429,23 +442,23 @@ function formulaCell(e: Entry, ctx: HoContext, text = ""): HTMLElement {
return cell;
}
function nameCell(e: Entry, label: string, covered: boolean): HTMLElement {
function nameCell(e: Entry, label: string, tag: string): HTMLElement {
return el("div", {
children: [
el("div", { text: label }),
...(e.share && label === e.name ? [createBadge(`${e.share} ${tl("Share")}`, "accent")] : []),
...(e.spec ? [el("div", { className: "ui-text ui-text--muted", text: e.spec })] : []),
...(e.warn ? [createBadge(`${tx("Calc_UnitWarn")} · ${e.warn}`, "warning")] : []),
...(covered ? [createBadge(th("Edited"), "accent")] : []),
...(tag ? [createBadge(tag, "accent")] : []),
],
});
}
/** 보통 줄 · 몫 줄 — 누르면 그 줄 모달 */
function lineRow(e: Entry, ctx: HoContext, label = e.name): HTMLTableRowElement {
const covered = !!ctx.edit?.covers[e.idx];
const tag = editTag(ctx, e.idx);
const tr = tableRow(COLS, [
nameCell(e, label, covered && !e.share),
nameCell(e, label, e.share ? "" : tag),
e.qtyValue,
e.unit,
formulaCell(e, ctx),
@@ -454,7 +467,7 @@ function lineRow(e: Entry, ctx: HoContext, label = e.name): HTMLTableRowElement
]);
tr.classList.add("m01lab__line");
if (e.extra) tr.classList.add("m01lab__line--extra");
if (covered) tr.classList.add("is-changed");
if (tag) tr.classList.add("is-changed");
tr.tabIndex = 0;
tr.dataset.line = e.name;
tr.addEventListener("click", () => e.open(e));
@@ -486,9 +499,9 @@ function mergedRows(m: HoMerged<Entry>, ctx: HoContext): HTMLTableRowElement[] {
.replace("{a}", share("노무비"))
.replace("{b}", share("재료비"))
.replace("{c}", share("경비"));
const covered = !!ctx.edit?.covers[m.idx];
const tag = editTag(ctx, m.idx);
const head = tableRow(COLS, [
nameCell(first, `▸ ${first.name}`, covered),
nameCell(first, `▸ ${first.name}`, tag),
first.qtyValue,
first.unit,
formulaCell(first, ctx, short),
@@ -496,7 +509,7 @@ function mergedRows(m: HoMerged<Entry>, ctx: HoContext): HTMLTableRowElement[] {
money(m.금액),
]);
head.classList.add("m01lab__line");
if (covered) head.classList.add("is-changed");
if (tag) head.classList.add("is-changed");
head.dataset.equip = first.name;
head.title = th("Fold");
head.tabIndex = 0;
@@ -12,7 +12,7 @@ import {
type SubBrief,
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { LaborCalc, LaborFixed, LaborRef, LaborSpec } from "./M02_MasterTemplete_Labor_Calc";
import type { LinePatch } from "./M02_Sangeun_Cover_Model";
import type { LinePatch } from "./logic/M02_Sangeun_Cover_Model";
import {
call,
getActiveLayer,
@@ -2,11 +2,10 @@
* M02_MasterTemplete_Labor_RowSk.ts
* 일위대가 줄 모달 안 단가산출 상세 = 717-2b 단가산출(SK) 상세 「줄 모드」(PLAN 723-3 · 정함 4 · 5).
* · 줄 → 이름 없는 SK 문서(`lineDoc` · 고름 · 자재표시 = 바탕 줄 정체 cid) → `mountSangeunDetail` 그대로 얹음
* (보기 넷 · 품 · 단가 덮개 · 줄 빼기 · 더하기 · 시험 입력 칩)
* (보기 넷 · 공용 상세(724-1 `LogicLab_Ho`) 줄 모달 품 · 단가 덮개 · 줄 빼기 · 더하기 · 박스 옵션 · 입력 칩)
* · `take()` = [적용] 때 그 줄에 남길 것 — 패치 `{덮개, 뺀줄, 더한줄}`(M02 변경 항목 · M01 안 바뀜 · 내역 17-2) +
* 옵션(시험 입력 → 그 줄 `입력값` · 정함 5)
* · [단가산출로도 저장](기본 꺼짐) = `saveAsSk` — 개인 층 새 SK(`from-line`)
* 공용 부품 갈아 끼움은 724(sub2) — 지금은 717-2b 상세를 그대로 씀.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
@@ -15,13 +14,7 @@ import { fetchAuto } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { LaborRow } from "./M02_MasterTemplete_Labor_Api";
import { calcSangeun, sangeunFromLine, type SkScope } from "./M02_MasterTemplete_Sangeun_Cover";
import { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail";
import {
cleanPatch,
lineDoc,
needsIds,
type LinePatch,
type SkCalc,
} from "./M02_Sangeun_Cover_Model";
import { cleanPatch, lineDoc, needsIds, type LinePatch } from "./logic/M02_Sangeun_Cover_Model";
export interface RowSkHandle {
/** 고친 것(패치 · 옵션) — 없으면 null */
@@ -49,7 +42,7 @@ export function mountRowSk(
let ids: string[] | null = null;
try {
if (needsIds(o.row))
ids = (await calcSangeun<SkCalc>(o.scope, lineDoc(o.row, null, null))).정체 ?? null;
ids = (await calcSangeun(o.scope, lineDoc(o.row, null, null))).정체 ?? null;
} catch {
ids = null; // 정체 못 받음 = 고름 · 자재표시 없이(셈 까닭은 상세가 보임)
}
@@ -1,57 +1,26 @@
/* =============================================================================
* M02_MasterTemplete_Labor_SangeunModal.ts
* 일위대가 줄 [단가산출 보기] 모달(PLAN 717-3 · 요구서 내역12 4장 4 · 16 · 24 · 4-1 확정 5) —
* 로직 줄 · 단가산출(SK) 참조 줄 하나의 단가산출을 보고 그 일위대가에서만 고침.
* · 위 = 일위대가 머리(「1 ㎡ 당」) · 이 줄 수량 · 1 단위 금액 · 짝 잃은 덮개 알림
* · 보기 고르기 = 인력·재료·경비(기본 · 공용 계산서 `calc_sheet`) | 단가산출 상세(품 · 단가 칸 고침 · `Sangeun_Cover`)
* · 고침 = 그 일위대가 줄 칸 `패치 {덮개, 뺀줄, 더한줄}` · [적용] = 화면 캐시(저장은 페이지 [저장] ·
* 원값은 저장 때 서버가 셈) · 적용 안 하고 닫으면 물음
* 일위대가 줄 [단가산출 보기] 모달(PLAN 717-3 · 724-1 · 요구서 내역12 4장 4 · 16 · 24 · 내역 16 · 17-2) —
* 구조물 조합 일위대가 상세(`Labor_Detail`)의 로직 줄 · 단가산출(SK) 참조 줄 하나의 단가산출을 보고 그 일위대가에서만 고침.
* · 위 = 일위대가 머리(「1 ㎡ 당」) · 이 줄 수량
* · 몸 = 단가산출 상세 줄 모드(`Labor_RowSk` — 717-2b 보기 넷 · 공용 상세 줄 모달 그 자리 고침)
* · [적용] = 그 일위대가 줄 칸 `패치 {덮개, 뺀줄, 더한줄}`(M02 변경 항목 · M01 안 바뀜) · 화면 캐시(저장은 페이지 [저장]) ·
* 적용 안 하고 닫으면 물음
* · [단가산출로 저장] = 개인 층 새 SK(이름 받음 · `from-line`) · 일위대가가 자기 개인 층이면 그 줄을 새 SK 참조 줄로
* · 셈 = 서버 셈 길(로그인 · 저장 안 함 · 최신 응답만 · CLAUDE.md 5장)
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { askConfirm, askText, openModal } from "@ui/ui_template_modal";
import { createSegment } from "@ui/ui_template_segment";
import { createText } from "@ui/ui_template_text";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { calcSheetRows } from "../A00_Common/calc_sheet";
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
import type { LaborRow } from "./M02_MasterTemplete_Labor_Api";
import {
calcSangeun,
coverTable,
sangeunFromLine,
type SkScope,
} from "./M02_MasterTemplete_Sangeun_Cover";
import {
cleanPatch,
lineDoc,
needsIds,
setCover,
sheetGroups,
type LinePatch,
type SkCalc,
type SkLine,
} from "./M02_Sangeun_Cover_Model";
import { mountRowSk, saveAsSk } from "./M02_MasterTemplete_Labor_RowSk";
import type { SkScope } from "./M02_MasterTemplete_Sangeun_Cover";
import type { LinePatch } from "./logic/M02_Sangeun_Cover_Model";
const TEXT = {
Title: ["단가산출", "Unit-price build-up"],
Sheet: ["인력·재료·경비", "Labor · material · expense"],
Detail: ["단가산출 상세", "Detail"],
Qty: ["이 줄 수량", "Line quantity"],
Per: ["1 {u} 당", "per 1 {u}"],
Sum: ["계", "Total"],
Name: ["이름", "Name"],
Poom: ["품 · 수량", "Qty"],
Unit: ["단위", "Unit"],
Price: ["단가", "Unit price"],
Amount: ["금액", "Amount"],
Was: ["원래", "was"],
Tag: ["이 일위대가에서 고침", "Edited in this unit price"],
Empty: ["줄 없음", "No lines"],
Loading: ["셈하는 중…", "Calculating…"],
Lost: ["덮개 짝 잃음", "Override lost"],
Apply: ["적용", "Apply"],
ToSk: ["단가산출로 저장", "Save as unit-price build-up"],
AskName: ["새 단가산출 이름(내 개인 층)", "New build-up name (my layer)"],
@@ -82,130 +51,63 @@ export interface RowSangeunOptions {
replace?: (row: LaborRow) => void;
}
const same = (a: LinePatch | null, b: LinePatch | null): boolean =>
JSON.stringify(a) === JSON.stringify(b);
export function openRowSangeun(o: RowSangeunOptions): void {
let applied = cleanPatch(o.row.패치);
let patch = applied;
let ids: string[] | null = null;
let orig = new Map<string, SkLine>();
let calc: SkCalc | null = null;
let fail: string | null = null;
let view = "sheet";
let seq = 0;
let timer = 0;
const info = el("div", { className: "m02-std-sheet__body", attrs: { "data-sk-head": "" } });
const main = el("div", { className: "m02-std-sheet__body", attrs: { "data-sk-body": "" } });
const seg = createSegment({
options: [
{ value: "sheet", label: t("Sheet") },
{ value: "detail", label: t("Detail") },
],
value: view,
ariaLabel: t("Title"),
onChange: (v) => {
view = v;
draw();
},
});
const draw = (): void => {
const unit = o.unit || "식";
const one = calc?.["1단위"]?.계;
info.replaceChildren(
const info = el("div", {
className: "m02-std-sheet__body",
attrs: { "data-sk-head": "" },
children: [
createText(o.head, "muted", 14),
createText(`${o.title} · ${t("Qty")} ${o.qty}`, "muted", 14),
...(one != null ? [createText(`${t("Per").replace("{u}", unit)} ${formatMoney(one)}`)] : []),
...(o.readOnly ? [createText(t("ReadOnly"), "hint")] : []),
...(calc?.짝잃음 ?? []).map((x) =>
createText(`${t("Lost")} ${x.cid} · ${x.까닭}`, "warning"),
),
);
if (!calc)
return void main.replaceChildren(createText(fail ?? t("Loading"), fail ? "error" : "muted"));
const base = calc.배 && calc.배 !== 1 ? `${calc.배} ${unit}` : `1 ${unit}`;
main.replaceChildren(
...(fail ? [createText(fail, "error")] : []),
...(view === "sheet"
? calcSheetRows({
묶음: sheetGroups(calc),
계: calc.기준?.계 ?? null,
sumLabel: `${t("Sum")} (${base})`,
})
: [
coverTable(calc, orig, patch, {
edit: true,
text: {
name: t("Name"),
qty: t("Poom"),
unit: t("Unit"),
price: t("Price"),
amount: t("Amount"),
was: t("Was"),
tag: t("Tag"),
empty: t("Empty"),
},
onCover: (cid, key, value) => {
patch = setCover(patch, cid, key, value);
recalc();
},
}),
]),
);
applyBtn.disabled = o.readOnly || same(patch, applied);
],
});
const host = el("div", { attrs: { "data-sk-body": "" } });
// 읽기만이어도 고쳐 볼 수 있음([단가산출로 저장]으로만 남김) — 717-3 그대로
const sk = mountRowSk(host, {
row: o.row,
scope: o.scope,
title: o.title,
unit: o.unit,
readOnly: false,
});
/** 지금 패치(고침 없으면 그 줄 원래 패치) */
const now = (): LinePatch | null => {
const got = sk.take();
return got ? got.patch : (o.row.패치 ?? null);
};
/** 서버 셈 — 늦게 온 답은 버림 */
const recalc = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(async () => {
const mine = ++seq;
try {
const got = await calcSangeun<SkCalc>(o.scope, lineDoc(o.row, ids, patch));
if (mine !== seq) return;
calc = got;
fail = null;
} catch (error) {
if (mine !== seq) return;
fail = error instanceof Error ? error.message : String(error);
}
draw();
}, 150);
};
let close = (): void => {};
let done = false;
const applyBtn = createButton({
label: t("Apply"),
variant: "filled",
onClick: () => {
o.apply(patch);
applied = patch;
if (sk.take()) o.apply(now());
done = true;
close();
},
});
applyBtn.dataset.skApply = "";
applyBtn.hidden = o.readOnly;
const saveBtn = createButton({
label: t("ToSk"),
variant: "ghost",
onClick: async () => {
const name = (await askText({ message: t("AskName"), value: o.title }))?.trim();
if (!name) return;
const line: Record<string, unknown> = { ...o.row };
delete line.패치;
if (patch) line.패치 = patch;
try {
const got = await sangeunFromLine(
{ layer: "personal", owner: null },
line,
name,
o.unit || "식",
);
showToast(t("Saved").replace("{k}", got.이름), "success");
const key = await saveAsSk(o.row, now(), name, o.unit);
showToast(t("Saved").replace("{k}", key), "success");
if (!o.replace) return;
o.replace({ 로직: "", 입력값: {}, 메모: null, 별칭: null, 수량: o.row.수량, ...got.줄 });
showToast(t("Replaced").replace("{k}", got.이름), "info");
applied = patch;
o.replace({
로직: "",
입력값: {},
메모: null,
별칭: null,
수량: o.row.수량,
참조: { 층: "personal", 주인: null, 키: key },
});
showToast(t("Replaced").replace("{k}", key), "info");
done = true;
close();
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
@@ -218,24 +120,13 @@ export function openRowSangeun(o: RowSangeunOptions): void {
closeLabel: t("Close"),
width: "1100px",
wide: true,
actions: [seg.root, saveBtn, applyBtn],
beforeClose: () => same(patch, applied) || askConfirm({ message: t("Discard"), danger: true }),
mount: (body) => body.append(info, main),
actions: [saveBtn, applyBtn],
beforeClose: async () => {
if (!done && sk.take() && !(await askConfirm({ message: t("Discard"), danger: true })))
return false;
sk.destroy();
return true;
},
mount: (body) => body.append(info, host),
}));
draw();
// 원래 값(그 일위대가 고침 없음) 먼저 — 고름 · 자재표시가 있으면 바탕 줄 정체부터
void (async () => {
try {
if (needsIds(o.row))
ids = (await calcSangeun<SkCalc>(o.scope, lineDoc(o.row, null, null))).정체 ?? null;
const base = await calcSangeun<SkCalc>(o.scope, lineDoc(o.row, ids, null));
orig = new Map(base.줄.map((x) => [x.cid, x]));
if (patch) return recalc();
calc = base;
} catch (error) {
fail = error instanceof Error ? error.message : String(error);
}
draw();
})();
}
@@ -1,36 +1,18 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_Cover.ts
* 단가산출(SK) 고침 표 · 셈 길 부름(PLAN 717-2b · 717-3 · 요구서 내역12 4장 4 · 24) — 두 표가 셈 길 한 벌을 씀.
* 단가산출(SK) 셈 길 부름 · 뺀 줄 띠(PLAN 717-2b · 717-3 · 724-1)
* · 셈 = `calcSangeun`(`POST /layers/{층}/sangeun/calc` · 저장 안 함 · 부르는 쪽이 최신 응답만 받음) ·
* [단가산출로 저장] = `sangeunFromLine`(`/sangeun/from-line`)
* · `coverTable`(717-3 일위대가 모달) = 이름 · 품 · 단위 · 단가 · 금액 한 표 · 고친 줄 = 표 부품 「바뀜」
* (tr.is-changed) + 원래 값 곁 + 꼬리표 · 단가 칸은 한 비목 줄만 · 덧줄(%) = 율만(서버 `_covered` 규칙)
* · `skCoverTable`(717-2b SK 상세) = M01 「단가산출 상세」 꼴(인력 · 재료 · 경비 · 하위 묶음 + 소계 · 계 ·
* 열 = 이름 · 수량 · 단위 · 로직 · 단가 · 금액) · 품 · 단가 칸 고침 · 줄 빼기 · 고친 칸 = `is-edited`
* + 바탕 값 곁 글 + 꼬리표 · 값 = 서버 셈 답 그대로
* · 고침 표는 공용 상세(`M01_MasterData_UI_LogicLab_Ho` · 724-1) — 여기는 뺀 줄 되살림 띠만
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { el } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { t as L } from "@ui/ui_template_locale";
import { tableRow, tableShell, type Column } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import { formatNumber } from "../M01_MasterData/M01_MasterData_UI_Logic_Edit";
import { formatMoney, formatPrice } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import type { CoverRow, SkAnswer } from "./logic/M02_Sangeun_Cover_Model";
import {
priceKey,
qtyOf,
type CoverKey,
type LinePatch,
type PatchRow,
type SkCalc,
type SkLine,
} from "./M02_Sangeun_Cover_Model";
import type { PatchRow, SkAnswer } from "./logic/M02_Sangeun_Cover_Model";
import "./M02_MasterTemplete_Sangeun.css";
export interface SkScope {
@@ -40,8 +22,7 @@ export interface SkScope {
const q = (scope: SkScope): string =>
scope.owner && scope.layer !== "system" ? `?owner=${encodeURIComponent(scope.owner)}` : "";
/** 셈(저장 안 함) — 문서 = 화면 문서 · `given` = 시험 입력(빈 칸 뺌) · 서버가 M01 엔진 한 벌로 셈 ·
* 답 꼴 = 717-2b `SkAnswer`(기본) · 717-3 `SkCalc` */
/** 셈(저장 안 함) — 문서 = 화면 문서 · `given` = 시험 입력(빈 칸 뺌) · 서버가 M01 엔진 한 벌로 셈 */
export function calcSangeun<T = SkAnswer>(
scope: SkScope,
doc: Record<string, unknown>,
@@ -66,295 +47,38 @@ export const sangeunFromLine = (
body: JSON.stringify({ 줄: line, 이름, 단위 }),
});
const num = (v: number | null | undefined): string =>
v == null ? "—" : Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
export interface CoverTableText {
name: string;
qty: string;
unit: string;
price: string;
amount: string;
/** 원래 값 곁 글 머리(「원래」) */
was: string;
/** 고친 줄 꼬리표(「이 일위대가에서 고침」) */
tag: string;
empty: string;
}
export interface CoverTableOptions {
edit: boolean;
text: CoverTableText;
/** 칸 고침 — 빈 글 = null(그 칸 원래 값) */
onCover: (cid: string, key: CoverKey, value: number | null) => void;
}
/** 고칠 칸 — 글 칸(Enter · 떠남 때 넘김 · 숫자 아니면 그대로 되돌림) · 곁에 원래 값 */
function cell(
shown: number | null | undefined,
was: number | null | undefined,
covered: boolean,
o: CoverTableOptions,
set: ((v: number | null) => void) | null,
): HTMLElement {
const note =
covered && was != null && was !== shown ? [createText(`${o.text.was} ${num(was)}`)] : [];
if (!o.edit || !set) return el("div", { children: [el("span", { text: num(shown) }), ...note] });
const box = createInputField({ value: shown == null ? "" : String(shown), compact: true });
box.input.inputMode = "decimal";
box.input.dataset.coverCell = "";
box.input.addEventListener("keydown", (e) => e.key === "Enter" && box.input.blur());
box.input.addEventListener("change", () => {
const raw = box.input.value.replace(/,/g, "").trim();
const v = raw === "" ? null : Number(raw);
if (v !== null && (!Number.isFinite(v) || v < 0)) {
box.input.value = shown == null ? "" : String(shown);
return;
}
set(v);
});
return el("div", { children: [box.root, ...note] });
}
/** 고침 표 — `calc` = 지금 셈 답 · `orig` = 고치기 전 셈 줄(cid → 줄) · `patch` = 지금 줄 패치 */
export function coverTable(
calc: SkCalc,
orig: ReadonlyMap<string, SkLine>,
patch: LinePatch | null,
o: CoverTableOptions,
): HTMLElement {
const cols: Column[] = [
{ label: o.text.name },
{ label: o.text.qty, align: "end", width: "10rem" },
{ label: o.text.unit, width: "5rem" },
{ label: o.text.price, align: "end", width: "10rem" },
{ label: o.text.amount, align: "end", width: "9rem" },
];
const { root, tbody } = tableShell(cols, { density: "data" });
for (const line of calc.줄) {
const mine = patch?.덮개?.[line.cid] ?? {};
const was = orig.get(line.cid);
const pk = priceKey(line);
const covered = Object.keys(mine).length > 0 || (line.덮개?.length ?? 0) > 0;
const name = el("div", {
children: [
el("span", { text: line.이름, attrs: { title: line.출처 ?? line.이름 } }),
...(Object.keys(mine).length ? [createBadge(o.text.tag, "warning", { size: 18 })] : []),
],
});
const tr = tableRow(cols, [
name,
// 고친 칸 = 적은 값(서버가 아직 안 받는 칸도 보임) · 그 밖 = 서버 셈 값
cell(mine.qty ?? qtyOf(line), was && qtyOf(was), "qty" in mine, o, (v) =>
o.onCover(line.cid, "qty", v),
),
line.율 != null ? "%" : (line.단위 ?? ""),
cell(
(pk && mine[pk]) ?? line.단가,
was?.단가,
!!pk && pk in mine,
o,
pk ? (v) => o.onCover(line.cid, pk, v) : null,
),
line.금액 == null ? "—" : formatMoney(line.금액),
]);
tr.dataset.cid = line.cid;
if (covered) tr.classList.add("is-changed");
tbody.append(tr);
}
if (!calc.줄.length)
tbody.append(tableRow(cols, [{ content: calc.까닭 ?? o.text.empty, colSpan: cols.length }]));
return root;
}
/* ── 717-2b SK 상세 고침 표 ── */
export interface SkCoverOptions {
/** 고칠 수 있음(쓰기 층 · 읽기만 아님) */
edit: boolean;
/** 열린입력 있는 SK — 품 칸 = 배율 */
opened: boolean;
/** 고친 칸 꼬리표(「단가산출에서 고침」 · 717-3 = 「이 일위대가에서 고침」) */
tag: string;
total: number | null;
/** 뺀 줄(cid · 이름) — 되살림 */
removed: { cid: string; 이름: string }[];
onQty: (row: CoverRow, value: number | null) => void;
onPrice: (row: CoverRow, value: number | null) => void;
onDrop: (row: CoverRow) => void;
onRestore: (cid: string) => void;
/** 더한 재료 줄 별도계상 켬 · 끔 */
onApart: (row: CoverRow, on: boolean) => void;
}
const ORDER = ["인력", "재료", "경비", "하위"];
/** 칸에 넣을 값 — 유효 자리만(긴 소수 줄임) */
const shown = (v: number | null, digits: number): string =>
v == null ? "" : String(Number(v.toPrecision(digits)));
const parse = (raw: string): number | null => {
const v = Number(raw.replaceAll(",", "").trim());
return raw.trim() === "" || !Number.isFinite(v) || v < 0 ? null : v;
};
/** 고침 칸 — 숫자 입력(바뀜 = Enter · 칸 떠남) + 고쳤으면 바탕 값 곁 글 */
function editCell(
value: string,
was: string | null,
covered: boolean,
unit: string,
done: (v: number | null) => void,
): HTMLElement {
const box = createInputField({ value, compact: true, size: 24, ...(unit ? { unit } : {}) });
box.input.inputMode = "decimal";
box.input.dataset.cover = "";
const start = parse(value); // 보인 값 그대로면 고침 아님(자리 줄인 값)
box.input.addEventListener("change", () => {
const v = parse(box.input.value);
if (v !== start) done(v);
});
box.input.addEventListener("keydown", (ev) => ev.key === "Enter" && box.input.blur());
return el("div", {
className: "m02-sk__cell",
children: [
box.root,
...(covered && was !== null ? [createText(`${L("M02_SangeunWas")} ${was}`)] : []),
],
});
}
function rowCells(r: CoverRow, o: SkCoverOptions): (string | Node)[] {
const can = o.edit;
const qtyText = r.수량 == null ? "—" : formatNumber(r.수량);
const wasQty = r.was?.qty == null ? null : formatNumber(r.was.qty);
const wasPrice = r.was?.단가 == null ? null : formatPrice(r.was.단가);
const scaled = o.opened && r.added === null; // 열린입력 SK = 품 칸이 배율
const qty = can
? editCell(
scaled ? String(r.배율 ?? 1) : shown(r.수량, 6),
wasQty,
r.covered.qty,
scaled ? "×" : "",
(v) => o.onQty(r, v),
)
: qtyText;
const price =
can && r.priceEdit
? editCell(shown(r.단가, 9), wasPrice, r.covered.price, "", (v) => o.onPrice(r, v))
: r.단가 == null
? ""
: formatPrice(r.단가);
const tags = [
...(r.covered.qty || r.covered.price ? [createBadge(o.tag, "warning")] : []),
...(r.added !== null ? [createBadge(L("M02_SangeunAddedTag"), "accent")] : []),
...(r.pct ? [createBadge(L("M02_SangeunPct"), "neutral")] : []),
...(can && r.apartPick
? [
createCheckbox(L("M02_SangeunApart"), {
chip: true,
size: 22,
checked: r.apart,
onChange: (on) => o.onApart(r, on),
}).root,
]
: r.apart
? [createBadge(L("M02_SangeunApart"), "neutral")]
: []),
...(r.revised ? [createBadge(L("M02_SangeunRevised"), "danger")] : []),
];
const drop = createButton({
label: L("M02_SangeunDrop"),
variant: "danger-ghost",
size: 22,
onClick: () => o.onDrop(r),
});
/** 뺀 줄(cid · 이름) 띠 — 고칠 수 있으면 [되살림] */
export function removedStrip(
removed: { cid: string; 이름: string }[],
edit: boolean,
onRestore: (cid: string) => void,
): HTMLElement[] {
if (!removed.length) return [];
return [
r.이름,
qty,
r.단위,
el("div", { className: "m02-sk__tags", children: tags }),
price,
r.apart || r.금액 == null
? r.금액 == null
? ""
: `(${formatMoney(r.금액)})`
: formatMoney(r.금액),
...(o.edit ? [drop] : []),
el("div", {
className: "m02-sk__removed",
attrs: { "data-removed": "" },
children: [
createText(L("M02_SangeunRemoved")),
...removed.map((x) =>
el("span", {
className: "m02-sk__removed-item",
children: [
createBadge(x.이름, "neutral"),
...(edit
? [
createButton({
label: L("M02_SangeunRestore"),
variant: "ghost",
size: 22,
onClick: () => onRestore(x.cid),
}),
]
: []),
],
}),
),
],
}),
];
}
/** 묶음 표 넷 + 계 + 뺀 줄 */
export function skCoverTable(rows: CoverRow[], o: SkCoverOptions): HTMLElement {
const cols: Column[] = [
{ label: L("M02_SangeunColName") },
{ label: L("M02_SangeunColQty"), align: "end" },
{ label: L("M02_SangeunColUnit") },
{ label: L("M02_SangeunColLogic") },
{ label: L("M02_SangeunColPrice"), align: "end" },
{ label: L("M02_SangeunColAmount"), align: "end" },
...(o.edit ? [{ label: "", width: "5em" }] : []),
];
const names = [...new Set([...ORDER, ...rows.map((r) => r.묶음)])];
const blocks = names.flatMap((name) => {
const mine = rows.filter((r) => r.묶음 === name);
if (!mine.length && !ORDER.slice(0, 3).includes(name)) return [];
const { root, tbody } = tableShell(cols, { density: "data" });
for (const r of mine) {
const tr = tableRow(cols, rowCells(r, o));
tr.dataset.cid = r.cid;
tr.querySelectorAll("td")[1]?.classList.toggle("is-edited", r.covered.qty);
tr.querySelectorAll("td")[4]?.classList.toggle("is-edited", r.covered.price);
tbody.append(tr);
}
const sub = mine.reduce((s, r) => s + (r.apart ? 0 : (r.금액 ?? 0)), 0);
const subRow = tableRow(cols, [
{ content: `${name} ${L("M02_SangeunSubtotal")}`, colSpan: 5 },
formatMoney(sub),
...(o.edit ? [""] : []),
]);
subRow.dataset.subtotal = String(sub);
subRow.dataset.group = name;
tbody.append(subRow);
return [el("h4", { className: "m01lab__group", text: name }), root];
});
const removed = o.removed.length
? [
el("div", {
className: "m02-sk__removed",
attrs: { "data-removed": "" },
children: [
createText(L("M02_SangeunRemoved")),
...o.removed.map((x) =>
el("span", {
className: "m02-sk__removed-item",
children: [
createBadge(x.이름, "neutral"),
...(o.edit
? [
createButton({
label: L("M02_SangeunRestore"),
variant: "ghost",
size: 22,
onClick: () => o.onRestore(x.cid),
}),
]
: []),
],
}),
),
],
}),
]
: [];
return el("div", {
className: "m02-sk__cover",
attrs: { "data-sk-cover": "" },
children: [
...blocks,
el("p", {
className: "m02-sk__total",
attrs: { "data-sum": o.total == null ? "" : String(o.total) },
text: `${L("M02_SangeunTotal")} ${o.total == null ? "—" : formatMoney(o.total)}`,
}),
...removed,
],
});
}
@@ -1,8 +1,9 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_Detail.ts
* M02 단가산출(SK) 상세(PLAN 717-2b · 요구서 4장 3 · 4 · 13 · 17 · 24) — M01 「단가산출 로직」 상세 부품을 M02 길로.
* 보기 넷(`LogicLab_View`) = 단가산출 상세(고침 표 · `Sangeun_Cover`) · 인력·재료·경비(공용 계산서 · SK 셈 답) ·
* 시험 계산(`Logic_Calc` · 바탕 M01 로직) · 텍스트 수식(바탕 M01 로직 식) · 기본정보 = 바탕 로직(맨 위 접힘)
* M02 단가산출(SK) 상세(PLAN 717-2b · 724-1 · 요구서 4장 3 · 4 · 13 · 17 · 24) — M01 「단가산출 로직」 상세 부품을 M02 길로.
* 보기 넷(`LogicLab_View`) = 단가산출 상세(공용 상세 `LogicLab_Ho` · SK 셈 답 줄 = `hoView` · 줄 모달 그 자리 고침 =
* HoEdit → SK 덮개 · 더한줄 · 줄 빼기 · 별도계상) · 인력·재료·경비(공용 계산서 · SK 셈 답) ·
* 시험 계산(`Logic_Calc` · 바탕 M01 로직 + 덮개 품 · 단가 사본) · 텍스트 수식(같은 사본 식) · 기본정보 = 바탕 로직(맨 위 접힘)
* 고침 = SK 덮개 · 더한줄(`Sangeun_Pick`) · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`) · 값 = 서버 셈 길 최신 응답만
* 시험 입력 = SK 셈 `입력값`(저장 안 함) · [저장] = 화면 문서 → 서버가 다시 셈(원값 · 바탕판)
* 바탕 없는 SK = [기본 로직 선택] → 바탕 · 단위 · 바탕 없이 줄만도 됨
@@ -11,6 +12,7 @@
import { createBadge } from "@ui/ui_template_badge";
import { createBanner } from "@ui/ui_template_banner";
import { createButton } from "@ui/ui_template_button";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { el } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { t as L } from "@ui/ui_template_locale";
@@ -20,24 +22,31 @@ import { buildCalc } from "../M01_MasterData/M01_MasterData_UI_Logic_Calc";
import {
fetchLogic,
setLogicApiPaths,
type CalcLine,
type LogicOne,
type LogicRow,
type TextAnswer,
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { applyCovers } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Cover";
import { buildLabDetail } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Detail";
import { buildHo, type HoEdit } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Ho";
import { createViewPicker } from "../M01_MasterData/M01_MasterData_UI_LogicLab_View";
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
import { openFindPicker } from "./M02_MasterTemplete_Find_Pick";
import { calcSangeun, skCoverTable } from "./M02_MasterTemplete_Sangeun_Cover";
import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover";
import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick";
import {
baseCovers,
dropLine,
dropText,
hoView,
restoreLine,
setApart,
rowsOf,
setPrice,
setQty,
setApart,
setLineCover,
sheetGroups,
type CoverRow,
type HoView,
type SkAnswer,
type SkDoc,
} from "./logic/M02_Sangeun_Cover_Model";
@@ -227,19 +236,31 @@ export function mountSangeunDetail(
baseHint.hidden = !base;
const rows = ans ? rowsOf(ans, bare, doc) : [];
const names = new Map((bare ? rowsOf(bare, null, {}) : []).map((r) => [r.cid, r.이름]));
const ho = skCoverTable(rows, {
edit,
opened: opened(),
tag: L("M02_SangeunEditedHere"),
total: ans?.기준?.계 ?? null,
removed: (doc.뺀줄 ?? []).map((cid) => ({ cid, 이름: names.get(cid) ?? cid })),
onQty: (r, v) => (setQty(doc, r, v, opened()), touch()),
onPrice: (r, v) => (setPrice(doc, r, v), touch()),
onDrop: (r) => (dropLine(doc, r), touch()),
onRestore: (cid) => (restoreLine(doc, cid), touch()),
onApart: (r, on) => (setApart(doc, r, on), touch()),
});
const removed = (doc.뺀줄 ?? []).map((cid) => ({ cid, 이름: names.get(cid) ?? cid }));
const row = base?.logic ?? standIn();
const view = ans ? hoView(ans, bare, base?.logic ?? null, doc) : null;
const baseNames = [...(row.호표 ?? []), ...(row.덧줄 ?? [])].map((x) =>
String(x.이름 ?? ("요소" in x ? x.요소 : "")),
);
const cut = <T extends TextAnswer | undefined>(t: T): T =>
(t ? dropText(t, baseNames, bare, doc) : t) as T;
const ho = el("div", {
attrs: { "data-sk-cover": "" },
children: [
...removedStrip(removed, edit, (cid) => (restoreLine(doc, cid), touch())),
...buildHo({
row: view ? { ...row, 호표: view.호표, 덧줄: view.덧줄 } : row,
prices: base?.prices ?? {},
lines: (view?.lines ?? null) as CalcLine[] | null,
text: cut(text ?? undefined) ?? null,
lastText: cut(lastText),
values,
onInput,
calcHost,
edit: view && edit ? hoEdit(view, rows) : undefined,
}),
],
});
const est = el("div", {
attrs: { "data-est": "" },
children: ans
@@ -271,6 +292,51 @@ export function mountSangeunDetail(
});
}
/** 공용 상세 고침 고리 — 열쇠 = Ho 줄 차례 → cid · 고침 = SK 덮개 · 더한줄(M01 안 바뀜 · 내역 17-2) ·
* 모달 덧 동작 = 줄 빼기 · 더한 재료 별도계상 */
function hoEdit(view: HoView, rows: CoverRow[]): HoEdit {
const by = new Map(rows.map((r) => [r.cid, r]));
const at = (idx: number): CoverRow | undefined => by.get(view.cids[idx]);
return {
covers: view.covers,
scope: "m02",
set: (idx, c) => {
const r = at(idx);
if (!r) return;
setLineCover(doc, r, c, opened());
touch();
if (base) rerunCalc(); // 시험 계산 · 텍스트 수식 = 덮개 사본으로
},
more: (idx, close) => {
const r = at(idx);
if (!r) return [];
const drop = createButton({
label: L("M02_SangeunDrop"),
variant: "danger-ghost",
size: 28,
onClick: () => (close(), dropLine(doc, r), touch()),
});
drop.dataset.skDrop = "";
const apart = r.apartPick
? [
createCheckbox(L("M02_SangeunApart"), {
chip: true,
size: 28,
checked: r.apart,
onChange: (on) => (close(), setApart(doc, r, on), touch()),
}).root,
]
: [];
return [el("div", { className: "m02-sk__tags", children: [...apart, drop] })];
},
};
}
function rerunCalc(): void {
drawCalc();
calcHost.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
}
/** 텍스트 수식 · 계산서 입력 단추 — 시험 입력 고침 → 시험 계산 다시 그림 · 셈 */
function onInput(name: string, v: string): void {
values[name] = v;
@@ -283,12 +349,14 @@ export function mountSangeunDetail(
calcHost.replaceChildren(createText(L("M02_SangeunNoInputs")));
return;
}
const covers = baseCovers(bare, base.logic.호표?.length ?? 0, doc);
const covered = Object.keys(covers).length > 0;
buildCalc(calcHost, {
savedKey: base.logic.키,
file: base.file,
row: base.logic,
row: covered ? applyCovers(base.logic, covers) : base.logic,
prices: base.prices,
dirty: () => false,
dirty: () => covered, // 덮개 사본 = 저장 안 한 줄로 셈(메모리만)
values,
onText: (t) => {
text = t;
@@ -311,9 +379,9 @@ export function mountSangeunDetail(
}
}
if (!alive) return;
drawCalc();
draw();
await recalc();
await recalc(); // 바탕 정체(bare) 먼저 — 시험 계산 · 텍스트 수식 = 덮개 사본
if (alive) drawCalc();
}
void loadBase();
@@ -1,166 +0,0 @@
/* =============================================================================
* M02_Sangeun_Cover_Model.ts
* 단가산출 고친 칸(덮개) 순수 함수(PLAN 717-3 · 요구서 내역12 4장 4 · 16 · 24) — 화면 · fetch 없음.
* · `lineDoc` = 일위대가 줄 → 이름 없는 단가산출(SK) 문서(서버 `M02_Labor_Patch.as_doc` 꼴 · 셈 길 몸) ·
* 줄 `고름` · `자재표시`(호표 차례 열쇠)는 바탕 줄 정체(`ids` · 셈 답 `정체`)로 cid 열쇠로 바꿈
* · `setCover` · `cleanPatch` = 줄 칸 `패치 {덮개, 뺀줄, 더한줄}` 고침(숫자만 · `원값`은 서버가 저장 때 셈)
* · `sheetGroups` = 셈 답 줄 → 공용 계산서(`A00_Common/calc_sheet`) 묶음(값 = 서버 셈 그대로 · 다시 안 셈)
* 계산 자리 = 서버(`M02_Sangeun_Calc`) 단독 — 여기는 문서 모양만.
* ========================================================================== */
export const PARTS = ["노무비", "재료비", "경비"] as const;
export type Part = (typeof PARTS)[number];
/** 덮개 칸(서버 `M02_Sangeun_Doc.COVER_KEYS`) — qty = 품 · labor · material · expense = 줄 단가 */
export const PART_KEY = { 노무비: "labor", 재료비: "material", 경비: "expense" } as const;
export type CoverKey = "qty" | "배율" | (typeof PART_KEY)[Part];
export type Cover = Partial<Record<CoverKey, number>>;
export interface LinePatch {
덮개?: Record<string, Cover>;
뺀줄?: string[];
더한줄?: unknown[];
}
export type Money = Record<Part | "계", number>;
/** 셈 답 한 줄(M01 시험 계산 줄 + cid · 고친 칸 이름) */
export interface SkLine {
cid: string;
이름: string;
단위?: string;
수량: number;
단가?: number;
금액?: number;
비목?: Partial<Record<Part, number>>;
율?: number | null;
출처?: string;
덮개?: string[];
}
export interface SkCalc {
기준: Money | null;
줄: SkLine[];
까닭?: string | null;
"1단위"?: Money | null;
배?: number;
정체?: string[];
짝잃음?: { cid: string; 까닭: string }[];
머리?: string | null;
}
/** 줄 꼴(일위대가 `담은로직[]` 한 줄 · 쓰는 칸만) */
export interface PatchRow {
로직?: string;
참조?: { 층: string; 주인?: string | null; 키: string };
입력값?: Record<string, unknown>;
켜쌓기율?: number | null;
고름?: Record<string, unknown>;
자재표시?: Record<string, unknown>;
패치?: LinePatch;
}
const keyed = (value: Record<string, unknown> | undefined, ids: string[]) =>
Object.fromEntries(
Object.entries(value ?? {}).flatMap(([n, v]) =>
/^\d+$/.test(n) && Number(n) < ids.length ? [[ids[Number(n)], v]] : [],
),
);
/** 고름 · 자재표시가 있는 로직 줄 = 바탕 줄 정체가 먼저 있어야 함 */
export const needsIds = (row: PatchRow): boolean =>
!row.참조 &&
(Object.keys(row.고름 ?? {}).length > 0 || Object.keys(row.자재표시 ?? {}).length > 0);
/** 줄 → 이름 없는 SK 문서 — `patch` null = 그 일위대가 고침 없는 원래 값 */
export function lineDoc(
row: PatchRow,
ids: string[] | null,
patch: LinePatch | null,
): Record<string, unknown> {
const head = {
키: "SK-000000",
이름: "줄",
단위: "식",
덮개: patch?.덮개 ?? {},
뺀줄: patch?.뺀줄 ?? [],
더한줄: patch?.더한줄 ?? [],
};
if (row.참조) return { ...head, 바탕: { 참조: row.참조 } };
return {
...head,
...(row.켜쌓기율 != null ? { 켜쌓기율: row.켜쌓기율 } : {}),
바탕: { 로직: row.로직 ?? "" },
입력값: { ...(row.입력값 ?? {}) },
고름: ids ? keyed(row.고름, ids) : {},
자재표시: ids ? keyed(row.자재표시, ids) : {},
};
}
/** 단가를 고칠 수 있는 비목 — 금액이 한 비목에만 있는 줄만(덧줄 % 줄 = 율만 · 없음 = null) */
export function priceKey(line: SkLine): CoverKey | null {
if (line.율 != null || line.cid.startsWith("pct:")) return null;
const parts = PARTS.filter((p) => (line.비목?.[p] ?? 0) !== 0);
return parts.length === 1 ? PART_KEY[parts[0]] : null;
}
/** 품 칸 값 — 덧줄 = 율 · 그 밖 = 수량 */
export const qtyOf = (line: SkLine): number => line.율 ?? line.수량;
/** 빈 칸 · 원값 뺀 패치 — 다 비면 null(줄 칸 안 둠) */
export function cleanPatch(patch: LinePatch | null | undefined): LinePatch | null {
const covers = Object.fromEntries(
Object.entries(patch?.덮개 ?? {}).flatMap(([cid, c]) => {
const keep = Object.fromEntries(
Object.entries(c ?? {}).filter(([k, v]) => k !== "원값" && typeof v === "number"),
);
return Object.keys(keep).length ? [[cid, keep]] : [];
}),
);
const out: LinePatch = {
...(Object.keys(covers).length ? { 덮개: covers } : {}),
...(patch?.뺀줄?.length ? { 뺀줄: [...patch.뺀줄] } : {}),
...(patch?.더한줄?.length ? { 더한줄: [...patch.더한줄] } : {}),
};
return Object.keys(out).length ? out : null;
}
/** 칸 하나 고침 — `value` null = 그 칸 덮개 풂(원래 값) · 같은 값도 덮개로 둠(사용자가 적은 값) */
export function setCover(
patch: LinePatch | null,
cid: string,
key: CoverKey,
value: number | null,
): LinePatch | null {
const covers = { ...(patch?.덮개 ?? {}) };
const one: Cover = { ...(covers[cid] ?? {}) };
if (value === null) delete one[key];
else one[key] = value;
if (key === "qty") delete one.배율; // 품은 qty · 배율 중 하나(서버 400)
covers[cid] = one;
return cleanPatch({ ...patch, 덮개: covers });
}
const fmt = (v: number | null | undefined): string =>
v == null ? "-" : Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
/** 계산서 묶음 — 비목마다 그 비목 금액이 있는 줄 · 식 = 「단가 * 품 = 금액」(한 비목 줄) · 소계 = 서버 기준 */
export function sheetGroups(calc: SkCalc): {
비목: string;
소계: number | null;
줄: { 이름: string; 글: string; 금액: number | null }[];
}[] {
return PARTS.flatMap((p) => {
const lines = calc.줄.filter((x) => (x.비목?.[p] ?? 0) !== 0);
if (!lines.length) return [];
return [
{
비목: p,
소계: calc.기준?.[p] ?? null,
줄: lines.map((x) => {
const amount = x.비목?.[p] ?? null;
const single = priceKey(x) !== null && x.단가 != null;
const pct = x.율 != null ? " %" : ""; // 덧줄 = 율 %
const text = single ? `${fmt(x.단가)} * ${fmt(qtyOf(x))}` : `${fmt(qtyOf(x))}${pct}`;
return { 이름: x.이름, 글: `${text} = ${fmt(amount)}`, 금액: amount };
}),
},
];
});
}
@@ -1,10 +1,12 @@
/* =============================================================================
* M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts
* 단가산출(SK) 고침 꼴(PLAN 717-2b · 717-3 같이 씀) — 서버 셈 답(`/layers/{층}/sangeun/calc`) → 표 줄 ·
* 고친 칸 → SK 문서 덮개 · 더한줄 · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`). 금액은 늘 서버가 셈 —
* 여기는 줄 모양 · 문서 칸 옮김만(값 셈 없음 · 로직 줄 단가를 노 · 재 · 경 몫 비로 나누는 것만).
* 단가산출(SK) 고침 꼴(PLAN 717-2b · 717-3 · 724-1 — SK 상세 · 일위대가 줄 모드 같이 씀) —
* 서버 셈 답(`/layers/{층}/sangeun/calc`) → 공용 상세(`M01_MasterData_UI_LogicLab_Ho`) 줄 · 계산서 줄 ·
* 고친 칸(`LineCover` · 호표 차례 열쇠) → SK 문서 덮개 · 더한줄 · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`).
* 금액은 늘 서버가 셈 — 여기는 줄 모양 · 문서 칸 옮김만.
* 줄 정체 cid = `kind:키#n`(바탕 줄) · `add:#k`(더한줄 k 째) · 덮개 원값 = 저장 때 서버가 다시 적음
* (화면은 고친 첫때 바탕 값을 적어 둠 — 곁 글용) · 열린입력 있는 SK = 품은 배율만
* 일위대가 줄 → 이름 없는 SK 문서 = `lineDoc`(서버 `M02_Labor_Patch.as_doc` 꼴)
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
* ========================================================================== */
@@ -113,7 +115,6 @@ export interface CoverRow {
/* ── 순수 셈 ── */
const BUNDLE_OF: Record<string, string> = { 노무비: "인력", 재료비: "재료", 경비: "경비" };
const OWN_PART: Record<string, string> = { 인력: "노무비", 재료: "재료비" };
const ADD_RE = /^add:#(\d+)$/;
const COVER_KEYS = ["qty", "배율", ...PARTS] as const;
@@ -215,40 +216,28 @@ function putCover(doc: SkDoc, r: CoverRow, cover: SkCover): void {
doc.덮개 = covers;
}
/** 품 고침 — 더한줄 = 그 줄 수량 · 바탕 줄 = 덮개 qty(열린입력 SK = 배율 · 1 = 지움) · 바탕 값과 같거나 빈 값 = 덮개 지움 */
export function setQty(doc: SkDoc, r: CoverRow, v: number | null, opened: boolean): void {
if (r.added !== null) {
const one = doc.더한줄?.[r.added];
if (one && v !== null) one.수량 = v;
return;
}
const cover: SkCover = { ...(doc.덮개?.[r.cid] ?? {}) };
delete cover.qty;
delete cover.배율;
if (v !== null && opened && v !== 1) cover.배율 = v;
if (v !== null && !opened && !near(v, r.was?.qty)) cover.qty = v;
putCover(doc, r, cover);
}
/** 공용 상세 고침 칸(`LogicLab_Cover.LineCover` 와 같은 꼴) — 품 · 비목 단가(로직 줄 = 1 단위 노 · 재 · 경) */
export type LineCover = Pick<SkCover, "qty" | CoverPart>;
/** 단가 고침 — 요소 줄 = 그 비목 칸 · 로직 줄 = 노 · 재 · 경 몫 비로 나눔 · 더한 요소 줄 = 그 줄 단가 */
export function setPrice(doc: SkDoc, r: CoverRow, v: number | null): void {
if (!r.priceEdit) return;
/** 공용 상세 고침 한 줄 → 문서 — 더한줄 = 그 줄 수량 · 단가(요소 줄만) · 바탕 줄 = 덮개
* (열린입력 SK = 품 대신 배율 = 고친 품 ÷ 바탕 품 · 바탕 값과 같은 품 = 덮개 안 둠) · `c` null = 고침 풂 */
export function setLineCover(doc: SkDoc, r: CoverRow, c: LineCover | null, opened: boolean): void {
const price = r.priceEdit ? PARTS.map((k) => c?.[k]).find((v) => v != null) : undefined;
if (r.added !== null) {
const one = doc.더한줄?.[r.added];
if (!one) return;
if (v === null) delete one.단가;
else one.단가 = v;
if (c?.qty != null) one.수량 = c.qty;
if (price == null) delete one.단가;
else one.단가 = price;
return;
}
const cover: SkCover = { ...(doc.덮개?.[r.cid] ?? {}) };
for (const k of PARTS) delete cover[k];
if (v !== null && !near(v, r.was?.단가)) {
const keys = Object.keys(r.parts).filter((k) => PART_OF[k]);
const sum = keys.reduce((s, k) => s + r.parts[k], 0);
const own = PART_OF[keys[0] ?? ""] ?? PART_OF[OWN_PART[r.묶음] ?? "경비"];
if (keys.length === 1 || !sum) cover[own] = v;
else for (const k of keys) cover[PART_OF[k]] = Math.round(((v * r.parts[k]) / sum) * 1e4) / 1e4;
}
const cover: SkCover = {};
const qty = c?.qty;
// ponytail: 열린입력 배율 = 고친 품 ÷ 바탕 품(바탕 품 0 · 없음 = 품 고침 못 함)
if (qty != null && opened && r.was?.qty) cover.배율 = qty / r.was.qty;
if (qty != null && !opened && !near(qty, r.was?.qty)) cover.qty = qty;
if (r.priceEdit) for (const k of PARTS) if (c?.[k] != null) cover[k] = c[k];
if (cover.배율 != null && near(cover.배율, 1)) delete cover.배율;
putCover(doc, r, cover);
}
@@ -309,4 +298,221 @@ export function sheetGroups(rows: CoverRow[]): SheetGroup[] {
const order = Object.keys(PART_OF);
return [...groups.values()].sort((a, b) => order.indexOf(a.비목) - order.indexOf(b.비목));
}
/* ── 공용 상세(Ho) 줄 ── */
/** Ho 호표 · 덧줄 꼴(`M01_MasterData_UI_Logic_Api` HoLine · NamedFormula 중 쓰는 칸) */
export interface HoItem {
종류: string;
요소: string;
이름?: string;
규격?: string;
단위?: string;
수량: string;
비목?: string;
}
export interface HoExtra {
이름: string;
식: string;
비목?: string;
}
export interface HoView {
호표: HoItem[];
덧줄: HoExtra[];
/** Ho 줄 차례(호표 → 덧줄)의 셈 줄 — 셈 전 · 못 셈 = null · 별도계상 빠진 줄 = 빈 칸 */
lines: (SkLine | undefined)[] | null;
/** Ho 줄 차례 → cid(고침 열쇠 옮김) */
cids: string[];
/** Ho 줄 차례 → 지금 고침(덮개 · 더한줄) */
covers: Record<number, LineCover>;
}
const KIND_OF: Record<string, string> = { labor: "인력", material: "재료", machine: "기계" };
const ELEMENT_KINDS = ["인력", "재료", "기계"];
/** SK 셈 답 → 공용 상세 줄 — 줄 차례 = 서버 `정체`(바탕 남은 호표 · 더한줄 · 바탕 남은 덧줄) ·
* 바탕 로직 줄을 알면(`base` + 바탕 셈 `bare.정체` 차례) 그 줄 그대로(식 · 요소 · 규격) ·
* 더한줄 = 문서 줄 · 그 밖(참조 바탕) = 셈 줄로 지음 */
export function hoView(
ans: SkAnswer,
bare: SkAnswer | null,
base: { 호표?: HoItem[]; 덧줄?: HoExtra[] } | null,
doc: SkDoc,
): HoView {
const head = base?.호표 ?? [];
const known = new Map<string, HoItem | HoExtra>();
if (base && bare)
bare.정체.forEach((cid, i) => {
const it = i < head.length ? head[i] : base.덧줄?.[i - head.length];
if (it) known.set(cid, it);
});
const lineBy = new Map(ans.줄.map((l) => [l.cid, l]));
const single = (l?: SkLine): string | undefined => {
const parts = Object.entries(l?.비목 ?? {}).filter(([, v]) => v);
return parts.length === 1 ? parts[0][0] : undefined;
};
const ho: [string, HoItem][] = [];
const extra: [string, HoExtra][] = [];
for (const cid of ans.정체) {
const l = lineBy.get(cid);
const name = l?.이름 ?? cid;
if (cid.startsWith("pct:")) {
extra.push([cid, (known.get(cid) as HoExtra) ?? { 이름: name, 식: "" }]);
continue;
}
const hit = ADD_RE.exec(cid);
const a = hit ? doc.더한줄?.[Number(hit[1])] : undefined;
const kind = a
? ELEMENT_KINDS.includes(String(a.종류))
? String(a.종류)
: "로직"
: (KIND_OF[cid.split(":")[0]] ?? "로직");
const made: HoItem = {
종류: kind,
요소: typeof a?.요소 === "string" ? a.요소 : "",
이름: name,
단위: l?.단위 ?? String(a?.단위 ?? ""),
수량: String(a?.수량 ?? l?.율 ?? l?.수량 ?? ""),
비목: typeof a?.비목 === "string" ? a.비목 : single(l),
};
ho.push([cid, a ? made : ((known.get(cid) as HoItem) ?? made)]);
}
const cids = [...ho, ...extra].map(([cid]) => cid);
const covers: Record<number, LineCover> = {};
cids.forEach((cid, i) => {
const hit = ADD_RE.exec(cid);
const a = hit ? doc.더한줄?.[Number(hit[1])] : undefined;
const part = PART_OF[String(a?.비목 ?? "")];
if (a) covers[i] = { qty: a.수량, ...(a.단가 != null && part ? { [part]: a.단가 } : {}) };
const c = hit ? undefined : doc.덮개?.[cid];
if (!c) return;
const l = lineBy.get(cid);
const out: LineCover = {};
if (c.qty != null) out.qty = c.qty;
else if (c.배율 != null && l) out.qty = l.율 ?? l.수량 ?? undefined;
for (const k of PARTS) if (c[k] != null) out[k] = c[k];
covers[i] = out;
});
return {
호표: ho.map(([, x]) => x),
덧줄: extra.map(([, x]) => x),
lines: ans.줄.length ? cids.map((c) => lineBy.get(c)) : null,
cids,
covers,
};
}
/** 바탕 로직 호표 차례 고침(덮개 qty · 비목 — 배율 뺌) — 시험 계산 · 텍스트 수식 줄 사본(`applyCovers`)용 */
export function baseCovers(
bare: SkAnswer | null,
count: number,
doc: SkDoc,
): Record<number, LineCover> {
const out: Record<number, LineCover> = {};
(bare?.정체 ?? []).slice(0, count).forEach((cid, i) => {
const c = doc.덮개?.[cid];
const one: LineCover = {};
if (c?.qty != null) one.qty = c.qty;
for (const k of PARTS) if (c?.[k] != null) one[k] = c[k];
if (Object.keys(one).length) out[i] = one;
});
return out;
}
/** 텍스트 수식 답에서 뺀 바탕 줄 글 거름 — 공용 상세는 줄 글을 이름 차례로 붙임(같은 이름 더한줄이
* 뺀 줄 글을 받지 않게) · 차례 = 바탕 이름(호표 → 덧줄)을 따라감(`Ho.attachPieces` 와 같은 길) */
export function dropText<T extends { ok: boolean; groups?: { 줄: { 이름: string }[] }[] }>(
text: T | null,
names: string[],
bare: SkAnswer | null,
doc: SkDoc,
): T | null {
const drop = new Set(doc.뺀줄 ?? []);
const gone = new Set((bare?.정체 ?? []).flatMap((cid, i) => (drop.has(cid) ? [i] : [])));
if (!text?.ok || !text.groups || !gone.size) return text;
const groups = text.groups.map((g) => {
let from = 0;
const 줄 = g.줄.filter((line) => {
const at = names.findIndex((n, i) => i >= from && n === line.이름);
if (at < 0) return true;
from = at + 1;
return !gone.has(at);
});
return { ...g, 줄 };
});
return { ...text, groups };
}
/* ── 일위대가 줄 → SK 문서(717-3 · 723-3 줄 모드) ── */
export interface LinePatch {
덮개?: Record<string, SkCover>;
뺀줄?: string[];
더한줄?: unknown[];
}
/** 줄 꼴(일위대가 `담은로직[]` 한 줄 · 쓰는 칸만) */
export interface PatchRow {
로직?: string;
참조?: { 층: string; 주인?: string | null; 키: string };
입력값?: Record<string, unknown>;
켜쌓기율?: number | null;
고름?: Record<string, unknown>;
자재표시?: Record<string, unknown>;
패치?: LinePatch;
}
const keyed = (value: Record<string, unknown> | undefined, ids: string[]) =>
Object.fromEntries(
Object.entries(value ?? {}).flatMap(([n, v]) =>
/^\d+$/.test(n) && Number(n) < ids.length ? [[ids[Number(n)], v]] : [],
),
);
/** 고름 · 자재표시가 있는 로직 줄 = 바탕 줄 정체가 먼저 있어야 함 */
export const needsIds = (row: PatchRow): boolean =>
!row.참조 &&
(Object.keys(row.고름 ?? {}).length > 0 || Object.keys(row.자재표시 ?? {}).length > 0);
/** 줄 → 이름 없는 SK 문서 — `patch` null = 그 일위대가 고침 없는 원래 값 ·
* 고름 · 자재표시(호표 차례 열쇠)는 바탕 줄 정체(`ids` · 셈 답 `정체`)로 cid 열쇠로 */
export function lineDoc(
row: PatchRow,
ids: string[] | null,
patch: LinePatch | null,
): Record<string, unknown> {
const head = {
키: "SK-000000",
이름: "줄",
단위: "식",
덮개: patch?.덮개 ?? {},
뺀줄: patch?.뺀줄 ?? [],
더한줄: patch?.더한줄 ?? [],
};
if (row.참조) return { ...head, 바탕: { 참조: row.참조 } };
return {
...head,
...(row.켜쌓기율 != null ? { 켜쌓기율: row.켜쌓기율 } : {}),
바탕: { 로직: row.로직 ?? "" },
입력값: { ...(row.입력값 ?? {}) },
고름: ids ? keyed(row.고름, ids) : {},
자재표시: ids ? keyed(row.자재표시, ids) : {},
};
}
/** 빈 칸 · 원값 뺀 패치 — 다 비면 null(줄 칸 안 둠) */
export function cleanPatch(patch: LinePatch | null | undefined): LinePatch | null {
const covers = Object.fromEntries(
Object.entries(patch?.덮개 ?? {}).flatMap(([cid, c]) => {
const keep = Object.fromEntries(
Object.entries(c ?? {}).filter(([k, v]) => k !== "원값" && typeof v === "number"),
);
return Object.keys(keep).length ? [[cid, keep]] : [];
}),
);
const out: LinePatch = {
...(Object.keys(covers).length ? { 덮개: covers } : {}),
...(patch?.뺀줄?.length ? { 뺀줄: [...patch.뺀줄] } : {}),
...(patch?.더한줄?.length ? { 더한줄: [...patch.더한줄] } : {}),
};
return Object.keys(out).length ? out : null;
}
/* ── 순수 셈 끝 ── */
+56 -13
View File
@@ -1,7 +1,9 @@
"""717-2b M02 단가산출 상세 · 고침 지킴 시험 — 순수 꼴(node) · 글 · 서버 저장 왕복(임시 storage).
- 고친 칸 → SK 덮개(qty · 배율 · 비목 칸 · 원값 곁 글) · 더한줄 수량 · 단가 · 뺀줄 · 되살림
- 로직 줄 단가 = 노 · 재 · 경 몫 비로 나눔 · 계산서 묶음 = 표 줄 그대로(별도계상 재료 = 금액 없음)
- 공용 상세 고침(724-1 `setLineCover`) → SK 덮개(qty · 배율 · 비목 칸 · 원값 곁 글) ·
더한줄 수량 · 단가 · 뺀줄 · 되살림
- SK 셈 답 → 공용 상세 줄(`hoView` — 정체 차례 · 바탕 줄 그대로 · 더한줄 · 고침 열쇠) ·
계산서 묶음 = 표 줄 그대로
- 화면 문서 꼴이 서버 저장(`clean_doc`)을 지나고 서버가 원값 · 바탕판을 다시 적음 · 셈 = 손셈
- 상세 = M01 상세 부품(M02 길) · 부품 상자만 · 마스터 이동 단추 = 등록 없으면 숨김
"""
@@ -44,7 +46,7 @@ SAMPLE = """
const line = (cid, 이름, 수량, 단가, 비목, 묶음) => ({ cid, 이름, 단위: "인", 수량, 단가,
금액: Object.values(비목).reduce((a, b) => a + b, 0), 비목, 묶음 });
const bare = { 기준: null, 짝잃음: [], 바탕판: "x",
정체: ["labor:A#0", "logic:G#0", "material:M#0"],
정체: ["labor:A#0", "logic:G#0", "material:M#0", "pct:공구#0"],
줄: [line("labor:A#0", "석공", 0.1, 1000, { 노무비: 100 }, "인력"),
line("logic:G#0", "굴착기", 2, 50, { 노무비: 60, 재료비: 30, 경비: 10 }, "경비")],
재료: [{ 차례: 2, 이름: "모르타르", 수량: 0.5, 단가: 200, 금액: 100 }] };
@@ -56,23 +58,25 @@ def test_고침_꼴():
body = (
SAMPLE
+ """
const doc = { 덮개: {}, 더한줄: [{ 종류: "재료", 요소: "MT1", 수량: 1 }], 뺀줄: [] };
const doc = { 덮개: {}, 뺀줄: [],
더한줄: [{ 종류: "재료", 요소: "MT1", 비목: "재료비", 수량: 1 }] };
const ans = { ...bare, 정체: [...bare.정체, "add:#0"],
줄: [...bare.줄, line("add:#0", "더한 재료", 1, 300, { 재료비: 300 }, "재료")] };
const rows = rowsOf(ans, bare, doc);
const by = (cid) => rowsOf(ans, bare, doc).find((r) => r.cid === cid);
const out = {};
out.rows = rows.map((r) => [r.cid, r.묶음, r.apart, r.added, r.priceEdit]);
setQty(doc, by("labor:A#0"), 0.2, false);
setLineCover(doc, by("labor:A#0"), { qty: 0.2 }, false);
out.qty = doc.덮개["labor:A#0"];
setQty(doc, by("labor:A#0"), 0.1, false); // 바탕 값과 같음 = 덮개 지움
setLineCover(doc, by("labor:A#0"), { qty: 0.1 }, false); // 바탕 값과 같음 = 덮개 지움
out.qtyBack = doc.덮개["labor:A#0"] ?? null;
setPrice(doc, by("labor:A#0"), 1200);
setLineCover(doc, by("labor:A#0"), { labor: 1200 }, false);
out.price = doc.덮개["labor:A#0"];
setPrice(doc, by("logic:G#0"), 100); // 노 60 · 재 30 · 경 10 비
setLineCover(doc, by("labor:A#0"), null, false); // 고침 풂
out.unset = doc.덮개["labor:A#0"] ?? null;
setLineCover(doc, by("logic:G#0"), { labor: 60, material: 30, expense: 10 }, false);
out.split = doc.덮개["logic:G#0"];
setQty(doc, by("add:#0"), 3, false);
setPrice(doc, by("add:#0"), 350);
setLineCover(doc, by("add:#0"), { qty: 3, material: 350 }, false);
out.added = doc.더한줄[0];
out.pick = [by("add:#0").apartPick, by("labor:A#0").apartPick];
setApart(doc, by("add:#0"), true);
@@ -80,9 +84,9 @@ out.apartOn = doc.더한줄[0].별도계상 ?? null;
setApart(doc, by("add:#0"), false);
out.apartOff = doc.더한줄[0].별도계상 ?? null;
const opened = { 덮개: {} };
setQty(opened, by("labor:A#0"), 1.5, true);
setLineCover(opened, by("labor:A#0"), { qty: 0.15 }, true); // 바탕 품 0.1 → 배율 1.5
out.scale = opened.덮개["labor:A#0"];
setQty(opened, by("labor:A#0"), 1, true);
setLineCover(opened, by("labor:A#0"), { qty: 0.1 }, true);
out.scaleBack = opened.덮개["labor:A#0"] ?? null;
dropLine(doc, by("labor:A#0"));
out.drop = [doc.뺀줄, doc.덮개["labor:A#0"] ?? null];
@@ -94,6 +98,24 @@ const covered = { 덮개: { "labor:A#0": { qty: 0.3, 원값: { qty: 0.09, 단가
out.revised = rowsOf(bare, bare, covered).find((r) => r.cid === "labor:A#0").revised;
out.sheet = sheetGroups(rowsOf(bare, bare, {}))
.map((g) => [g.비목, g.소계, g.줄.map((x) => x.금액)]);
// 공용 상세 줄 — 바탕 줄 그대로 · 뺀 줄 없음 · 더한줄 · 별도계상 빠진 줄 = 빈 칸 · 덧줄
const base = { 호표: [{ 종류: "인력", 요소: "A", 이름: "석공", 수량: "0.1*x" },
{ 종류: "로직", 요소: "로직(G)", 수량: "2" }, { 종류: "재료", 요소: "M", 수량: "0.5" }],
덧줄: [{ 이름: "공구손료", 식: "노무비 * 3 / 100" }] };
const doc2 = { 덮개: { "labor:A#0": { qty: 0.3, 원값: { qty: 0.1 } } }, 뺀줄: ["logic:G#0"],
더한줄: [{ 종류: "인력", 요소: "LB2", 이름: "보통인부", 비목: "노무비",
수량: 0.01, 단가: 500 }] };
const ans2 = { ...bare, 정체: ["labor:A#0", "material:M#0", "add:#0", "pct:공구#0"],
줄: [line("labor:A#0", "석공", 0.3, 1000, { 노무비: 300 }, "인력"),
line("add:#0", "보통인부", 0.01, 500, { 노무비: 5 }, "인력"),
{ ...line("pct:공구#0", "공구손료", 9.15, 1, { 경비: 9.15 }, "경비"), 율: 3 }] };
const v = hoView(ans2, bare, base, doc2);
out.view = [v.호표.map((x) => [x.종류, x.요소, x.수량, x.비목 ?? null]), v.덧줄, v.cids,
v.lines.map((l) => l?.cid ?? null), v.covers];
out.viewRef = hoView(ans2, bare, null, doc2).호표[0];
out.baseCovers = baseCovers(bare, 3, doc2);
const text = { ok: true, groups: [{ 비목: "노무비", 줄: [{ 이름: "석공" }, { 이름: "굴착기" }] }] };
out.cut = dropText(text, ["석공", "굴착기", "모르타르"], bare, doc2).groups[0].줄.map((x) => x.이름);
console.log(JSON.stringify(out));
"""
)
@@ -107,13 +129,14 @@ console.log(JSON.stringify(out));
assert got["qty"] == {"qty": 0.2, "원값": {"qty": 0.1, "단가": 1000}}
assert got["qtyBack"] is None
assert got["price"] == {"labor": 1200, "원값": {"qty": 0.1, "단가": 1000}}
assert got["unset"] is None
assert got["split"]["labor"] == 60 and got["split"]["material"] == 30
assert got["split"]["expense"] == 10
assert got["added"]["수량"] == 3 and got["added"]["단가"] == 350
# 더한 재료 줄만 별도계상 고름 · 켬 = 칸 true · 끔 = 칸 지움(기본 계 안)
assert got["pick"] == [True, False]
assert got["apartOn"] is True and got["apartOff"] is None
assert got["scale"]["배율"] == 1.5 and "qty" not in got["scale"]
assert got["scale"]["배율"] == pytest.approx(1.5) and "qty" not in got["scale"]
assert got["scaleBack"] is None
assert got["drop"] == [["labor:A#0"], None]
assert got["restore"] == [] and got["dropAdded"] == 0
@@ -124,6 +147,21 @@ console.log(JSON.stringify(out));
["재료비", 30, [30, None]],
["경비", 10, [10]],
]
ho, extra, cids, lines, covers = got["view"]
assert ho == [
["인력", "A", "0.1*x", None], # 바탕 줄 그대로(식)
["재료", "M", "0.5", None],
["인력", "LB2", "0.01", "노무비"], # 더한줄
]
assert extra == [{"이름": "공구손료", "식": "노무비 * 3 / 100"}]
assert cids == ["labor:A#0", "material:M#0", "add:#0", "pct:공구#0"]
assert lines == ["labor:A#0", None, "add:#0", "pct:공구#0"] # 별도계상 빠진 재료 = 빈 칸
assert covers == {"0": {"qty": 0.3}, "2": {"qty": 0.01, "labor": 500}}
assert got["viewRef"] == { # 참조 바탕 = 셈 줄로 지음
"종류": "인력", "요소": "", "이름": "석공", "단위": "인", "수량": "0.3", "비목": "노무비",
} # fmt: skip
assert got["baseCovers"] == {"0": {"qty": 0.3}}
assert got["cut"] == ["석공"] # 뺀 바탕 줄 글 거름(같은 이름 더한줄이 안 받음)
def test_상세_부품_길_부품상자():
@@ -135,6 +173,11 @@ def test_상세_부품_길_부품상자():
assert "buildLabDetail(editor" in detail and "cover: { ho, est }" in detail
assert "buildCalc(calcHost" in detail and "openSangeunLinePicker(scope" in detail
assert "if (mine !== seq || !alive) return" in detail # 최신 응답만
# 724-1 — 단가산출 상세 = 공용 상세(Ho) · 고침 고리 = SK 덮개 · 옛 고침 표 걷음
assert "...buildHo({" in detail and 'scope: "m02",' in detail
assert "setLineCover(doc, r, c, opened());" in detail and "skCoverTable" not in detail
ho = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Ho.ts")
assert "edit.more?.(e.idx, close)" in ho and '"EditedM02"' in ho
for s in (detail, cover, _text(MODEL)):
assert "<button" not in s and "<input" not in s and "<table" not in s
assert "document.createElement" not in s and "sessionStorage." not in s
+18 -39
View File
@@ -1,9 +1,11 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""일위대가 줄 [단가산출 보기] 모달(PLAN 717-3) — 임시 storage · 순수 함수는 Node 로.
"""일위대가 줄 [단가산출 보기] 모달(PLAN 717-3 · 724-1) — 임시 storage · 순수 함수는 Node 로.
화면 문서(`M02_Sangeun_Cover_Model.lineDoc`) = 서버 `M02_Labor_Patch.as_doc` 꼴(같은 고름 · 자재표시 cid) ·
화면 문서(`logic/M02_Sangeun_Cover_Model.lineDoc`) = 서버 `M02_Labor_Patch.as_doc` 꼴
(같은 고름 · 자재표시 cid) ·
모달 셈 길(`/sangeun/calc` = `calc_doc`) 값 = 일위대가 셈의 그 줄 값 · 고친 칸 = 덮개 칸 이름 ·
덮개 고침 순수 함수 · 계산서 묶음 · `normalizeLabor` 가 줄 `패치` 를 지킴 · 화면 이음.
패치 정리 순수 함수 · `normalizeLabor` 가 줄 `패치` 를 지킴 ·
화면 이음(모달 = 줄 모드 SK 상세 · 공용 상세).
"""
import json
@@ -32,7 +34,7 @@ const fs = require("fs");
const path = require("path");
const root = process.argv[1];
const ts = require(path.join(root, "config", "node_modules", "typescript"));
const f = path.join(root, "M02_MasterTemplete", "M02_Sangeun_Cover_Model.ts");
const f = path.join(root, "M02_MasterTemplete", "logic", "M02_Sangeun_Cover_Model.ts");
const js = ts.transpileModule(fs.readFileSync(f, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
@@ -116,45 +118,20 @@ def test_모달_셈_길_값_일위대가_줄_값(world):
assert cover["덮개"] == ["qty"] and cover["수량"] == pytest.approx(0.5)
def test_덮개_순수_함수_계산서_묶음():
"""칸 고침 · 풂 · 원값 뺌 · 단가 칸 비목 · 묶음 = 비목마다 서버 값 그대로."""
lines = [
{"cid": "labor:LB1#0", "이름": "보통인부", "수량": 0.2, "단가": 100, "금액": 20,
"비목": {"노무비": 20, "재료비": 0, "경비": 0}},
{"cid": "logic:GC1#0", "이름": "하위", "수량": 1, "금액": 30,
"비목": {"노무비": 10, "재료비": 20, "경비": 0}},
{"cid": "pct:공구#0", "이름": "공구손료", "수량": 0.6, "율": 3, "금액": 0.6,
"비목": {"노무비": 0, "재료비": 0, "경비": 0.6}},
] # fmt: skip
calc_ = {"기준": {"노무비": 30, "재료비": 20, "경비": 0.6, "계": 50.6}, "줄": lines}
one = {"덮개": {"labor:LB1#0": {"qty": 0.5, "원값": {"qty": 0.2}}}}
def test_패치_정리_순수_함수():
"""원값 뺌 · 빈 칸 뺌 · 다 비면 null(줄 칸 안 둠) — 덮개 고침 자체는 717-2b `setLineCover`."""
one = {"덮개": {"labor:LB1#0": {"qty": 0.5, "원값": {"qty": 0.2}}, "x:a#0": {"원값": {}}}}
got = _node(
{
"set": ["setCover", None, "labor:LB1#0", "labor", 120],
"qty": ["setCover", {"덮개": {"x:a#0": {"배율": 2}}}, "x:a#0", "qty", 1],
"off": ["setCover", {"덮개": {"labor:LB1#0": {"qty": 1}}}, "labor:LB1#0", "qty", None],
"clean": ["cleanPatch", one],
"empty": ["cleanPatch", {"덮개": {}, "뺀줄": [], "더한줄": []}],
"none": ["cleanPatch", None],
"keep": ["cleanPatch", {"뺀줄": ["a:b#0"]}],
"p0": ["priceKey", lines[0]],
"p1": ["priceKey", lines[1]],
"p2": ["priceKey", lines[2]],
"q2": ["qtyOf", lines[2]],
"groups": ["sheetGroups", calc_],
}
)
assert got["set"] == {"덮개": {"labor:LB1#0": {"labor": 120}}}
assert got["qty"] == {"덮개": {"x:a#0": {"qty": 1}}} # 품은 qty · 배율 중 하나
assert got["off"] is None and got["empty"] is None
assert got["clean"] == {"덮개": {"labor:LB1#0": {"qty": 0.5}}} # 원값 = 서버 몫
assert got["empty"] is None and got["none"] is None
assert got["keep"] == {"뺀줄": ["a:b#0"]}
assert (got["p0"], got["p1"], got["p2"], got["q2"]) == ("labor", None, None, 3)
groups = got["groups"]
assert [g["비목"] for g in groups] == ["노무비", "재료비", "경비"]
assert [g["소계"] for g in groups] == [30, 20, 0.6]
assert groups[0]["줄"][0] == {"이름": "보통인부", "글": "100 * 0.2 = 20", "금액": 20}
assert [x["이름"] for x in groups[0]["줄"]] == ["보통인부", "하위"]
assert groups[2]["줄"][0]["글"] == "3 % = 0.6"
def test_normalizeLabor_줄_패치_지킴():
@@ -182,17 +159,19 @@ def test_화면_이음():
assert "const sk = opt.calc ? {} : { sangeun: openSk };" in detail
assert 'edit && opt.layer === "personal" && !library.owner' in detail
modal = (M02 / "M02_MasterTemplete_Labor_SangeunModal.ts").read_text("utf-8")
assert "same(patch, applied) || askConfirm(" in modal
assert '{ layer: "personal", owner: null }' in modal # 새 SK = 내 개인 층
assert "if (mine !== seq) return;" in modal # 최신 응답만
assert "mountRowSk(host," in modal # 724-1 — 줄 모드 SK 상세(공용 상세)
assert "!done && sk.take() && !(await askConfirm(" in modal # 적용 안 한 고침 물음
assert "saveAsSk(o.row, now(), name, o.unit)" in modal # 새 SK = 내 개인 층(saveAsSk)
sk = (M02 / "M02_MasterTemplete_Labor_RowSk.ts").read_text("utf-8")
assert '{ layer: "personal", owner: null }' in sk
cover = (M02 / "M02_MasterTemplete_Sangeun_Cover.ts").read_text("utf-8")
assert "/sangeun/calc" in cover and "/sangeun/from-line" in cover
assert 'tr.classList.add("is-changed")' in cover # 표 부품 「바뀜」 상태
assert "coverTable" not in cover and not (M02 / "M02_Sangeun_Cover_Model.ts").exists()
for name in (
"M02_MasterTemplete_Labor_Rows.ts",
"M02_MasterTemplete_Labor_Detail.ts",
"M02_MasterTemplete_Labor_SangeunModal.ts",
"M02_MasterTemplete_Sangeun_Cover.ts",
"M02_Sangeun_Cover_Model.ts",
"logic/M02_Sangeun_Cover_Model.ts",
):
assert len((M02 / name).read_text("utf-8").splitlines()) <= 700, name
+2 -2
View File
@@ -224,8 +224,8 @@ export const ui_locales_m2 = {
],
M02_SangeunScaleOnly: ["열린 입력 — 품은 배율만", "Open inputs — quantity as multiplier only"],
M02_SangeunBaseViews: [
"시험 계산 · 텍스트 수식 = 바탕 M01 로직(고치기 전)",
"Test calc · formula = base M01 logic (before edits)",
"시험 계산 · 텍스트 수식 = 바탕 M01 로직 + 고친 품 · 단가(뺀 줄 · 더한 줄 빼고)",
"Test calc · formula = base M01 logic + edited qty · price (no dropped · added lines)",
],
M02_SangeunWait: ["셈 기다리는 중", "Calculating…"],
M02_SangeunNoInputs: [