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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user