Files
Aislo/common_util/common_util_formula_doc_work.ts
T
eomsangdonandClaude Opus 5.5 a53fae747c feat(846-1): 기계 줄 시간당 작업량 — 기계 값 칸 작업량 · 덮음 일당 | 시간당 · 계산기 짝(금액 불변)
- 기계 자료: M01 기계 221 대 값 칸 `작업량` = [{식 · 단위 · 변수(규격에서 정해지는 칸)}] (건설 공통 8-2 장 작업량 틀 · 빌더 build_기계_작업량_846_1.py · --check 0 · 다른 칸 · 시간당 비용 그대로)
- 계산기 짝 master_doc_work.py ↔ common_util_formula_doc_work.ts: 기계 줄 수량에서 시간당 작업량 자리 읽기(일당 = 시공량 ÷ (8 × 대수) · 식 = …/hr 나눔 변수) · 셈 답 줄 칸 `작업량` · 덮음 `작업량: {방식 시간당, 식, 변수?}` = 그 자리를 1 ÷ Q(기계 작업량 식)로 바꿔 셈 · 부착 기계(브레이커 · 집게) 줄 따로 · 식 없는 기계 · 단위 안 맞음 = 시간당 거절
- 덮음 짝(master_doc_cover · common_util_formula_doc_cover): 작업량은 기계 식 문서에 안 얹음 · prune 이 작업량 정리(일당 · 기본과 같은 변수 = 지움) · 몸 검사 · CoverEntry · CoverBase 꼴
- 저장 길: 사본 부름 묶음에 시간당 식 키(M02_Doc_Copy.calls) · 내 기계 clean 이 작업량 받음 · 연결 읽기 작업량 칸(master_doc_link)
- 기계 보기: 작업량 칸 = 제어 구역 몫(ownedCols — 바탕 칸 숫자 입력으로 안 그림 · 그리기는 846-2)
- 지킴: 1,520 문서 · 기계 655 대 · 견본 SK 금액 전후 다름 0 · 빌더 802-2 --check 1,520 같음(옵션 표본 18,873) · 둘 다 가능 줄 전수 시간당 셈 멈춤 0
- 시험: test_846_1_machine_work(일당 · 시간당 손셈 · E · K 덮음 · 정리 · 거절 · 부착 · 거울 · 저장 길 · 내 기계) 신설 · test_816_1 기대값 고침(까닭 — 4륜 덤프 1톤 기계 줄에 작업량 칸이 생겨 값 칸 비교에서 뺌)

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

434 lines
17 KiB
TypeScript

