Files
Aislo/common_util/common_util_formula_doc_cover.ts
T
eomsangdonandClaude Opus 5.5 e8d5534c11 feat(842-3): 기계 시간당 셈 한 길 — 식 문서 계산기 + 기계 줄 인력 · 잡품 목록 바탕 덮음
- 읽기 view(Master.value) = 인력 · 잡품 목록 → 식이 읽는 조종원 수 · 직종 · 잡재료율(옛 꼴 = 그대로)
- 목록이 식 한 줄로 안 풀리면 row_entry 바탕 덮음 · 단가산출 기계덮음은 merge 로 부분별 위에(짝 TS)
- M02_Machine_Hourly.hourly · brief · M02_MyMachine.hourly = 문서 엔진(doc_sums) · 식 문서 없는 식만 옛 로직 엔진
- pick · _operated · why · logic_key · 호표 기계 고름 · 불러오기 기계 수 = 인력 줄로
- 내 기계 clean = 인력 · 잡품 목록 받음(옛 칸 = from_old) · M01 fix · check_master = 목록 꼴 검사
- 사본 자동 따라옴 = 옛 꼴 사본 줄 대 새 꼴 M01 줄은 새 꼴로 바꿔 대조(덮음 안 지움)
- 시험 836_1 몸 검사: 조종원 빈 키 = 식 입력 직종으로 받음 → 틀림 꼴을 「키 없음」으로 고침
- 전후: 655 대 로직 엔진 = 문서 엔진 611 같음 · 다름 0 · 코드만 바꾼 뒤 655 대 hourly · brief · 문서 셈 전후 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 18:23:04 +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) && (crew.length || parts.crew.length)) {
const rows = crew as CrewRow[];
const jobs = new Set(rows.map((r) => jobOf(r?.키)));
if (!(parts.crew.length && rows.length && jobs.size === 1)) out.조종원 = rows;
}
const misc = vals[MISC];
if (Array.isArray(misc) && misc.length) {
const rows = misc as MiscRow[];
const names = parts.base.map((i) => String(lines[i].이름 ?? "")).sort();
const plainMisc =
parts.misc !== null &&
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;
}