- 자리 = SK 수식.문서.기계덮음 {기계 입력 글: {값 · 조종원 줄 · 잡품 줄}} · 사본 줄 그대로
- 계산기(파이썬 · TS 짝)가 기계 식 부를 때 그 기계 덮음을 얹음 · 덮음 없음 = 문서 그대로
- 서버 저장 · 미리 셈 = 몸 검사 + 원본과 같은 칸 · 안 부르는 기계 뺌
- 거울 · 손셈(굴착기 1.0㎥ 108,958) · 저장 길 지킴 시험 test_836_1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
299 lines
13 KiB
TypeScript
299 lines
13 KiB
TypeScript
/* =============================================================================
|
||
* common_util_formula_doc_cover.ts
|
||
* 기계 덮음(PLAN 836-1 · 요구 846 · 849 · 850 · 853) — 단가산출 안 기계 내부 값 고침(사본 줄은 그대로 · 고친 것만 따로)
|
||
* 자리 = 문서 칸 `기계덮음` {기계 입력 글: {값?: {칸: 값}, 조종원?: [{키, 인}], 잡품?: [{줄? | 키?, 이름?, 수량?, 율}]}}
|
||
* 기계 입력 글 = 부름 입력 `기계` 값(따옴표 뗌 · 원문번호 · 개인 EQP…) — 같은 기계 줄 여럿 = 한 벌
|
||
* 값 = 기계 식이 읽는 기계 칸(요소 `EQ:{S_기계}` 칸 — 조종원 · 조종원직종 · 잡재료율 빼고) · 조종원 = 인력 줄(노임 키 · 인/일) ·
|
||
* 잡품 = 재료 줄(율 % · 줄 = 그 금액 줄 단가 · 수량 따름(기본) · 키 = 고른 재료 요소) · 원본과 같은 칸 = 없음(`prune`)
|
||
* 셈 규칙(`coverDoc`) = 계산기가 기계 식을 부를 때 그 기계 덮음을 얹은 문서로 셈 — 덮음 없음 = 문서 그대로(금액 전후 같음)
|
||
* 파이썬 · TS 짝 — 짝 = resources/master_data/scripts/master_doc_cover.py · 거울 시험 test_836_1_machine_cover.py
|
||
* · 화면 · DOM · fetch 없음
|
||
* ========================================================================== */
|
||
|
||
import {
|
||
names,
|
||
parse,
|
||
symId,
|
||
varId,
|
||
type DocVar,
|
||
type FormulaDoc,
|
||
type MoneyLine,
|
||
} from "./common_util_formula_doc";
|
||
|
||
export const COVER = "기계덮음";
|
||
/** 값 칸에서 빼는 기계 칸 — 줄(조종원 · 잡품)로 다룸 */
|
||
export const NOT_VALUE = ["조종원", "조종원직종", "잡재료율"];
|
||
const HOUR = "Q_시간환산";
|
||
/** 조종원 줄을 새로 넣을 때(조종원 없는 식) 시간환산 — GC000267 과 같은 꼴(건설품셈 공통 1-1-3 2) */
|
||
const HOUR_VARS: DocVar[] = [
|
||
{
|
||
열쇠: "Q_분자^표", 기호: "Q", 아래: "분자", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분자", 단위: "",
|
||
연결: { 갈래: "표", 표: "CC000336", 조건: { 구분: "'시간환산'" }, 칸: "분자" },
|
||
},
|
||
{
|
||
열쇠: "Q_분모^표", 기호: "Q", 아래: "분모", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분모", 단위: "",
|
||
연결: { 갈래: "표", 표: "CC000336", 조건: { 구분: "'시간환산'" }, 칸: "분모" },
|
||
},
|
||
{ 열쇠: "시간환산", 기호: "Q", 아래: "시간환산", 위: "", 이름: "시간환산", 단위: "", 연결: { 갈래: "식" } },
|
||
]; // prettier-ignore
|
||
const HOUR_LINE = { 기호: HOUR, 식: "Q_분자^표 ÷ Q_분모^표" };
|
||
|
||
export interface CrewRow {
|
||
키: string;
|
||
인: number;
|
||
}
|
||
export interface MiscRow {
|
||
줄?: string;
|
||
키?: string;
|
||
이름?: string;
|
||
단위?: string;
|
||
수량?: number;
|
||
율: number;
|
||
}
|
||
export interface MachineCover {
|
||
값?: Record<string, number | string>;
|
||
조종원?: CrewRow[];
|
||
잡품?: MiscRow[];
|
||
}
|
||
export type Cover = Record<string, MachineCover>;
|
||
type Holder = FormulaDoc & { [COVER]?: Cover };
|
||
|
||
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
||
const plain = (n: number): string => String(Number(n.toPrecision(15)));
|
||
|
||
export const coverOf = (doc: FormulaDoc | null | undefined): Cover => {
|
||
const got = (doc as Holder | null | undefined)?.[COVER];
|
||
return got && typeof got === "object" && !Array.isArray(got) ? got : {};
|
||
};
|
||
|
||
/** 부름 입력 → 기계 열쇠(따옴표 뗌) · 기계 입력 없음 = "" */
|
||
export function machineKey(given: Record<string, unknown> | null | undefined): string {
|
||
const v = given?.기계;
|
||
if (v === undefined || v === null) return "";
|
||
const t = String(v).trim();
|
||
return /^'.*'$/.test(t) ? t.slice(1, -1) : t;
|
||
}
|
||
|
||
const namesOf = (text: unknown): string[] => {
|
||
if (text === null || text === undefined || String(text).trim() === "") return [];
|
||
try {
|
||
return names(parse(text));
|
||
} catch {
|
||
return [];
|
||
}
|
||
};
|
||
|
||
export interface CoverParts {
|
||
/** 값 칸 — 칸 이름 · 그 칸을 읽는 기호(차례 = 문서 변수 차례) */
|
||
fields: { 칸: string; ids: string[] }[];
|
||
/** 조종원 금액 줄 차례 · 사람 수 기호 */
|
||
crew: number[];
|
||
crewQty: Record<number, string>;
|
||
/** 잡품(잡재료비) 금액 줄 차례 · 율 기호 · 밑 재료 줄 차례 */
|
||
misc: number | null;
|
||
rate: string | null;
|
||
base: number[];
|
||
}
|
||
|
||
/** 기계 식 짜임 — 기계 칸 요소(`EQ:…{S_기계}…`) · 조종원 줄(노무비 · 수량이 칸 「조종원」) · 잡품 줄(재료비 누계 × 율) */
|
||
export function partsOf(doc: FormulaDoc): CoverParts {
|
||
const eq = new Map<string, string>();
|
||
for (const v of doc.변수 ?? []) {
|
||
const l = v.연결 ?? {};
|
||
const k = String(l.키 ?? "");
|
||
if (l.갈래 === "요소" && k.startsWith("EQ:") && k.includes("{S_기계}") && l.칸)
|
||
eq.set(varId(v), l.칸);
|
||
}
|
||
const fields: CoverParts["fields"] = [];
|
||
for (const [id, col] of eq) {
|
||
if (NOT_VALUE.includes(col)) continue;
|
||
const at = fields.find((f) => f.칸 === col);
|
||
if (at) at.ids.push(id);
|
||
else fields.push({ 칸: col, ids: [id] });
|
||
}
|
||
const lines = doc.금액 ?? [];
|
||
const crew: number[] = [];
|
||
const crewQty: Record<number, string> = {};
|
||
let misc: number | null = null;
|
||
let rate: string | null = null;
|
||
lines.forEach((l, i) => {
|
||
const q = namesOf(l.수량).find((n) => eq.get(n) === "조종원");
|
||
if (l.비목 === "노무비" && q) {
|
||
crew.push(i);
|
||
crewQty[i] = q;
|
||
}
|
||
const p = namesOf(l.단가);
|
||
const other = p.filter((n) => n !== "재료비");
|
||
if (misc === null && l.비목 === "재료비" && p.includes("재료비") && other.length === 1) {
|
||
misc = i;
|
||
rate = other[0];
|
||
}
|
||
});
|
||
const base =
|
||
misc === null
|
||
? []
|
||
: lines.flatMap((l, i) => (i < misc! && l.비목 === "재료비" && !l.합밖 ? [i] : []));
|
||
return { fields, crew, crewQty, misc, rate, base };
|
||
}
|
||
|
||
type Vals = Record<string, unknown>;
|
||
const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
||
/** 요소 키 글의 구멍을 값으로 채움(`LB:{S_직종}` → `LB:1048`) */
|
||
const fillKey = (text: string, vals: Vals): string =>
|
||
text.replace(/\{([^{}]+)\}/g, (_, h: string) => {
|
||
const v = vals[h];
|
||
return typeof v === "number" ? plain(v) : String(v ?? "");
|
||
});
|
||
|
||
/** 원본(덮음 없음) 기본값 — 셈 답 값(`vals` = 그 기계로 셈한 원본 답 `값`) */
|
||
export function defaultsOf(doc: FormulaDoc, vals: Vals): Required<MachineCover> {
|
||
const parts = partsOf(doc);
|
||
const lines = doc.금액 ?? [];
|
||
const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v]));
|
||
const 값: Record<string, number | string> = {};
|
||
for (const f of parts.fields) {
|
||
const v = vals[f.ids[0]];
|
||
if (typeof v === "number" || typeof v === "string") 값[f.칸] = v;
|
||
}
|
||
const 조종원 = parts.crew.flatMap((i) => {
|
||
const n = num(vals[parts.crewQty[i]]);
|
||
const sym = namesOf(lines[i].단가)[0];
|
||
const key = String(vars.get(sym ?? "")?.연결?.키 ?? "");
|
||
return n && key ? [{ 키: fillKey(key, vals), 인: n }] : [];
|
||
});
|
||
const r = parts.rate ? num(vals[parts.rate]) : null;
|
||
const 잡품 = r ? parts.base.map((i) => ({ 줄: String(lines[i].이름 ?? ""), 율: r })) : [];
|
||
return { 값, 조종원, 잡품 };
|
||
}
|
||
|
||
const canon = (v: unknown): string =>
|
||
JSON.stringify(v, (_, x: unknown) =>
|
||
x && typeof x === "object" && !Array.isArray(x)
|
||
? Object.fromEntries(
|
||
Object.entries(x as Record<string, unknown>)
|
||
.filter(([, y]) => y !== undefined && y !== null && y !== "")
|
||
.sort(([a], [b]) => (a < b ? -1 : 1)),
|
||
)
|
||
: typeof x === "number"
|
||
? Number(x.toPrecision(12))
|
||
: x,
|
||
);
|
||
const sameVal = (a: unknown, b: unknown): boolean =>
|
||
typeof a === "number" && typeof b === "number"
|
||
? Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b))
|
||
: String(a) === String(b);
|
||
|
||
/** 원본과 같은 칸 · 줄을 뺀 덮음(바꿨다 되돌림 = 안 고침) · 다 빠지면 null */
|
||
export function prune(entry: MachineCover, base: Required<MachineCover>): MachineCover | null {
|
||
const out: MachineCover = {};
|
||
const vals = Object.entries(entry.값 ?? {}).filter(
|
||
([k, v]) =>
|
||
v !== null && v !== undefined && v !== "" && !(k in base.값 && sameVal(v, base.값[k])),
|
||
);
|
||
if (vals.length) out.값 = Object.fromEntries(vals);
|
||
if (entry.조종원 && canon(entry.조종원) !== canon(base.조종원)) out.조종원 = entry.조종원;
|
||
if (entry.잡품 && canon(entry.잡품) !== canon(base.잡품)) out.잡품 = entry.잡품;
|
||
return Object.keys(out).length ? out : null;
|
||
}
|
||
|
||
/** 덮음 한 기계 고침 → 문서 칸(원본 대조로 정리 · 비면 칸 지움) */
|
||
export function putCover(
|
||
doc: FormulaDoc,
|
||
machine: string,
|
||
entry: MachineCover,
|
||
base: Required<MachineCover>,
|
||
): void {
|
||
const box = doc as Holder;
|
||
const all = { ...coverOf(doc) };
|
||
const kept = prune(entry, base);
|
||
if (kept) all[machine] = kept;
|
||
else delete all[machine];
|
||
if (Object.keys(all).length) box[COVER] = all;
|
||
else delete box[COVER];
|
||
}
|
||
|
||
// ── 셈 규칙 ───────────────────────────────────────────────────────────
|
||
/** 새 요소 기호(`w_조종원1` · `P_잡품1` — 문서에 없는 첫 번호) */
|
||
function addVar(doc: FormulaDoc, sign: string, low: string, name: string, key: string): string {
|
||
const have = new Set([
|
||
...(doc.변수 ?? []).map(varId),
|
||
...(doc.식 ?? []).map((l) => String(l.기호 ?? "")),
|
||
]);
|
||
let n = 1;
|
||
while (have.has(symId(sign, `${low}${n}`))) n++;
|
||
const 아래 = `${low}${n}`;
|
||
const id = symId(sign, 아래);
|
||
(doc.변수 ??= []).push({
|
||
열쇠: id, 기호: sign, 아래, 위: "", 이름: name, 단위: "", 연결: { 갈래: "요소", 키: key },
|
||
}); // prettier-ignore
|
||
return id;
|
||
}
|
||
|
||
function crewLines(doc: FormulaDoc, rows: CrewRow[]): MoneyLine[] {
|
||
const have = (doc.변수 ?? []).some((v) => varId(v) === HOUR) || (doc.식 ?? []).some((l) => l.기호 === HOUR); // prettier-ignore
|
||
if (rows.length && !have) {
|
||
const ids = new Set((doc.변수 ?? []).map(varId));
|
||
for (const v of HOUR_VARS) if (!ids.has(varId(v))) (doc.변수 ??= []).push(clone(v));
|
||
(doc.식 ??= []).push({ ...HOUR_LINE });
|
||
}
|
||
return rows.map((r) => ({
|
||
이름: "조종원",
|
||
단위: "인",
|
||
비목: "노무비",
|
||
수량: `${plain(r.인)} × ${HOUR}`,
|
||
단가: addVar(doc, "w", "조종원", "조종원 노임", r.키),
|
||
}));
|
||
}
|
||
|
||
function miscLines(doc: FormulaDoc, rows: MiscRow[], lines: MoneyLine[]): MoneyLine[] {
|
||
return rows.flatMap((r) => {
|
||
let price: string;
|
||
let qty: string;
|
||
let label = r.이름 ?? "";
|
||
if (r.키) {
|
||
price = addVar(doc, "P", "잡품", label || r.키, r.키);
|
||
qty = plain(r.수량 ?? 1);
|
||
} else {
|
||
const b = lines.find((l) => String(l.이름 ?? "") === r.줄);
|
||
if (!b || !b.단가) return [];
|
||
price = String(b.단가);
|
||
qty = r.수량 !== undefined && r.수량 !== null ? plain(r.수량) : `(${b.수량 ?? 1})`;
|
||
label ||= String(b.이름 ?? "");
|
||
}
|
||
return [{ 이름: "잡품", 규격: label, 단위: "", 비목: "재료비", 수량: `${qty} × ${plain(r.율)} ÷ 100`, 단가: price } as MoneyLine]; // prettier-ignore
|
||
});
|
||
}
|
||
|
||
/** 기계 식 문서에 그 기계 덮음을 얹음(새 문서) — 덮음 없음 = 그대로(같은 물건) */
|
||
export function coverDoc(
|
||
doc: FormulaDoc,
|
||
given: Record<string, unknown> | null | undefined,
|
||
cover: Cover | null | undefined,
|
||
): FormulaDoc {
|
||
const entry = cover?.[machineKey(given)];
|
||
if (!entry || !machineKey(given)) return doc;
|
||
const out = clone(doc) as FormulaDoc & { 쌓기?: unknown };
|
||
delete out.쌓기; // 금액 차례가 바뀜 — 기본 차림
|
||
const parts = partsOf(out);
|
||
for (const f of parts.fields)
|
||
if (entry.값 && f.칸 in entry.값)
|
||
for (const v of out.변수 ?? [])
|
||
if (f.ids.includes(varId(v))) v.연결 = { 갈래: "숫자", 값: entry.값[f.칸] };
|
||
const lines = out.금액 ?? [];
|
||
const crew = entry.조종원 ? crewLines(out, entry.조종원) : null;
|
||
const misc = entry.잡품 && (parts.misc !== null || entry.잡품.length) ? miscLines(out, entry.잡품, lines) : null; // prettier-ignore
|
||
const gone = new Set([...(crew ? parts.crew : []), ...(misc && parts.misc !== null ? [parts.misc] : [])]); // prettier-ignore
|
||
const tail = lines.findIndex((l) => l.비목 === "경비");
|
||
const end = tail < 0 ? lines.length : tail;
|
||
const crewAt = parts.crew.length ? parts.crew[0] : end;
|
||
const miscAt = parts.misc ?? end;
|
||
const next: MoneyLine[] = [];
|
||
for (let i = 0; i <= lines.length; i++) {
|
||
if (crew && i === crewAt) next.push(...crew);
|
||
if (misc && i === miscAt) next.push(...misc);
|
||
if (i < lines.length && !gone.has(i)) next.push(lines[i]);
|
||
}
|
||
out.금액 = next;
|
||
return out;
|
||
}
|