Files
Aislo/common_util/common_util_formula_doc_cover.ts
T
eomsangdonandClaude Opus 5.5 7435bd30e2 feat(842-3): M01 기계 자료 옮김 — 조종원 · 조종원직종 · 잡재료율 칸 → 인력 · 잡품 목록(619 대)
- 빌더 build_기계_인력줄_842_3.py(규칙 = master_machine_crew.from_old_row · 다시 돌려도 같음)
- 인력 줄 301 대(1 인 282 · 2 인 18 · 7 인 1 · 직종 빈 줄 42 = 빈 키 = 식 입력 직종 고르기 그대로)
- 잡품 줄 328 대(잡재료율 > 0 = 주연료 · 전력 줄마다 그 율 · 「73.7+」 글 연료도 줄) · 범위 글 「13∼18」 2 대 = 잡품 없음(값원문 그대로)
- 선원류 칸 79 대 · 비활성 표지 79 대 그대로 · 옛 칸 남음 0
- 바탕 덮음 = 식에 그 줄이 있을 때만(손료만 식을 일부러 부른 단가산출 GC000324 → GC000273 크롤러드릴 = 조종원 안 더함)
- 식 고름 = 그 줄 인력도 조종원 있음으로 봄 · 로직 검사 · 값 모으기 = 기계 칸을 목록 읽기로(master_formula_check)
- 시험 고침: 821_2 · 822_2 미리보기 = 목록 읽은 값으로 · 822_1 노임 키 = 인력 줄 · 822_2 범위 까닭 글 = 「잡재료율 없음」(범위 글은 값원문)
- 전후: 655 대 시간당(hourly · brief · 문서 셈) 653 같음 · 다름 2 = 펌프 준설선 EQ000552 · 553(비활성 · 범위 글) 까닭 글만

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 18:54:54 +09:00

