feat(717-2b): M02 단가산출 상세 · 고침 — M01 상세 부품(보기 넷)을 M02 길로 · 품 · 단가 칸 덮개 · 줄 더하기 · 빼기 · 바탕 M01 로직 고름
- 새 Sangeun_Detail(mountSangeunDetail) · Sangeun_Cover(고침 표 · 셈 길 최신 응답만) · logic/M02_Sangeun_Cover_Model(순수 꼴) - 고친 칸 = 칸 색 + 바탕 값 곁 글 + 「단가산출에서 고침」 · M01 개정 배지(바탕판 · 원값) · 짝잃음 띠 · 별도계상 재료 = 계 밖 표기 - M01 buildLabDetail 덧칸 cover(단가산출 상세 · 인력·재료·경비 덩이 갈음) · 마스터 이동 단추 = 등록 없으면 숨김(hasMasterJump) - Sangeun_View 자리표 · 옛 글 열쇠(준비 중 · 덮개 · 더한 줄 · 열린 입력) 걷음 - 시험 test_717_2b_sangeun_detail 3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,8 @@ export interface DetailContext {
|
||||
admin?: boolean;
|
||||
/** 714-3 텍스트 수식 입력 단추 — 시험 계산 입력 고침 + 다시 셈 */
|
||||
onInput?: (name: string, value: string) => void;
|
||||
/** 717-2b M02 단가산출 — 단가산출 상세 · 인력·재료·경비 덩이를 이 칸으로(SK 셈 답 · 고침 표) */
|
||||
cover?: { ho: HTMLElement; est: HTMLElement };
|
||||
}
|
||||
|
||||
type Group = "labor" | "material" | "cost" | "other";
|
||||
@@ -526,12 +528,19 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
|
||||
...infoBox(ctx),
|
||||
block(section(tl("Calc_Title"), [ctx.calcHost]), "calc"),
|
||||
block(
|
||||
section(tl("View_Est"), [buildEst({ text: ctx.text, unit: ctx.row.결과단위, ...fx })]),
|
||||
section(tl("View_Est"), [
|
||||
ctx.cover?.est ?? buildEst({ text: ctx.text, unit: ctx.row.결과단위, ...fx }),
|
||||
]),
|
||||
"est",
|
||||
),
|
||||
...(ctx.flowHost
|
||||
...(ctx.flowHost && !ctx.cover
|
||||
? [block(nowInputs(ctx.values), "ho"), ctx.flowHost]
|
||||
: [hoBox(ctx), block(section(tl("Formula_Title"), [formula]), "formula")]),
|
||||
: [
|
||||
ctx.cover
|
||||
? block(section(tl("Ho_Title"), [nowInputs(ctx.values), ctx.cover.ho]), "ho")
|
||||
: hoBox(ctx),
|
||||
block(section(tl("Formula_Title"), [formula]), "formula"),
|
||||
]),
|
||||
);
|
||||
const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음
|
||||
const back = own ? host.querySelector<HTMLInputElement>(`[data-own="${own}"]`) : kept?.node;
|
||||
|
||||
@@ -23,7 +23,7 @@ import { createStat, statGrid } from "@ui/ui_template_stat";
|
||||
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money";
|
||||
import { explain, loadTable, type TableRow } from "./M01_MasterData_UI_Logic_Note";
|
||||
import { jumpToMaster, type MasterJump } from "./M01_MasterData_UI_Side";
|
||||
import { hasMasterJump, jumpToMaster, type MasterJump } from "./M01_MasterData_UI_Side";
|
||||
import { fragmentRow, type Fragment } from "./M01_MasterData_UI_LogicLab_Pill";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
@@ -180,11 +180,13 @@ const groupOf = (ref: string): string =>
|
||||
ref.startsWith("LB") ? "인력" : ref.startsWith("M") ? "재료" : ref.startsWith("EQ") ? "기계" : "";
|
||||
|
||||
function goButton(target: MasterJump): HTMLElement {
|
||||
return createButton({
|
||||
const go = createButton({
|
||||
label: tl("Pill_Go"),
|
||||
variant: "ghost",
|
||||
onClick: () => jumpToMaster(target),
|
||||
});
|
||||
go.hidden = !hasMasterJump();
|
||||
return go;
|
||||
}
|
||||
|
||||
/** 자재품목 값 칸 넷 — 위 가로 줄(관급은 뺌) */
|
||||
|
||||
@@ -79,11 +79,12 @@ export function takePendingLogicKey(): string | null {
|
||||
export type MasterJump =
|
||||
{ kind: "table"; key: string } | { kind: "element"; group: string; ref: string; file?: string };
|
||||
|
||||
let masterJump: (target: MasterJump) => void = () => {};
|
||||
let masterJump: ((target: MasterJump) => void) | null = null;
|
||||
export const setMasterJump = (fn: (target: MasterJump) => void): void => {
|
||||
masterJump = fn;
|
||||
};
|
||||
export const jumpToMaster = (target: MasterJump): void => masterJump(target);
|
||||
export const jumpToMaster = (target: MasterJump): void => masterJump?.(target);
|
||||
export const hasMasterJump = (): boolean => masterJump !== null; // 등록 없으면(M02) 이동 단추 숨김
|
||||
|
||||
/** 요소 화면에서 열 것 — 파일 · 하위 거름(구분·세부분류) · 상세구분 · 제목 */
|
||||
export interface Pick {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/* M02 단가산출(SK) 상세 · 고침 표(PLAN 717-2b) — 표 · 단추 · 배지는 부품 상자 · 여기는 자리 · 고친 칸 색만 */
|
||||
|
||||
.m02-sk {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.m02-sk__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.m02-sk__head .ui-field {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.m02-sk__cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.m02-sk__cell .ui-input {
|
||||
width: 9em;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.m02-sk__tags,
|
||||
.m02-sk__removed,
|
||||
.m02-sk__removed-item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.m02-sk__removed {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 고친 칸 — B09 고친 칸과 같은 꼴(색) · 곁 글 = 바탕 값 */
|
||||
.m02-sk__cover td.is-edited {
|
||||
background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.m02-sk__total {
|
||||
text-align: end;
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Sangeun_Cover.ts
|
||||
* 단가산출(SK) 고침 표(PLAN 717-2b · 717-3 일위대가 모달도 씀) — M01 「단가산출 상세」 꼴(인력 · 재료 · 경비 ·
|
||||
* 하위 묶음 + 소계 · 계 · 열 = 이름 · 수량 · 단위 · 로직 · 단가 · 금액)에 품 · 단가 칸 고침 · 줄 빼기.
|
||||
* 고친 칸 = 칸 색(`is-edited`) + 바탕 값 곁 글 + 「단가산출에서 고침」 꼬리표 · 값 = 서버 셈 답 그대로
|
||||
* 셈 길 = `calcSangeun`(`POST /layers/{층}/sangeun/calc` · 저장 안 함) — 부르는 쪽이 최신 응답만 받음
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
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, SkDoc } from "./logic/M02_Sangeun_Cover_Model";
|
||||
import "./M02_MasterTemplete_Sangeun.css";
|
||||
|
||||
/** SK 셈(저장 안 함) — 문서 = 화면 문서 · 입력값 = 시험 입력 */
|
||||
export function calcSangeun(
|
||||
scope: { layer: Layer; owner: string | null },
|
||||
doc: SkDoc,
|
||||
given: Record<string, string> = {},
|
||||
): Promise<SkAnswer> {
|
||||
const q =
|
||||
scope.owner && scope.layer !== "system" ? `?owner=${encodeURIComponent(scope.owner)}` : "";
|
||||
const 입력값 = Object.fromEntries(Object.entries(given).filter(([, v]) => String(v).trim()));
|
||||
return call<SkAnswer>(`/layers/${scope.layer}/sangeun/calc${q}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ 문서: doc, 입력값 }),
|
||||
});
|
||||
}
|
||||
|
||||
export interface CoverTableOptions {
|
||||
/** 고칠 수 있음(쓰기 층 · 읽기만 아님) */
|
||||
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;
|
||||
}
|
||||
|
||||
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: CoverTableOptions): (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")] : []),
|
||||
...(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),
|
||||
});
|
||||
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] : []),
|
||||
];
|
||||
}
|
||||
|
||||
/** 묶음 표 넷 + 계 + 뺀 줄 */
|
||||
export function coverTable(rows: CoverRow[], o: CoverTableOptions): 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,
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
/* =============================================================================
|
||||
* 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 로직 식) · 기본정보 = 바탕 로직(맨 위 접힘)
|
||||
* 고침 = SK 덮개 · 더한줄(`Sangeun_Pick`) · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`) · 값 = 서버 셈 길 최신 응답만
|
||||
* 시험 입력 = SK 셈 `입력값`(저장 안 함) · [저장] = 화면 문서 → 서버가 다시 셈(원값 · 바탕판)
|
||||
* 바탕 없는 SK = [M01 로직 고름] → 바탕 · 단위 · 바탕 없이 줄만도 됨
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createBanner } from "@ui/ui_template_banner";
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
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 { calcSheetRows } from "../A00_Common/calc_sheet";
|
||||
import { buildCalc } from "../M01_MasterData/M01_MasterData_UI_Logic_Calc";
|
||||
import {
|
||||
fetchLogic,
|
||||
setLogicApiPaths,
|
||||
type LogicOne,
|
||||
type LogicRow,
|
||||
type TextAnswer,
|
||||
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import { buildLabDetail } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Detail";
|
||||
import { createViewPicker } from "../M01_MasterData/M01_MasterData_UI_LogicLab_View";
|
||||
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { calcSangeun, coverTable } from "./M02_MasterTemplete_Sangeun_Cover";
|
||||
import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick";
|
||||
import {
|
||||
dropLine,
|
||||
restoreLine,
|
||||
rowsOf,
|
||||
setPrice,
|
||||
setQty,
|
||||
sheetGroups,
|
||||
type SkAnswer,
|
||||
type SkDoc,
|
||||
} from "./logic/M02_Sangeun_Cover_Model";
|
||||
|
||||
export interface SangeunDetailOptions {
|
||||
layer: Layer;
|
||||
owner: string | null;
|
||||
readOnly: boolean;
|
||||
onChanged: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
const LIB = { read: "/m02/logic-lib", calc: "/m02/logic-lib" };
|
||||
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
||||
/** 결과단위 「원/10㎡」 → SK 단위 「㎡」(1 단위 값 · 배는 서버가 나눔) */
|
||||
const unitOf = (result: string): string => result.replace(/^원\s*\/\s*[\d.,]*\s*/, "").trim();
|
||||
|
||||
export function mountSangeunDetail(
|
||||
host: HTMLElement,
|
||||
doc0: unknown,
|
||||
opt: SangeunDetailOptions,
|
||||
): { getDoc: () => SkDoc; destroy: () => void } {
|
||||
setLogicApiPaths(LIB); // 상세 부품 길 = M02 로그인 길(717-1)
|
||||
const doc = clone((doc0 ?? {}) as SkDoc);
|
||||
let saved = JSON.stringify(doc);
|
||||
const edit = !opt.readOnly;
|
||||
const scope = { layer: opt.layer, owner: opt.owner };
|
||||
const values: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(doc.입력값 ?? {}).map(([k, v]) => [k, String(v)]),
|
||||
);
|
||||
let base: LogicOne | null = null;
|
||||
let text: TextAnswer | null = null;
|
||||
let lastText: TextAnswer | undefined;
|
||||
let ans: SkAnswer | null = null;
|
||||
let bare: SkAnswer | null = null;
|
||||
let fail = "";
|
||||
let seq = 0;
|
||||
let alive = true;
|
||||
|
||||
const head = el("div", { className: "m02-sk__head", attrs: { "data-sk-head": "" } });
|
||||
const notes = el("div");
|
||||
const editor = el("div", { className: "m01-logic__editor" });
|
||||
const views = createViewPicker(editor);
|
||||
const calcHost = el("div", { className: "m01lab__calc" });
|
||||
const ownHost = el("div");
|
||||
const addButton = createButton({
|
||||
label: L("M02_SangeunAddLine"),
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () =>
|
||||
openSangeunLinePicker(scope, (line) => {
|
||||
doc.더한줄 = [
|
||||
...(doc.더한줄 ?? []),
|
||||
line as unknown as NonNullable<SkDoc["더한줄"]>[number],
|
||||
];
|
||||
touch();
|
||||
}),
|
||||
});
|
||||
addButton.hidden = !edit;
|
||||
const baseHint = createText(L("M02_SangeunBaseViews"));
|
||||
host.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-sk",
|
||||
attrs: { "data-sangeun-detail": doc.키 ?? "" },
|
||||
children: [
|
||||
head,
|
||||
notes,
|
||||
el("div", { className: "m02-sk__head", children: [views.root, addButton, baseHint] }),
|
||||
editor,
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const opened = (): boolean => (doc.열린입력 ?? []).length > 0;
|
||||
const baseKey = (): string | null => doc.바탕?.로직 ?? null;
|
||||
|
||||
/** 바탕 없는 · 참조 바탕 SK 의 기본정보 자리 줄 */
|
||||
const standIn = (): LogicRow => ({
|
||||
키: doc.키 ?? "",
|
||||
원문번호: "",
|
||||
이름: doc.이름 ?? "",
|
||||
결과단위: `원/${doc.단위 ?? ""}`,
|
||||
출처: doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase"),
|
||||
입력: [],
|
||||
중간: [],
|
||||
호표: [],
|
||||
덧줄: [],
|
||||
});
|
||||
|
||||
const touch = (again = true): void => {
|
||||
opt.onChanged(edit && JSON.stringify(doc) !== saved);
|
||||
if (again) void recalc();
|
||||
else drawHead();
|
||||
};
|
||||
|
||||
async function recalc(): Promise<void> {
|
||||
const mine = ++seq;
|
||||
const plain = { ...doc, 덮개: {}, 뺀줄: [], 더한줄: [] };
|
||||
try {
|
||||
const [got, was] = await Promise.all([
|
||||
calcSangeun(scope, doc, values),
|
||||
doc.바탕 ? calcSangeun(scope, plain, values) : Promise.resolve(null),
|
||||
]);
|
||||
if (mine !== seq || !alive) return; // 늦게 온 답 — 버림
|
||||
[ans, bare, fail] = [got, was, ""];
|
||||
} catch (error) {
|
||||
if (mine !== seq || !alive) return;
|
||||
[ans, fail] = [null, error instanceof Error ? error.message : String(error)];
|
||||
}
|
||||
draw();
|
||||
}
|
||||
|
||||
function drawHead(): void {
|
||||
const field = (label: string, key: "이름" | "단위"): HTMLElement => {
|
||||
const box = createInputField({ label, value: doc[key] ?? "", compact: true, size: 28 });
|
||||
box.input.dataset.sk = key;
|
||||
box.input.disabled = !edit;
|
||||
box.input.addEventListener("change", () => {
|
||||
doc[key] = box.input.value.trim();
|
||||
box.setError(doc[key] ? undefined : L("M02_SangeunNeed"));
|
||||
touch(key === "단위");
|
||||
});
|
||||
return box.root;
|
||||
};
|
||||
const baseName = base ? `${base.logic.키} ${base.logic.이름}` : null;
|
||||
const baseText = baseName ?? doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase");
|
||||
const revised = !!(doc.바탕판 && ans?.바탕판 && doc.바탕판 !== ans.바탕판);
|
||||
const pick = createButton({
|
||||
label: L("M02_SangeunPickBase"),
|
||||
variant: "filled",
|
||||
size: 28,
|
||||
onClick: () =>
|
||||
openLogicPicker((x) => {
|
||||
doc.바탕 = { 로직: x.키 };
|
||||
delete doc.바탕판; // 새 바탕 — 개정 견줄 판 없음
|
||||
if (!doc.단위 || doc.단위 === "식") doc.단위 = unitOf(x.결과단위) || doc.단위;
|
||||
touch(false);
|
||||
void loadBase();
|
||||
}),
|
||||
});
|
||||
pick.hidden = !edit || !!doc.바탕 || !!doc.뺀줄?.length || !!Object.keys(doc.덮개 ?? {}).length;
|
||||
head.replaceChildren(
|
||||
field(L("M02_Name"), "이름"),
|
||||
field(L("M02_SangeunUnit"), "단위"),
|
||||
el("div", {
|
||||
className: "m02-sk__tags",
|
||||
attrs: { "data-sk-base": baseKey() ?? "" },
|
||||
children: [
|
||||
createText(`${L("M02_SangeunBase")} · ${baseText}`, "muted", 14),
|
||||
...(ans?.머리 ? [createBadge(ans.머리, "info")] : []),
|
||||
...(revised ? [createBadge(L("M02_SangeunRevisedBase"), "danger")] : []),
|
||||
...(opened() ? [createBadge(L("M02_SangeunScaleOnly"), "warning")] : []),
|
||||
],
|
||||
}),
|
||||
pick,
|
||||
);
|
||||
}
|
||||
|
||||
function drawNotes(): void {
|
||||
const lost = ans?.짝잃음 ?? [];
|
||||
const revised = !!(doc.바탕판 && ans?.바탕판 && doc.바탕판 !== ans.바탕판);
|
||||
notes.replaceChildren(
|
||||
...(fail || ans?.까닭
|
||||
? [createBanner({ tone: "danger", text: fail || String(ans?.까닭) })]
|
||||
: []),
|
||||
...(revised ? [createBanner({ tone: "warning", text: L("M02_SangeunRevisedNote") })] : []),
|
||||
...(lost.length
|
||||
? [
|
||||
createBanner({
|
||||
tone: "warning",
|
||||
text: L("M02_SangeunLost"),
|
||||
details: lost.map((x) => `${x.cid} — ${x.까닭}`).join("\n"),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
);
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
if (!alive) return;
|
||||
drawHead();
|
||||
drawNotes();
|
||||
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 = coverTable(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()),
|
||||
});
|
||||
const row = base?.logic ?? standIn();
|
||||
const est = el("div", {
|
||||
attrs: { "data-est": "" },
|
||||
children: ans
|
||||
? calcSheetRows({
|
||||
묶음: sheetGroups(rows),
|
||||
계: ans.기준?.계 ?? null,
|
||||
sumLabel: `${L("M02_SangeunTotal")} (${row.결과단위 || "원"})`,
|
||||
bundle: true,
|
||||
inputs: row.입력,
|
||||
values,
|
||||
onInput,
|
||||
})
|
||||
: [createText(L("M02_SangeunWait"))],
|
||||
});
|
||||
buildLabDetail(editor, {
|
||||
row,
|
||||
file: base?.file ?? "",
|
||||
reasons: base?.reasons ?? [],
|
||||
prices: base?.prices ?? {},
|
||||
lines: null,
|
||||
text,
|
||||
lastText,
|
||||
values,
|
||||
ownHost,
|
||||
onChange: () => undefined,
|
||||
calcHost,
|
||||
onInput,
|
||||
cover: { ho, est },
|
||||
});
|
||||
}
|
||||
|
||||
/** 텍스트 수식 · 계산서 입력 단추 — 시험 입력 고침 → 시험 계산 다시 그림 · 셈 */
|
||||
function onInput(name: string, v: string): void {
|
||||
values[name] = v;
|
||||
drawCalc();
|
||||
calcHost.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
|
||||
}
|
||||
|
||||
function drawCalc(): void {
|
||||
if (!base) {
|
||||
calcHost.replaceChildren(createText(L("M02_SangeunNoInputs")));
|
||||
return;
|
||||
}
|
||||
buildCalc(calcHost, {
|
||||
savedKey: base.logic.키,
|
||||
file: base.file,
|
||||
row: base.logic,
|
||||
prices: base.prices,
|
||||
dirty: () => false,
|
||||
values,
|
||||
onText: (t) => {
|
||||
text = t;
|
||||
if (t.ok) lastText = t;
|
||||
draw();
|
||||
},
|
||||
onLines: () => void recalc(), // 시험 입력이 바뀜 — SK 도 같은 입력으로
|
||||
});
|
||||
}
|
||||
|
||||
async function loadBase(): Promise<void> {
|
||||
const key = baseKey();
|
||||
base = null;
|
||||
text = null;
|
||||
if (key) {
|
||||
try {
|
||||
base = await fetchLogic(key);
|
||||
} catch (error) {
|
||||
fail = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
if (!alive) return;
|
||||
drawCalc();
|
||||
draw();
|
||||
await recalc();
|
||||
}
|
||||
|
||||
void loadBase();
|
||||
return {
|
||||
getDoc: () => {
|
||||
if (ans?.바탕판 && doc.바탕) doc.바탕판 = ans.바탕판; // 저장 = 지금 바탕 확인(서버가 다시 적음)
|
||||
saved = JSON.stringify(doc);
|
||||
return clone(doc);
|
||||
},
|
||||
destroy: () => {
|
||||
alive = false;
|
||||
host.replaceChildren();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,61 +1,22 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Sangeun_View.ts
|
||||
* 단가산출(SK) 상세 자리(717-2a) — 편집 칸 종류 sangeun 이 여는 곳(UI_Main 갈래 한 줄).
|
||||
* 지금 = 이름 · 단위 · 바탕 · 고친 줄 요약(덮개 · 더한 줄 · 뺀 줄 · 열린 입력) + 상세 자리표.
|
||||
* 717-2b(sub2) = 자리표 줄을 `mountSangeunDetail(slot, doc, opt)`(`Sangeun_Detail.ts`)로 갈음 ·
|
||||
* [저장]은 그 손잡이 `getDoc` · 고침 알림은 `opt.onChanged`. 지금 [저장] = 읽은 문서 그대로.
|
||||
* 717-2b = 상세 · 고침(`Sangeun_Detail.ts` mountSangeunDetail) · [저장]은 그 손잡이 `getDoc` · 고침 알림 `opt.onChanged`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { createKv } from "@ui/ui_template_kv";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail";
|
||||
import type { Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
/** SK 문서 중 이 자리가 읽는 칸(꼴 = 서버 `M02_Sangeun_Doc`) */
|
||||
export interface SangeunDoc {
|
||||
키?: string;
|
||||
이름?: string;
|
||||
단위?: string;
|
||||
바탕?: { 로직?: string; 참조?: { 키?: string } } | null;
|
||||
덮개?: Record<string, unknown>;
|
||||
더한줄?: unknown[];
|
||||
뺀줄?: unknown[];
|
||||
열린입력?: string[];
|
||||
}
|
||||
|
||||
/** 고름(층 · 주인 · SK 키 = name) + 읽기만 · 고침 알림 */
|
||||
export type SangeunViewOptions = Pick<Selection, "layer" | "owner" | "name"> & {
|
||||
readOnly: boolean;
|
||||
onChanged: (dirty: boolean) => void;
|
||||
};
|
||||
|
||||
/** 바탕 한 줄 — 로직 키 · 참조 SK 키 · 없으면 「바탕 없음」 */
|
||||
export const baseText = (doc: SangeunDoc): string =>
|
||||
doc.바탕?.로직 ?? doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase");
|
||||
|
||||
export function mountSangeunView(
|
||||
host: HTMLElement,
|
||||
doc: unknown,
|
||||
_opt: SangeunViewOptions,
|
||||
opt: SangeunViewOptions,
|
||||
): { getDoc: () => unknown; destroy: () => void } {
|
||||
const sk = (doc ?? {}) as SangeunDoc;
|
||||
const count = (v: unknown[] | Record<string, unknown> | undefined): number =>
|
||||
Array.isArray(v) ? v.length : Object.keys(v ?? {}).length;
|
||||
const head = createKv([
|
||||
{ label: L("M02_Name"), value: sk.이름 ?? "" },
|
||||
{ label: tc("Lib_Unit"), value: sk.단위 ?? "" },
|
||||
{ label: L("M02_SangeunBase"), value: baseText(sk) },
|
||||
{ label: L("M02_SangeunCover"), value: count(sk.덮개) },
|
||||
{ label: L("M02_SangeunAdded"), value: count(sk.더한줄) },
|
||||
{ label: L("M02_SangeunRemoved"), value: count(sk.뺀줄) },
|
||||
{ label: L("M02_SangeunOpened"), value: (sk.열린입력 ?? []).join(" · ") || null },
|
||||
]);
|
||||
const slot = el("div", {
|
||||
attrs: { "data-sangeun-detail": sk.키 ?? "" },
|
||||
children: [createEmpty({ text: L("M02_SangeunDetailSoon") })],
|
||||
});
|
||||
host.replaceChildren(el("div", { attrs: { "data-sangeun-view": "" }, children: [head, slot] }));
|
||||
return { getDoc: () => doc, destroy: () => host.replaceChildren() };
|
||||
return mountSangeunDetail(host, doc, { ...opt, owner: opt.owner ?? null });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts
|
||||
* 단가산출(SK) 고침 꼴(PLAN 717-2b · 717-3 같이 씀) — 서버 셈 답(`/layers/{층}/sangeun/calc`) → 표 줄 ·
|
||||
* 고친 칸 → SK 문서 덮개 · 더한줄 · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`). 금액은 늘 서버가 셈 —
|
||||
* 여기는 줄 모양 · 문서 칸 옮김만(값 셈 없음 · 로직 줄 단가를 노 · 재 · 경 몫 비로 나누는 것만).
|
||||
* 줄 정체 cid = `kind:키#n`(바탕 줄) · `add:#k`(더한줄 k 째) · 덮개 원값 = 저장 때 서버가 다시 적음
|
||||
* (화면은 고친 첫때 바탕 값을 적어 둠 — 곁 글용) · 열린입력 있는 SK = 품은 배율만
|
||||
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SheetGroup } from "../../A00_Common/calc_sheet";
|
||||
|
||||
export type CoverPart = "labor" | "material" | "expense";
|
||||
export const PART_OF: Record<string, CoverPart> = {
|
||||
노무비: "labor",
|
||||
재료비: "material",
|
||||
경비: "expense",
|
||||
};
|
||||
const PARTS: CoverPart[] = ["labor", "material", "expense"];
|
||||
|
||||
export interface SkCover {
|
||||
qty?: number;
|
||||
배율?: number;
|
||||
labor?: number;
|
||||
material?: number;
|
||||
expense?: number;
|
||||
원값?: { qty?: number; 단가?: number };
|
||||
}
|
||||
|
||||
export interface SkAdded {
|
||||
종류?: string;
|
||||
수량: number;
|
||||
단가?: number;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/** SK 문서 중 고침이 만지는 칸 */
|
||||
export interface SkDoc {
|
||||
키?: string;
|
||||
이름?: string;
|
||||
단위?: string;
|
||||
바탕?: { 로직?: string; 참조?: { 층?: string; 주인?: string | null; 키?: string } } | null;
|
||||
입력값?: Record<string, unknown>;
|
||||
열린입력?: string[];
|
||||
덮개?: Record<string, SkCover>;
|
||||
더한줄?: SkAdded[];
|
||||
뺀줄?: string[];
|
||||
고름?: Record<string, string>;
|
||||
자재표시?: Record<string, unknown>;
|
||||
바탕판?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SkLine {
|
||||
이름: string;
|
||||
단위?: string;
|
||||
수량: number | null;
|
||||
단가: number | null;
|
||||
금액: number | null;
|
||||
비목?: Record<string, number | null>;
|
||||
묶음?: string | null;
|
||||
cid: string;
|
||||
율?: number;
|
||||
}
|
||||
|
||||
export interface SkMat {
|
||||
차례: number;
|
||||
이름: string;
|
||||
단위?: string;
|
||||
수량: number | null;
|
||||
단가: number | null;
|
||||
금액: number | null;
|
||||
}
|
||||
|
||||
export interface SkAnswer {
|
||||
기준: Record<string, number> | null;
|
||||
줄: SkLine[];
|
||||
재료?: SkMat[];
|
||||
까닭?: string | null;
|
||||
정체: string[];
|
||||
짝잃음: { cid: string; 까닭: string; 바탕?: string }[];
|
||||
바탕판: string;
|
||||
머리?: string;
|
||||
}
|
||||
|
||||
export interface CoverRow {
|
||||
cid: string;
|
||||
묶음: string;
|
||||
이름: string;
|
||||
단위: string;
|
||||
수량: number | null;
|
||||
단가: number | null;
|
||||
금액: number | null;
|
||||
/** 비목 몫 금액 */
|
||||
parts: Record<string, number>;
|
||||
/** % 덧줄 — 수량 = 율 · 단가 못 고침 */
|
||||
pct: boolean;
|
||||
/** 더한줄 차례 · 바탕 줄 = null */
|
||||
added: number | null;
|
||||
/** 기준 밖 재료(일위대가 자재목록으로 셈) — 금액이 계에 안 듦 */
|
||||
apart: boolean;
|
||||
covered: { qty: boolean; price: boolean };
|
||||
/** 덮개 배율(열린입력 SK) */
|
||||
배율: number | null;
|
||||
/** 바탕 값(덮개 · 더한줄 · 뺀줄 없이 셈) · 없으면 null */
|
||||
was: { qty: number | null; 단가: number | null } | null;
|
||||
/** 저장 때 적힌 원값이 지금 바탕 값과 다름(M01 개정) */
|
||||
revised: boolean;
|
||||
priceEdit: boolean;
|
||||
}
|
||||
|
||||
/* ── 순수 셈 ── */
|
||||
const BUNDLE_OF: Record<string, string> = { 노무비: "인력", 재료비: "재료", 경비: "경비" };
|
||||
const OWN_PART: Record<string, string> = { 인력: "노무비", 재료: "재료비" };
|
||||
const ADD_RE = /^add:#(\d+)$/;
|
||||
const COVER_KEYS = ["qty", "배율", ...PARTS] as const;
|
||||
|
||||
const partsOf = (l: { 비목?: Record<string, number | null> }): Record<string, number> =>
|
||||
Object.fromEntries(Object.entries(l.비목 ?? {}).map(([k, v]) => [k, v ?? 0]));
|
||||
|
||||
/** 바탕 값 — 바탕 셈(덮개 · 더한줄 · 뺀줄 없이)의 줄마다 품(덧줄 = 율) · 단가 */
|
||||
export function baseValues(
|
||||
bare: SkAnswer | null,
|
||||
): Map<string, { qty: number | null; 단가: number | null }> {
|
||||
const out = new Map<string, { qty: number | null; 단가: number | null }>();
|
||||
if (!bare) return out;
|
||||
for (const l of bare.줄) out.set(l.cid, { qty: l.율 ?? l.수량, 단가: l.단가 });
|
||||
for (const m of bare.재료 ?? []) {
|
||||
const cid = bare.정체[m.차례];
|
||||
if (cid && !out.has(cid)) out.set(cid, { qty: m.수량, 단가: m.단가 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const near = (a: number | null | undefined, b: number | null | undefined): boolean =>
|
||||
a != null && b != null && Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b));
|
||||
|
||||
/** 셈 답 → 표 줄(엔진 줄 + 기준 밖 재료) */
|
||||
export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverRow[] {
|
||||
const was = baseValues(bare);
|
||||
const rows: CoverRow[] = [];
|
||||
const row = (
|
||||
cid: string,
|
||||
l: {
|
||||
이름: string;
|
||||
단위?: string;
|
||||
수량: number | null;
|
||||
단가: number | null;
|
||||
금액: number | null;
|
||||
},
|
||||
parts: Record<string, number>,
|
||||
extra: { 묶음?: string | null; 율?: number; apart?: boolean },
|
||||
): CoverRow => {
|
||||
const hit = ADD_RE.exec(cid);
|
||||
const added = hit ? Number(hit[1]) : null;
|
||||
const one = added === null ? undefined : doc.더한줄?.[added];
|
||||
const cover = added === null ? doc.덮개?.[cid] : undefined;
|
||||
const pct = cid.startsWith("pct:");
|
||||
const keys = Object.keys(parts);
|
||||
const base = added === null ? (was.get(cid) ?? null) : null;
|
||||
const old = cover?.원값;
|
||||
return {
|
||||
cid,
|
||||
묶음: extra.묶음 || BUNDLE_OF[keys[0] ?? ""] || "하위",
|
||||
이름: l.이름,
|
||||
단위: pct ? "%" : (l.단위 ?? ""),
|
||||
수량: pct ? (extra.율 ?? l.수량) : l.수량,
|
||||
단가: pct ? null : l.단가,
|
||||
금액: l.금액,
|
||||
parts,
|
||||
pct,
|
||||
added,
|
||||
apart: !!extra.apart,
|
||||
covered: {
|
||||
qty: !!cover && ("qty" in cover || "배율" in cover),
|
||||
price: added === null ? PARTS.some((k) => cover?.[k] != null) : one?.단가 != null,
|
||||
},
|
||||
배율: cover?.배율 ?? null,
|
||||
was: base,
|
||||
revised:
|
||||
!!old &&
|
||||
!!base &&
|
||||
((old.qty != null && !near(old.qty, base.qty)) ||
|
||||
(old.단가 != null && !near(old.단가, base.단가))),
|
||||
priceEdit: !pct && (added === null || ["인력", "재료", "기계"].includes(String(one?.종류))),
|
||||
};
|
||||
};
|
||||
for (const l of ans.줄) rows.push(row(l.cid, l, partsOf(l), l));
|
||||
const seen = new Set(rows.map((r) => r.cid));
|
||||
for (const m of ans.재료 ?? []) {
|
||||
const cid = ans.정체[m.차례];
|
||||
if (!cid || seen.has(cid)) continue;
|
||||
rows.push(row(cid, m, { 재료비: m.금액 ?? 0 }, { 묶음: "재료", apart: true }));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const memo = (base: CoverRow["was"]): SkCover["원값"] | undefined => {
|
||||
if (!base) return undefined;
|
||||
const out: { qty?: number; 단가?: number } = {};
|
||||
if (base.qty != null) out.qty = base.qty;
|
||||
if (base.단가 != null) out.단가 = base.단가;
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
};
|
||||
|
||||
function putCover(doc: SkDoc, r: CoverRow, cover: SkCover): void {
|
||||
const covers = { ...(doc.덮개 ?? {}) };
|
||||
if (COVER_KEYS.some((k) => cover[k] != null)) {
|
||||
const old = cover.원값 ?? memo(r.was);
|
||||
covers[r.cid] = { ...cover, ...(old ? { 원값: old } : {}) };
|
||||
} else delete covers[r.cid];
|
||||
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);
|
||||
}
|
||||
|
||||
/** 단가 고침 — 요소 줄 = 그 비목 칸 · 로직 줄 = 노 · 재 · 경 몫 비로 나눔 · 더한 요소 줄 = 그 줄 단가 */
|
||||
export function setPrice(doc: SkDoc, r: CoverRow, v: number | null): void {
|
||||
if (!r.priceEdit) return;
|
||||
if (r.added !== null) {
|
||||
const one = doc.더한줄?.[r.added];
|
||||
if (!one) return;
|
||||
if (v === null) delete one.단가;
|
||||
else one.단가 = v;
|
||||
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;
|
||||
}
|
||||
putCover(doc, r, cover);
|
||||
}
|
||||
|
||||
/** 줄 빼기 — 더한줄 = 그 줄 지움 · 바탕 줄 = 뺀줄에 cid(덮개 · 고름 · 자재표시도 지움) */
|
||||
export function dropLine(doc: SkDoc, r: CoverRow): void {
|
||||
if (r.added !== null) {
|
||||
doc.더한줄 = (doc.더한줄 ?? []).filter((_, i) => i !== r.added);
|
||||
return;
|
||||
}
|
||||
doc.뺀줄 = [...(doc.뺀줄 ?? []).filter((c) => c !== r.cid), r.cid];
|
||||
for (const k of ["덮개", "고름", "자재표시"] as const) {
|
||||
const map = { ...((doc[k] as Record<string, unknown> | undefined) ?? {}) };
|
||||
delete map[r.cid];
|
||||
(doc as Record<string, unknown>)[k] = map;
|
||||
}
|
||||
}
|
||||
|
||||
/** 뺀 줄 되살림 */
|
||||
export function restoreLine(doc: SkDoc, cid: string): void {
|
||||
doc.뺀줄 = (doc.뺀줄 ?? []).filter((c) => c !== cid);
|
||||
}
|
||||
|
||||
const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 });
|
||||
const num = (v: number | null): string => (v == null ? "—" : NUM.format(v));
|
||||
|
||||
/** 표 줄 → 공용 계산서(`calc_sheet`) 비목 묶음 — 줄 글 「단가 * 품 = 금액」 · 기준 밖 재료 = 금액 없음 */
|
||||
export function sheetGroups(rows: CoverRow[]): SheetGroup[] {
|
||||
const groups = new Map<string, SheetGroup>();
|
||||
const take = (k: string): SheetGroup => {
|
||||
let g = groups.get(k);
|
||||
if (!g) groups.set(k, (g = { 비목: k, 소계: 0, 줄: [] }));
|
||||
return g;
|
||||
};
|
||||
for (const r of rows) {
|
||||
const entries = Object.entries(r.parts);
|
||||
for (const [k, v] of entries.length ? entries : [["경비", 0] as [string, number]]) {
|
||||
const share = entries.length > 1 && r.금액 ? (r.단가 ?? 0) * (v / r.금액) : r.단가;
|
||||
const amount = r.apart ? null : v;
|
||||
const g = take(k);
|
||||
g.소계 = (g.소계 ?? 0) + (amount ?? 0);
|
||||
g.줄.push({
|
||||
이름: r.이름,
|
||||
글: r.pct ? `${num(r.수량)} %` : `${num(share)} * ${num(r.수량)} = ${num(amount)}`,
|
||||
금액: amount,
|
||||
묶음: r.묶음,
|
||||
장비: entries.length > 1 && r.cid.startsWith("logic:"),
|
||||
});
|
||||
}
|
||||
}
|
||||
const order = Object.keys(PART_OF);
|
||||
return [...groups.values()].sort((a, b) => order.indexOf(a.비목) - order.indexOf(b.비목));
|
||||
}
|
||||
/* ── 순수 셈 끝 ── */
|
||||
@@ -0,0 +1,184 @@
|
||||
"""717-2b M02 단가산출 상세 · 고침 지킴 시험 — 순수 꼴(node) · 글 · 서버 저장 왕복(임시 storage).
|
||||
|
||||
- 고친 칸 → SK 덮개(qty · 배율 · 비목 칸 · 원값 곁 글) · 더한줄 수량 · 단가 · 뺀줄 · 되살림
|
||||
- 로직 줄 단가 = 노 · 재 · 경 몫 비로 나눔 · 계산서 묶음 = 표 줄 그대로(별도계상 재료 = 금액 없음)
|
||||
- 화면 문서 꼴이 서버 저장(`clean_doc`)을 지나고 서버가 원값 · 바탕판을 다시 적음 · 셈 = 손셈
|
||||
- 상세 = M01 상세 부품(M02 길) · 부품 상자만 · 마스터 이동 단추 = 등록 없으면 숨김
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
M02 = ROOT / "M02_MasterTemplete"
|
||||
MODEL = M02 / "logic" / "M02_Sangeun_Cover_Model.ts"
|
||||
BASE = "GC000278"
|
||||
|
||||
|
||||
def _text(p: Path) -> str:
|
||||
return p.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _node(body: str) -> object:
|
||||
src = _text(MODEL) + "\n" + body
|
||||
out = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", src],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert out.returncode == 0, out.stderr
|
||||
return json.loads(out.stdout)
|
||||
|
||||
|
||||
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"],
|
||||
줄: [line("labor:A#0", "석공", 0.1, 1000, { 노무비: 100 }, "인력"),
|
||||
line("logic:G#0", "굴착기", 2, 50, { 노무비: 60, 재료비: 30, 경비: 10 }, "경비")],
|
||||
재료: [{ 차례: 2, 이름: "모르타르", 수량: 0.5, 단가: 200, 금액: 100 }] };
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_고침_꼴():
|
||||
body = (
|
||||
SAMPLE
|
||||
+ """
|
||||
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);
|
||||
out.qty = doc.덮개["labor:A#0"];
|
||||
setQty(doc, by("labor:A#0"), 0.1, false); // 바탕 값과 같음 = 덮개 지움
|
||||
out.qtyBack = doc.덮개["labor:A#0"] ?? null;
|
||||
setPrice(doc, by("labor:A#0"), 1200);
|
||||
out.price = doc.덮개["labor:A#0"];
|
||||
setPrice(doc, by("logic:G#0"), 100); // 노 60 · 재 30 · 경 10 비
|
||||
out.split = doc.덮개["logic:G#0"];
|
||||
setQty(doc, by("add:#0"), 3, false);
|
||||
setPrice(doc, by("add:#0"), 350);
|
||||
out.added = doc.더한줄[0];
|
||||
const opened = { 덮개: {} };
|
||||
setQty(opened, by("labor:A#0"), 1.5, true);
|
||||
out.scale = opened.덮개["labor:A#0"];
|
||||
setQty(opened, by("labor:A#0"), 1, true);
|
||||
out.scaleBack = opened.덮개["labor:A#0"] ?? null;
|
||||
dropLine(doc, by("labor:A#0"));
|
||||
out.drop = [doc.뺀줄, doc.덮개["labor:A#0"] ?? null];
|
||||
restoreLine(doc, "labor:A#0");
|
||||
out.restore = doc.뺀줄;
|
||||
dropLine(doc, by("add:#0"));
|
||||
out.dropAdded = doc.더한줄.length;
|
||||
const covered = { 덮개: { "labor:A#0": { qty: 0.3, 원값: { qty: 0.09, 단가: 1000 } } } };
|
||||
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.금액)]);
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
)
|
||||
got = _node(body)
|
||||
assert got["rows"] == [
|
||||
["labor:A#0", "인력", False, None, True],
|
||||
["logic:G#0", "경비", False, None, True],
|
||||
["add:#0", "재료", False, 0, True],
|
||||
["material:M#0", "재료", True, None, True],
|
||||
]
|
||||
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["split"]["labor"] == 60 and got["split"]["material"] == 30
|
||||
assert got["split"]["expense"] == 10
|
||||
assert got["added"]["수량"] == 3 and got["added"]["단가"] == 350
|
||||
assert got["scale"]["배율"] == 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
|
||||
assert got["revised"] is True
|
||||
# 계산서 = 표 줄 그대로 · 별도계상 재료 금액 없음(계에 안 듦)
|
||||
assert got["sheet"] == [
|
||||
["노무비", 160, [100, 60]],
|
||||
["재료비", 30, [30, None]],
|
||||
["경비", 10, [10]],
|
||||
]
|
||||
|
||||
|
||||
def test_상세_부품_길_부품상자():
|
||||
view = _text(M02 / "M02_MasterTemplete_Sangeun_View.ts")
|
||||
detail = _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts")
|
||||
cover = _text(M02 / "M02_MasterTemplete_Sangeun_Cover.ts")
|
||||
assert "mountSangeunDetail(host, doc" in view and "createEmpty" not in view
|
||||
assert 'read: "/m02/logic-lib"' in detail and "setLogicApiPaths(LIB)" in detail
|
||||
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 # 최신 응답만
|
||||
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
|
||||
lab = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Detail.ts")
|
||||
assert "ctx.cover?.est ??" in lab and "ctx.cover.ho" in lab
|
||||
side = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_Side.ts")
|
||||
modal = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Modal.ts")
|
||||
assert "export const hasMasterJump" in side and "go.hidden = !hasMasterJump()" in modal
|
||||
assert len(_text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab.ts").splitlines()) <= 627
|
||||
|
||||
|
||||
def test_화면_문서_꼴_서버_저장_셈(world): # noqa: F811 — 가져온 fixture
|
||||
"""화면 꼴(덮개 + 원값 곁 글 · 로직 줄 나눈 단가 · 더한줄 · 뺀줄) → 저장 · 셈 = 손셈."""
|
||||
mine = world["personal"]
|
||||
with store.at(mine):
|
||||
plain = calc.calc_doc(
|
||||
{"키": "SK-000000", "이름": "t", "단위": "㎥", "바탕": {"로직": BASE}}
|
||||
)
|
||||
lines = {x["cid"]: x for x in plain["줄"]}
|
||||
a, g = lines["labor:LB000018#0"], lines["logic:GC000267#0"]
|
||||
parts = {k: Decimal(str(v)) for k, v in g["비목"].items()}
|
||||
whole_amount = sum(parts.values())
|
||||
split = {
|
||||
{"노무비": "labor", "재료비": "material", "경비": "expense"}[k]: float(
|
||||
round(Decimal(90000) * v / whole_amount, 4)
|
||||
)
|
||||
for k, v in parts.items()
|
||||
}
|
||||
doc = {
|
||||
"이름": "717-2b 시험",
|
||||
"단위": "㎥",
|
||||
"바탕": {"로직": BASE},
|
||||
"덮개": {
|
||||
"labor:LB000018#0": {"qty": 0.02, "원값": {"qty": 1, "단가": 1}}, # 화면 곁 글
|
||||
"logic:GC000267#0": {**split, "원값": {"qty": 9}},
|
||||
},
|
||||
"뺀줄": ["labor:LB000002#1"],
|
||||
"더한줄": [
|
||||
{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": 0.01}
|
||||
],
|
||||
}
|
||||
key = refs.create("sangeun", doc)["이름"]
|
||||
saved = store.read("sangeun", key)["문서"]
|
||||
got = calc.calc_doc(saved)
|
||||
cover = saved["덮개"]["labor:LB000018#0"]
|
||||
assert cover["원값"]["qty"] == pytest.approx(a["수량"]) # 서버가 다시 적음(화면 값 버림)
|
||||
assert saved["바탕판"] == plain["바탕판"] and saved["뺀줄"] == ["labor:LB000002#1"]
|
||||
by = {x["cid"]: x for x in got["줄"]}
|
||||
assert "labor:LB000002#1" not in by
|
||||
assert by["labor:LB000018#0"]["금액"] == pytest.approx(0.02 * a["단가"])
|
||||
assert by["logic:GC000267#0"]["금액"] == pytest.approx(g["수량"] * 90000, rel=1e-6)
|
||||
added = by["add:#0"] # 더한 보통인부 = 바탕 보통인부 단가 × 0.01
|
||||
assert added["수량"] == pytest.approx(0.01)
|
||||
assert added["단가"] == pytest.approx(lines["labor:LB000002#0"]["단가"])
|
||||
assert got["기준"]["계"] == pytest.approx(sum(x["금액"] for x in got["줄"]))
|
||||
@@ -199,14 +199,46 @@ export const ui_locales_m2 = {
|
||||
M02_SangeunNoBase: ["바탕 없음", "No base"],
|
||||
M02_SangeunEdited: ["고친 줄", "Edited rows"],
|
||||
M02_SangeunUsers: ["쓰는 곳", "Used by"],
|
||||
M02_SangeunCover: ["덮개", "Overrides"],
|
||||
M02_SangeunAdded: ["더한 줄", "Added rows"],
|
||||
M02_SangeunRemoved: ["뺀 줄", "Removed rows"],
|
||||
M02_SangeunOpened: ["열린 입력", "Open inputs"],
|
||||
M02_SangeunDetailSoon: [
|
||||
"단가산출 상세 표 · 고침 — 준비 중(717-2b)",
|
||||
"Unit-price detail table and editing — coming next (717-2b)",
|
||||
M02_SangeunUnit: ["단위(1 단위 값)", "Unit (value per 1)"],
|
||||
M02_SangeunNeed: ["비우면 저장 안 됨", "Required"],
|
||||
M02_SangeunPickBase: ["M01 로직 고름", "Pick M01 logic"],
|
||||
M02_SangeunAddLine: ["줄 더하기", "Add row"],
|
||||
M02_SangeunDrop: ["빼기", "Remove"],
|
||||
M02_SangeunRestore: ["되살리기", "Restore"],
|
||||
M02_SangeunWas: ["바탕", "Base"],
|
||||
M02_SangeunEditedHere: ["단가산출에서 고침", "Edited in this analysis"],
|
||||
M02_SangeunAddedTag: ["더한 줄", "Added row"],
|
||||
M02_SangeunPct: ["% 덧줄", "% row"],
|
||||
M02_SangeunApart: ["별도계상", "Counted separately"],
|
||||
M02_SangeunRevised: ["M01 개정 — 확인", "M01 revised — check"],
|
||||
M02_SangeunRevisedBase: ["M01 개정 — 확인", "M01 revised — check"],
|
||||
M02_SangeunRevisedNote: [
|
||||
"바탕 M01 로직이 저장 뒤 바뀜 — 고친 칸의 바탕 값을 확인하고 [저장]하면 새 판으로 맞춤",
|
||||
"The base M01 logic changed since saving — check edited cells, then Save to adopt the new version",
|
||||
],
|
||||
M02_SangeunLost: [
|
||||
"바탕에서 짝을 잃은 고침 — 지우지 않고 남겨 둠",
|
||||
"Edits that lost their base row — kept, not deleted",
|
||||
],
|
||||
M02_SangeunScaleOnly: ["열린 입력 — 품은 배율만", "Open inputs — quantity as multiplier only"],
|
||||
M02_SangeunBaseViews: [
|
||||
"시험 계산 · 텍스트 수식 = 바탕 M01 로직(고치기 전)",
|
||||
"Test calc · formula = base M01 logic (before edits)",
|
||||
],
|
||||
M02_SangeunWait: ["셈 기다리는 중", "Calculating…"],
|
||||
M02_SangeunNoInputs: [
|
||||
"바탕 M01 로직 없음 — 시험 입력 없음",
|
||||
"No base M01 logic — no test inputs",
|
||||
],
|
||||
M02_SangeunColName: ["이름", "Name"],
|
||||
M02_SangeunColQty: ["수량", "Qty"],
|
||||
M02_SangeunColUnit: ["단위", "Unit"],
|
||||
M02_SangeunColLogic: ["로직", "Logic"],
|
||||
M02_SangeunColPrice: ["단가", "Unit price"],
|
||||
M02_SangeunColAmount: ["금액", "Amount"],
|
||||
M02_SangeunSubtotal: ["소계", "subtotal"],
|
||||
M02_SangeunTotal: ["계", "Total"],
|
||||
M02_PickStd: ["기준 목록", "Standard list"],
|
||||
M02_PickSangeun: ["단가산출", "Unit-cost calcs"],
|
||||
M02_PickElement: ["M01 요소", "M01 elements"],
|
||||
|
||||
Reference in New Issue
Block a user