Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1
This commit is contained in:
@@ -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;
|
||||
}
|
||||
/* ── 순수 셈 끝 ── */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user