feat(846-2): 기계 줄 시간당 작업량 화면 — 산출근거 「시간당 작업량」 줄 먼저 · 비목 ÷ 시간당 작업량 · 제어 구역 일당 / 시간당 식 고르기 · 변수 칸 · 기계 보기 작업량 식

- 산출근거: 일당 「시간당 작업량 = 시공량 560 ㎥/일 ÷ (1 대 × 8 시간) = 70 ㎥/시간」 · 시간당 식 = q · K · f · E · cm 값 줄 + 한 층 분수 Q 식 · 비목 줄 「59,020 원/시간 ÷ 70 ㎥/시간 = 843.1 원」(금액 그대로)
- 제어 구역: 「시간당 작업량」 묶음 맨 위 — 방식 고르기(식 없는 기계 = 잠김 + 까닭) · 시간당 = 작업량 틀 변수 칸(고침 · 원래 값) · 덮음 = 기계덮음.<기계>.작업량
- 기계 보기: 왼쪽 작업량 카드(기계 정본 변수) · 오른쪽 풀이 · 서버가 작업량 틀 연결을 실음(M02_MyMachine_Sheet._work)
- 시험: test_846_2_work_view 새로 · test_845_2 GC000088 비목 줄 기대값을 새 꼴로 고침(846-2 표시 변경) · 헬퍼 가짜 DOM 이 진짜 DOM 처럼 요소를 옮김(같은 요소 두 줄 재사용 흠을 잡게)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-16 00:00:35 +09:00
co-authored by Claude Opus 5.5
parent 3a8ba840f4
commit c8cd98dec1
16 changed files with 925 additions and 22 deletions
+143
View File
@@ -0,0 +1,143 @@
/* =============================================================================
* line_stack_work.ts
* 산출근거 시간당 작업량 줄(PLAN 846-2 · 요구 879 · 881 · 882 · 883) — 기계 줄은 「시간당 작업량」 을 먼저 보이고
* 비목 줄을 「노무비 : 59,020 원/시간 ÷ 70 ㎥/시간 = 843.1 원」 으로(금액 = 계산기 답 그대로 · 그리기만)
* 일당 = 「시간당 작업량 = 시공량 560 ㎥/일 ÷ (1 대 × 8 시간) = 70 ㎥/시간」
* 문서 식 = 「시간당 작업량 = Q_작업 = 24.89 ㎥/시간」(식 풀이는 위 공식 줄)
* 시간당 식 = 그 기계 작업량 틀 풀이 — 값 줄(q · K · f · E · cm) + 「Q = 3,600 × q × K × f × E ÷ cm = … ㎥/시간」
* (원문 한 층 분수 · 겹분수 규칙 = 845-2 `frac` 1 층) · 기계 보기도 같은 줄(`hourlyRows`)
* 머리 작은 글 = 방식(「품셈 일당」 · 「문서 식」 · 「시간당 식」) · 자료 = `common_util_line_stack_work`
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { el } from "@ui/ui_template_elements";
import { eqText } from "@ui/ui_template_equation";
import { varId, type FormulaDoc, type Node } from "../common_util/common_util_formula_doc";
import {
calcDoc,
isNum,
type DocAnswer,
type DocLinks,
} from "../common_util/common_util_formula_doc_calc";
import type { WorkInfo } from "../common_util/common_util_formula_doc_work";
import type { Values } from "../common_util/common_util_formula_doc_view";
import { nodeOf } from "../common_util/common_util_line_stack";
import { shownUnitOf } from "../common_util/common_util_line_stack_unit";
import {
dailyParts,
dayUnit,
hourlyParts,
restOfQty,
workUnit,
} from "../common_util/common_util_line_stack_work";
import { katexNode, symTex, tex, txt, valTex } from "./line_stack_tex";
import { lt, type LsKey } from "./line_stack_text";
export interface WorkRow {
h: (string | HTMLElement)[];
cls?: string;
}
/** 방식 작은 글 */
export function workTag(kind: string, title = ""): HTMLElement {
const key = `Work_${kind}` as LsKey;
const b = createBadge(lt(key) || kind, "neutral", { size: 18 });
b.dataset.work = kind;
if (title) b.title = title;
return b;
}
/** 「a = b = c」 — 조각마다 조판(좁은 폭에서 조각 사이로 줄이 바뀜 · 공식 줄과 같음) */
const chain = (...xs: string[]): (string | HTMLElement)[] =>
xs.flatMap((x, i) => (i ? [" = ", katexNode(x)] : [katexNode(x)]));
const unitTex = (u: string): string => (u ? `\\ ${txt(u)}` : "");
const amount = (v: unknown, u: string): string => `${valTex(v)}${unitTex(u)}`;
/** 작업량 틀 풀이 — 값 줄 + 결과 식 줄(없으면 빈 목록) */
export function hourlyRows(
tpl: FormulaDoc,
a: DocAnswer,
look?: (p: Values) => unknown,
): WorkRow[] {
const parts = hourlyParts(tpl);
if (!parts) return [];
const vals = (a.값 ?? {}) as Values;
const vars = new Map((tpl.변수 ?? []).map((v) => [varId(v), v]));
const unit = (id: string): string => workUnit(shownUnitOf(vars.get(id), vals, look));
const pack = parts.uses.map((id) =>
katexNode(`${symTex(id)} = ${isNum(vals[id]) ? amount(vals[id], unit(id)) : txt("—")}`),
);
const res = vals[parts.id];
const sym = tex(parts.node, { side: "sym", vals, frac: 1 });
const val = tex(parts.node, { side: "val", vals, frac: 1 });
const end = isNum(res) ? amount(res, workUnit(tpl.결과단위)) : txt("—");
return [
...(pack.length ? [{ h: pack, cls: "packs" }] : []),
{ h: chain(symTex(parts.id), sym, val, end), cls: "work" },
];
}
export interface WorkCtx {
doc: FormulaDoc;
vals: Values;
/** 시간당 식 풀이 때만(작업량 틀 · 표) */
links?: DocLinks;
/** 캐시에 없는 연결(작업량 틀) — 뒤에서 서버 */
miss: () => void;
look?: (p: Values) => unknown;
}
/** 시간당 작업량 줄(머리 + 풀이) — 값 없으면 빈 목록(까닭은 금액 줄이 보임) */
export function workRows(w: WorkInfo, c: WorkCtx): WorkRow[] {
const head = lt("Work_Head");
const u = workUnit(w.단위);
if (w.꼴 === "시간당") {
let tpl: FormulaDoc | null = null;
try {
tpl = c.links?.doc(w.율) ?? null;
} catch {
c.miss();
}
const tag = workTag(w.꼴, tpl?.이름 ?? w.율);
if (!tpl) return [{ h: [tag, `${head} = `, isNum(w.값) ? `${eqText(w.값)} ${u}`.trim() : lt("Work_Wait")] }]; // prettier-ignore
const a = calcDoc(tpl, c.links!, w.변수 ?? {}, [], new Map(), {});
const name = el("span", { className: "lstk-hint", text: tpl.이름 ?? w.율 });
return [{ h: [tag, name] }, ...hourlyRows(tpl, a, c.look)];
}
if (!isNum(w.값)) return [];
const tag = workTag(w.꼴 || "일당");
const res = amount(w.값, u);
const parts = w.꼴 === "일당" ? dailyParts(w.율) : null;
if (parts) {
const val = (n: Node): string => tex(n, { side: "val", vals: c.vals, inline: true });
const dayName = parts.day[0] === "var" ? nameOf(c.doc, parts.day[1]) : "";
const day = `${dayName ? `${txt(dayName)}\\ ` : ""}${val(parts.day)}${unitTex(dayUnit(w.단위))}`;
const per = parts.per
.map((p) => `${val(p.node)}${unitTex(lt(p.hours ? "Work_Hours" : "Work_Unit"))}`)
.join(" \\times ");
const den = parts.per.length > 1 ? `\\left(${per}\\right)` : per;
return [{ h: [tag, ...chain(txt(head), `${day} \\div ${den}`, res)], cls: "work" }];
}
const n = nodeOf(w.율);
const body = n ? [tex(n, { side: "sym", vals: c.vals, inline: true })] : [];
return [{ h: [tag, ...chain(txt(head), ...body, res)], cls: "work" }];
}
/** 비목 줄 수량 자리 — 「÷ 70 ㎥/시간 (× 나머지)」 · 꼴이 다르면 null(지금 꼴 그대로) */
export function workQty(
doc: FormulaDoc,
qty: unknown,
w: WorkInfo | null,
scope: Values,
alias?: Record<string, string>,
): HTMLElement[] | null {
if (!w || !isNum(w.값) || w.값 <= 0) return null;
const got = restOfQty(doc, qty);
if (!got) return null;
const by = katexNode(`{}\\div ${amount(w.값, workUnit(w.단위))}`); // {} = 두 항 사이 ÷ 간격
if (!got.rest) return [by];
return [by, katexNode(`\\times ${tex(got.rest, { side: "val", vals: scope, inline: true, alias })}`)]; // prettier-ignore
}
const nameOf = (doc: FormulaDoc, id: string): string =>
(doc.변수 ?? []).find((v) => varId(v) === id)?.이름 ?? "";