feat(750-2): 단가산출 보기 넷 = SK 셈 답 하나 — 시험 계산 · 텍스트 수식도 SK 답(글 · 중간) · 바탕 M01 로직 따로 셈 걷음 · 옵션 바꿈 서버 셈 한 번

- Sangeun_Detail — 텍스트 수식 = 답 `글` · 시험 계산 = 답 `줄` · `기준` · `중간`(`buildCalc` 덧칸 `answer` · `shown` · `held`) · `/logic-lib/calc` · `/text` · 미리보기 부름 · `baseCovers` · `dropText` · `applyCovers` 걷음 · 바탕 원값 셈 = `보기: false`
- 재료 고름(줄 모달 · 시험 계산 재료 칸) = SK 문서 `고름`(cid 열쇠 · 둘째 기초 세부 줄은 보기만)
- LogicLab_Ho — 읽는 식 줄을 `cid` 로 이음(`HoContext.cids` · 이름 차례 추측은 M01 만) · LogicLab_Formula — 단위 환산 `나눔` 머리 한 줄
- 낡은 시험 고침 — test_717_2b(`baseCovers` · `dropText` 단정 뺌 · 함수 지움) · test_725_1(`void recalc()` 새 꼴) · 새 지킴 test_750_2
- M01 단가산출 기초 = 덧칸 안 넘겨 옛 길 그대로

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 23:01:57 +09:00
co-authored by Claude Sonnet 5.5
parent d87c7b14fd
commit db590e6fa8
12 changed files with 155 additions and 93 deletions
@@ -28,11 +28,12 @@ export function calcSangeun<T = SkAnswer>(
scope: SkScope,
doc: Record<string, unknown>,
given: Record<string, string> = {},
seen = true,
): Promise<T> {
const 입력값 = Object.fromEntries(Object.entries(given).filter(([, v]) => String(v).trim()));
return call<T>(`/layers/${scope.layer}/sangeun/calc${q(scope)}`, {
method: "POST",
body: JSON.stringify({ 문서: doc, 입력값 }),
body: JSON.stringify({ 문서: doc, 입력값, 보기: seen }), // 보기 false = 글 · 중간 덧칸 안 지음(바탕 원값 셈)
});
}
@@ -21,7 +21,7 @@ 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 } from "../M01_MasterData/M01_MasterData_UI_Logic_Calc";
import { buildCalc, type CalcShown } from "../M01_MasterData/M01_MasterData_UI_Logic_Calc";
import {
fetchLogic,
setLogicApiPaths,
@@ -31,7 +31,6 @@ import {
type LogicRow,
type TextAnswer,
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { applyCovers } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Cover";
import { buildLabDetail } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Detail";
import { buildHo, lineOpener, type HoEdit } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Ho";
import { createViewPicker } from "../M01_MasterData/M01_MasterData_UI_LogicLab_View";
@@ -41,10 +40,8 @@ import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover";
import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick";
import { baseUnitOf, buildUnitSwap, type SwapHow } from "./M02_MasterTemplete_Sangeun_Unit";
import {
baseCovers,
dropBase,
dropLine,
dropText,
goneRows,
hoView,
removedSubs,
@@ -89,6 +86,7 @@ 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[]>();
@@ -204,21 +202,26 @@ 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) : Promise.resolve(null),
doc.바탕 ? calcSangeun(scope, plain, values, false) : Promise.resolve(null), // 바탕 원값 = 보기 덧칸 없이
]);
if (mine !== seq || !alive) return; // 늦게 온 답 — 버림
[ans, bare, fail] = [got, was, ""];
[ans, bare, fail, okKey] = [got, was, "", key];
text = got.글 ?? null; // 750-2 텍스트 수식 · 시험 계산 = 이 답 하나(바탕 로직 따로 셈 없음)
if (text?.ok) lastText = text;
} catch (error) {
if (mine !== seq || !alive) return;
[ans, fail] = [null, error instanceof Error ? error.message : String(error)];
[ans, text, fail] = [null, null, error instanceof Error ? error.message : String(error)];
}
draw();
}
@@ -313,21 +316,18 @@ export function mountSangeunDetail(
];
const row = base ? { ...base.logic, 입력: [...(base.logic.입력 ?? []), ...extra] } : standIn();
const view = ans ? hoView(ans, bare, base?.logic ?? null, doc) : null;
const baseNames = [...(row.호표 ?? []), ...(row.덧줄 ?? [])].map((x) =>
String(x.이름 ?? ("요소" in x ? x.요소 : "")),
);
const cut = <T extends TextAnswer | undefined>(t: T): T =>
(t ? dropText(t, baseNames, bare, doc) : t) as T;
const hoCtx = {
row: view ? { ...row, 호표: view.호표, 덧줄: view.덧줄 } : row,
prices: base?.prices ?? {},
lines: (view?.lines ?? null) as CalcLine[] | null,
text: cut(text ?? undefined) ?? null,
lastText: cut(lastText),
text,
lastText,
values,
onInput,
calcHost,
edit: view && edit ? hoEdit(view, rows) : undefined,
cids: view?.cids,
pickMat: (idx: number) => pickMat(view?.cids[idx]),
};
const ho = el("div", {
attrs: { "data-sk-cover": "" },
@@ -381,6 +381,16 @@ export function mountSangeunDetail(
onInput,
cover: { ho, est, open: lineOpener(hoCtx) }, // 748-2 텍스트 수식 변수 · 값 누름 = 같은 줄 모달
});
drawCalc();
}
/** 재료 고름(줄 모달 · 시험 계산 재료 칸) = SK 문서 `고름`(cid → MT) — 둘째 기초 세부 줄은 보기만 */
function pickMat(cid: string | undefined): ((it: { ref: string }) => void) | undefined {
if (!cid || cid.startsWith("add:#")) return undefined;
return (it) => {
doc.고름 = { ...(doc.고름 ?? {}), [cid]: it.ref };
touch();
};
}
/** 공용 상세 고침 고리 — 열쇠 = Ho 줄 차례 → cid · 고침 = SK 덮개 · 더한줄(M01 안 바뀜 · 내역 17-2) ·
@@ -396,7 +406,6 @@ export function mountSangeunDetail(
if (!r) return;
setLineCover(doc, r, c, opened());
touch();
if (base) rerunCalc(); // 시험 계산 · 텍스트 수식 = 덮개 사본으로
},
more: (idx, close) => {
const r = at(idx);
@@ -442,11 +451,6 @@ export function mountSangeunDetail(
};
}
function rerunCalc(): void {
drawCalc();
calcHost.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
}
/** 옵션 칸 · 텍스트 수식 · 계산서 입력 단추 — 입력 고침 → SK `입력값`(727-3 · 열린입력 = 시험 값만) ·
* 시험 계산 다시 그림 · 셈 */
function onInput(name: string, v: string): void {
@@ -458,9 +462,7 @@ export function mountSangeunDetail(
doc.입력값 = kept;
touch(false);
}
drawCalc();
calcHost.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
if (!base) void recalc(); // 바탕 없음 — 시험 계산 단추 없음 · SK 셈을 바로
void recalc(); // 750-2 옵션 바꿈 = 서버 셈 한 번 — 네 보기가 그 답 하나로 다시 그려짐
}
function drawCalc(): void {
@@ -468,24 +470,38 @@ export function mountSangeunDetail(
calcHost.replaceChildren(createText(L("M02_SangeunNoInputs")));
return;
}
const covers = baseCovers(bare, base.logic.호표?.length ?? 0, doc);
const covered = Object.keys(covers).length > 0;
const count = base.logic.호표?.length ?? 0;
buildCalc(calcHost, {
savedKey: base.logic.키,
file: base.file,
row: covered ? applyCovers(base.logic, covers) : base.logic,
row: base.logic,
prices: base.prices,
dirty: () => covered, // 덮개 사본 = 저장 안 한 줄로 셈(메모리만)
dirty: () => false,
values,
onText: (t) => {
text = t;
if (t.ok) lastText = t;
draw();
answer: async () => {
if (okKey !== inputKey()) await recalc(); // 칸을 고치고 [계산] — SK 셈 한 번
return shownOf(count);
},
onLines: () => void recalc(), // 시험 입력이 바뀜 — SK 도 같은 입력으로
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)),
};
}
/** `fresh` = 막 넣은 바탕 — 단위가 비거나 「식」이면 기초 기준단위(「100본」 · 바꾸면 서버가 환산 · 728-2) */
async function loadBase(fresh = false): Promise<void> {
const key = baseKey();
@@ -504,8 +520,7 @@ export function mountSangeunDetail(
touch(false);
}
draw();
await recalc(); // 바탕 정체(bare) 먼저 — 시험 계산 · 텍스트 수식 = 덮개 사본
if (alive) drawCalc();
await recalc(); // 바탕 정체(bare) 먼저 — 시험 계산 칸 항목(재료 잡힌 품목)이 쓰임
}
void loadBase();
@@ -13,6 +13,7 @@
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
* ========================================================================== */
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 { forgetAdd, goneName, reviveAdd, setQty, resetQty } from "./M02_Sangeun_Undo";
@@ -87,6 +88,8 @@ export interface SkLine {
바탕값?: { qty?: number; 단가?: number } | null;
/** 둘째 기초 세부 줄(744-1) — 그 기초 1 기준단위 몫(M01 원문 · 머리 수량 안 곱함) · 화면 품 칸 */
원문?: { 수량: number | null; 금액: number | null; 비목?: Record<string, number | null> };
/** 재료 줄이 잡은 품목(시험 계산 재료 칸 · 줄 모달) */
품목?: CalcLine["품목"];
}
export interface SkMat {
@@ -114,6 +117,9 @@ export interface SkAnswer {
/** 바탕 기준단위 → SK 단위 배(728-2) · 화면 줄을 나눈 배(없으면 1 · 문서 품 = 화면 품 × 화면배) */
배?: number;
화면배?: number;
/** 보기 덧칸(750-1) — 텍스트 수식 글(줄마다 cid · 금액 = SK 줄) · 시험 계산 중간값 · 몸 `보기: false` 면 없음 */
글?: TextAnswer;
중간?: Record<string, unknown>;
}
export interface CoverRow {
@@ -545,47 +551,6 @@ export function hoView(
};
}
/** 바탕 로직 호표 차례 고침(덮개 qty · 비목 — 배율 뺌) — 시험 계산 · 텍스트 수식 줄 사본(`applyCovers`)용 */
export function baseCovers(
bare: SkAnswer | null,
count: number,
doc: SkDoc,
): Record<number, LineCover> {
const out: Record<number, LineCover> = {};
(bare?.정체 ?? []).slice(0, count).forEach((cid, i) => {
const c = doc.덮개?.[cid];
const one: LineCover = {};
if (c?.qty != null) one.qty = c.qty;
for (const k of PARTS) if (c?.[k] != null) one[k] = c[k];
if (Object.keys(one).length) out[i] = one;
});
return out;
}
/** 텍스트 수식 답에서 뺀 바탕 줄 글 거름 — 공용 상세는 줄 글을 이름 차례로 붙임(같은 이름 더한줄이
* 뺀 줄 글을 받지 않게) · 차례 = 바탕 이름(호표 → 덧줄)을 따라감(`Ho.attachPieces` 와 같은 길) */
export function dropText<T extends { ok: boolean; groups?: { 줄: { 이름: string }[] }[] }>(
text: T | null,
names: string[],
bare: SkAnswer | null,
doc: SkDoc,
): T | null {
const drop = new Set(doc.뺀줄 ?? []);
const gone = new Set((bare?.정체 ?? []).flatMap((cid, i) => (drop.has(cid) ? [i] : [])));
if (!text?.ok || !text.groups || !gone.size) return text;
const groups = text.groups.map((g) => {
let from = 0;
const 줄 = g.줄.filter((line) => {
const at = names.findIndex((n, i) => i >= from && n === line.이름);
if (at < 0) return true;
from = at + 1;
return !gone.has(at);
});
return { ...g, 줄 };
});
return { ...text, groups };
}
/* ── 일위대가 줄 → SK 문서(717-3 · 723-3 줄 모드) ── */
export interface LinePatch {
덮개?: Record<string, SkCover>;