Files
Aislo/M02_MasterTemplete/logic/M02_Sangeun_Sheet_Model.ts
T
eomsangdonandClaude Opus 5.5 48d3d4e1af feat(790-1): 단가산출 줄 식 시트 — 상세 첫 탭 「시트」 · 이름 | 식 | 값 | 비목 · 산출서 보기 · 상태줄 금액 셋
- 새 M02_MasterTemplete_Sangeun_Sheet.ts(+ .css) — 보기 고르기(시트 + 옛 넷 그대로) · 줄 갈래(근거 · 옵션 · 변수 · 계산 · 자원 · 율 · 소계 · 합계) · 칸에서 옵션 · 품 · 단가 · 율 % · 빼기 · 되살림 · 되돌림 · [+ 자원 줄] · [+ 율 줄] · 고칠 때마다 서버 셈(최신 응답만) · M01 과 다른 줄 색 띠
- 새 logic/M02_Sangeun_Sheet_Model.ts — 시트 줄 · 읽기 쉬운 식(텍스트 수식 이름글 · 별칭) · 엔진 날식 숨김 · 산출서 한 줄 글 · 상태줄(M01 원래 · 지금 · 고치기 전)
- ui_template_calc_row 시트 줄(createCalcSheetRow · is-changed) + 부품 모음 견본 한 줄
- Sangeun_Detail = 탭 등록만(createViewPicker → createSangeunSheet)
- 시험 test_790_1 새 · test_721 단추 자리 단언을 새 보기 고르기 이름으로 고침(createViewPicker 걷음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:05:43 +09:00

198 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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;
}
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;
}): 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;
if (NUM_RE.test(raw)) item({ kind: "상수", 이름: f.이름, 값: 값 ?? Number(raw) });
else if (LOOKUP_RE.test(raw)) item({ kind: "표", 이름: f.이름, 값, 표: tablesOf(raw) });
else item({ kind: "계산", 이름: f.이름, 식: 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,
});
}
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,
): 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 : [];
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}` });
}
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))}`;