feat(748-0): 단가산출 둘째 기초 — 세부 품 M01 원문 · 머리 수량 칸 하나 · 되돌림 셋(기초 빼기 · 더한 항목 빼기 · 더한 항목 수량)

- 둘째 기초 세부 줄 품 칸 = 서버 `원문`(0.871 그대로) · 금액 · 비목은 상자 합 몫 그대로 · 세부 고침도 원문 몫(머리 수량 · 화면배 안 나눔)
- 더한 기초 머리 상자(새 `Sangeun_BaseHead`) — 기초마다 이름 · 수량(기준단위 몇 개) 칸 · [되돌림] · [기초 빼기]
- 새 `logic/M02_Sangeun_Undo` — 뺀더한줄 기억 · 되살림(열쇠 당김 · 밂) · 처음수량 되돌림 · 넣을 때 처음수량 적음
- 되살림 띠에 뺀 더한줄도 · 일위대가 줄 패치 뺀더한줄 이음(RowSk · LinePatch)
- 낡은 시험 고침: test_727 세부 고침 ÷수량 기대 → 원문 그대로 · test_717_2b 모델 인라인에 Undo 추가
- 새 시험 test_748_0_sk_undo(거울 helper_748_0)
This commit is contained in:
2026-11-02 22:05:31 +09:00
parent 1c26a6f791
commit 94e330a5f3
13 changed files with 412 additions and 20 deletions
@@ -47,7 +47,12 @@ export function mountRowSk(
ids = null; // 정체 못 받음 = 고름 · 자재표시 없이(셈 까닭은 상세가 보임)
}
if (!alive) return;
const doc = { ...lineDoc(o.row, ids, before), 이름: o.title, 단위: o.unit || "식" };
const doc = {
...lineDoc(o.row, ids, before),
...(before?.뺀더한줄 ? { 뺀더한줄: before.뺀더한줄 } : {}), // 되살림 기억(744-1 · 셈엔 안 듦)
이름: o.title,
단위: o.unit || "식",
};
const box = el("div", { attrs: { "data-row-sk": "" } });
host.replaceChildren(box);
handle = mountSangeunDetail(box, doc, {
@@ -66,6 +71,7 @@ export function mountRowSk(
덮개: d.덮개 as LinePatch["덮개"],
뺀줄: d.뺀줄,
더한줄: d.더한줄,
뺀더한줄: d.뺀더한줄,
});
const values = o.row.참조
? values0
@@ -30,6 +30,7 @@
}
.m02-sk__tags,
.m02-sk__basehead,
.m02-sk__removed,
.m02-sk__removed-item {
display: flex;
@@ -42,6 +43,19 @@
gap: 8px;
}
/* 748-0 둘째 기초 머리 줄 — 이름 · 수량 칸 · 단추 한 줄 */
.m02-sk__basehead {
gap: var(--spacing-8);
align-items: flex-end;
}
.m02-sk__basename {
flex: 0 1 16em;
min-width: 0;
align-self: center;
font-weight: var(--font-weight-bold);
}
/* 고친 칸 — B09 고친 칸과 같은 꼴(색) · 곁 글 = 바탕 값 */
.m02-sk__cover td.is-edited {
background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas));
@@ -0,0 +1,92 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_BaseHead.ts
* 단가산출(SK) 둘째 기초 머리 줄(PLAN 748-0) — 기초마다 한 줄: 이름 · 「수량(기준단위 몇 개)」 칸 하나 ·
* [되돌림](넣을 때 수량으로) · [기초 빼기]. 세부 품은 M01 원문 그대로 보임(머리 수량은 이 칸 하나로).
* 값은 문서 `더한줄[k].수량`(화면배 t 로 나눈 화면 값) — 금액은 서버가 셈 · 여기는 칸 · 단추만.
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { createCard } from "@ui/ui_template_card";
import { el } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { t as L } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import type { SkAnswer, SkDoc } from "./logic/M02_Sangeun_Cover_Model";
export interface BaseHeadOptions {
doc: SkDoc;
ans: SkAnswer | null;
edit: boolean;
/** 칸에서 고른 새 수량(화면 값) */
onQty: (k: number, qty: number) => void;
onReset: (k: number) => void;
onDrop: (k: number, name: string) => void;
}
const shown = (v: number): string => String(Number(v.toPrecision(8)));
const same = (a: number, b: number): boolean => Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a));
/** 둘째 기초(더한줄 로직)마다 머리 줄 한 상자 — 없으면 빈 목록 */
export function baseHeads(o: BaseHeadOptions): HTMLElement[] {
const t = o.ans?.화면배 ?? 1;
const rows = (o.doc.더한줄 ?? []).flatMap((one, k) => {
if (one.종류 !== "로직") return [];
const line = o.ans?.줄.find((x) => x.cid === `add:#${k}`);
const name = line?.이름 || String(one.로직 ?? "");
const box = createInputField({
label: L("M02_SangeunBaseQty"),
value: shown(one.수량 / t),
compact: true,
size: 28,
});
box.input.inputMode = "decimal";
box.input.disabled = !o.edit;
box.input.dataset.baseQty = String(k);
box.input.addEventListener("change", () => {
const v = Number(box.input.value.replaceAll(",", "").trim());
if (box.input.value.trim() === "" || !Number.isFinite(v) || v < 0)
return box.setError(L("M02_SangeunBaseQtyBad"));
box.setError(undefined);
if (!same(v, one.수량 / t)) o.onQty(k, v);
});
const first = one.처음수량;
return [
el("div", {
className: "m02-sk__basehead",
attrs: { "data-base-head": String(k) },
children: [
el("span", { className: "m02-sk__basename", text: name }),
...(line?.꼬리표 ? [createBadge(line.꼬리표, "neutral")] : []),
box.root,
...(line?.단위 ? [createText(line.단위, "muted")] : []),
...(o.edit && first != null && !same(first, one.수량)
? [
createButton({
label: L("M02_SangeunQtyUndo"),
variant: "ghost",
size: 28,
onClick: () => o.onReset(k),
}),
]
: []),
...(o.edit
? [
createButton({
label: L("M02_SangeunDropBase"),
variant: "danger-ghost",
size: 28,
onClick: () => o.onDrop(k, name),
}),
]
: []),
],
}),
];
});
if (!rows.length) return [];
const card = createCard({ title: L("M02_SangeunBases"), pad: 12, body: rows });
card.classList.add("m01lab__ho-box"); // 상자 제목 · 안 간격 = 공용 상세 상자와 같음
card.dataset.baseHeads = "";
return [card];
}
@@ -36,6 +36,7 @@ import { buildLabDetail } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Det
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 { baseHeads } from "./M02_MasterTemplete_Sangeun_BaseHead";
import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover";
import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick";
import { baseUnitOf, buildUnitSwap, type SwapHow } from "./M02_MasterTemplete_Sangeun_Unit";
@@ -44,6 +45,7 @@ import {
dropBase,
dropLine,
dropText,
goneRows,
hoView,
removedSubs,
restoreLine,
@@ -57,6 +59,7 @@ import {
type SkAnswer,
type SkDoc,
} from "./logic/M02_Sangeun_Cover_Model";
import { resetQty, setQty, withFirst } from "./logic/M02_Sangeun_Undo";
export interface SangeunDetailOptions {
layer: Layer;
@@ -108,7 +111,7 @@ export function mountSangeunDetail(
if ("종류" in line && line.종류 === "로직") return void addBase(line);
doc.더한줄 = [
...(doc.더한줄 ?? []),
line as unknown as NonNullable<SkDoc["더한줄"]>[number],
withFirst(line as unknown as NonNullable<SkDoc["더한줄"]>[number]),
];
touch();
}),
@@ -129,7 +132,7 @@ export function mountSangeunDetail(
if (!alive) return;
doc.더한줄 = [
...(doc.더한줄 ?? []),
{ ...line, 수량: qty } as unknown as NonNullable<SkDoc["더한줄"]>[number],
withFirst({ ...line, 수량: qty } as unknown as NonNullable<SkDoc["더한줄"]>[number]),
];
touch();
}
@@ -306,6 +309,7 @@ export function mountSangeunDetail(
const removed = [
...(doc.뺀줄 ?? []).map((cid) => ({ cid, 이름: names.get(cid) ?? cid })),
...removedSubs(ans),
...goneRows(doc), // 뺀 더한줄(744-1)
];
const row = base ? { ...base.logic, 입력: [...(base.logic.입력 ?? []), ...extra] } : standIn();
const view = ans ? hoView(ans, bare, base?.logic ?? null, doc) : null;
@@ -318,6 +322,22 @@ export function mountSangeunDetail(
attrs: { "data-sk-cover": "" },
children: [
...removedStrip(removed, edit, (cid) => (restoreLine(doc, cid), touch())),
...baseHeads({
doc,
ans,
edit,
onQty: (k, qty) => {
const one = doc.더한줄?.[k];
if (one) setQty(one, qty * (ans?.화면배 ?? 1));
touch();
},
onReset: (k) => {
const one = doc.더한줄?.[k];
if (one) resetQty(one);
touch();
},
onDrop: (k, name) => (dropBase(doc, k, name), touch()),
}),
...buildHo({
row: view ? { ...row, 호표: view.호표, 덧줄: view.덧줄 } : row,
prices: base?.prices ?? {},
@@ -394,7 +414,11 @@ export function mountSangeunDetail(
label: L("M02_SangeunDropBase"),
variant: "danger-ghost",
size: 28,
onClick: () => (close(), dropBase(doc, sub.k), touch()),
onClick: () => (
close(),
dropBase(doc, sub.k, ans?.줄.find((x) => x.cid === `add:#${sub.k}`)?.이름),
touch()
),
}),
]
: [];
@@ -15,6 +15,7 @@
import { bornOfCid } from "./M02_Born_Tag";
import type { SheetGroup } from "../../A00_Common/calc_sheet";
import { forgetAdd, goneName, reviveAdd, setQty, resetQty } from "./M02_Sangeun_Undo";
export type CoverPart = "labor" | "material" | "expense";
export const PART_OF: Record<string, CoverPart> = {
@@ -41,6 +42,8 @@ export interface SkAdded {
덮개?: Record<string, SkCover>;
뺀줄?: string[];
바탕판?: string;
/** 넣을 때 수량(744-1) — [되돌림] 값 */
처음수량?: number;
[k: string]: unknown;
}
@@ -55,6 +58,8 @@ export interface SkDoc {
덮개?: Record<string, SkCover>;
더한줄?: SkAdded[];
뺀줄?: string[];
/** 뺀 더한줄 기억(744-1) — [되살림] = 그 차례에 다시 끼움 */
뺀더한줄?: { 차례: number; 줄: SkAdded }[];
고름?: Record<string, string>;
자재표시?: Record<string, unknown>;
바탕판?: string;
@@ -80,6 +85,8 @@ export interface SkLine {
꼬리표?: string;
/** 세부 줄 — 덮기 전 1 기준단위 품 · 단가 */
바탕값?: { qty?: number; 단가?: number } | null;
/** 둘째 기초 세부 줄(744-1) — 그 기초 1 기준단위 몫(M01 원문 · 머리 수량 안 곱함) · 화면 품 칸 */
원문?: { 수량: number | null; 금액: number | null; 비목?: Record<string, number | null> };
}
export interface SkMat {
@@ -268,7 +275,7 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR
priceEdit: !pct && (added === null || ["인력", "재료", "기계"].includes(String(one?.종류))),
apartPick: one?.종류 === "재료",
sub: at ? { ...at, scale: Number(owner?.수량 ?? 1) } : null,
t: pct ? 1 : t,
t: pct || at ? 1 : t, // 세부 줄 품 = M01 원문 그대로(744-1) — 화면배 안 곱함
};
};
for (const l of flatLines(ans)) rows.push(row(l.cid, l, partsOf(l), l));
@@ -315,15 +322,15 @@ export function setLineCover(doc: SkDoc, r: CoverRow, c: LineCover | null, opene
if (r.added !== null) {
const one = doc.더한줄?.[r.added];
if (!one) return;
if (c?.qty != null) one.수량 = c.qty * r.t;
if (c?.qty != null) setQty(one, c.qty * r.t);
else if (!c) resetQty(one); // [되돌림] = 넣을 때 수량(744-1)
if (price == null) delete one.단가;
else one.단가 = price;
return;
}
const cover: SkCover = {};
// 세부 줄 품 = 화면 품 ÷ 더한줄 수량(1 기준단위 몫 · 수량 0 = 그대로)
const raw = c?.qty == null ? c?.qty : c.qty * r.t;
const qty = r.sub && raw != null && r.sub.scale ? raw / r.sub.scale : raw;
// 세부 줄 품 = 화면 품 그대로(M01 원문 = 1 기준단위 몫 · 744-1) — 화면배 · 머리 수량 안 곱함
const qty = c?.qty == null ? c?.qty : c.qty * r.t;
const open = opened && !r.sub; // 열린입력 = 바탕 입력 — 세부 줄은 품 그대로
// ponytail: 열린입력 배율 = 고친 품 ÷ 바탕 품(바탕 품 0 · 없음 = 품 고침 못 함)
if (qty != null && open && r.was?.qty) cover.배율 = qty / r.was.qty;
@@ -342,7 +349,7 @@ export function dropLine(doc: SkDoc, r: CoverRow): void {
return;
}
if (r.added !== null) {
doc.더한줄 = (doc.더한줄 ?? []).filter((_, i) => i !== r.added);
forgetAdd(doc, r.added, r.이름); // 뺀더한줄 기억 — 744-1 [되살림]
return;
}
doc.뺀줄 = [...(doc.뺀줄 ?? []).filter((c) => c !== r.cid), r.cid];
@@ -363,6 +370,8 @@ export function setApart(doc: SkDoc, r: CoverRow, on: boolean): void {
/** 뺀 줄 되살림 — 세부 열쇠면 그 더한줄의 뺀줄 */
export function restoreLine(doc: SkDoc, cid: string): void {
const gone = /^gone:(\d+)$/.exec(cid); // 뺀 더한줄(744-1) — 그 차례에 다시 끼움
if (gone) return reviveAdd(doc, Number(gone[1]));
const at = subOf(cid);
const one = at ? doc.더한줄?.[at.k] : undefined;
if (one && at) {
@@ -373,11 +382,15 @@ export function restoreLine(doc: SkDoc, cid: string): void {
doc.뺀줄 = (doc.뺀줄 ?? []).filter((c) => c !== cid);
}
/** 둘째 기초 통째 빼기 — 그 더한줄 지움 */
export function dropBase(doc: SkDoc, k: number): void {
doc.더한줄 = (doc.더한줄 ?? []).filter((_, i) => i !== k);
/** 둘째 기초 통째 빼기 — 그 더한줄을 뺌(기억 744-1 · 되살림 띠 글 = `name`) */
export function dropBase(doc: SkDoc, k: number, name = ""): void {
forgetAdd(doc, k, name);
}
/** 뺀 더한줄 되살림 띠 항목 — 열쇠 `gone:n` */
export const goneRows = (doc: SkDoc): { cid: string; 이름: string }[] =>
(doc.뺀더한줄 ?? []).map((g, n) => ({ cid: `gone:${n}`, 이름: goneName(g.줄) }));
const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 });
const num = (v: number | null): string => (v == null ? "—" : NUM.format(v));
@@ -465,6 +478,9 @@ export function hoView(
const parts = Object.entries(l?.비목 ?? {}).filter(([, v]) => v);
return parts.length === 1 ? parts[0][0] : undefined;
};
/** 세부 줄 = 수량만 M01 원문(금액 · 비목은 상자 합 몫 그대로) */
const shown = (l?: SkLine): SkLine | undefined =>
l?.원문 ? { ...l, 수량: l.원문.수량 ?? l.수량 } : l;
const ho: [string, HoItem][] = [];
const extra: [string, HoExtra][] = [];
for (const cid of flatIds(ans)) {
@@ -479,7 +495,7 @@ export function hoView(
}
if (at) {
const kind = KIND_OF[at.cid.split(":")[0]] ?? "로직";
const qty = String(l?.율 ?? l?.수량 ?? "");
const qty = String(l?.율 ?? l?.원문?.수량 ?? l?.수량 ?? ""); // 세부 품 = M01 원문(744-1)
const item = { 종류: kind, 요소: "", 이름: name, 단위: l?.단위 ?? "", 수량: qty };
ho.push([cid, { ...item, 비목: single(l), ...tag }]);
continue;
@@ -514,8 +530,8 @@ export function hoView(
if (!c) return;
const l = lineBy.get(cid);
const out: LineCover = {};
const per = (at?.cid ?? cid).startsWith("pct:") ? 1 : t; // 율 = 그대로
if (c.qty != null) out.qty = (at ? c.qty * Number(owner?.수량 ?? 1) : c.qty) / per;
const per = at || cid.startsWith("pct:") ? 1 : t; // 율 · 세부 품 = 그대로
if (c.qty != null) out.qty = c.qty / per;
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;
@@ -523,7 +539,7 @@ export function hoView(
return {
호표: ho.map(([, x]) => x),
덧줄: extra.map(([, x]) => x),
lines: ans.줄.length ? cids.map((c) => lineBy.get(c)) : null,
lines: ans.줄.length ? cids.map((c) => shown(lineBy.get(c))) : null,
cids,
covers,
};
@@ -575,6 +591,8 @@ export interface LinePatch {
덮개?: Record<string, SkCover>;
뺀줄?: string[];
더한줄?: unknown[];
/** 뺀 더한줄 기억(744-1) */
뺀더한줄?: SkDoc["뺀더한줄"];
}
/** 줄 꼴(일위대가 `담은로직[]` 한 줄 · 쓰는 칸만) */
@@ -640,6 +658,7 @@ export function cleanPatch(patch: LinePatch | null | undefined): LinePatch | nul
...(Object.keys(covers).length ? { 덮개: covers } : {}),
...(patch?.뺀줄?.length ? { 뺀줄: [...patch.뺀줄] } : {}),
...(patch?.더한줄?.length ? { 더한줄: [...patch.더한줄] } : {}),
...(patch?.뺀더한줄?.length ? { 뺀더한줄: [...patch.뺀더한줄] } : {}),
};
return Object.keys(out).length ? out : null;
}
@@ -0,0 +1,85 @@
/* =============================================================================
* M02_MasterTemplete/logic/M02_Sangeun_Undo.ts
* 단가산출(SK) 되돌리기 기억(PLAN 744-1 서버 · 748-0 화면) — 더한 기초 · 더한 항목 빼기 → 문서 `뺀더한줄`
* `[{차례, 줄}]`(되살림 = 그 차례에 다시 끼움) · 더한 항목 수량 → `처음수량`(넣을 때 값) 되돌림.
* 더한줄 지움 · 끼움 때 뒤 더한줄 열쇠 `add:#j[/…]`(덮개 · 고름 · 자재표시)를 한 칸씩 당김 · 밂.
* 금액은 늘 서버가 셈 — 여기는 문서 칸 옮김만 · 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
* ========================================================================== */
import type { SkAdded, SkDoc } from "./M02_Sangeun_Cover_Model";
/* ── 순수 셈 ── */
const KEYED = ["덮개", "고름", "자재표시"] as const;
const KEY_RE = /^add:#(\d+)(.*)$/;
/** 문서 열쇠 맵의 `add:#j…` 를 옮김 — `drop` = 지운 j(그 열쇠는 버림) · 뒤 j 는 한 칸 당김 / `push` = 끼운 j 부터 한 칸 밂 */
function moveKeys(doc: SkDoc, mode: { drop: number } | { push: number }): void {
for (const name of KEYED) {
const map = doc[name] as Record<string, unknown> | undefined;
if (!map) continue;
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(map)) {
const hit = KEY_RE.exec(key);
if (!hit) {
out[key] = value;
continue;
}
const j = Number(hit[1]);
if ("drop" in mode) {
if (j === mode.drop) continue;
out[j > mode.drop ? `add:#${j - 1}${hit[2]}` : key] = value;
} else out[j >= mode.push ? `add:#${j + 1}${hit[2]}` : key] = value;
}
(doc as Record<string, unknown>)[name] = out;
}
}
/** 더한줄 k 를 빼고 기억에 둠 — 이름은 되살림 띠 글(서버가 저장 때 다시 적음) */
export function forgetAdd(doc: SkDoc, k: number, name: string): void {
const one = doc.더한줄?.[k];
if (!one) return;
doc.뺀더한줄 = [
...(doc.뺀더한줄 ?? []),
{ 차례: k, 줄: JSON.parse(JSON.stringify({ ...one, ...(name ? { 이름: name } : {}) })) },
];
doc.더한줄 = (doc.더한줄 ?? []).filter((_, i) => i !== k);
moveKeys(doc, { drop: k });
}
/** 기억 n 번째를 그 차례에 다시 끼움(차례가 줄 수보다 크면 맨 끝) */
export function reviveAdd(doc: SkDoc, n: number): void {
const gone = doc.뺀더한줄?.[n];
if (!gone) return;
const list = [...(doc.더한줄 ?? [])];
const at = Math.min(Math.max(gone.차례, 0), list.length);
list.splice(at, 0, gone.줄);
doc.더한줄 = list;
moveKeys(doc, { push: at });
const rest = (doc.뺀더한줄 ?? []).filter((_, i) => i !== n);
if (rest.length) doc.뺀더한줄 = rest;
else delete doc.뺀더한줄;
}
/** 되살림 띠 글 — 줄 이름 · 없으면 로직 · 요소 · 참조 키 */
export function goneName(one: SkAdded): string {
const ref = (one.참조 as { 키?: string } | undefined)?.키;
return String(one.이름 || one.로직 || one.요소 || ref || "");
}
/** 더한줄 수량 고침 — 처음 값(옛 문서엔 없음)을 먼저 적어 둠 */
export function setQty(one: SkAdded, qty: number): void {
one.처음수량 ??= one.수량;
one.수량 = qty;
}
/** 더한줄 수량 처음 값으로 — 처음 값 모르면 그대로 */
export function resetQty(one: SkAdded): void {
if (one.처음수량 != null) one.수량 = one.처음수량;
}
/** 넣을 때 수량 적어 둠 */
export const withFirst = <T extends { 수량: number }>(line: T): T & { 처음수량: number } => ({
...line,
처음수량: line.수량,
});
/* ── 순수 셈 끝 ── */