412 lines
18 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.
/* =============================================================================
* common_util_formula_doc_cover.ts
* 기계 덮음(PLAN 836-1 · 요구 846 · 849 · 850 · 853) — 단가산출 안 기계 내부 값 고침(사본 줄은 그대로 · 고친 것만 따로)
* 자리 = 문서 칸 `기계덮음` {기계 입력 글: {값?: {칸: 값}, 조종원?: [{키, 인}], 잡품?: [{줄? | 키?, 이름?, 수량?, 율}]}}
* 기계 입력 글 = 부름 입력 `기계` 값(따옴표 뗌 · 원문번호 · 개인 EQP…) — 같은 기계 줄 여럿 = 한 벌
* 값 = 기계 식이 읽는 기계 칸(요소 `EQ:{S_기계}` 칸 — 조종원 · 조종원직종 · 잡재료율 빼고) · 조종원 = 인력 줄(노임 키 · 인/일) ·
* 잡품 = 재료 줄(율 % · 줄 = 그 금액 줄 단가 · 수량 따름(기본) · 키 = 고른 재료 요소) · 원본과 같은 칸 = 없음(`prune`)
* 셈 규칙(`coverDoc`) = 계산기가 기계 식을 부를 때 그 기계 덮음을 얹은 문서로 셈 — 덮음 없음 = 문서 그대로(금액 전후 같음)
* 기계 줄 목록(842-3 · `common_util_machine_crew`) = 바탕 덮음(`rowEntry` — 식 한 줄로 안 풀리는 부분만) ·
* 단가산출 덮음이 그 위에 부분별로 덮음(`mergeCover` — 값 = 칸별 · 조종원 · 잡품 = 목록 통째)
* 파이썬 · TS 짝 — 짝 = resources/master_data/scripts/master_doc_cover.py · 거울 시험 test_836_1_machine_cover.py ·
* test_842_3_machine_crew.py
* · 화면 · DOM · fetch 없음
* ========================================================================== */
import {
names,
parse,
symId,
varId,
type DocVar,
type FormulaDoc,
type MoneyLine,
} from "./common_util_formula_doc";
import { CREW, MISC, code as jobOf } from "./common_util_machine_crew";
export const COVER = "기계덮음";
/** 값 칸에서 빼는 기계 칸 — 줄(조종원 · 잡품)로 다룸 */
export const NOT_VALUE = ["조종원", "조종원직종", "잡재료율"];
const HOUR = "Q_시간환산";
/** 조종원 줄을 새로 넣을 때(조종원 없는 식) 시간환산 — GC000267 과 같은 꼴(1/8 × 16/12 × 25/20 · 건협기술-2230) */
const HOUR_VARS: DocVar[] = [
{
열쇠: "Q_분자^표", 기호: "Q", 아래: "분자", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분자", 단위: "",
연결: { 갈래: "표", 표: "CC000336", 조건: { 구분: "'시간환산'" }, 칸: "분자" },
},
{
열쇠: "Q_분모^표", 기호: "Q", 아래: "분모", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분모", 단위: "",
연결: { 갈래: "표", 표: "CC000336", 조건: { 구분: "'시간환산'" }, 칸: "분모" },
},
{
열쇠: "Q_분자2^표", 기호: "Q", 아래: "분자2", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분자", 단위: "",
연결: { 갈래: "표", 표: "CC000336", 조건: { 구분: "'상여계수'" }, 칸: "분자" },
},
{
열쇠: "Q_분모2^표", 기호: "Q", 아래: "분모2", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분모", 단위: "",
연결: { 갈래: "표", 표: "CC000336", 조건: { 구분: "'상여계수'" }, 칸: "분모" },
},
{
열쇠: "Q_분자3^표", 기호: "Q", 아래: "분자3", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분자", 단위: "",
연결: { 갈래: "표", 표: "CC000336", 조건: { 구분: "'휴지계수'" }, 칸: "분자" },
},
{
열쇠: "Q_분모3^표", 기호: "Q", 아래: "분모3", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분모", 단위: "",
연결: { 갈래: "표", 표: "CC000336", 조건: { 구분: "'휴지계수'" }, 칸: "분모" },
},
{ 열쇠: "시간환산", 기호: "Q", 아래: "시간환산", 위: "", 이름: "시간환산", 단위: "", 연결: { 갈래: "식" } },
]; // prettier-ignore
const HOUR_LINE = {
기호: HOUR,
식: "Q_분자^표 ÷ Q_분모^표 × Q_분자2^표 ÷ Q_분모2^표 × Q_분자3^표 ÷ Q_분모3^표",
};
export interface CrewRow {
키: string;
인: number;
/** 화면 이름(직종) — 셈 · 원본 대조 안 봄 */
이름?: string;
}
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` = 그 기계로 셈한 원본 답 `값`) ·
* `base` = 기계 줄 바탕 덮음(`rowEntry`) — 있는 부분은 그 목록(빈 직종 키 = 식이 고른 직종) */
export function defaultsOf(
doc: FormulaDoc,
vals: Vals,
base?: MachineCover | null,
): 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;
let 잡품: MiscRow[] = r
? parts.base.map((i) => ({ 줄: String(lines[i].이름 ?? ""), 율: r }))
: [];
let crew: CrewRow[] = 조종원;
if (base?.조종원) {
const own = 조종원[0]?.키 ?? "";
crew = base.조종원.filter((x) => x.키 || own).map((x) => ({ ...x, 키: x.키 || own }));
}
if (base?.잡품) 잡품 = base.잡품;
return { 값, 조종원: crew, 잡품 };
}
/** 원본 대조 꼴 — 이름 · 단위(화면 글) · 빈 칸 뺌 · 수 12 자리 */
const LABEL = ["이름", "단위"];
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(([k, y]) => y !== undefined && y !== null && y !== "" && !LABEL.includes(k))
.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;
}
/** 조종원 줄 — 빈 키 줄 = 식의 조종원 단가 기호(`own` · 식 입력 직종) · 없으면 뺌 */
function crewLines(doc: FormulaDoc, all: CrewRow[], own = ""): MoneyLine[] {
const rows = all.filter((r) => r.키 || own);
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) => ({
이름: "조종원",
...(r.이름 ? { 규격: r.이름 } : {}),
단위: "인",
비목: "노무비",
수량: `${plain(r.인)} × ${HOUR}`,
단가: r.키 ? addVar(doc, "w", "조종원", "조종원 노임", r.키) : own,
}));
}
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
});
}
/** 기계 식 문서에 그 기계 덮음을 얹음(새 문서) — `base` = 기계 줄 바탕 덮음(`rowEntry`) · 덮음 없음 = 그대로(같은 물건) */
export function coverDoc(
doc: FormulaDoc,
given: Record<string, unknown> | null | undefined,
cover: Cover | null | undefined,
base?: MachineCover | null,
): FormulaDoc {
const key = machineKey(given);
const entry = mergeCover(base, key ? cover?.[key] : undefined);
if (!entry) 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 own = parts.crew.length ? String(lines[parts.crew[0]].단가 ?? "") : "";
const crew = entry.조종원 ? crewLines(out, entry.조종원, own) : 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;
}
// ── 기계 줄 목록 → 바탕 덮음(842-3) ─────────────────────────────────────
/** 기계 시간당 식 — 결과 원/시간 · 요소 `EQ:{S_기계}` 칸을 읽음 */
export const isMachine = (doc: FormulaDoc | null | undefined): boolean =>
!!doc &&
doc.결과단위 === "원/시간" &&
(doc.변수 ?? []).some(
(v) => v.연결?.갈래 === "요소" && String(v.연결?.키 ?? "").includes("{S_기계}"),
);
/** 기계 줄 목록(`인력` · `잡품`) 가운데 식 한 줄로 안 풀리는 부분 = 바탕 덮음 · 다 풀림 = null ·
* 식에 그 줄이 없음 = 안 얹음(손료만 식을 일부러 부른 단가산출) */
export function rowEntry(doc: FormulaDoc, vals: Vals | null | undefined): MachineCover | null {
if (!vals || !isMachine(doc)) return null;
const parts = partsOf(doc);
const lines = doc.금액 ?? [];
const out: MachineCover = {};
const crew = vals[CREW];
if (Array.isArray(crew) && parts.crew.length) {
const rows = crew as CrewRow[];
const jobs = new Set(rows.map((r) => jobOf(r?.키)));
if (!(rows.length && jobs.size === 1)) out.조종원 = rows;
}
const misc = vals[MISC];
if (Array.isArray(misc) && misc.length && parts.misc !== null) {
const rows = misc as MiscRow[];
const names = parts.base.map((i) => String(lines[i].이름 ?? "")).sort();
const plainMisc =
rows.every((r) => r && r.줄 && !r.키) &&
rows.every((r) => r.수량 === undefined || r.수량 === null) &&
new Set(rows.map((r) => canon(r.율))).size === 1 &&
JSON.stringify(rows.map((r) => String(r.줄)).sort()) === JSON.stringify(names);
if (!plainMisc) out.잡품 = rows;
}
return Object.keys(out).length ? out : null;
}
/** 바탕 덮음 위에 단가산출 덮음 — 값 = 칸별 · 조종원 · 잡품 = 목록 통째(있는 부분만) */
export function mergeCover(
base: MachineCover | null | undefined,
top: MachineCover | null | undefined,
): MachineCover | null {
if (!base || !Object.keys(base).length) return top && Object.keys(top).length ? top : null;
if (!top || !Object.keys(top).length) return base;
const out: MachineCover = { ...base, ...top };
const 값 = { ...(base.값 ?? {}), ...(top.값 ?? {}) };
if (Object.keys(값).length) out.값 = 값;
else delete out.값;
return out;
}
/** 셈 연결로 그 기계 줄 목록 읽기 — {인력?, 잡품?} · 못 읽음 = 빈 묶음(요소 `EQ:<기계>` 칸) */
export function listsOf(
element: (p: { 키: string; 칸: string }) => unknown,
given: Record<string, unknown> | null | undefined,
): Vals {
const key = machineKey(given);
const out: Vals = {};
if (key)
for (const col of [CREW, MISC])
try {
out[col] = element({ 키: `EQ:${key}`, 칸: col });
} catch {
/* 못 읽음 = 그 목록 없음 */
}
return out;
}