feat(799-2): M01 로직 상세 · M02 단가산출 상세 옛 탭 넷(시트 · 인력/재료/경비 · 시험 계산 · 텍스트 수식) 걷음 — 보기는 수식 · 단가산출 상세 · 셈 구동 따로

- 셈 구동 떼기: 새 M01_MasterData_UI_Logic_Drive.ts(driveRun · previewText · autoFill · createDrive) — M01 상세는 숨은 시험 계산 칸 단추 대신 구동 함수 · 시험 계산 칸(Logic_Calc)은 로직 복사 화면이 쓰므로 남기고 같은 구동을 부름 · 재료 고름은 rerun 고리
- M02: 새 _Sangeun_Tabs.ts(수식 · 단가산출 상세 보기 고르기) · Detail 의 시험 계산 · 인력/재료/경비 걷음
- 걷은 파일(git rm): M01 LogicLab_Sheet · Logic_Sheet_Model · LogicLab_Formula(.ts .css) · FormulaTokens · LogicLab_Est / M02 Sangeun_Sheet(.ts .css) · SheetEdit · SheetFormula · logic Sheet_Model · Const_Model · Formula_Model · 부품 시트 줄(createCalcSheetRow) · Cover_Model.sheetGroups · Calc 의 M02 고리(answer · shown · held) · 죽은 CSS · 글
- 서버 길 · 문서 칸(변수줄 · 글줄 · 덮개)은 남김 — 금액 그대로
- 낡은 시험: test_790_1(시트 화면 전부) · test_714_3 · test_748_2(+ helper)는 화면이 없어져 지움 · test_794_1 · test_790_2 는 화면 · TS 거울만 덜고 서버 시험 남김 · test_714_2 · 717_2b · 721 · 722(원래 틀린 한 줄 고침) · 734 · 737 · 750_2 · 793_3b · 796_2 · 796_2b 는 보기 둘 · 새 파일에 맞춤 · 새 test_799_2_old_tabs_gone

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 12:57:30 +09:00
co-authored by Claude Sonnet 5.5
parent d13b5b1753
commit c9bdf6d891
49 changed files with 389 additions and 3817 deletions
@@ -17,18 +17,6 @@
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__basehead,
.m02-sk__removed,
@@ -56,16 +44,6 @@
font-weight: var(--font-weight-bold);
}
/* 고친 칸 — B09 고친 칸과 같은 꼴(색) · 곁 글 = 바탕 값 */
.m02-sk__cover td.is-edited {
background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas));
}
.m02-sk__total {
text-align: end;
font-weight: var(--font-weight-bold);
}
/* 740-2 이름 + 출처 꼬리표 한 줄 — 이름이 말줄임으로 줄고 꼬리표는 그대로 */
.m02-sk .m01lab__namerow {
display: flex;
@@ -1,9 +1,8 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_Detail.ts
* 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 로직 + 덮개 품 · 단가 사본) · 텍스트 수식(같은 사본 식) · 기본정보 = 바탕 로직(맨 위 접힘)
* 보기 둘(`_Sangeun_Tabs` · 799-2) = 수식(796-2b) · 단가산출 상세(공용 상세 `LogicLab_Ho` · SK 셈 답 줄 = `hoView` · 줄 모달 그 자리 고침 =
* HoEdit → SK 덮개 · 더한줄 · 줄 빼기 · 별도계상) · 기본정보 = 바탕 로직(맨 위 접힘) — 시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식은 걷음
* 고침 = SK 덮개 · 더한줄(`Sangeun_Pick`) · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`) · 값 = 서버 셈 길 최신 응답만
* 옵션 칸 = SK `입력값`(727-3 · 저장 · 열린입력만 시험 값) · [저장] = 화면 문서 → 서버가 다시 셈(원값 · 바탕판)
* 둘째 기초(727-2) = 서버 `펼침` 세부 줄 · 인력 · 재료 · 경비 상자에 함께 + 꼬리표(기초 둘 이상) · 세부 덮개 ·
@@ -19,9 +18,7 @@ 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 { swapUnit } from "../A00_Common/master_unit_swap";
import { buildCalc, type CalcShown } from "../M01_MasterData/M01_MasterData_UI_Logic_Calc";
import {
fetchLogic,
setLogicApiPaths,
@@ -38,7 +35,7 @@ import { baseHeads } from "./M02_MasterTemplete_Sangeun_BaseHead";
import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover";
import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick";
import { openRateLine } from "./M02_MasterTemplete_Sangeun_Rate";
import { createSangeunSheet } from "./M02_MasterTemplete_Sangeun_Sheet";
import { createSangeunTabs } from "./M02_MasterTemplete_Sangeun_Tabs";
import { createSangeunSide, tabOf, type SourceTab } from "./M02_MasterTemplete_Sangeun_Side";
import { baseUnitOf, buildUnitSwap, type SwapHow } from "./M02_MasterTemplete_Sangeun_Unit";
import {
@@ -52,7 +49,6 @@ import {
rowsOf,
setApart,
setLineCover,
sheetGroups,
type CoverRow,
type HoView,
type SkAnswer,
@@ -90,7 +86,6 @@ export function mountSangeunDetail(
let lastText: TextAnswer | undefined;
let ans: SkAnswer | null = null;
let bare: SkAnswer | null = null;
let okKey = ""; // 마지막으로 잘 받은 셈의 입력(문서 + 시험 값) — 같으면 [계산] 이 다시 안 셈
let fail = "";
let extra: LogicInput[] = []; // 더한줄 로직 입력 중 바탕에 없는 것 = 옵션 칸(725-6 · 727-3)
const inputsOf = new Map<string, LogicInput[]>();
@@ -103,11 +98,12 @@ export function mountSangeunDetail(
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 sheet = createSangeunSheet(editor, {
...{ edit, opened: () => opened(), touch: () => touch(), onInput: (n, v) => onInput(n, v) },
...{ addLine: () => addButton.click(), addRate: () => rateButton.click(), scope }, // 790-1 · 792 식 검사 층
const tabs = createSangeunTabs(editor, {
edit,
scope,
touch: () => touch(),
onInput: (n, v) => onInput(n, v),
});
const calcHost = el("div", { className: "m01lab__calc" });
const ownHost = el("div");
const addButton = createButton({
label: L("M02_SangeunAddLine"),
@@ -169,8 +165,8 @@ export function mountSangeunDetail(
children: [
head,
notes,
el("div", { className: "m02-sk__head", children: [sheet.picker, addButton, rateButton] }),
sheet.host,
el("div", { className: "m02-sk__head", children: [tabs.picker, addButton, rateButton] }),
tabs.host,
editor,
],
});
@@ -227,21 +223,18 @@ export function mountSangeunDetail(
else drawHead();
};
const inputKey = (): string => JSON.stringify([doc, values]);
async function recalc(): Promise<void> {
const mine = ++seq;
await syncExtra();
if (mine !== seq || !alive) return;
const plain = { ...doc, 덮개: {}, 뺀줄: [], 더한줄: [] };
const key = inputKey();
try {
const [got, was] = await Promise.all([
calcSangeun(scope, doc, values),
doc.바탕 ? calcSangeun(scope, plain, values, false) : Promise.resolve(null), // 바탕 원값 = 보기 덧칸 없이
]);
if (mine !== seq || !alive) return; // 늦게 온 답 — 버림
[ans, bare, fail, okKey] = [got, was, "", key];
[ans, bare, fail] = [got, was, ""];
text = got.글 ?? null; // 750-2 텍스트 수식 · 시험 계산 = 이 답 하나(바탕 로직 따로 셈 없음)
if (text?.ok) lastText = text;
} catch (error) {
@@ -340,7 +333,7 @@ export function mountSangeunDetail(
];
const row = base ? { ...base.logic, 입력: [...(base.logic.입력 ?? []), ...extra] } : standIn();
const view = ans ? hoView(ans, bare, base?.logic ?? null, doc) : null;
sheet.draw({ doc, ans, bare, base: row, text, rows, view, values, removed, fail });
tabs.draw({ doc, ans, base: row, values });
const hoCtx = {
row: view ? { ...row, 호표: view.호표, 덧줄: view.덧줄 } : row,
prices: base?.prices ?? {},
@@ -349,7 +342,6 @@ export function mountSangeunDetail(
lastText,
values,
onInput,
calcHost,
edit: view && edit ? hoEdit(view, rows) : undefined,
cids: view?.cids,
pickMat: (idx: number) => pickMat(view?.cids[idx]),
@@ -377,20 +369,6 @@ export function mountSangeunDetail(
...buildHo(hoCtx),
],
});
const est = el("div", {
attrs: { "data-est": "" },
children: ans
? calcSheetRows({
묶음: sheetGroups(rows),
계: ans.기준?.계 ?? null,
sumLabel: `${L("M02_SangeunTotal")} (${doc.단위 && doc.단위 !== "식" ? `원/${doc.단위}` : row.결과단위 || "원"})`,
bundle: true,
inputs: row.입력,
values,
onInput,
})
: [createText(L("M02_SangeunWait"))],
});
buildLabDetail(editor, {
row: base?.logic ?? row,
file: base?.file ?? "",
@@ -402,18 +380,16 @@ export function mountSangeunDetail(
values,
ownHost,
onChange: () => undefined,
calcHost,
onInput,
cover: { ho, est, open: lineOpener(hoCtx) }, // 748-2 텍스트 수식 변수 · 값 누름 = 같은 줄 모달
cover: { ho, open: lineOpener(hoCtx) },
});
drawCalc();
side?.set([
...(base ? [tabOf(base.logic)] : []),
...extraKeys.flatMap((k) => (k === baseKey() ? [] : infoOf.has(k) ? [infoOf.get(k)!] : [])),
]);
}
/** 재료 고름(줄 모달 · 시험 계산 재료 칸) = SK 문서 `고름`(cid → MT) — 둘째 기초 세부 줄은 보기만 */
/** 재료 고름(줄 모달) = SK 문서 `고름`(cid → MT) — 둘째 기초 세부 줄은 보기만 */
function pickMat(cid: string | undefined): ((it: { ref: string }) => void) | undefined {
if (!cid || cid.startsWith("add:#")) return undefined;
return (it) => {
@@ -480,8 +456,7 @@ export function mountSangeunDetail(
};
}
/** 옵션 칸 · 텍스트 수식 · 계산서 입력 단추 — 입력 고침 → SK `입력값`(727-3 · 열린입력 = 시험 값만) ·
* 시험 계산 다시 그림 · 셈 */
/** 옵션 칸(수식 탭 · 단가산출 상세) — 입력 고침 → SK `입력값`(727-3 · 열린입력 = 시험 값만) · 셈 */
function onInput(name: string, v: string): void {
values[name] = v;
if (edit && !(doc.열린입력 ?? []).includes(name)) {
@@ -491,44 +466,7 @@ export function mountSangeunDetail(
doc.입력값 = kept;
touch(false);
}
void recalc(); // 750-2 옵션 바꿈 = 서버 셈 한 번 — 네 보기가 그 답 하나로 다시 그려짐
}
function drawCalc(): void {
if (!base) {
calcHost.replaceChildren(createText(L("M02_SangeunNoInputs")));
return;
}
const count = base.logic.호표?.length ?? 0;
buildCalc(calcHost, {
savedKey: base.logic.키,
file: base.file,
row: base.logic,
prices: base.prices,
dirty: () => false,
values,
answer: async () => {
if (okKey !== inputKey()) await recalc(); // 칸을 고치고 [계산] — SK 셈 한 번
return shownOf(count);
},
shown: shownOf(count),
held: (i, it) => pickMat(bare?.정체[i])?.(it),
});
}
/** 시험 계산 칸에 그릴 것 = SK 셈 답(줄 · 기준 · 중간) */
function shownOf(count: number): CalcShown | null {
if (!ans) return fail ? { answer: { ok: false, reason: fail } } : null;
const by = new Map(ans.줄.map((l) => [l.cid, l as unknown as CalcLine]));
return {
answer: {
ok: true,
lines: ans.줄 as unknown as CalcLine[], // 못 셈 줄 = 금액 「—」(계에 안 듦)
sums: ans.기준 ?? undefined,
middle: ans.중간 ?? {},
},
items: (bare?.정체 ?? []).slice(0, count).map((cid) => by.get(cid)),
};
void recalc(); // 750-2 옵션 바꿈 = 서버 셈 한 번 — 수식 · 단가산출 상세가 그 답 하나로 다시 그려짐
}
/** `fresh` = 막 넣은 바탕 — 단위가 비거나 「식」이면 기초 기준단위(「100본」 · 바꾸면 서버가 환산 · 728-2) */
@@ -563,7 +501,7 @@ export function mountSangeunDetail(
if (one) one.바탕판 = l.바탕판; // 둘째 기초도 같음(727-2)
}
saved = JSON.stringify(doc);
sheet.saved();
tabs.saved();
return clone(doc);
},
getValues: () => ({ ...values }), // 723-3 — 일위대가 줄 모달 [적용] = 옵션을 그 줄에 남김
@@ -1,142 +0,0 @@
/* M02 단가산출 줄 식 시트(790-1) — 줄 꼴은 부품 상자(calc_row 시트 줄) · 여기는 자리 · 덩이 머리 · 산출서 글만 */
.m02-sks {
container-type: inline-size;
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.m02-sks[hidden] {
display: none;
}
.m02-sks__status,
.m02-sks__amount,
.m02-sks__edit,
.m02-sks__cell {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4);
}
.m02-sks__status {
gap: var(--spacing-12);
}
.m02-sks__amount .ui-text,
.m02-sks__cell .ui-text {
margin: 0;
}
.m02-sks__body {
display: flex;
flex-direction: column;
}
.m02-sks__head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
margin-top: var(--spacing-12);
padding-bottom: var(--spacing-4);
border-bottom: 1px solid var(--color-border-strong);
}
.m02-sks__head .ui-text {
margin: 0;
font-weight: var(--font-weight-bold);
}
.m02-sks__mid {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.m02-sks__mid .ui-calc-formula {
overflow-wrap: anywhere;
}
.m02-sks__cell .ui-field {
width: 8em;
}
.m02-sks__paper {
margin: 0;
padding: var(--spacing-4) var(--spacing-8);
color: var(--color-text-body);
font-size: var(--text-body-sm);
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.m02-sks__paper--sum {
font-weight: var(--font-weight-bold);
border-bottom: 1px solid var(--color-border);
}
.m02-sks__paper[data-paper="머리"] {
margin-top: var(--spacing-8);
color: var(--color-text-secondary);
}
/* 790-3 숫자 넓힘 — 식 속 숫자 · 표 값 칸 · 까닭 · 글 줄 */
.m02-sks__formula,
.m02-sks__find {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4);
font-size: var(--text-body-sm);
font-weight: var(--font-weight-regular);
color: var(--color-text-body);
font-variant-numeric: tabular-nums;
}
.m02-sks__frag {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.m02-sks__num {
width: 6.5em;
}
.m02-sks__num[data-cover] {
outline: 2px solid var(--color-warning);
border-radius: var(--radius-sm);
}
.m02-sks__reason .ui-field {
width: min(24em, 100%);
}
.m02-sks__note .ui-field {
width: 100%;
}
.m02-sks__paper--note {
color: var(--color-text-secondary);
font-style: italic;
}
/* 792 식 칸 — 칸 + 검사 알림 한 줄 */
.m02-sks__fx {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.m02-sks__fx .ui-field {
width: 100%;
}
.m02-sks__fx .ui-text:empty {
display: none;
}
@@ -1,497 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_Sheet.ts
* M02 단가산출(SK) 줄 식 시트(PLAN 790-1 · E1 · 요구 650~652 · 요구서_652 1 장 4) — 상세 첫 탭 「시트」.
* 보기 고르기 = 수식(첫 탭 · 796-2b `_Sangeun_Equation`) · 단가산출 상세 · 시트 · 옛 보기 셋(인력·재료·경비 · 시험 계산 · 텍스트 수식 — 뒤로만)
* 시트 = 한 줄 「이름 | 식 | 값 | 비목」(줄 꼴 = `logic/M02_Sangeun_Sheet_Model`) · 안 보기 전환 「시트 ↔ 산출서」
* 고침 = 지금 되는 것만(모델 안 바꿈) — 옵션 고름 · 품 · 단가 · 율 % · 줄 빼기 · 되살림 · [+ 자원 줄] · [+ 율 줄]
* → SK 문서(덮개 · 더한줄 · 뺀줄 = `M02_Sangeun_Cover_Model`) → 상세 `touch` = 서버 셈 한 번(최신 응답만)
* 상태줄 = M01 원래 · 지금 · 고치기 전(화면 캐시) + 차이 · 자동저장 없음([저장] = 상세 그대로)
* 790-3 숫자 넓힘(식 속 상수 · 표값 · 글 줄 · 까닭 · [M01 원래로] · M01 바뀜) = `_Sangeun_SheetEdit`
* 792 식 칸(줄 식 고침 · 칠 때마다 검사 · 변수 줄) = `_Sangeun_SheetFormula`
* 부품 = `ui_template_calc_row` 시트 줄 · 입력 · 고르기 · 단추 · 배지 · 글 — 날 단추 · 입력 없음
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { createCalcFormula, createCalcSheetRow } from "@ui/ui_template_calc_row";
import { el } from "@ui/ui_template_elements";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createSegment } from "@ui/ui_template_segment";
import { createText } from "@ui/ui_template_text";
import { createToolbar } from "@ui/ui_template_toolbar";
import { formatLineMoney, formatMoney } from "../common_util/common_util_money";
import type { LogicRow, TextAnswer } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { tl } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Text";
import { removedStrip } from "./M02_MasterTemplete_Sangeun_Cover";
import { createSangeunEquation } from "./M02_MasterTemplete_Sangeun_Equation";
import {
addTopNote,
formulaEditor,
lostBanner,
noteRows,
reasonCell,
removedReasons,
restoreAll,
spotTail,
} from "./M02_MasterTemplete_Sangeun_SheetEdit";
import {
addVarButton,
formulaToggle,
rowFormula,
varRows,
type FormulaCtx,
} from "./M02_MasterTemplete_Sangeun_SheetFormula";
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
import { coverKinds, dropAddKeys, type ConstDoc } from "./logic/M02_Sangeun_Const_Model";
import {
dropLine,
PART_OF,
restoreLine,
setLineCover,
type CoverRow,
type HoView,
type LineCover,
type SkAnswer,
type SkDoc,
} from "./logic/M02_Sangeun_Cover_Model";
import {
diffText,
liftAddKeys,
num,
paperLines,
sheetItems,
spotsOf,
statusOf,
type SheetItem,
type SheetKind,
} from "./logic/M02_Sangeun_Sheet_Model";
import "./M02_MasterTemplete_Sangeun_Sheet.css";
const TEXT = {
Sheet: ["시트", "Sheet"],
Paper: ["산출서", "Statement"],
Mode: ["시트 보기", "Sheet view"],
Origin: ["M01 원래", "M01 original"],
Excel: ["엑셀 원래", "Excel original"], // 793-3b 가져온 식(셈 답 `가져온`) = 원래 = 엑셀 금액
Now: ["지금", "Now"],
Before: ["고치기 전", "Before edit"],
근거: ["근거", "Basis"],
옵션: ["옵션", "Options"],
상수: ["변수 · 계산", "Variables"],
자원: ["자원", "Resources"],
율: ["율", "Rates"],
소계: ["소계 · 합계", "Subtotals"],
Qty: ["품", "Qty"],
Price: ["단가", "Price"],
Rate: ["율 %", "Rate %"],
Drop: ["빼기", "Remove"],
AddRes: ["+ 자원 줄", "+ Resource"],
AddRate: ["+ 율 줄", "+ Rate line"],
Table: ["표", "Table"],
Hidden: ["조건 · 표 값", "Condition · table value"],
Wait: ["셈 기다리는 중", "Calculating…"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
/** 줄 갈래 → 덩이(같은 머리 아래) */
const SECTION: Record<SheetKind, keyof typeof TEXT> = {
근거: "근거",
옵션: "옵션",
상수: "상수",
표: "상수",
계산: "상수",
자원: "자원",
율: "율",
소계: "소계",
합계: "소계",
};
export interface SheetDraw {
doc: SkDoc;
ans: SkAnswer | null;
bare: SkAnswer | null;
base: LogicRow | null;
text: TextAnswer | null;
rows: CoverRow[];
view: HoView | null;
values: Record<string, string>;
removed: { cid: string; 이름: string }[];
fail: string;
}
export interface SheetOptions {
edit: boolean;
opened: () => boolean;
touch: () => void;
onInput: (name: string, value: string) => void;
addLine: () => void;
addRate: () => void;
/** 792 식 검사 길 층 */
scope: { layer: Layer; owner: string | null };
}
type View = "equation" | "sheet" | "ho" | "est" | "calc" | "formula";
/** 보기 고르기(시트 + 옛 넷) · 시트 그릇 — `editor` = 옛 보기 그릇(시트일 때 숨김) */
export function createSangeunSheet(
editor: HTMLElement,
opt: SheetOptions,
): { picker: HTMLElement; host: HTMLElement; draw: (d: SheetDraw) => void; saved: () => void } {
const sheetHost = el("div", { className: "m02-sks", attrs: { "data-sk-sheet": "" } });
const eqView = createSangeunEquation(opt); // 796-2b 수식 첫 탭
const host = el("div", { children: [eqView.root, sheetHost] });
const status = el("div", { className: "m02-sks__status", attrs: { "data-sk-status": "" } });
const body = el("div", { className: "m02-sks__body" });
let mode: "sheet" | "paper" = "sheet";
let last: SheetDraw | null = null;
let before: number | null = null; // 고치기 전 = 연 뒤 · 저장 뒤 첫 셈 계(화면 캐시)
let ctx: FormulaCtx | null = null; // 790-3 숫자 넓힘 · 792 식 칸 — 그릴 때마다 새로
const open = new Set<string>(); // 식 칸 연 자리
const drafts = new Map<string, string>(); // 치는 중 식 글
let focusNext = ""; // 새 글 줄 칸 — 다음 그림 뒤 초점
const show = (v: View): void => {
eqView.root.hidden = v !== "equation";
sheetHost.hidden = v !== "sheet";
editor.hidden = v === "sheet" || v === "equation";
if (!editor.hidden) editor.dataset.view = v;
};
const picker = createSegment({
options: [
{ value: "equation", label: tl("View_Equation") },
{ value: "ho", label: tl("Ho_Title") },
{ value: "sheet", label: t("Sheet") },
{ value: "est", label: tl("View_Est") },
{ value: "calc", label: tl("Calc_Title") },
{ value: "formula", label: tl("Formula_Title") },
],
value: "equation",
size: 32,
ariaLabel: tl("View_Label"),
onChange: (v) => show(v as View),
});
picker.root.dataset.skViews = "";
const inner = createSegment({
options: [
{ value: "sheet", label: t("Sheet") },
{ value: "paper", label: t("Paper") },
],
value: mode,
size: 28,
ariaLabel: t("Mode"),
onChange: (v) => {
mode = v as typeof mode;
if (last) draw(last);
},
});
inner.root.dataset.skMode = "";
sheetHost.append(
createToolbar({ items: [inner.root, "divider", status], label: t("Mode") }),
body,
);
show("equation");
function drawStatus(d: SheetDraw): void {
const s = statusOf(d.ans, d.bare, before);
const part = (key: string, label: string, v: number | null, diff = ""): HTMLElement =>
el("span", {
className: "m02-sks__amount",
attrs: { "data-sk-amount": key },
children: [
createText(`${label} ${v == null ? "—" : formatMoney(v)}`, "muted", 14),
...(diff ? [createBadge(diff, diff.startsWith("+") ? "warning" : "info")] : []),
],
});
status.replaceChildren(
part("origin", t(d.ans && "가져온" in d.ans ? "Excel" : "Origin"), s.원래),
part("now", t("Now"), s.지금, diffText(s.지금, s.원래)),
part("before", t("Before"), s.전, diffText(s.지금, s.전)),
...(ctx ? restoreAll(ctx) : []),
);
}
/** 글 칸 — 바꾸면(Enter · 떠남) `onSet(글)` · 이름표 없으면 칸만 */
const textCell = (
key: string,
label: string,
v: string,
onSet: (raw: string) => void,
): HTMLElement => {
const box = createInputField({ value: v, compact: true, size: 24 });
box.input.setAttribute("aria-label", label);
box.input.dataset.sheetCell = key;
box.input.addEventListener("change", () => onSet(box.input.value.trim()));
if (!label) return box.root;
return el("span", {
className: "m02-sks__cell",
children: [createText(label, "muted"), box.root],
});
};
/** 숫자 칸 — 빈 칸 = null(그 칸 풂) · 못 읽으면 되돌림 */
const numCell = (
key: string,
label: string,
v: number | null,
onSet: (x: number | null) => void,
): HTMLElement => {
const cell = textCell(key, label, v == null ? "" : String(Number(v.toFixed(6))), (raw) => {
const x = raw ? Number(raw.replaceAll(",", "")) : null;
if (x !== null && !Number.isFinite(x))
return void (input.value = v == null ? "" : String(Number(v.toFixed(6))));
onSet(x);
});
const input = cell.querySelector("input")!;
input.inputMode = "decimal";
return cell;
};
/** 자원 · 율 줄 고침 — 덮개 한 칸 바꿈(빈 칸 = 그 칸 풂) → 상세 다시 셈 */
function setCell(d: SheetDraw, r: CoverRow, key: keyof LineCover, x: number | null): void {
const i = d.view?.cids.indexOf(r.cid) ?? -1;
const cur: LineCover = { ...(i >= 0 ? d.view?.covers[i] : undefined) };
if (x == null) delete cur[key];
else cur[key] = x;
setLineCover(d.doc, r, Object.keys(cur).length ? cur : null, opt.opened());
opt.touch();
}
function editLine(d: SheetDraw, r: CoverRow): HTMLElement[] {
if (!opt.edit) return [];
const parts = Object.keys(r.parts);
const part = PART_OF[parts[0] ?? ""];
const fx = coverKinds(d.doc as ConstDoc, r.cid).includes("식"); // 식 덮개 = 품 칸 안 둠(792)
const cells = [
...(fx
? []
: [
numCell(`${r.cid}|qty`, r.pct ? t("Rate") : t("Qty"), r.수량, (x) =>
setCell(d, r, "qty", x),
),
]),
...(r.priceEdit && part && parts.length === 1
? [numCell(`${r.cid}|price`, t("Price"), r.단가, (x) => setCell(d, r, part, x))]
: []),
];
return [el("div", { className: "m02-sks__edit", children: cells })];
}
function tailOf(d: SheetDraw, it: SheetItem, nums: boolean): HTMLElement {
const r = it.row;
const kids: HTMLElement[] = it.비목
? [createBadge(it.비목, it.비목 === "별도" ? "warning" : "neutral")]
: [];
if (!nums) for (const tag of it.표) kids.push(createBadge(`${t("Table")} ${tag}`, "info"));
if (ctx && fx(it)) kids.push(...formulaToggle(ctx, it.자리, ctx.spots?.자리[it.자리]));
if (ctx) kids.push(...spotTail(ctx, it.자리, !!r && r.added !== null));
if (r && opt.edit) {
const drop = createButton({
label: t("Drop"),
variant: "danger-ghost",
size: 22,
onClick: () => {
dropLine(d.doc, r);
if (r.added !== null && !r.sub) apply(d, dropAddKeys(d.doc as ConstDoc, r.added));
else opt.touch();
},
});
drop.dataset.sheetDrop = r.cid;
kids.push(drop);
}
return el("span", { children: kids });
}
function optionCell(d: SheetDraw, it: SheetItem): HTMLElement {
const spec = it.input!;
const value = d.values[spec.이름] ?? "";
if (!opt.edit) return createCalcFormula(value || "—");
if (spec.고르기?.length) {
const sel = createSelectField({
options: [...(value ? [] : [""]), ...spec.고르기.map(String)].map((o) => ({
value: o,
text: o,
})),
value,
compact: true,
size: 24,
ariaLabel: spec.이름,
onChange: (v) => opt.onInput(spec.이름, v),
});
sel.select.dataset.sheetCell = `opt|${spec.이름}`;
return sel.root;
}
return textCell(`opt|${spec.이름}`, "", value, (v) => opt.onInput(spec.이름, v));
}
const valueOf = (it: SheetItem): HTMLElement => {
const v = it.값;
const text =
typeof v === "number"
? it.kind === "자원" || it.kind === "율"
? formatLineMoney(v)
: it.kind === "소계" || it.kind === "합계"
? formatMoney(v)
: num(v)
: it.kind === "옵션"
? (it.input?.단위 ?? "")
: (v ?? (it.kind === "근거" ? "" : "—"));
const key = it.kind === "소계" || it.kind === "합계";
return el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: String(text) });
};
/** 식 칸 되는 줄 — 율 · 로직 더한줄 · 둘째 기초 세부 줄 뺌 */
const fx = (it: SheetItem): boolean =>
!!it.자리 && !(it.row?.sub || (it.row?.added != null && it.row.pct));
function rowOf(d: SheetDraw, it: SheetItem): HTMLElement[] {
const own = ctx && fx(it) ? rowFormula(ctx, it.자리, ctx.spots?.자리[it.자리]) : null;
const nums = ctx && it.자리 && !own ? formulaEditor(ctx, it.자리) : null;
const shown = it.식 || (it.kind === "계산" ? t("Hidden") : "");
const middle =
it.kind === "옵션"
? optionCell(d, it)
: el("div", {
className: "m02-sks__mid",
children: [
...(it.row || !nums ? [createCalcFormula(shown)] : []),
...(nums ? [nums] : []),
...(own ? [own] : []),
...(it.row ? editLine(d, it.row) : []),
...(ctx ? reasonCell(ctx, it.자리, it.changed) : []),
],
});
const row = createCalcSheetRow(it.이름, middle, valueOf(it), tailOf(d, it, !!nums));
row.dataset.sheetKind = it.kind;
if (it.row) row.dataset.sheetCid = it.row.cid;
if (it.자리) row.dataset.sheetSpot = it.자리;
if (it.changed) row.classList.add("is-changed");
const notes = ctx && it.자리 ? noteRows(ctx, (x) => x.자리 === it.자리) : [];
const after = ctx && it.자리 ? varRows(ctx, (v) => v.자리 === it.자리) : [];
return [row, ...notes, ...after];
}
/** 790-3 — 새 문서로 바꿔 끼움(상세가 쥔 같은 객체) → 다시 셈 */
function apply(d: SheetDraw, next: ConstDoc): void {
const doc = d.doc as Record<string, unknown>;
for (const k of Object.keys(doc)) if (!(k in next)) delete doc[k];
Object.assign(doc, next);
opt.touch();
}
const head = (key: keyof typeof TEXT, extra: HTMLElement[] = []): HTMLElement =>
el("div", {
className: "m02-sks__head",
attrs: { "data-sheet-section": key },
children: [createText(t(key), "muted", 14), ...extra],
});
function drawSheet(d: SheetDraw): HTMLElement[] {
const items = sheetItems({
base: d.base,
inputs: d.base?.입력 ?? [],
values: d.values,
ans: d.ans,
rows: d.rows,
text: d.text,
covered: (s) => coverKinds(d.doc as ConstDoc, s).length > 0,
});
const spots = new Set(items.map((it) => it.자리).filter(Boolean));
const names = new Set(
((d.doc as { 변수줄?: { 이름: string }[] }).변수줄 ?? []).map((v) => v.이름),
);
const varSpot = (s: string): boolean => s.startsWith("mid:") && names.has(s.slice(4));
const out: HTMLElement[] = ctx ? lostBanner(ctx) : [];
const add = (label: keyof typeof TEXT, click: () => void): HTMLElement[] =>
opt.edit
? [createButton({ label: t(label), variant: "ghost", size: 22, onClick: click })]
: [];
const order: (keyof typeof TEXT)[] = ["근거", "옵션", "상수", "자원", "율", "소계"];
for (const sec of order) {
const mine = items.filter((it) => SECTION[it.kind] === sec);
const tools =
sec === "자원"
? add("AddRes", opt.addLine)
: sec === "율"
? add("AddRate", opt.addRate)
: sec === "근거" && ctx
? addTopNote(ctx)
: sec === "상수" && ctx
? addVarButton(ctx)
: [];
if (!mine.length && !tools.length) continue;
out.push(head(sec, tools));
if (sec === "자원")
out.push(
...removedStrip(d.removed, opt.edit, (cid) => revive(d, cid)),
...(ctx ? removedReasons(ctx, d.removed) : []),
);
if (sec === "상수" && ctx)
out.push(...varRows(ctx, (v) => !v.자리 || (!spots.has(v.자리) && !varSpot(v.자리))));
for (const it of mine) out.push(...rowOf(d, it));
if (sec === "근거" && ctx) out.push(...noteRows(ctx, (x) => !spots.has(x.자리)));
}
return out;
}
/** 뺀 줄 되살림 — 뺀 더한줄(`gone:N`)이면 까닭 · 글줄 열쇠를 그 차례부터 한 칸 밂 */
function revive(d: SheetDraw, cid: string): void {
const gone = /^gone:(\d+)$/.exec(cid);
const at = gone ? d.doc.뺀더한줄?.[Number(gone[1])]?.차례 : undefined;
restoreLine(d.doc, cid);
if (at != null) apply(d, liftAddKeys(d.doc as ConstDoc, at));
else opt.touch();
}
function drawPaper(d: SheetDraw): HTMLElement[] {
return paperLines(d.text, d.rows, d.ans, (d.doc as ConstDoc).글줄).map((p) => {
const line = el("p", {
className: `m02-sks__paper m02-sks__paper--${p.kind === "줄" ? "line" : p.kind === "글" ? "note" : "sum"}`,
attrs: { "data-paper": p.kind },
text: p.글,
});
return line;
});
}
function draw(d: SheetDraw): void {
last = d;
eqView.draw(d);
if (before === null && d.ans?.기준?.계 != null) before = d.ans.기준.계;
ctx = {
doc: d.doc as ConstDoc,
edit: opt.edit,
spots: spotsOf(d.ans),
apply: (next) => apply(d, next),
focus: (key) => void (focusNext = key),
scope: opt.scope,
open,
drafts,
redraw: () => last && draw(last),
};
drawStatus(d);
const active = document.activeElement as HTMLElement | null;
const keep =
focusNext || (active && sheetHost.contains(active) ? active.dataset.sheetCell : "");
focusNext = "";
body.dataset.mode = mode;
body.replaceChildren(
...(d.ans
? mode === "sheet"
? drawSheet(d)
: drawPaper(d)
: [createText(d.fail || t("Wait"), d.fail ? "error" : "muted")]),
);
if (keep) body.querySelector<HTMLElement>(`[data-sheet-cell="${CSS.escape(keep)}"]`)?.focus();
}
return {
picker: picker.root,
host,
draw,
saved: () => {
eqView.saved(); // 796-2b 사본 할일 뗌
before = last?.ans?.기준?.계 ?? null; // 저장 = 고치기 전 새로(상태줄만 다시)
if (last) drawStatus(last);
},
};
}
@@ -1,296 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_SheetEdit.ts
* 단가산출(SK) 시트 숫자 넓힘 칸(PLAN 790-3 · E2 · _분석_650-H3 1 · 2 장) — 시트(`_Sangeun_Sheet`)가 부름.
* 식 칸 숫자 = 상수 덮개(식 모양 그대로 · 옵션 따라감) · 표 꼬리표 값 = 표값 덮개(「표 연결 끊김」)
* 글 줄(근거 글 · 셈 없음 · 산출서에 찍힘) · 까닭 칸 · [M01 원래로] · 「M01 바뀜」 · 짝잃음 띠
* 문서 고치기 = `logic/M02_Sangeun_Const_Model`(790-2 짝) · 금액은 늘 서버 셈(상세 touch)
* 부품 = 입력 · 단추 · 배지 · 띠 알림 · calc_row — 날 단추 · 입력 없음
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createBanner } from "@ui/ui_template_banner";
import { createButton } from "@ui/ui_template_button";
import { createCalcFormula, createCalcSheetRow } from "@ui/ui_template_calc_row";
import { el } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import {
addText,
coverKinds,
dropText,
editText,
restoreM01,
setConst,
setReason,
setTable,
type ConstDoc,
} from "./logic/M02_Sangeun_Const_Model";
import {
formulaParts,
type NoteLine,
type SheetSpots,
type SpotInfo,
} from "./logic/M02_Sangeun_Sheet_Model";
const TEXT = {
Num: ["식 속 숫자", "Constant"],
TableVal: ["표 값", "Table value"],
Cut: ["표 연결 끊김", "Table link cut"],
Changed: ["M01 바뀜", "M01 changed"],
Restore: ["M01 원래로", "Reset to M01"],
RestoreAll: ["전체 M01 원래로", "Reset all to M01"],
Reason: ["까닭", "Reason"],
ReasonHint: ["고친 까닭(짧게)", "Why changed (short)"],
Note: ["글", "Note"],
NoteHint: ["근거 글", "Basis note"],
AddNote: ["+ 글", "+ Note"],
AddTop: ["+ 근거 글", "+ Basis note"],
DropNote: ["글 지움", "Delete note"],
Lost: ["짝 잃은 고침 — M01 이 바뀌어 붙일 자리가 없음", "Orphan edits — M01 changed"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
export interface EditCtx {
doc: ConstDoc;
edit: boolean;
spots: SheetSpots | null;
/** 새 문서로 바꿔 끼움(같은 객체 유지) → 다시 셈 */
apply: (next: ConstDoc) => void;
/** 다음 그림 뒤 이 칸에 초점 */
focus: (key: string) => void;
}
const strip = (v: number | null | undefined): string =>
v == null ? "" : String(Number(Number(v).toFixed(6)));
/** 작은 칸 — 바꾸면(Enter · 떠남) `onSet(글)` */
function smallCell(
key: string,
label: string,
v: string,
hint: string,
onSet: (raw: string) => void,
): HTMLElement {
const box = createInputField({ value: v, compact: true, size: 24, placeholder: hint });
box.input.setAttribute("aria-label", label);
box.input.dataset.sheetCell = key;
box.input.addEventListener("change", () => onSet(box.input.value.trim()));
return box.root;
}
/** 숫자 칸 — 빈 칸 · 원값과 같음 = null(덮개 풂) · 못 읽으면 되돌림 */
function numBox(
key: string,
label: string,
v: number | null,
was: number | null,
cover: boolean,
onSet: (x: number | null) => void,
): HTMLElement {
const root = smallCell(key, label, strip(v), strip(was), (raw) => {
const x = raw ? Number(raw.replaceAll(",", "")) : null;
if (x !== null && !Number.isFinite(x)) return void (input.value = strip(v));
onSet(x === null || (was != null && x === Number(was)) ? null : x);
});
const input = root.querySelector("input")!;
input.inputMode = "decimal";
root.classList.add("m02-sks__num");
if (cover) root.dataset.cover = "";
return root;
}
/** 식 칸 — 숫자 · 표 값을 칸으로(편집 · 서버 차례와 맞을 때만) · 못 그리면 null */
export function formulaEditor(c: EditCtx, spot: string): HTMLElement | null {
const info = c.spots?.자리[spot];
const parts = c.edit && !info?.식덮개 ? formulaParts(info) : null;
if (!info || !parts) return null;
const nums = c.doc.상수덮개?.[spot] ?? {};
const finds = c.doc.표값덮개?.[spot] ?? {};
const kids = parts.map((p): HTMLElement => {
if (p.t === "글") return el("span", { className: "m02-sks__frag", text: p.글 });
if (p.t === "수") {
const one = info.상수![p.n];
const mine = nums[String(p.n)];
return numBox(
`${spot}|n${p.n}`,
`${t("Num")} ${p.n + 1}`,
mine ?? one.값,
one.원값,
mine != null,
(x) => c.apply(setConst(c.doc, spot, p.n, x)),
);
}
const one = info.표값!.find((x) => x.n === p.n);
const was = typeof one?.원값 === "number" ? one.원값 : null;
const mine = finds[String(p.n)];
const shown = mine ?? (typeof one?.값 === "number" ? one.값 : null);
return el("span", {
className: "m02-sks__find",
children: [
numBox(`${spot}|t${p.n}`, `${t("TableVal")} ${p.n + 1}`, shown, was, mine != null, (x) =>
c.apply(setTable(c.doc, spot, p.n, x)),
),
...(p.표.length ? [createBadge(p.표.join(" "), "info")] : []),
...(mine != null ? [createBadge(t("Cut"), "warning")] : []),
],
});
});
const box = el("div", {
className: "m02-sks__formula",
attrs: { "data-sheet-formula": spot },
children: kids,
});
return box;
}
/** 꼬리 표지 · 단추 — M01 바뀜 · 표 연결 끊김 · [M01 원래로] · [+ 글] */
export function spotTail(c: EditCtx, spot: string, added: boolean): HTMLElement[] {
if (!spot) return [];
const info: SpotInfo | undefined = c.spots?.자리[spot];
const kinds = coverKinds(c.doc, spot);
const out: HTMLElement[] = [];
if (info?.M01바뀜) out.push(createBadge(t("Changed"), "danger"));
if (!c.edit) return out;
if (!added && kinds.length) {
const back = createButton({
label: t("Restore"),
variant: "ghost",
size: 22,
onClick: () => c.apply(restoreM01(c.doc, spot)),
});
back.dataset.sheetRestore = spot;
out.push(back);
}
const note = createButton({
label: t("AddNote"),
variant: "ghost",
size: 22,
onClick: () => addNote(c, spot),
});
note.dataset.sheetAddNote = spot;
out.push(note);
return out;
}
/** 까닭 칸 — 고친 자리(덮개 · 새 덮개 · 뺀줄 · 더한줄)만 */
export function reasonCell(c: EditCtx, key: string, need: boolean): HTMLElement[] {
const v = c.doc.까닭?.[key] ?? "";
if (!key || (!need && !v)) return [];
if (!c.edit) return [createCalcFormula(`${t("Reason")} · ${v}`)];
return [
el("span", {
className: "m02-sks__cell m02-sks__reason",
children: [
smallCell(`${key}|why`, t("Reason"), v, t("ReasonHint"), (x) =>
c.apply(setReason(c.doc, key, x)),
),
],
}),
];
}
export const isCovered = (doc: ConstDoc, spot: string): boolean =>
coverKinds(doc, spot).some((k) => k === "상수" || k === "표값");
function addNote(c: EditCtx, spot: string): void {
const lines = c.doc.글줄 ?? [];
let at = lines.length; // 같은 자리 글 줄 뒤
for (let i = lines.length - 1; i >= 0; i--)
if (lines[i].자리 === spot) {
at = i + 1;
break;
}
if (spot === "" && !lines.some((x) => x.자리 === "")) at = 0;
c.focus(`note|${at}`);
c.apply(addText(c.doc, spot, "", at));
}
/** 자리 뒤 글 줄들 — 편집 = 글 칸 · 지움 */
export function noteRows(c: EditCtx, pick: (x: NoteLine) => boolean): HTMLElement[] {
return (c.doc.글줄 ?? []).flatMap((x, i) => {
if (!pick(x)) return [];
const middle = c.edit
? smallCell(`note|${i}`, t("Note"), x.글, t("NoteHint"), (v) =>
c.apply(editText(c.doc, i, v)),
)
: createCalcFormula(x.글);
const tail = c.edit
? [
createButton({
label: t("DropNote"),
variant: "danger-ghost",
size: 22,
onClick: () => c.apply(dropText(c.doc, i)),
}),
]
: [];
const row = createCalcSheetRow(t("Note"), middle, el("span"), el("span", { children: tail }));
row.dataset.sheetKind = "글";
row.classList.add("m02-sks__note");
return [row];
});
}
export const addTopNote = (c: EditCtx): HTMLElement[] =>
c.edit
? [
createButton({
label: t("AddTop"),
variant: "ghost",
size: 22,
onClick: () => addNote(c, ""),
}),
]
: [];
/** 상태줄 [전체 M01 원래로] — 덮개 · 뺀줄 · 상수 · 표값 덮개가 있을 때만 */
export function restoreAll(c: EditCtx): HTMLElement[] {
const d = c.doc;
const any =
Object.keys(d.덮개 ?? {}).length +
(d.뺀줄 ?? []).length +
Object.keys(d.상수덮개 ?? {}).length +
Object.keys(d.표값덮개 ?? {}).length +
Object.keys((d.식덮개 as object | undefined) ?? {}).length;
if (!c.edit || !any) return [];
const b = createButton({
label: t("RestoreAll"),
variant: "ghost",
size: 22,
onClick: () => c.apply(restoreM01(c.doc)),
});
b.dataset.sheetRestore = "*";
return [b];
}
/** 짝 잃은 새 덮개 띠(790-2 `시트.짝잃음`) */
export function lostBanner(c: EditCtx): HTMLElement[] {
const gone = c.spots?.짝잃음 ?? [];
return gone.length
? [
createBanner({
tone: "warning",
text: t("Lost"),
details: gone.map((x) => `${x.cid} — ${x.까닭}`).join("\n"),
}),
]
: [];
}
/** 뺀 바탕 줄 까닭 칸(뺀 줄 띠 아래) */
export function removedReasons(
c: EditCtx,
removed: { cid: string; 이름: string }[],
): HTMLElement[] {
if (!c.edit) return [];
return removed
.filter((x) => (c.doc.뺀줄 ?? []).includes(x.cid))
.map((x) =>
el("div", {
className: "m02-sks__cell m02-sks__reason",
attrs: { "data-sheet-removed": x.cid },
children: [createText(x.이름, "muted"), ...reasonCell(c, x.cid, true)],
}),
);
}
@@ -1,234 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_SheetFormula.ts
* 단가산출(SK) 시트 식 칸(PLAN 792 · E4 · _분석_650-H3 2 장 「식 고침」) — 시트(`_Sangeun_Sheet`)가 부름.
* 식 칸 = 이름 꼴 글(`[노임 보통인부] × 무게 / 운반량`) · 칠 때마다 검사 길(`formula-check` · 옛 요청 끊기)
* → 틀리면 빨간 칸 + 까닭 한 줄 · 되면 바꿀 때(Enter · 떠남) 엔진 식을 문서에(`setFormula`) → 서버 셈
* 변수 줄 = 이름 · 식 · 값 · [+ 변수 줄] · 빼기(`addVar` · `editVar` · `dropVar`)
* 빈 칸 · M01 식 그대로 = 식 덮개 풂 · 같은 자리 상수 · 표값 · 품 덮개는 식 덮개와 같이 못 둠 → 걷고 넣음
* 문서 고치기 = `logic/M02_Sangeun_Formula_Model`(792-1 짝) · 금액은 늘 서버 셈 · 자동저장 없음
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { createCalcSheetRow } from "@ui/ui_template_calc_row";
import { el } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createFormulaCell } from "../A00_Common/sheet_formula_cell";
import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import type { EditCtx } from "./M02_MasterTemplete_Sangeun_SheetEdit";
import {
addVar,
dropVar,
editVar,
setFormula,
type FormulaDoc,
type VarLine,
} from "./logic/M02_Sangeun_Formula_Model";
import { num, type SpotInfo } from "./logic/M02_Sangeun_Sheet_Model";
const TEXT = {
Edit: ["식 고침", "Edit formula"],
Close: ["식 닫기", "Close formula"],
Var: ["변수", "Variable"],
VarName: ["변수 이름", "Variable name"],
AddVar: ["+ 변수 줄", "+ Variable"],
DropVar: ["변수 빼기", "Remove variable"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
export interface CheckAnswer {
되나: boolean;
까닭: string[];
알림: string[];
/** 엔진 식(문서에 넣을 것) */
식: string;
/** 이름 꼴 글 */
글: string;
}
export interface FormulaCtx extends EditCtx {
scope: { layer: Layer; owner: string | null };
/** 식 칸 연 자리(화면 캐시) */
open: Set<string>;
/** 치는 중 글(다시 그려도 안 잃음) — 넣으면 지움 */
drafts: Map<string, string>;
redraw: () => void;
}
const q = (c: FormulaCtx): string =>
c.scope.owner && c.scope.layer !== "system" ? `?owner=${encodeURIComponent(c.scope.owner)}` : "";
/** 검사 길(792-1) — 셈 · 저장 없음 */
export const checkFormula = (
c: FormulaCtx,
spot: string,
text: string,
signal?: AbortSignal,
): Promise<CheckAnswer> =>
call<CheckAnswer>(`/layers/${c.scope.layer}/sangeun/formula-check${q(c)}`, {
method: "POST",
body: JSON.stringify({ 문서: c.doc, 자리: spot, 식: text }),
signal,
});
/** 식 덮개와 같이 못 두는 같은 자리 상수 · 표값 · 품 덮개 걷음(배율은 둠) */
export function clearNums<T extends FormulaDoc>(doc: T, spot: string): T {
const out: FormulaDoc = { ...doc };
for (const name of ["상수덮개", "표값덮개"] as const) {
const all = { ...(doc[name] ?? {}) };
delete all[spot];
if (Object.keys(all).length) out[name] = all;
else delete out[name];
}
const one = doc.덮개?.[spot];
if (one && "qty" in one) {
const rest = { ...one };
delete rest.qty;
const covers = { ...doc.덮개 };
if (Object.keys(rest).length) covers[spot] = rest;
else delete covers[spot];
out.덮개 = covers;
}
return out as T;
}
/** 식 칸 — 칠 때마다 검사(앞 요청 끊음) · 바꿀 때 넣음 · `put(엔진 식 | null)` · 몸 = 공용 `sheet_formula_cell` */
function formulaBox(
c: FormulaCtx,
spot: string,
key: string,
shown: string,
origin: string,
put: (engine: string | null) => void,
): HTMLElement {
return createFormulaCell({
key,
spot,
shown,
origin,
drafts: c.drafts,
check: (text, signal) => checkFormula(c, spot, text, signal),
put,
});
}
/** 줄 식 칸 — 열었거나 식 덮개 있는 자리만 · 못 고치는 자리(로직 · 율 더한줄) = null */
export function rowFormula(
c: FormulaCtx,
spot: string,
info: SpotInfo | undefined,
): HTMLElement | null {
if (!c.edit || !spot || !info || (!c.open.has(spot) && !info.식덮개)) return null;
const shown = info.식글 ?? info.식 ?? "";
const origin = info.원식글 ?? "";
return formulaBox(c, spot, `${spot}|f`, shown, origin, (engine) => {
const doc = engine ? clearNums(c.doc as FormulaDoc, spot) : (c.doc as FormulaDoc);
c.apply(setFormula(doc, spot, engine));
});
}
/** 꼬리 [식 고침] · [식 닫기] — 식 덮개 있으면 늘 열림(닫기 없음) */
export function formulaToggle(
c: FormulaCtx,
spot: string,
info: SpotInfo | undefined,
): HTMLElement[] {
const addOne = /^add:#\d+$/.test(spot); // 더한줄 수량식(율 · 로직 줄은 부르는 쪽이 뺌)
if (!c.edit || !spot || !info || info.식덮개 || (info.식 == null && !addOne)) return [];
const on = c.open.has(spot);
const b = createButton({
label: on ? t("Close") : t("Edit"),
variant: "ghost",
size: 22,
onClick: () => {
if (on) c.open.delete(spot);
else c.open.add(spot);
c.drafts.delete(`${spot}|f`);
c.focus(on ? "" : `${spot}|f`);
c.redraw();
},
});
b.dataset.sheetFxOpen = spot;
return [b];
}
const vars = (c: FormulaCtx): VarLine[] => (c.doc as FormulaDoc).변수줄 ?? [];
/** 자리 뒤 변수 줄들(이어 붙은 변수까지) */
export function varRows(c: FormulaCtx, after: (v: VarLine) => boolean): HTMLElement[] {
return vars(c).flatMap((v) =>
after(v) ? [varRow(c, v), ...varRows(c, (x) => x.자리 === `mid:${v.이름}`)] : [],
);
}
function varRow(c: FormulaCtx, v: VarLine): HTMLElement {
const spot = `mid:${v.이름}`;
const info = c.spots?.자리[spot];
const label = c.edit
? (() => {
const box = createInputField({ value: v.이름, compact: true, size: 24 });
box.input.setAttribute("aria-label", t("VarName"));
box.input.dataset.sheetCell = `var|${v.이름}`;
box.input.addEventListener("change", () => {
const to = box.input.value.trim();
if (!to || to === v.이름 || vars(c).some((x) => x.이름 === to))
return void (box.input.value = v.이름);
c.focus(`var|${to}`);
c.apply(editVar(c.doc as FormulaDoc, v.이름, { 이름: to }));
});
return box.root;
})()
: el("span", { className: "ui-calc-row__label", text: v.이름 });
const middle = c.edit
? formulaBox(c, spot, `${spot}|f`, info?.식글 ?? v.식, "", (engine) =>
c.apply(editVar(c.doc as FormulaDoc, v.이름, { 식: engine ?? "0" })),
)
: el("span", { className: "ui-calc-formula", text: info?.식글 ?? v.식 });
const value = el("span", {
className: "ui-calc-row__out",
text: typeof info?.지금 === "number" ? num(info.지금) : "—",
});
const tail = el("span", {
children: [
createBadge(t("Var"), "accent"),
...(c.edit
? [
createButton({
label: t("DropVar"),
variant: "danger-ghost",
size: 22,
onClick: () => c.apply(dropVar(c.doc as FormulaDoc, v.이름)),
}),
]
: []),
],
});
const row = createCalcSheetRow(label, middle, value, tail);
row.dataset.sheetKind = "변수";
row.dataset.sheetSpot = spot;
row.classList.add("is-changed");
return row;
}
/** [+ 변수 줄] — 맨 위(자리 "") · 이름 = 안 겹치는 「변수n」 */
export function addVarButton(c: FormulaCtx): HTMLElement[] {
if (!c.edit) return [];
const b = createButton({
label: t("AddVar"),
variant: "ghost",
size: 22,
onClick: () => {
const used = new Set([
...vars(c).map((v) => v.이름),
...(c.spots?.이름표 ?? []).map((x) => x.글),
]);
let n = 1;
while (used.has(`변수${n}`)) n++;
c.focus(`var|변수${n}`);
c.apply(addVar(c.doc as FormulaDoc, { 이름: `변수${n}`, 식: "0", 자리: "" }, 0));
},
});
b.dataset.sheetAddVar = "";
return [b];
}
@@ -0,0 +1,52 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_Tabs.ts
* M02 단가산출(SK) 상세 보기 고르기(PLAN 799-2 · 요구 655-8) — 수식(첫 탭 · 796-2b `_Sangeun_Equation`) · 단가산출 상세 둘.
* 시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 보기는 걷음(옛 `_Sangeun_Sheet*` · 790-1) — 서버 문서 꼴(변수줄 · 글줄 · 덮개)은 그대로
* 수식 = 이 파일이 쥔 그릇 · 단가산출 상세 = 상세가 쥔 `editor`(수식일 때 숨김)
* ========================================================================== */
import { createSegment } from "@ui/ui_template_segment";
import { tl } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Text";
import {
createSangeunEquation,
type SkEquationDraw,
type SkEquationOptions,
} from "./M02_MasterTemplete_Sangeun_Equation";
type View = "equation" | "ho";
/** 보기 고르기 · 수식 그릇 — `editor` = 단가산출 상세 그릇(수식일 때 숨김) */
export function createSangeunTabs(
editor: HTMLElement,
opt: SkEquationOptions,
): {
picker: HTMLElement;
host: HTMLElement;
draw: (d: SkEquationDraw) => void;
saved: () => void;
} {
const eqView = createSangeunEquation(opt);
const show = (v: View): void => {
eqView.root.hidden = v !== "equation";
editor.hidden = v === "equation";
if (!editor.hidden) editor.dataset.view = v;
};
const picker = createSegment({
options: [
{ value: "equation", label: tl("View_Equation") },
{ value: "ho", label: tl("Ho_Title") },
],
value: "equation",
size: 32,
ariaLabel: tl("View_Label"),
onChange: (v) => show(v as View),
});
picker.root.dataset.skViews = "";
show("equation");
return {
picker: picker.root,
host: eqView.root,
draw: eqView.draw,
saved: eqView.saved, // 796-2b 사본 할일 뗌
};
}
+2 -3
View File
@@ -1,7 +1,6 @@
"""M02 단가산출(SK) 숫자 넓힘(PLAN 790-2 · 요구서_652 · _분석_650-H3 E2) — 새 덮개 갈래 · 시트 답.
짝 = `logic/M02_Sangeun_Const_Model.ts`(캐시 고치기 · M01 원래로 · 까닭 · 더한줄 열쇠 옮김 ·
같은 꼴) · 거울 시험 `resources/tester/test_790_2_sangeun_const.py`.
시험 `resources/tester/test_790_2_sangeun_const.py` · 799-2 — 화면 짝(`Const_Model.ts` · 시트 탭)은 걷음, 서버만 남음.
문서 덧칸(없으면 안 둠 — 옛 SK 그대로 · 지문 그대로):
상수덮개 {자리: {n: 값}} 자리 식의 n 번째 숫자 상수(0 부터 · 엔진 파서 차례 · 찾기 머리 · 키 ·
@@ -260,7 +259,7 @@ def stamp(row: dict, doc: dict | None = None) -> dict[str, Any]:
return {"바탕판": calc.fingerprint(row), "M01판": dict(zip(spots, map(_print, lines)))}
# ── 고치기(짝 = Const_Model.ts) ──────────────────────────────────────
# ── 고치기 ──────────────────────────────────────
def restore(doc: dict, key: str | None = None) -> dict:
"""[M01 원래로] — 전체(None) = 덮개 · 뺀줄 · 상수 · 표값 · 식 덮개 · 그 까닭 지움(더한줄 ·
글줄 · 변수줄 · 옵션 남김) · 열쇠 하나 = 그 자리 덮개 · 뺀줄 · 새 덮개 · 까닭(세부
+1 -2
View File
@@ -1,8 +1,7 @@
"""M02 단가산출(SK) 사용자 식 칸(PLAN 792-1 · 요구서_652 1 장 3 · _분석_650-H3 E4) — 변수 줄 ·
식 덮개 · 더한줄 수량식 · 이름 꼴 ↔ 엔진 식 · 검사(「덮개 = 숫자만」 원칙을 바꿈).
짝 = `logic/M02_Sangeun_Formula_Model.ts`(캐시 고치기 — 서버 짝 함수 없음 · 꼴만 같음) ·
시험 `resources/tester/test_792_1_sangeun_formula.py`.
시험 `resources/tester/test_792_1_sangeun_formula.py` · 799-2 — 화면 짝(`Formula_Model.ts` · 시트 탭)은 걷음, 서버만 남음.
문서 덧칸(없으면 안 둠 — 옛 SK 그대로 · 저장 = 엔진 식 · 이름 꼴 글도 받아 서버가 번역):
변수줄 [{이름, 식, 표시?, 자리}] 엔진 중간 줄로 끼움 · 자리 = 그 뒤 중간 `mid:이름`
@@ -1,195 +0,0 @@
/* =============================================================================
* M02_MasterTemplete/logic/M02_Sangeun_Const_Model.ts
* 단가산출(SK) 숫자 넓힘 캐시 고치기(PLAN 790-2 · 요구서_652 · _분석_650-H3 E2) —
* 짝 = 서버 `M02_MasterTemplete/M02_Sangeun_Const.py`(`restore` · `drop_add_keys` · `kinds` 같은 꼴)
* · 거울 시험 `resources/tester/test_790_2_sangeun_const.py`.
* 상수덮개 {자리: {n: 값}} 식 n 번째 숫자(0 부터 · 서버 답 `시트.자리[자리].상수` 의 n)
* 표값덮개 {자리: {n: 값}} 식 n 번째 `찾기(…).칸`(서버 답 `표값` 의 n)
* 글줄 [{자리, 글}] 셈 없는 글 줄 · 자리 뒤("" = 맨 위)
* 까닭 {열쇠: 글} 덮개 · 더한줄 · 뺀줄 · 새 덮개마다
* M01판 서버 몫 — 받은 그대로 둠(셈 때 「M01바뀜」 판정)
* 자리 = 바탕 cid · 중간 `mid:이름` · 열쇠 = 자리 · `add:#k` · `add:#k/세부 cid`.
* 금액은 늘 서버가 셈 — 여기는 문서 칸 고치기만(새 문서를 돌려줌 · 받은 문서 안 바꿈).
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
* ========================================================================== */
export type NumCovers = Record<string, Record<string, number>>;
export interface TextLine {
자리: string;
글: string;
}
export interface ConstDoc {
덮개?: Record<string, Record<string, unknown>>;
뺀줄?: string[];
더한줄?: Record<string, unknown>[];
상수덮개?: NumCovers;
표값덮개?: NumCovers;
글줄?: TextLine[];
까닭?: Record<string, string>;
[k: string]: unknown;
}
export type CoverKind = "품" | "단가" | "상수" | "표값" | "식" | "뺌";
const ADD = /^add:#(\d+)(\/.*)?$/;
const PARTS = ["labor", "material", "expense"];
/** 빈 묶음 · 목록 칸은 지움(서버 `clean_more` 처럼 없으면 안 둠). */
function put<T extends ConstDoc>(doc: T, name: string, value: unknown): T {
const out: ConstDoc = { ...doc };
const empty =
value == null ||
(Array.isArray(value)
? value.length === 0
: typeof value === "object" && Object.keys(value).length === 0);
if (empty) delete out[name];
else out[name] = value;
return out as T;
}
function setNum<T extends ConstDoc>(
doc: T,
name: "상수덮개" | "표값덮개",
spot: string,
n: number,
v: number | null,
): T {
const all: NumCovers = { ...(doc[name] ?? {}) };
const one = { ...(all[spot] ?? {}) };
if (v == null) delete one[String(n)];
else one[String(n)] = v;
if (Object.keys(one).length) all[spot] = one;
else delete all[spot];
return put(doc, name, all);
}
/** 식 속 n 번째 숫자 상수 → 값(null = 원래로). */
export const setConst = <T extends ConstDoc>(
doc: T,
spot: string,
n: number,
v: number | null,
): T => setNum(doc, "상수덮개", spot, n, v);
/** 식 속 n 번째 표 찾기 값 → 값(null = 표 연결 되살림). */
export const setTable = <T extends ConstDoc>(
doc: T,
spot: string,
n: number,
v: number | null,
): T => setNum(doc, "표값덮개", spot, n, v);
/** 글 줄 넣기 — `at` 차례(없으면 끝). */
export function addText<T extends ConstDoc>(doc: T, spot: string, text: string, at?: number): T {
const lines = [...(doc.글줄 ?? [])];
lines.splice(at ?? lines.length, 0, { 자리: spot, 글: text });
return put(doc, "글줄", lines);
}
export function editText<T extends ConstDoc>(doc: T, at: number, text: string): T {
const lines = (doc.글줄 ?? []).map((t, i) => (i === at ? { ...t, 글: text } : t));
return put(doc, "글줄", lines);
}
export function dropText<T extends ConstDoc>(doc: T, at: number): T {
return put(
doc,
"글줄",
(doc.글줄 ?? []).filter((_, i) => i !== at),
);
}
/** 까닭 — 빈 글 = 지움. */
export function setReason<T extends ConstDoc>(doc: T, key: string, text: string): T {
const why = { ...(doc.까닭 ?? {}) };
if (text.trim()) why[key] = text.trim();
else delete why[key];
return put(doc, "까닭", why);
}
const topAdd = (key: string): boolean => {
const hit = ADD.exec(key);
return !!hit && !hit[2];
};
/** [M01 원래로] — 열쇠 없음 = 전체(덮개 · 뺀줄 · 상수 · 표값 덮개 · 그 까닭 · 더한줄 · 글줄 · 옵션 남김) ·
* 열쇠 = 그 자리 덮개 · 뺀줄 · 새 덮개 · 까닭(`add:#k/cid` = 그 더한줄 세부) · 짝 `restore`. */
export function restoreM01<T extends ConstDoc>(doc: T, key?: string): T {
let out: ConstDoc = { ...doc };
delete out.상수덮개;
delete out.표값덮개;
delete out.식덮개; // 식 칸(792-1)
delete out.까닭;
const why = { ...(doc.까닭 ?? {}) };
if (key == null) {
out = { ...out, 덮개: {}, 뺀줄: [] };
const kept = Object.fromEntries(Object.entries(why).filter(([k]) => topAdd(k)));
return put(out, "까닭", kept) as T;
}
const hit = ADD.exec(key);
if (hit && hit[2]) {
const k = Number(hit[1]);
const sub = hit[2].slice(1);
const adds = (doc.더한줄 ?? []).map((x) => ({ ...x }));
if (k < adds.length) {
const was = adds[k];
const one: Record<string, unknown> = { ...was };
delete one.덮개;
delete one.뺀줄;
const covers = Object.fromEntries(
Object.entries((was.덮개 as Record<string, unknown>) ?? {}).filter(([c]) => c !== sub),
);
const drop = ((was.뺀줄 as string[]) ?? []).filter((c) => c !== sub);
if (Object.keys(covers).length) one.덮개 = covers;
if (drop.length) one.뺀줄 = drop;
adds[k] = one;
}
out.더한줄 = adds;
} else {
out.덮개 = Object.fromEntries(Object.entries(doc.덮개 ?? {}).filter(([c]) => c !== key));
out.뺀줄 = (doc.뺀줄 ?? []).filter((c) => c !== key);
for (const name of ["상수덮개", "표값덮개", "식덮개"] as const) {
const all = (doc[name] ?? {}) as Record<string, unknown>;
const got = Object.fromEntries(Object.entries(all).filter(([s]) => s !== key));
out = put(out, name, got);
}
}
delete why[key];
return put(out, "까닭", why) as T;
}
/** 더한줄 k 를 뺀 뒤 — 까닭 · 글줄 열쇠 `add:#j`(j > k) 한 칸 당김 · k 는 지움 · 짝 `drop_add_keys`. */
export function dropAddKeys<T extends ConstDoc>(doc: T, k: number): T {
const move = (key: string): string | null => {
const hit = ADD.exec(key);
if (!hit) return key;
const j = Number(hit[1]);
return j === k ? null : `add:#${j > k ? j - 1 : j}${hit[2] ?? ""}`;
};
let out: ConstDoc = { ...doc };
if (doc.까닭) {
const why: Record<string, string> = {};
for (const [x, v] of Object.entries(doc.까닭)) {
const to = move(x);
if (to) why[to] = v;
}
out = put(out, "까닭", why);
}
if (doc.글줄) out.글줄 = doc.글줄.map((t) => ({ ...t, 자리: move(t.자리) ?? "" }));
return out as T;
}
/** 자리의 덮개 갈래(차례 = 품 · 단가 · 상수 · 표값 · 뺌) · 짝 `kinds`. */
export function coverKinds(doc: ConstDoc, spot: string): CoverKind[] {
const cover = doc.덮개?.[spot] ?? {};
const out: CoverKind[] = [];
if ("qty" in cover || "배율" in cover) out.push("품");
if (PARTS.some((k) => k in cover)) out.push("단가");
if (doc.상수덮개?.[spot]) out.push("상수");
if (doc.표값덮개?.[spot]) out.push("표값");
if ((doc.식덮개 as Record<string, string> | undefined)?.[spot]) out.push("식");
if ((doc.뺀줄 ?? []).includes(spot)) out.push("뺌");
return out;
}
@@ -15,8 +15,6 @@
import type { CalcLine, TextAnswer } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { bornOfCid } from "./M02_Born_Tag";
import type { SheetGroup } from "../../A00_Common/calc_sheet";
import { rounddown } from "../../B09_Estimation/B09_Estimation_Calc_Base";
import { forgetAdd, goneName, reviveAdd, setQty, resetQty } from "./M02_Sangeun_Undo";
export type CoverPart = "labor" | "material" | "expense";
@@ -418,40 +416,6 @@ export function dropBase(doc: SkDoc, k: number, name = ""): void {
export const goneRows = (doc: SkDoc): { cid: string; 이름: string }[] =>
(doc.뺀더한줄 ?? []).map((g, n) => ({ cid: `gone:${n}`, 이름: goneName(g.줄) }));
const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 });
const num = (v: number | null): string => (v == null ? "—" : NUM.format(v));
/** 표 줄 → 공용 계산서(`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 || (!entries.length && r.금액 == null) ? 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.sub?.cid ?? r.cid).startsWith("logic:"),
...(r.off ? { 옵션꺼짐: true } : {}),
});
}
}
const order = Object.keys(PART_OF);
return [...groups.values()] // 751-3 소계 = 줄 합 1 원 버림(품셈 1-2-2 · 서버 `master_cut` 와 같은 값)
.map((g) => ({ ...g, 소계: rounddown(g.소계 ?? 0, 0) }))
.sort((a, b) => order.indexOf(a.비목) - order.indexOf(b.비목));
}
/* ── 공용 상세(Ho) 줄 ── */
/** Ho 호표 · 덧줄 꼴(`M01_MasterData_UI_Logic_Api` HoLine · NamedFormula 중 쓰는 칸) */
export interface HoItem {
@@ -1,116 +0,0 @@
/* =============================================================================
* M02_MasterTemplete/logic/M02_Sangeun_Formula_Model.ts
* 단가산출(SK) 사용자 식 칸 캐시 고치기(PLAN 792-1 · 요구서_652 · _분석_650-H3 E4) —
* 서버 `M02_MasterTemplete/M02_Sangeun_Formula.py`(꼴 검사 · 번역 · 셈)와 같은 꼴 · 서버 짝 함수 없음
* · 시험 `resources/tester/test_792_1_sangeun_formula.py`(여기 결과를 서버 `clean` 이 받음).
* 변수줄 [{이름, 식, 표시?, 자리}] 엔진 중간 줄 · 자리 = 그 뒤 중간 `mid:이름`("" = 맨 위)
* 식덮개 {자리: 식} 중간 `mid:이름` · 호표 · 덧줄 cid
* 더한줄[k].수량식 율 줄 · 로직 줄 빼고
* 식 글 = 검사 길(`formula-check`) 답 `식`(엔진) 을 넣음 · 이름 꼴 글을 넣어도 서버가 번역.
* 금액은 늘 서버가 셈 — 여기는 문서 칸 고치기만(새 문서를 돌려줌 · 받은 문서 안 바꿈).
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
* ========================================================================== */
import type { ConstDoc } from "./M02_Sangeun_Const_Model";
export interface VarLine {
이름: string;
식: string;
표시?: number;
자리: string;
}
export interface FormulaDoc extends ConstDoc {
변수줄?: VarLine[];
식덮개?: Record<string, string>;
}
const ADD_ONE = /^add:#(\d+)$/;
/** 빈 묶음 · 목록 칸은 지움(서버처럼 없으면 안 둠). */
function putField<T extends FormulaDoc>(doc: T, name: string, value: unknown): T {
const out: FormulaDoc = { ...doc };
const empty =
value == null ||
(Array.isArray(value)
? value.length === 0
: typeof value === "object" && Object.keys(value as object).length === 0);
if (empty) delete out[name];
else out[name] = value;
return out as T;
}
const isVar = (doc: FormulaDoc, spot: string): boolean =>
spot.startsWith("mid:") && (doc.변수줄 ?? []).some((v) => v.이름 === spot.slice(4));
/** 자리 식 — 변수 줄 = 그 식 · `add:#k` = 수량식 · 그 밖 = 식덮개 · null · "" = 지움(M01 식 · 수). */
export function setFormula<T extends FormulaDoc>(doc: T, spot: string, text: string | null): T {
const t = (text ?? "").trim();
if (isVar(doc, spot)) return editVar(doc, spot.slice(4), { 식: t });
const hit = ADD_ONE.exec(spot);
if (hit) {
const k = Number(hit[1]);
const adds = (doc.더한줄 ?? []).map((x, i) => {
if (i !== k) return x;
const one: Record<string, unknown> = { ...x };
if (t) one.수량식 = t;
else delete one.수량식;
return one;
});
return { ...doc, 더한줄: adds };
}
const covers = { ...(doc.식덮개 ?? {}) };
if (t) covers[spot] = t;
else delete covers[spot];
return putField(doc, "식덮개", covers);
}
/** 변수 줄 넣기 — `at` 차례(없으면 끝) · 같은 이름이 있으면 그대로. */
export function addVar<T extends FormulaDoc>(doc: T, line: VarLine, at?: number): T {
const lines = [...(doc.변수줄 ?? [])];
if (lines.some((v) => v.이름 === line.이름)) return doc;
lines.splice(at ?? lines.length, 0, { ...line });
return putField(doc, "변수줄", lines);
}
/** 변수 줄 고침 — 이름 바꾸면 까닭 · 글줄 · 다른 변수 자리 열쇠 `mid:이름` 도 따라감(식 속 이름은
* 그대로 — 검사 길이 짚음). */
export function editVar<T extends FormulaDoc>(doc: T, name: string, patch: Partial<VarLine>): T {
const to = patch.이름 ?? name;
const lines = (doc.변수줄 ?? []).map((v) => {
if (v.이름 === name) {
const one: VarLine = { ...v, ...patch };
if (patch.표시 == null && "표시" in patch) delete one.표시;
return one;
}
return v.자리 === `mid:${name}` ? { ...v, 자리: `mid:${to}` } : v;
});
const out = putField(doc, "변수줄", lines);
return to === name ? out : moveKey(out, `mid:${name}`, `mid:${to}`);
}
/** 변수 줄 빼기 — 그 뒤에 붙은 변수는 그 자리를 이어받음 · 까닭 지움 · 글줄은 맨 위로. */
export function dropVar<T extends FormulaDoc>(doc: T, name: string): T {
const gone = (doc.변수줄 ?? []).find((v) => v.이름 === name);
if (!gone) return doc;
const lines = (doc.변수줄 ?? [])
.filter((v) => v.이름 !== name)
.map((v) => (v.자리 === `mid:${name}` ? { ...v, 자리: gone.자리 } : v));
return moveKey(putField(doc, "변수줄", lines), `mid:${name}`, null);
}
/** 까닭 · 글줄 열쇠 옮김(null = 까닭 지움 · 글줄 맨 위). */
function moveKey<T extends FormulaDoc>(doc: T, from: string, to: string | null): T {
let out: T = doc;
if (doc.까닭 && from in doc.까닭) {
const why = { ...doc.까닭 };
const text = why[from];
delete why[from];
if (to) why[to] = text;
out = putField(out, "까닭", why);
}
if (doc.글줄) {
out = { ...out, 글줄: doc.글줄.map((t) => (t.자리 === from ? { ...t, 자리: to ?? "" } : t)) };
}
return out;
}
@@ -1,328 +0,0 @@
/* =============================================================================
* M02_MasterTemplete/logic/M02_Sangeun_Sheet_Model.ts
* 단가산출(SK) 줄 식 시트 꼴(PLAN 790-1 · 요구 650~652 · 요구서_652 1 장 4) — 셈 답 → 시트 줄 · 산출서 글 · 상태줄 금액.
* 줄 갈래 = 근거 글 · 옵션 · 상수 · 표 값 · 계산(바탕 로직 중간) · 자원 · 율(셈 답 줄) · 소계 · 합계
* 식 칸 = 읽기 쉬운 이름 꼴 — 자원 · 율 = 서버 텍스트 수식 「이름글」(「= 이름」 뗌 · 「*」 → 「×」) ·
* 중간 = 찾기 원문을 서버 별칭 이름으로 바꾼 식 · 엔진 날식(찾기 · 만약 · 로직)이 남으면 식 숨김(표 꼬리표만)
* 금액은 늘 서버 셈 — 여기는 줄 모양만 · 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장)
* ========================================================================== */
import type {
LogicInput,
NamedFormula,
TextAnswer,
TextLine,
} from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { CoverRow, SkAnswer } from "./M02_Sangeun_Cover_Model";
export type SheetKind = "근거" | "옵션" | "상수" | "표" | "계산" | "자원" | "율" | "소계" | "합계";
export interface SheetItem {
kind: SheetKind;
이름: string;
/** 읽기 쉬운 식 · 빈 글 = 식 없음(숨김 포함) */
식: string;
값: number | string | null;
/** 비목 줄임(노 · 재 · 경) · 별도계상 = 「별도」 */
비목: string;
/** 표 꼬리표(QC######) */
표: string[];
/** 자원 · 율 줄 = 고침 열쇠 */
row?: CoverRow;
/** 옵션 줄 = 입력 꼴 */
input?: LogicInput;
/** M01 과 다름(덮개 · 더한줄) */
changed: boolean;
/** 790-2 자리(바탕 cid · 중간 `mid:이름` · `add:#k`) · 없으면 "" */
자리: string;
}
export interface SheetBase {
출처?: string;
원문번호?: string;
비고?: string;
중간?: NamedFormula[];
}
const SHORT: Record<string, string> = { 노무비: "노", 재료비: "재", 경비: "경" };
const TABLE_RE = /[A-Z]{2}\d{6}/g;
const RAW_RE = /(찾기|만약|로직)\s*\(/;
const NUM_RE = /^-?\d+(\.\d+)?$/;
const LOOKUP_RE = /^찾기\([^()]*\)\.[^\s*/+\-()]+$/;
type Alias = { 이름: string; 원문: string };
const aliasesOf = (text: TextAnswer | null): Alias[] =>
text?.ok ? (((text as { 별칭?: Alias[] }).별칭 ?? []) as Alias[]) : [];
/** 엔진 식 → 읽기 쉬운 식(찾기 원문 → 별칭 이름 · 「*」 → 「×」) · 날식이 남으면 "" */
export function readable(formula: string, aliases: Alias[]): string {
let s = formula.trim();
for (const a of [...aliases].sort((x, y) => y.원문.length - x.원문.length))
if (a.원문) s = s.split(a.원문).join(a.이름);
if (RAW_RE.test(s)) return "";
return s.replace(/\s*\*\s*/g, " × ");
}
/** 텍스트 수식 「이름글」 → 식 칸 글(「= 이름」 뗌 · 「*」 → 「×」) */
export const nameFormula = (line: TextLine | undefined): string =>
(line?.이름글 ?? "").replace(/\s*=\s*[^=]*$/, "").replace(/\s*\*\s*/g, " × ");
/** 텍스트 수식 줄 cid 표 */
export function textLines(text: TextAnswer | null): Map<string, TextLine> {
const out = new Map<string, TextLine>();
if (text?.ok) for (const g of text.groups) for (const l of g.줄) if (l.cid) out.set(l.cid, l);
return out;
}
const tablesOf = (formula: string): string[] => [...new Set(formula.match(TABLE_RE) ?? [])];
const partTag = (r: CoverRow): string =>
r.apart
? "별도"
: Object.keys(r.parts)
.map((k) => SHORT[k] ?? k)
.join("·");
/** 시트 줄 — 근거 · 옵션 · 중간(상수 · 표 · 계산) · 자원 · 율 · 소계 · 합계 */
export function sheetItems(o: {
base: SheetBase | null;
inputs: LogicInput[];
values: Record<string, string>;
ans: SkAnswer | null;
rows: CoverRow[];
text: TextAnswer | null;
/** 790-3 자리에 새 덮개(상수 · 표값 · 뺌 …)가 있나 — 캐시 문서 기준 */
covered?: (spot: string) => boolean;
}): SheetItem[] {
const out: SheetItem[] = [];
const item = (x: Partial<SheetItem> & { kind: SheetKind; 이름: string }): void =>
void out.push({ 식: "", 값: null, 비목: "", 표: [], changed: false, 자리: "", ...x });
const b = o.base;
if (b?.출처) item({ kind: "근거", 이름: b.원문번호 || "", 식: b.출처 });
for (const line of (b?.비고 ?? "").split(/\r?\n/).filter((x) => x.trim()))
item({ kind: "근거", 이름: "", 식: line.trim() });
for (const input of o.inputs)
item({ kind: "옵션", 이름: input.이름, 값: o.values[input.이름] ?? "", input });
const mid = (o.ans?.중간 ?? {}) as Record<string, unknown>;
const aliases = aliasesOf(o.text);
const asked = new Set(o.inputs.map((i) => i.이름));
for (const f of b?.중간 ?? []) {
if (asked.has(f.이름)) continue;
const raw = String(f.식 ?? "").trim();
const v = mid[f.이름];
const 값 = typeof v === "number" || typeof v === "string" ? v : null;
const 자리 = `mid:${f.이름}`;
const one = { 이름: f.이름, 값, 자리, changed: !!o.covered?.(자리) };
if (NUM_RE.test(raw)) item({ ...one, kind: "상수", 값: 값 ?? Number(raw) });
else if (LOOKUP_RE.test(raw)) item({ ...one, kind: "표", 표: tablesOf(raw) });
else item({ ...one, kind: "계산", 식: readable(raw, aliases), 표: tablesOf(raw) });
}
const lines = textLines(o.text);
for (const pct of [false, true])
for (const r of o.rows) {
if (r.off || r.pct !== pct) continue;
const line = lines.get(r.cid);
out.push({
kind: pct ? "율" : "자원",
이름: r.단위 && !pct ? `${r.이름} (${r.단위})` : r.이름,
식: nameFormula(line) || (pct ? `${num(r.수량)} %` : `${num(r.단가)} × ${num(r.수량)}`),
값: r.금액,
비목: partTag(r),
표: tablesOf(line?.이름글 ?? ""),
row: r,
changed: r.covered.qty || r.covered.price || r.added !== null || !!o.covered?.(r.cid),
자리: r.cid,
});
}
const sums = o.ans?.기준 ?? null;
for (const k of Object.keys(SHORT))
if (sums && k in sums) item({ kind: "소계", 이름: k, 값: sums[k], 비목: SHORT[k] });
if (sums) item({ kind: "합계", 이름: "계", 값: sums.계 ?? null });
return out;
}
const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 });
export const num = (v: number | null | undefined): string => (v == null ? "—" : NUM.format(v));
/** 산출서 한 줄 글 — 경쟁사 꼴 「형틀목공 : 275,790 × 5 / 45 = 30,643.3」 · 소계 · 계 */
export interface PaperLine {
kind: "줄" | "소계" | "합계" | "머리" | "글";
글: string;
}
export function paperLines(
text: TextAnswer | null,
rows: CoverRow[],
ans: SkAnswer | null,
notes: NoteLine[] = [],
): PaperLine[] {
if (!ans) return [];
const out: PaperLine[] = [];
const by = new Map(rows.map((r) => [r.cid, r]));
const sums = ans.기준 ?? {};
const groups = text?.ok ? text.groups : [];
const shown = new Set(groups.flatMap((g) => g.줄.flatMap((l) => (l.옵션꺼짐 ? [] : [l.cid]))));
const note = (spot: string): PaperLine[] =>
notes.filter((x) => x.글 && x.자리 === spot).map((x) => ({ kind: "글", 글: x.글 }));
out.push(
...notes
.filter((x) => x.글 && !shown.has(x.자리))
.map((x) => ({ kind: "글" as const, 글: x.글 })),
);
for (const g of groups) {
out.push({ kind: "머리", 글: g.비목 });
for (const l of g.줄) {
if (l.옵션꺼짐) continue;
const r = l.cid ? by.get(l.cid) : undefined;
const body = l.글
? l.글.replace(/\s*\*\s*/g, " × ")
: r
? `${r.pct ? `${num(r.수량)} %` : `${num(r.단가)} × ${num(r.수량)}`} = ${num(r.금액)}`
: num(l.금액);
out.push({ kind: "줄", 글: `${l.이름} : ${body}` }, ...(l.cid ? note(l.cid) : []));
}
const sub = sums[g.비목] ?? g.소계;
out.push({ kind: "소계", 글: `${g.비목} 소계 = ${num(sub)}${g.끝수 ? ` (${g.끝수})` : ""}` });
}
out.push({ kind: "합계", 글: `계 = ${num(sums.계 ?? null)}` });
return out;
}
/** 상태줄 금액 셋 — M01 원래(790-2 셈 답 `원래.계` · 없으면 바탕 셈 계) · 지금 · 고치기 전(화면 캐시) */
export interface SheetStatus {
원래: number | null;
지금: number | null;
전: number | null;
}
export function statusOf(
ans: SkAnswer | null,
bare: SkAnswer | null,
before: number | null,
): SheetStatus {
const origin = (ans as (SkAnswer & { 원래?: { 계?: number } | null }) | null)?.원래?.계;
return {
원래: origin ?? bare?.기준?.계 ?? ans?.기준?.계 ?? null,
지금: ans?.기준?.계 ?? null,
전: before,
};
}
/** 차이 글 「+383」 · 「−1,200」 · 같으면 "" */
export const diffText = (now: number | null, was: number | null): string =>
now == null || was == null || now === was
? ""
: `${now > was ? "+" : "−"}${NUM.format(Math.abs(now - was))}`;
// ── 790-3 숫자 넓힘 화면 이음(790-2 셈 답 `시트` · 문서 `글줄`) ──────────────
/** 글 줄(셈 없음) — 자리 뒤 · "" = 맨 위 */
export interface NoteLine {
자리: string;
글: string;
}
/** 셈 답 `시트.자리[자리]`(790-2) */
export interface SpotInfo {
갈래: string;
이름: string;
식?: string;
상수?: { n: number; 값: number | null; 원값: number | null }[];
표값?: { n: number; 글: string; 값: unknown; 원값: unknown }[];
원래?: number | null;
지금?: unknown;
덮개?: string[];
까닭?: string | null;
M01바뀜?: boolean;
/** 792-1 — 지금 식 · M01 식(이름 꼴) · 식 덮개 있음 */
식글?: string;
원식글?: string;
식덮개?: boolean;
}
export interface SheetSpots {
자리: Record<string, SpotInfo>;
글줄: NoteLine[];
짝잃음: { cid: string; 까닭: string }[];
/** 792-1 식 칸에 쓸 수 있는 이름 */
이름표?: { 글: string; 갈래: string }[];
}
export const spotsOf = (ans: SkAnswer | null): SheetSpots | null =>
((ans as { 시트?: SheetSpots } | null)?.시트 ?? null) || null;
export type FormulaPart =
{ t: "글"; 글: string } | { t: "수"; n: number } | { t: "표"; n: number; 표: string[] };
const IDENT = /^[\p{L}_][\p{L}\p{N}_]*/u;
const DIGITS = /^\d+(\.\d+)?/;
/** 식 글 → 숫자 자리(왼쪽부터 · 따옴표 글 · 이름 안 숫자 뺌) — 서버 `scan` 숫자 차례와 같음 */
export function numSpots(s: string): [number, number][] {
const out: [number, number][] = [];
let i = 0;
while (i < s.length) {
const c = s[i];
if (c === "'" || c === '"') {
const j = s.indexOf(c, i + 1);
i = j < 0 ? s.length : j + 1;
continue;
}
const rest = s.slice(i);
const id = IDENT.exec(rest);
const d = id ? null : DIGITS.exec(rest);
if (d) out.push([i, i + d[0].length]);
i += id ? id[0].length : d ? d[0].length : 1;
}
return out;
}
/** 엔진 식을 조각으로 — 글 · 숫자(n) · 표 찾기(n) · 서버 차례와 안 맞으면 null(숫자 칸 안 그림) */
export function formulaParts(info: SpotInfo | undefined): FormulaPart[] | null {
const s = info?.식 ?? "";
const nums = info?.상수 ?? [];
const finds = info?.표값 ?? [];
if (!s || (!nums.length && !finds.length)) return null;
const at = numSpots(s);
if (at.length !== nums.length) return null;
if (at.some(([a, b], i) => Number(s.slice(a, b)) !== Number(nums[i].원값))) return null;
const cuts: { a: number; b: number; p: FormulaPart }[] = [];
let from = 0;
for (const f of finds) {
const a = s.indexOf(f.글, from);
if (a < 0) return null;
from = a + f.글.length;
cuts.push({ a, b: from, p: { t: "표", n: f.n, 표: tablesOf(f.글) } });
}
at.forEach(([a, b], n) => {
if (!cuts.some((c) => c.a <= a && b <= c.b)) cuts.push({ a, b, p: { t: "수", n } });
});
cuts.sort((x, y) => x.a - y.a);
const out: FormulaPart[] = [];
const text = (x: string): void => {
const 글 = x.replace(/\s*\*\s*/g, " × ");
if (글) out.push({ t: "글", 글 });
};
let pos = 0;
for (const c of cuts) {
text(s.slice(pos, c.a));
out.push(c.p);
pos = c.b;
}
text(s.slice(pos));
return out;
}
/** 뺀 더한줄 되살림(차례 k 에 다시 끼움) 뒤 — 까닭 · 글줄 열쇠 `add:#j`(j ≥ k) 한 칸 밂 · `dropAddKeys` 거꾸로 */
export function liftAddKeys<T extends { 까닭?: Record<string, string>; 글줄?: NoteLine[] }>(
doc: T,
k: number,
): T {
const move = (key: string): string =>
key.replace(/^add:#(\d+)/, (all, j: string) =>
Number(j) >= k ? `add:#${Number(j) + 1}` : all,
);
const out = { ...doc };
if (doc.까닭)
out.까닭 = Object.fromEntries(Object.entries(doc.까닭).map(([x, v]) => [move(x), v]));
if (doc.글줄) out.글줄 = doc.글줄.map((x) => ({ ...x, 자리: move(x.자리) }));
return out;
}