/* =============================================================================
* common_util_formula_doc_work.ts
* 기계 줄 시간당 작업량(PLAN 846-1 · 요구 881 · 882 · 883) — 기계 줄 수량을 「1 ÷ 시간당 작업량」 으로 읽고
* 단가산출 덮음으로 그 기계의 작업량 식(시간당 방식)으로 바꿔 셈.
* 읽기(`sites`) — 기계 줄 수량 식의 시간당 작업량 자리(저장 식 글은 안 바꿈 · 만약 갈래 안도 찾음):
* 일당 = 곱 · 나눔 줄에 8 과 나눔 하나(시공량 D) → 시간당 작업량 = D ÷ (8 × 대수)
* (대수 = 곱의 다른 변수 — 단위 % · `r_` 율은 곱에 남김)
* 식 = 나눔으로만 쓰는 변수 · 단위 「…/hr」(작업량 식 부품 · 표 값 · 빈 단위 = 정의 식에서 짐작)
* 기계 값 칸 `작업량` = [{식: 작업량 틀 키, 단위, 변수: {기계 규격에서 정해지는 칸}}](빌더 build_기계_작업량_846_1.py)
* 덮음 = `기계덮음` 기계 칸 `작업량: {방식: '시간당', 식, 변수?}` — 그 기계 줄의 작업량 자리를 `1 ÷ Q` 로 바꿈
* (Q = 그 식을 식 문서 기본 › 기계 변수 › 덮음 변수로 셈) · 다른 곱(율 등) 그대로 · 못 함 = 그 줄 멈춤(까닭)
* 부착 기계(브레이커 · 집게 입력) 줄 = 그 부착 기계 열쇠(굴착기 줄과 따로)
* 셈 답 기계 줄 칸 `작업량` = {기계, 꼴(일당 · 식 · 시간당), 율, 값, 단위, 식들, 변수?, 까닭?}(산출근거 자료)
* 파이썬 · TS 짝 — 짝 = resources/master_data/scripts/master_doc_work.py · 거울 시험 test_846_1_machine_work.py
* · 계산기(`common_util_formula_doc_calc.ts` money)가 줄마다 `lineWork` 를 부름 · 화면 · DOM · fetch 없음
* ========================================================================== */
import {
names,
parse,
textOf,
varId,
walk,
type DocVar,
type FormulaDoc,
type Mark,
type Node,
} from "./common_util_formula_doc";
import type { Cover } from "./common_util_formula_doc_cover";
export const WORK = "작업량";
export const HOURLY = "시간당";
export const DAILY = "일당";
export const Q_NEW = "Q_시간당작업량"; // 셈 범위 이름(문서 기호 아님)
export const NOT_MACHINE = ["지역", "보정작업", "조종원직종", "계약종별"]; // 기계 식 입력 가운데 기계 아닌 것
/** 기계 값 칸 `작업량` 한 줄 */
export interface WorkSpec {
식: string;
단위?: string;
변수?: Record<string, unknown>;
}
/** 덮음 `작업량` */
export interface WorkCover {
방식: string;
식?: string;
변수?: Record<string, unknown>;
}
/** 셈 답 기계 줄 `작업량` */
export interface WorkInfo {
기계: string;
꼴: string;
율: string;
값: number | null;
단위: string;
식들: string[];
변수?: Record<string, unknown>;
까닭?: string;
}
interface Site {
꼴: string;
노드: Node;
단위: string;
율: string;
뺌?: Set<number>;
}
type Fac = [number, Node];
export const normUnit = (u: unknown): string =>
String(u ?? "")
.trim()
.replace(/m3/g, "㎥")
.replace(/m2/g, "㎡")
.replace(/\/(시간|h|Hr|HR)$/, "/hr");
const varOf = (doc: FormulaDoc, name: string): DocVar | undefined =>
(doc.변수 ?? []).find((v) => varId(v) === name);
function parseOr(text: unknown): Node | null {
try {
return parse(text === null || text === undefined || text === "" ? "1" : text);
} catch {
return null;
}
}
/** 문서 결과 단위(원/X · 원/100㎡) → X/hr · 모름 = "" */
function perHour(doc: FormulaDoc): string {
const t = String(doc.결과단위 ?? "");
const low = t.includes("/")
? t
.split("/")
.slice(1)
.join("/")
.replace(/^[\d.,]+/, "")
.trim()
: "";
return low ? `${normUnit(low)}/hr` : "";
}
/** 변수 단위 — 빈 칸이면 정의 식에서 짐작(…/hr 변수를 씀 · 3600 이 든 식 = 문서 결과 단위/hr) */
export function unitOf(doc: FormulaDoc, name: string, depth = 0): string {
const u = normUnit(varOf(doc, name)?.단위);
const line = (doc.식 ?? []).find((x) => x.기호 === name);
if (u || depth > 3 || !line) return u;
const node = parseOr(line.식);
if (!node) return "";
for (const n of names(node)) {
const got = unitOf(doc, n, depth + 1);
if (got.endsWith("/hr")) return got;
}
return [...walk(node)].some((x) => x[0] === "num" && x[1] === 3600) ? perHour(doc) : "";
}
const held = (doc: FormulaDoc, name: string): boolean =>
name.startsWith("r_") || unitOf(doc, name) === "%";
// ── 식 나무 ───────────────────────────────────────────────────────────
const isChain = (n: Node): boolean => n[0] === "bin" && (n[1] === "*" || n[1] === "/");
function factors(node: Node, sign = 1, out: Fac[] = []): Fac[] {
if (node[0] === "bin" && (node[1] === "*" || node[1] === "/")) {
factors(node[2], sign, out);
factors(node[3], node[1] === "*" ? sign : -sign, out);
} else out.push([sign, node]);
return out;
}
/** (부호, 노드) 목록 → 곱 · 나눔 나무(차례 그대로 · 곱 먼저) */
function build(fs: Fac[]): Node {
const tops = fs.filter(([s]) => s > 0).map(([, n]) => n);
let node: Node = tops.length ? tops[0] : ["num", 1, "1"];
for (const n of tops.slice(1)) node = ["bin", "*", node, n];
for (const [s, n] of fs) if (s < 0) node = ["bin", "/", node, n];
return node;
}
function kids(node: Node): Node[] {
switch (node[0]) {
case "call":
return node[2];
case "bin":
return [node[2], node[3]];
case "neg":
case "pct":
case "field":
return [node[1]];
default:
return [];
}
}
function uses(node: Node, sign: number, out: Map<string, number[]>): void {
if (isChain(node)) {
for (const [s, n] of factors(node))
if (n[0] === "var") out.set(n[1], [...(out.get(n[1]) ?? []), s * sign]);
else uses(n, 0, out);
return;
}
if (node[0] === "var") {
out.set(node[1], [...(out.get(node[1]) ?? []), 0]);
return;
}
for (const k of kids(node)) uses(k, 0, out);
}
function rates(doc: FormulaDoc, node: Node): Set<string> {
const got = new Map<string, number[]>();
uses(node, 1, got);
return new Set(
[...got].filter(([n, signs]) => signs.every((s) => s < 0) && unitOf(doc, n).endsWith("/hr")).map(([n]) => n), // prettier-ignore
);
}
/** 곱 · 나눔 줄 하나 → 일당 자리 | null */
function daily(doc: FormulaDoc, node: Node, rs: Set<string>): Site | null {
const fs = factors(node);
const eight = fs.flatMap(([s, n], i) => (s > 0 && n[0] === "num" && n[1] === 8 ? [i] : []));
const den = fs.flatMap(([s, n], i) => (s < 0 && n[0] !== "num" ? [i] : [])); // 수 나눔(÷ 100) = 곱에 남김
if (!eight.length || den.length !== 1) return null;
const d = fs[den[0]][1];
if (d[0] === "var" && rs.has(d[1])) return null;
const crew = fs.flatMap(([s, n], i) => (s > 0 && n[0] === "var" && !held(doc, n[1]) ? [i] : []));
let per: Node = ["num", 8, "8"];
for (const i of crew) per = ["bin", "*", per, fs[i][1]];
const rate: Node = ["bin", "/", d, per];
const ns = names(d);
const u = ns.length === 1 ? unitOf(doc, ns[0]) : "";
return { 꼴: DAILY, 노드: rate, 단위: u ? `${u}/hr` : perHour(doc), 율: "", 뺌: new Set([eight[0], den[0], ...crew]) }; // prettier-ignore
}
type Test = (cond: Node) => boolean | null;
function find(doc: FormulaDoc, node: Node, rs: Set<string>, test: Test | null, out: Site[]): void {
if (node[0] === "call" && node[1] === "만약" && test && node[2].length === 3) {
const t = test(node[2][0]);
if (t !== null) return find(doc, t ? node[2][1] : node[2][2], rs, test, out);
}
if (isChain(node)) {
const site = daily(doc, node, rs);
if (site) {
out.push(site);
return;
}
for (const [s, n] of factors(node))
if (s < 0 && n[0] === "var" && rs.has(n[1]))
out.push({ 꼴: "식", 노드: n, 단위: unitOf(doc, n[1]), 율: "" });
else if (n[0] !== "var") find(doc, n, rs, test, out);
return;
}
for (const k of kids(node)) find(doc, k, rs, test, out);
}
/** 수량 글 → 시간당 작업량 자리 [{꼴, 노드, 율(글), 단위}] · test(조건 노드) → 참 · 거짓 · null(두 갈래 다) */
export function sites(doc: FormulaDoc, text: unknown, test: Test | null = null): Site[] {
const node = parseOr(text);
if (!node) return [];
const out: Site[] = [];
find(doc, node, rates(doc, node), test, out);
for (const s of out) s.율 = textOf(s.노드);
return out;
}
function swapNode(doc: FormulaDoc, node: Node, rs: Set<string>): Node {
if (isChain(node)) {
const site = daily(doc, node, rs);
const fs = factors(node);
if (site) return build([...fs.filter((_, i) => !site.뺌!.has(i)), [-1, ["var", Q_NEW]]]);
if (fs.some(([s, n]) => s < 0 && n[0] === "var" && rs.has(n[1])))
return build(fs.map(([s, n]) => [s, s < 0 && n[0] === "var" && rs.has(n[1]) ? ["var", Q_NEW] : n])); // prettier-ignore
const next: Fac[] = fs.map(([s, n]) => [s, n[0] === "var" ? n : swapNode(doc, n, rs)]);
return next.every((x, i) => x[1] === fs[i][1]) ? node : build(next);
}
const old = kids(node);
const next = old.map((k) => swapNode(doc, k, rs));
if (next.every((x, i) => x === old[i])) return node; // 자리 없음 = 그대로(글 차례 안 바뀜)
switch (node[0]) {
case "call":
return ["call", node[1], next];
case "field":
return ["field", next[0], node[2]];
case "bin":
return ["bin", node[1], next[0], next[1]];
case "neg":
case "pct":
return [node[0], next[0]];
default:
return node;
}
}
/** 수량 글의 작업량 자리(모든 갈래)를 `1 ÷ Q_NEW` 로 바꾼 글 */
export function swap(doc: FormulaDoc, text: unknown): string {
const node = parseOr(text);
return node ? textOf(swapNode(doc, node, rates(doc, node))) : String(text);
}
// ── 기계 작업량 · 덮음 ──────────────────────────────────────────────────
/** 기계 값 칸 `작업량` → [{식, 단위, 변수}] (꼴 틀림 = 뺌) */
export const specsOf = (got: unknown): WorkSpec[] =>
(Array.isArray(got) ? got : []).filter(
(x): x is WorkSpec => !!x && typeof x === "object" && typeof x.식 === "string" && !!x.식,
);
/** 시간당 작업량 단위에 맞는 식 키(단위 모름 = 전부) */
export const fits = (specs: WorkSpec[], unit: string): string[] =>
specs.filter((s) => !unit || normUnit(s.단위) === unit).map((s) => s.식);
/** 식 문서 입력 · 선택 기본 › 기계 변수 */
export function workDefaults(
tpl: FormulaDoc | null | undefined,
spec: WorkSpec | null | undefined,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const v of tpl?.변수 ?? []) {
const link = v.연결 ?? {};
if (["입력", "선택", "숫자"].includes(String(link.갈래)) && v.열쇠)
out[String(v.열쇠)] = (link as { 값?: unknown }).값;
}
return { ...out, ...(spec?.변수 ?? {}) };
}
const sameVal = (a: unknown, b: unknown): boolean => {
const x = Number(a);
const y = Number(b);
return a !== "" && b !== "" && Number.isFinite(x) && Number.isFinite(y)
? Math.abs(x - y) <= 1e-9 * Math.max(1, Math.abs(x))
: String(a) === String(b);
};
/** 작업량 덮음 정리 — 방식 시간당만 · 기본(`workDefaults`)과 같은 변수 뺌 · 남는 것 없음 = null */
export function pruneWork(
want: WorkCover | null | undefined,
base: Record<string, unknown> | null = null,
): WorkCover | null {
if (!want || want.방식 !== HOURLY) return null;
const got = Object.entries(want.변수 ?? {}).filter(
([k, v]) =>
v !== null && v !== undefined && v !== "" && !(base && k in base && sameVal(v, base[k])),
);
const out: WorkCover = { 방식: HOURLY };
if (want.식) out.식 = String(want.식);
if (got.length) out.변수 = Object.fromEntries(got);
return out;
}
/** 문서 `기계덮음` 의 시간당 작업량 식 키(파이썬 `cover_calls` 짝) */
export function coverCalls(doc: FormulaDoc | null | undefined): string[] {
const got = (doc as { 기계덮음?: Cover } | null | undefined)?.기계덮음;
return Object.values(got && typeof got === "object" ? got : {}).flatMap((e) => {
const w = (e as { [WORK]?: WorkCover })?.[WORK];
return w && w.방식 === HOURLY && w.식 ? [String(w.식)] : [];
});
}
// ── 셈(계산기 money 가 기계 줄마다 부름) ─────────────────────────────────
type Scope = Record<string, unknown>;
/** 계산기 셈 한 벌(`Run`)에서 쓰는 몫 */
export interface WorkRun {
doc: FormulaDoc;
vars: Map<string, DocVar>;
values: Map<string, unknown>;
marks: Mark[];
cover: Cover;
links: { element(p: Record<string, unknown>): unknown; doc(key: string): FormulaDoc | null | undefined }; // prettier-ignore
expr(text: unknown, where?: string, scope?: Scope): unknown;
ev(node: Node, scope: Scope | undefined, where: string): unknown;
sub(p: Record<string, unknown>, name: string): unknown;
}
/** 계산기 몫(계산기 → 이 파일 부름만 · 거꾸로 import 없음) */
export interface WorkKit {
Stop: new (why: string, kind?: string, sign?: string) => { why: string };
Gap: new (...a: never[]) => Error;
Broken: new (...a: never[]) => Error;
bad: (v: unknown) => boolean;
plainText: (v: unknown) => string;
}
function machineOf(run: WorkRun, line: { 단가?: unknown }): string | null {
const node = parseOr(line.단가);
for (const name of node ? names(node) : []) {
const link = (run.vars.get(name)?.연결 ?? {}) as { 갈래?: string; 입력?: Record<string, unknown> }; // prettier-ignore
const given = link.입력 ?? {};
if (link.갈래 === "단가산출" && "기계" in given) {
const tool = Object.entries(given).filter(([k]) => k !== "기계" && !NOT_MACHINE.includes(k));
return String(tool.length ? tool[tool.length - 1][1] : given.기계);
}
}
return null;
}
/** 기계 줄 하나 → [바꾼 수량 글 | 멈춤(표지는 계산기가 담) | null, 범위 더함, 답 칸 | null] */
export function lineWork(
run: WorkRun,
line: { 이름?: unknown; 수량?: unknown; 단가?: unknown },
scope: Scope,
kit: WorkKit,
): [string | { why: string } | null, Record<string, unknown>, WorkInfo | null] {
const expr = machineOf(run, line);
if (expr === null) return [null, {}, null];
const where = String(line.이름 ?? "");
/** 흔적 없는 셈 — 표지 · 기억한 값을 되돌림(진짜 셈이 표지를 다시 냄) */
const quiet = <T>(fn: () => T): T => {
const n = run.marks.length;
const seen = new Set(run.values.keys());
try {
return fn();
} finally {
run.marks.length = n;
for (const k of [...run.values.keys()]) if (!seen.has(k)) run.values.delete(k);
}
};
const keyVal = quiet(() => run.expr(expr, where, scope));
if (kit.bad(keyVal)) return [null, {}, null];
const raw = kit.plainText(keyVal).trim();
const key = /^'.*'$/.test(raw) ? raw.slice(1, -1) : raw; // 덮음 기계 열쇠(cover machineKey 와 같음)
const test: Test = (cond) => {
const v = quiet(() => run.ev(cond, scope, where));
return kit.bad(v) ? null : !!v;
};
const found = sites(run.doc, line.수량, test);
const want = (run.cover?.[key] as { [WORK]?: WorkCover } | undefined)?.[WORK];
const hourly = !!want && want.방식 === HOURLY;
if (!found.length && !hourly) return [null, {}, null];
let specs: WorkSpec[] = [];
try {
specs = specsOf(run.links.element({ 키: `EQ:${key}`, 칸: WORK }));
} catch (e) {
if (!(e instanceof kit.Gap) && !(e instanceof kit.Broken)) throw e;
}
const unit = found.length ? found[0].단위 : "";
const info: WorkInfo = {
기계: key, 꼴: found.length ? found[0].꼴 : "", 율: found.length ? found[0].율 : "",
값: null, 단위: unit, 식들: fits(specs, unit),
}; // prettier-ignore
if (!hourly) {
const v = quiet(() => run.ev(found[0].노드, scope, where));
info.값 = typeof v === "number" && Number.isFinite(v) ? v : null;
return [null, {}, info];
}
const pick = String(want!.식 ?? "") || (info.식들[0] ?? "");
const spec = specs.find((s) => s.식 === pick);
const why = !specs.length
? "시간당 작업량 식 없는 기계"
: !found.length
? "시간당 작업량 자리 없는 줄"
: !spec || !info.식들.includes(pick)
? `단위 ${unit} 맞는 작업량 식 없음`
: "";
info.꼴 = HOURLY;
info.율 = pick;
if (why) {
info.까닭 = why;
return [new kit.Stop(`${where} 시간당 거절 — ${why}`, "시간당 거절", where), {}, info];
}
const given = { ...(spec!.변수 ?? {}), ...(want!.변수 ?? {}) };
let q: unknown;
let tpl: FormulaDoc | null | undefined = null;
try {
q = run.sub({ 키: pick, 입력: given }, where);
tpl = run.links.doc(pick);
} catch (e) {
if (!(e instanceof kit.Gap) && !(e instanceof kit.Broken)) throw e;
q = new kit.Stop(e.message);
}
info.변수 = { ...workDefaults(tpl, spec), ...(want!.변수 ?? {}) };
if (typeof q !== "number" || !Number.isFinite(q) || q <= 0) {
info.까닭 = `작업량 식 ${pick} 못 셈${kit.bad(q) ? ` — ${(q as { why: string }).why}` : ""}`;
return [new kit.Stop(`${where} ${info.까닭}`, "시간당 거절", where), {}, info];
}
info.값 = q;
return [swap(run.doc, line.수량), { [Q_NEW]: q }, info];
}