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

This commit is contained in:
2026-11-02 22:15:45 +09:00
17 changed files with 465 additions and 40 deletions
@@ -93,6 +93,8 @@ export interface CoverFormOptions {
/** 「m02」 = 덮개 보관 안내 */
scope?: "m01" | "m02";
onApply: (cover: LineCover | null) => void;
/** 모달 머리 단추 자리(닫기 왼쪽 · 748-1) — 주면 [적용] 이 거기로 */
head?: HTMLElement;
}
const num = (raw: string): number | null => {
@@ -140,10 +142,17 @@ export function coverForm(o: CoverFormOptions): HTMLElement {
error.hidden = !bad;
if (!bad) o.onApply(Object.keys(next).length ? next : null);
};
const applyButton = createButton({
label: tc("Apply"),
variant: "filled",
size: 32,
onClick: apply,
});
o.head?.append(applyButton);
const actions = el("div", {
className: "m01lab-cover__actions",
children: [
createButton({ label: tc("Apply"), variant: "filled", size: 32, onClick: apply }),
...(o.head ? [] : [applyButton]),
...(o.cover
? [
createButton({
@@ -156,6 +165,7 @@ export function coverForm(o: CoverFormOptions): HTMLElement {
: []),
],
});
actions.hidden = !actions.childElementCount;
return el("section", {
className: "m01lab-cover",
attrs: { "data-cover-form": "" },
@@ -122,8 +122,8 @@ export interface HoEdit {
covers: Record<number, LineCover>;
set: (idx: number, cover: LineCover | null) => void;
scope?: "m01" | "m02";
/** (M02) 모달 고침 칸 아래 덧 동작(줄 빼기 · 별도계상 등) */
more?: (idx: number, close: () => void) => HTMLElement[];
/** (M02) 모달 덧 동작 — `head` = 닫기 왼쪽 단추(삭제) · `body` = 고침 칸 아래(기초 빼기 · 별도계상 등) */
more?: (idx: number, close: () => void) => { head: HTMLElement[]; body: HTMLElement[] };
}
/** 고친 줄 꼬리표 — M01 = 시험 고침 · M02 = 변경 항목 · 고침 없음 = "" */
@@ -258,7 +258,7 @@ function modalOf(e: Entry, ctx: HoContext, extra: { cond?: PickCond; item?: HoLi
const line = e.line;
const edit = ctx.edit;
const perUnit = (k: string): number | null =>
line && line.수량 ? (line.비목?.[k] ?? 0) / line.수량 : null;
line && line.수량 ? ((line.원문?.비목 ?? line.비목)?.[k] ?? 0) / line.수량 : null; // 세부 줄 = 원문 몫(744-1)
openMaterialModal({
unitFrag: e.unitFrag,
unitRef: line?.품목?.키 ?? e.unitRef,
@@ -279,8 +279,9 @@ function modalOf(e: Entry, ctx: HoContext, extra: { cond?: PickCond; item?: HoLi
// 덧줄(%) = M02 덮개만 율 고침(M01 시험 값은 호표 줄만)
...(edit && (e.extra ? edit.scope === "m02" : item)
? {
edit: (close: () => void) => {
edit: (close: () => void, head: HTMLElement) => {
const form = coverForm({
head,
qty: line ? pctView(line).qty : null,
prices: e.extra
? {}
@@ -295,8 +296,9 @@ function modalOf(e: Entry, ctx: HoContext, extra: { cond?: PickCond; item?: HoLi
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;
const more = edit.more?.(e.idx, close);
head.append(...(more?.head ?? []));
return more?.body.length ? el("div", { children: [form, ...more.body] }) : form;
},
}
: {}),
@@ -92,8 +92,8 @@ export interface ModalTarget {
usedSlot?: string;
/** 있으면 후보 줄을 눌러 고름(시험 입력 · 저장 안 함) — 고르면 모달 닫힘 */
onPick?: (item: { ref: string; label: string }) => void;
/** 722-2 그 자리 고침 칸(맨 위) — 받는 것 = 모달 닫기 */
edit?: (close: () => void) => HTMLElement;
/** 722-2 그 자리 고침 칸(맨 위) — 받는 것 = 모달 닫기 · 머리 단추 자리(닫기 왼쪽 · 748-1) */
edit?: (close: () => void, head: HTMLElement) => HTMLElement;
}
/** 수량 칸 표 알약 하나가 여는 자료 — 서버 조각 `참조`(키 · 행 · 열)가 곧 찾은 자리 */
@@ -287,7 +287,10 @@ function priceView(ref: string, brief: ElementBrief, usedSlot?: string): HTMLEle
}
/** 모달 겉틀(배경·닫기·포커스) — 몸은 `mount` 이 채움(닫기 함수도 받음) */
function openDialog(title: string, mount: (body: HTMLElement, close: () => void) => void): void {
function openDialog(
title: string,
mount: (body: HTMLElement, close: () => void, head: HTMLElement) => void,
): void {
openModal({
title,
closeLabel: tl("Modal_Close"),
@@ -428,7 +431,7 @@ function pickedOf(cond: PickCond, values: Record<string, string>, fallback: stri
/** 줄을 눌렀을 때 — 그 줄의 단가(재료·인력·기계 또는 다른 로직)를 바로 보임(알약 없이) ·
* 재료 고르기 줄(`cond`)은 잡힌 품목 단가 + 후보 목록 */
export function openMaterialModal(target: ModalTarget): void {
openDialog(target.title, (body, close) => {
openDialog(target.title, (body, close, head) => {
const cond = target.cond;
const element = target.element ?? "";
const base = element.split(".")[0];
@@ -445,6 +448,15 @@ export function openMaterialModal(target: ModalTarget): void {
? target.unitRef || pickedOf(cond, target.values, known?.ref ?? "")
: element;
const logicKey = /^로직\((\w+)/.exec(element)?.[1] ?? "";
// 748-1 연결 키 = 제목 오른쪽 (괄호) · 고침 칸이 있으면 요소 단가 정보 칸은 없음(키 · 값은 제목 · 단가 칸에)
const key = logicKey || (cond ? priceRef : element.includes("{") ? target.unitRef : base);
if (key)
body
.closest(".ui-modal")
?.querySelector("h3")
?.append(el("span", { className: "m01lab__modalkey", text: ` (${key})` }));
const info = (ref: string, brief: ElementBrief, slot?: string): HTMLElement =>
target.edit && !ref.startsWith("MT") ? el("span") : priceView(ref, brief, slot);
const sum = el("p", { className: "m01lab__sum" });
const setSum = (price: ElementBrief | null | undefined): void => {
const unit = price ? `${tl("Modal_UnitPrice")} ${formatPrice(price.값)} × ` : "";
@@ -457,9 +469,7 @@ export function openMaterialModal(target: ModalTarget): void {
/** `prices` 에 있으면 그대로 · 없으면 키로 찾아 옴 — 못 찾으면 「관리자 값 미확정」 */
const lookup = (ref: string, label: string): void => {
const show = (brief: ElementBrief | null): void => {
rate.replaceChildren(
brief ? priceView(ref, brief) : muted(`${label} — ${tl("Pill_NoValue")}`),
);
rate.replaceChildren(brief ? info(ref, brief) : muted(`${label} — ${tl("Pill_NoValue")}`));
setSum(brief);
};
rate.append(muted("…"));
@@ -471,7 +481,7 @@ export function openMaterialModal(target: ModalTarget): void {
.catch(() => show(null));
};
if (known && priceRef) {
rate.append(priceView(priceRef, known, target.usedSlot));
rate.append(info(priceRef, known, target.usedSlot));
} else if (logicKey) {
rate.append(
el("p", {
@@ -505,7 +515,7 @@ export function openMaterialModal(target: ModalTarget): void {
lookup(element, element); // 더한 줄(M02) 등 `prices` 에 없는 요소 — 키로 찾아 옴(725-4)
}
body.replaceChildren(
...(target.edit ? [target.edit(close)] : []),
...(target.edit ? [target.edit(close, head)] : []),
rate,
...(cond || target.kind === "재료" ? [candidateView(target, priceRef, close)] : []),
muted(tl("Modal_Sum")),
@@ -121,6 +121,13 @@
overflow: auto;
}
/* 748-1 모달 제목 오른쪽 연결 키 — 제목보다 가볍게 */
.m01lab__modalkey {
font-size: var(--text-body);
font-weight: var(--font-weight-regular, 400);
color: var(--color-text-secondary);
}
.m01lab__modal-body {
display: flex;
flex-direction: column;
@@ -103,6 +103,8 @@ export interface CalcLine {
규격?: string;
/** 못 셈 줄(725 · M02 단가산출) — 금액 없음 까닭 */
까닭?: string;
/** (M02 744-1) 둘째 기초 세부 줄 — `수량`은 M01 원문 · 이 칸은 1 기준단위 몫 비목(금액 · 비목은 머리 수량 곱한 상자 몫) */
원문?: { 비목?: Record<string, number | null> };
}
/** % 덧줄 표기(149-4) — 율 있으면 수량 = 율 · 단위 % · 이름 뒤 규격 괄호 · 금액은 그대로(M01 · M02 한 벌) */
@@ -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 ?? {},
@@ -379,11 +399,11 @@ export function mountSangeunDetail(
},
more: (idx, close) => {
const r = at(idx);
if (!r) return [];
if (!r) return { head: [], body: [] };
const drop = createButton({
label: L("M02_SangeunDrop"),
variant: "danger-ghost",
size: 28,
size: 32,
onClick: () => (close(), dropLine(doc, r), touch()),
});
drop.dataset.skDrop = "";
@@ -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()
),
}),
]
: [];
@@ -408,7 +432,11 @@ export function mountSangeunDetail(
}).root,
]
: [];
return [el("div", { className: "m02-sk__tags", children: [...apart, drop, ...whole] })];
const rest = [...apart, ...whole];
return {
head: [drop],
body: rest.length ? [el("div", { className: "m02-sk__tags", children: rest })] : [],
};
},
};
}
@@ -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.수량,
});
/* ── 순수 셈 끝 ── */
+102
View File
@@ -0,0 +1,102 @@
/* PLAN 748-0 헬퍼 — SK 고침 꼴(`logic/M02_Sangeun_Cover_Model.ts` · `M02_Sangeun_Undo.ts`):
* 둘째 기초 세부 품 = M01 원문 · 고침 = 원문 몫 그대로 · 빼기 기억(뺀더한줄) · 되살림 · 수량 처음값 되돌림.
* 짝 = `test_748_0_sk_undo.py`. 결과 = 표준출력 JSON.
*/
const path = require("path");
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
const { load } = makeLoader({ root: path.join(__dirname, "..", "..") });
const m = load("M02_MasterTemplete", "logic", "M02_Sangeun_Cover_Model.ts");
// 둘째 기초 GF 한 줄 — 원문 0.871 · 머리 수량 0.01 이면 상자 몫 0.00871
const detail = {
cid: "add:#1/labor:LB000101#0",
이름: "벌목부",
단위: "인",
수량: 0.00871,
단가: 100000,
금액: 871,
비목: { 노무비: 871 },
원문: { 수량: 0.871, 금액: 87100, 비목: { 노무비: 87100 } },
바탕값: { qty: 0.871, 단가: 100000 },
};
const ans = {
기준: { 계: 871 },
줄: [
{
cid: "labor:LB000002#0",
이름: "보통인부",
단위: "인",
수량: 1,
단가: 100,
금액: 100,
비목: { 노무비: 100 },
},
{
cid: "add:#0",
이름: "추가 인력",
단위: "인",
수량: 2,
단가: 100,
금액: 200,
비목: { 노무비: 200 },
},
{
cid: "add:#1",
이름: "둘째 기초",
단위: "100㎥",
수량: 0.01,
단가: null,
금액: 871,
비목: { 노무비: 871 },
펼침: [detail],
},
],
정체: ["labor:LB000002#0", "add:#0", "add:#1"],
짝잃음: [],
바탕판: "",
};
const doc = {
단위: "㎥",
바탕: { 로직: "GF000099" },
더한줄: [
{ 종류: "인력", 요소: "LB000002", 수량: 2, 처음수량: 2 },
{ 종류: "로직", 로직: "GF000316", 수량: 0.01, 처음수량: 0.01 },
],
고름: { "add:#1": "MT1" },
};
const out = {};
const rows = m.rowsOf(ans, null, doc);
const sub = rows.find((r) => r.cid === detail.cid);
const view = m.hoView(ans, null, null, doc);
const at = view.cids.indexOf(detail.cid);
out.shownQty = view.호표[at].수량; // 원문 그대로
out.lineQty = view.lines[at].수량;
out.lineAmount = view.lines[at].금액; // 상자 몫 그대로
out.t = sub.t;
// 세부 품 고침 — 화면 0.9 = 문서 덮개 0.9(머리 수량 안 나눔)
m.setLineCover(doc, sub, { qty: 0.9 }, false);
out.cover = doc.더한줄[1].덮개;
out.coverShown = m.hoView(ans, null, null, doc).covers[at].qty;
// 더한 항목 수량 고침 → 처음값 되돌림
const man = m.rowsOf(ans, null, doc).find((r) => r.cid === "add:#0");
m.setLineCover(doc, man, { qty: 5 }, false);
out.manQty = doc.더한줄[0].수량;
m.setLineCover(doc, man, null, false);
out.manBack = doc.더한줄[0].수량;
// 더한 항목 빼기 → 기억 + 열쇠 당김 → 되살림
m.dropLine(doc, man);
out.afterDrop = { adds: doc.더한줄.length, gone: doc.뺀더한줄, pick: doc.고름 };
out.goneRows = m.goneRows(doc);
m.restoreLine(doc, "gone:0");
out.afterBack = {
adds: doc.더한줄.map((x) => x.요소 ?? x.로직),
gone: doc.뺀더한줄 ?? null,
pick: doc.고름,
};
// 둘째 기초 통째 빼기 → 되살림
m.dropBase(doc, 1, "둘째 기초");
out.baseGone = { adds: doc.더한줄.length, name: m.goneRows(doc)[0].이름, pick: doc.고름 };
m.restoreLine(doc, "gone:0");
out.baseBack = doc.더한줄.map((x) => x.요소 ?? x.로직);
process.stdout.write(JSON.stringify(out));
@@ -35,7 +35,8 @@ def _text(p: Path) -> str:
def _node(body: str) -> object:
born = _text(M02 / "logic" / "M02_Born_Tag.ts") # 740-2 — 모델이 쓰는 꼬리표 글(import 줄은 뺌)
model = re.sub(r"^import .*$", "", _text(MODEL), flags=re.M)
src = born + "\n" + model + "\n" + body
undo = re.sub(r"^import .*$", "", _text(M02 / "logic" / "M02_Sangeun_Undo.ts"), flags=re.M)
src = born + "\n" + undo + "\n" + model + "\n" + body
out = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", src],
+1 -1
View File
@@ -69,7 +69,7 @@ def test_시험_고침은_캐시_밖():
cover = _text(COVER)
assert "drafts" not in cover and "touch" not in cover and "/save" not in cover
modal = _text(M01 / "M01_MasterData_UI_LogicLab_Modal.ts")
assert "...(target.edit ? [target.edit(close)] : [])" in modal
assert "...(target.edit ? [target.edit(close, head)] : [])" in modal
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
+4 -1
View File
@@ -209,6 +209,7 @@ def _model(ans, doc, script):
return _node(
[
"M02_MasterTemplete/logic/M02_Born_Tag.ts",
"M02_MasterTemplete/logic/M02_Sangeun_Undo.ts",
"M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts",
],
body,
@@ -242,7 +243,9 @@ console.log(JSON.stringify({
assert "add:#0" not in got["cids"] and "add:#0/labor:LB000036#0" in got["cids"]
assert got["sub"]["sub"] == {"k": 0, "cid": "labor:LB000036#0", "scale": 2}
assert got["sub"]["수량"] == pytest.approx(0.036) and got["sub"]["묶음"] == "인력"
assert got["covered"]["덮개"]["labor:LB000036#0"]["qty"] == pytest.approx(0.036) # ÷ 수량 2
assert got["covered"]["덮개"]["labor:LB000036#0"]["qty"] == pytest.approx(
0.072
) # 화면 품 그대로(M01 원문 몫 · 748-0 — 수량 2 로 안 나눔)
assert got["covered"]["덮개"]["labor:LB000036#0"]["원값"] == {"qty": 0.018, "단가": 233250}
assert got["dropped"] == DROP and got["back"] is None
assert got["tags"] == ["9-12-1", "9-12-1", "3-2-1", "3-2-1"] # 바탕 둘 · 둘째 기초 인력 둘
+40
View File
@@ -0,0 +1,40 @@
"""748-0 지킴 — SK 둘째 기초 세부 품 = M01 원문(머리 수량 안 곱함) · 고침 꼴 · 빼기 기억 · 되살림 · 수량 처음값.
(서버 짝 = `test_744_1_sk_detail_undo.py`)"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
def test_세부_원문_품_고침_빼기_되살림_처음값():
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_748_0_sk_undo.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
out = json.loads(proc.stdout)
assert float(out["shownQty"]) == pytest.approx(0.871) # 곱하지 않은 M01 원문
assert out["lineQty"] == pytest.approx(0.871)
assert out["lineAmount"] == pytest.approx(871) # 상자 몫은 그대로
assert out["t"] == 1
assert out["cover"]["labor:LB000101#0"]["qty"] == pytest.approx(0.9) # 머리 수량 안 나눔
assert out["coverShown"] == pytest.approx(0.9) # 다시 그려도 같음
assert out["manQty"] == 5 and out["manBack"] == 2 # [되돌림] = 넣을 때 수량
drop = out["afterDrop"]
assert drop["adds"] == 1 and drop["gone"][0]["차례"] == 0
assert drop["pick"] == {"add:#0": "MT1"} # 뒤 더한줄 열쇠 당김
assert out["goneRows"][0]["cid"] == "gone:0"
back = out["afterBack"]
assert back["adds"] == ["LB000002", "GF000316"] and back["gone"] is None
assert back["pick"] == {"add:#1": "MT1"} # 되살림 = 밂
assert out["baseGone"] == {"adds": 1, "name": "둘째 기초", "pick": {}}
assert out["baseBack"] == ["LB000002", "GF000316"]
+5 -1
View File
@@ -180,8 +180,12 @@ export const ui_locales_m2 = {
M02_SangeunSwapWeight: ["단위중량", "Unit weight"],
M02_SangeunSwapPick: ["고르세요", "Choose"],
M02_SangeunAddLine: ["항목 추가", "Add item"],
M02_SangeunDrop: ["빼기", "Remove"],
M02_SangeunDrop: ["삭제", "Delete"],
M02_SangeunDropBase: ["기초 빼기", "Remove base"],
M02_SangeunBases: ["더한 기초", "Added bases"],
M02_SangeunBaseQty: ["수량(기준단위 몇 개)", "Quantity (base units)"],
M02_SangeunBaseQtyBad: ["0 이상 수만", "Numbers ≥ 0 only"],
M02_SangeunQtyUndo: ["되돌림", "Undo"],
M02_SangeunRestore: ["되살리기", "Restore"],
M02_SangeunWas: ["바탕", "Base"],
M02_SangeunEditedHere: ["단가산출에서 고침", "Edited in this analysis"],