Files
Aislo/common_util/common_util_formula_doc_cover.ts
T
eomsangdonandClaude Opus 5.5 3caa3e2193 fix(840-5): 조종원 노임 시간 환산 = 1/8 × 16/12 × 25/20(요구 864 · 경쟁사와 같게) — 기계 식 열 개가 계수표 CC000336 시간환산 · 상여 · 휴지 세 줄을 읽음
- 정본 로직(08장 아홉 · 산림 10장 4륜 덤프) 시간환산 중간 식 = 세 줄 분자 ÷ 분모 곱(713-4 전 꼴) · 수식 문서 = 빌더 변환으로 다시 지음(변환 대조 1,520 같음 · 채택 1 원 셋 = 같은 까닭 714-3)
- 체인톱 GF000337 은 그대로(시간환산 = 하루 연료 · 손료 → 시간 · 조종원 없음)
- 짝: 기계 양식 시간환산(M01 lists = 세 줄 곱) · master_machine_coef ↔ common_util_machine_coef 기본값 5/24 · 기계 덮음 새 조종원 줄 꼴(master_doc_cover ↔ common_util_formula_doc_cover) · 기호 이름 상여분자 · 상여분모 · 휴지분자 · 휴지분모
- 전후: 조종원 기계 295 대 노무비만 ×5/3 · 조종원 없는 기계 불변 · 로직 1,520 중 기계 부르는 431 만 오름
- B09 는 안 고침(계수표 세 줄을 스스로 곱함 · 두 번 곱 0)

낡은 시험 고침: 713-4 지킴을 세 계수 꼴로 · masterdata_check · 796-1 · 821-2 · 821-4 · 822-2 · 836-1 · 839-1 기대값 = 조종원 5/24 · 802-2 채택 차이 셋 · 821-3 줄 쌓기 내 기계 = 836-2 뒤 개인 층 자동 따라옴(옛 단가새값 · 단가갱신 기대 걷음)

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

323 lines
14 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`) = 계산기가 기계 식을 부를 때 그 기계 덮음을 얹은 문서로 셈 — 덮음 없음 = 문서 그대로(금액 전후 같음)
* 파이썬 · 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/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` = 그 기계로 셈한 원본 답 `값`) */
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 { 값, 조종원, 잡품 };
}
/** 원본 대조 꼴 — 이름 · 단위(화면 글) · 빈 칸 뺌 · 수 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;
}
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) => ({
이름: "조종원",
...(r.이름 ? { 규격: 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;
}