From c8cd98dec13d7defadbc84f73dc28c7ea907517d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 16 Nov 2026 00:00:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(846-2):=20=EA=B8=B0=EA=B3=84=20=EC=A4=84?= =?UTF-8?q?=20=EC=8B=9C=EA=B0=84=EB=8B=B9=20=EC=9E=91=EC=97=85=EB=9F=89=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=E2=80=94=20=EC=82=B0=EC=B6=9C=EA=B7=BC?= =?UTF-8?q?=EA=B1=B0=20=E3=80=8C=EC=8B=9C=EA=B0=84=EB=8B=B9=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=EB=9F=89=E3=80=8D=20=EC=A4=84=20=EB=A8=BC=EC=A0=80=20?= =?UTF-8?q?=C2=B7=20=EB=B9=84=EB=AA=A9=20=C3=B7=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EB=8B=B9=20=EC=9E=91=EC=97=85=EB=9F=89=20=C2=B7=20=EC=A0=9C?= =?UTF-8?q?=EC=96=B4=20=EA=B5=AC=EC=97=AD=20=EC=9D=BC=EB=8B=B9=20/=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=EB=8B=B9=20=EC=8B=9D=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EB=B3=80=EC=88=98=20=EC=B9=B8=20=C2=B7=20?= =?UTF-8?q?=EA=B8=B0=EA=B3=84=20=EB=B3=B4=EA=B8=B0=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=EB=9F=89=20=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 산출근거: 일당 「시간당 작업량 = 시공량 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 --- A00_Common/line_stack.css | 17 ++ A00_Common/line_stack_edit.ts | 4 + A00_Common/line_stack_machine_ctl.ts | 33 ++- A00_Common/line_stack_machine_sheet.css | 9 + A00_Common/line_stack_machine_sheet.ts | 22 +- A00_Common/line_stack_machine_sheet_work.ts | 105 ++++++++++ A00_Common/line_stack_machine_work.ts | 216 ++++++++++++++++++++ A00_Common/line_stack_out.ts | 35 +++- A00_Common/line_stack_screen.ts | 2 + A00_Common/line_stack_text.ts | 17 +- A00_Common/line_stack_work.ts | 143 +++++++++++++ M02_MasterTemplete/M02_MyMachine_Sheet.py | 15 ++ common_util/common_util_line_stack_work.ts | 103 ++++++++++ resources/tester/helper_845_2_unit_show.cjs | 15 +- resources/tester/test_845_2_unit_show.py | 10 +- resources/tester/test_846_2_work_view.py | 201 ++++++++++++++++++ 16 files changed, 925 insertions(+), 22 deletions(-) create mode 100644 A00_Common/line_stack_machine_sheet_work.ts create mode 100644 A00_Common/line_stack_machine_work.ts create mode 100644 A00_Common/line_stack_work.ts create mode 100644 common_util/common_util_line_stack_work.ts create mode 100644 resources/tester/test_846_2_work_view.py diff --git a/A00_Common/line_stack.css b/A00_Common/line_stack.css index 332d9af32..5afa6f18e 100644 --- a/A00_Common/line_stack.css +++ b/A00_Common/line_stack.css @@ -530,3 +530,20 @@ .lstk-mctl .lstk-line.k-ctl > :last-child { margin-inline-start: auto; } + +/* 846-2 시간당 작업량 줄 — 방식 작은 글 + 풀이(왼쪽 제어 구역 · 기계 보기) */ +.lstk-work { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4) var(--spacing-8); + min-width: 0; +} + +.lstk-work.packs { + column-gap: var(--spacing-24); +} + +.lstk-out tr.work .lstk-h { + gap: var(--spacing-8); +} diff --git a/A00_Common/line_stack_edit.ts b/A00_Common/line_stack_edit.ts index f988673a6..108c9a349 100644 --- a/A00_Common/line_stack_edit.ts +++ b/A00_Common/line_stack_edit.ts @@ -51,6 +51,7 @@ import { shownUnitOf, type TableLook } from "../common_util/common_util_line_sta import { createUnitPick, NO_UNIT } from "../M02_MasterTemplete/DataFlow/M02_DF_UnitPick"; import { reach } from "./formula_doc_render"; import { attachDrag } from "./line_stack_drag"; +import type { WorkSpec } from "../common_util/common_util_formula_doc_work"; import { openMachineWindow, type MachineCtx } from "./line_stack_machine"; import { buildMachineCtl, machineOfLine } from "./line_stack_machine_ctl"; import { openStackPick, type StackTake } from "./line_stack_pick"; @@ -82,6 +83,8 @@ export interface EditHooks { lists?: (given: Record) => Record | null; /** 845-2 표 한 칸 읽기(캐시) — 「줄 따름」 값의 고른 줄 단위 */ look?: TableLook; + /** 846-2 그 기계 작업량 줄 */ + work?: (machine: string) => WorkSpec[]; } const field = ( @@ -513,6 +516,7 @@ export function buildEdit(h: EditHooks): HTMLElement { const zone = buildMachineCtl( { ...h, calc: h.calc, change: (fn) => h.change((d) => fn(d)), readOnly: !h.machine?.scope }, // prettier-ignore h.doc.변수?.find((x) => varId(x) === own)?.연결, + line.i, ); if (zone) card.append(zone); } diff --git a/A00_Common/line_stack_machine_ctl.ts b/A00_Common/line_stack_machine_ctl.ts index e050d1b72..7a9dcee55 100644 --- a/A00_Common/line_stack_machine_ctl.ts +++ b/A00_Common/line_stack_machine_ctl.ts @@ -4,6 +4,7 @@ * ㉠ 값 칸 = 그 기계 식이 읽는 기계 칸(단위 · 기본 = 사본 값 · 고친 칸 = 「고침」 + [원래 값]) * ㉡ 조종원 = 인력 줄(노임 고르기 · 인/일 · 더하기 · 빼기 · 기본 = 기계 값 조종원 · 직종) * ㉢ 잡품 = 재료 줄(재료 · 유가 고르기 · 수량 · 율 % · 기본 = 밑 재료 줄 · 품셈 잡재료율) + * ㉣ 시간당 작업량(846-2 · 맨 위) = 일당 / 시간당 식 고르기 · 시간당 변수 칸(`line_stack_machine_work`) * 고침 = 문서 칸 `기계덮음`(캐시 · [저장] 때 서버가 정본으로 다시 셈) · 원본과 같으면 지움(`putCover`) * 기본값 = 덮음 없이 셈한 그 기계 식 답 + 기계 줄 인력 · 잡품 목록(`defaultsOf` · `rowEntry` · 842-3) · * 읽기(M01 기초) = 칸 잠김 · 단추 없음 @@ -32,8 +33,11 @@ import { import { subInputs, type StackDoc } from "../common_util/common_util_line_stack"; import type { Picked } from "../common_util/common_util_line_stack_pick"; import { machineUnit } from "../common_util/common_util_line_stack_unit"; +import { workAt, workOfMachine } from "../common_util/common_util_line_stack_work"; +import { sites, type WorkCover, type WorkSpec } from "../common_util/common_util_formula_doc_work"; import { machineCellName } from "../common_util/common_util_machine_symbol_name"; import { openFindPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick"; +import { buildWorkGroup } from "./line_stack_machine_work"; import { openStackPick } from "./line_stack_pick"; import { lt } from "./line_stack_text"; @@ -51,6 +55,8 @@ export interface CtlHooks { lists?: (given: Record) => Record | null; /** 잠긴 값 칸 */ lock?: string[]; + /** 846-2 그 기계 작업량 줄(기계 값 칸 `작업량`) */ + work?: (machine: string) => WorkSpec[]; } const FUELS = ["경유", "휘발유", "실내등유"]; @@ -80,7 +86,7 @@ const num = (t: string): number | null => { }; /** 수 칸 — 빈 글 = 원래대로(null) · 음수 · 글 = 알림 · 단위 = 칸 바로 뒤(공식 묶음 값 칸과 같은 자리 · 845-2 ㉥) */ -function numBox( +export function numBox( label: string, value: unknown, unit: string, @@ -115,8 +121,12 @@ function numBox( const kind = (text: string): HTMLElement => el("span", { className: "lstk-kind", text }); -/** 기계 제어 구역 — `link` = 그 기계 줄 단가 기호 연결 */ -export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElement | null { +/** 기계 제어 구역 — `link` = 그 기계 줄 단가 기호 연결 · `at` = 그 금액 줄 차례(작업량) */ +export function buildMachineCtl( + h: CtlHooks, + link: Link | undefined, + at?: number, +): HTMLElement | null { const got = ctlOf(h, link); if (!got) return null; const { machine, sub, vals, base } = got; @@ -269,10 +279,27 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen ]), ], }); + // ㉣ 시간당 작업량(846-2) + const info = (at === undefined ? null : workAt(h.answer, at)) ?? workOfMachine(h.answer, machine); + const workNow = (entry as MachineCover & { 작업량?: WorkCover }).작업량; + const work = info + ? buildWorkGroup({ + doc: h.doc, + info, + kind: info.꼴 === "시간당" ? (sites(h.doc, h.doc.금액?.[at ?? -1]?.수량)[0]?.꼴 ?? "일당") : info.꼴, // prettier-ignore + entry: workNow, + specs: h.work?.(machine) ?? [], + subDoc: h.subDoc, + vals: (h.answer.값 ?? {}) as Record, + ro, + put: (w) => put({ ...entry, 작업량: w } as MachineCover), + }) + : []; const box = el("div", { className: "lstk-mctl lstk-wide", attrs: { "data-mctl": machine }, children: [ + ...work, ...(values.length ? [el("div", { className: "lstk-fhead", children: [kind(lt("Ctl_Values"))] }), ...values] : []), diff --git a/A00_Common/line_stack_machine_sheet.css b/A00_Common/line_stack_machine_sheet.css index 16f044378..f064a0556 100644 --- a/A00_Common/line_stack_machine_sheet.css +++ b/A00_Common/line_stack_machine_sheet.css @@ -2,3 +2,12 @@ .lstk-msheet-similar .ui-find__list { max-height: 240px; } + +/* 846-2 오른쪽 시간당 작업량 풀이(시간당 경비 아래) */ +.lstk-msheet-work { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + margin-block-start: var(--spacing-16); + overflow-x: auto; +} diff --git a/A00_Common/line_stack_machine_sheet.ts b/A00_Common/line_stack_machine_sheet.ts index 7fc60c0bc..0b4905984 100644 --- a/A00_Common/line_stack_machine_sheet.ts +++ b/A00_Common/line_stack_machine_sheet.ts @@ -6,6 +6,7 @@ * 오른쪽 = 그 기계 시간당 경비 산출근거(`line_stack_out` · 노 · 재 · 경 · 계 합계 상자) — 값을 바꾸면 브라우저 계산기로 즉시 * 서버(`POST /api/m02/machine-sheet` · `M02_MyMachine_Sheet`) = 식 고름 + 처음 줄 값을 박은 식 문서 + 연결 기록 · * 고친 값은 덮음(`기계덮음` 꼴)으로 얹어 셈 · 캐시에 없는 연결 · 식이 바뀔 칸만 뒤에서 다시 물음(옛 요청 끊음) + * 시간당 작업량(846-2) = 왼쪽 접이 카드(작업량 식 변수 = 기계 값) · 오른쪽 풀이(`line_stack_machine_sheet_work`) * 줄 = 저장 꼴(M01 기계 줄 · 내 기계 줄) — 덮음 ↔ 줄은 `common_util_machine_sheet` · 저장은 부르는 쪽(M01 상세 · 내 기계) * ========================================================================== */ @@ -63,6 +64,7 @@ import { import { machineCellName } from "../common_util/common_util_machine_symbol_name"; import { docFoot } from "./formula_doc_foot"; import { buildMachineCtl } from "./line_stack_machine_ctl"; +import { sheetWorkCard, sheetWorkOut, type SheetWorkOpts } from "./line_stack_machine_sheet_work"; import { buildOut } from "./line_stack_out"; import "./line_stack.css"; import "./line_stack_machine_sheet.css"; @@ -158,6 +160,7 @@ export function createMachineSheet(o: MachineSheetOptions) { const warn = el("div"); const ctlBox = el("div"); const baseBox = el("div"); + const workBox = el("div"); const root = el("div", { className: "lstk", attrs: { "data-edit": "1", "data-machine-sheet": no }, @@ -528,8 +531,24 @@ export function createMachineSheet(o: MachineSheetOptions) { redraw: () => drawRight(), }), ); + right.append(sheetWorkOut(work())); + if (cache.takeMissing()) ask(); foot.replaceChildren(docFoot(a)); }; + // 846-2 시간당 작업량 — 지금 줄 `값.작업량`(고침 = 지금 줄 · 저장은 부르는 쪽) + const work = (): SheetWorkOpts => ({ + cur, + orig, + ro, + links: cache.links, + miss: () => undefined, + changed: () => changed(), + }); + const drawWork = (): void => { + const keep = workBox.firstElementChild?.classList.contains("is-collapsed") ?? false; + workBox.replaceChildren(sheetWorkCard(work(), keep)); + if (cache.takeMissing()) ask(); + }; const drawWarn = (): void => { const vals: MachineValues = crewView({ ...engineVals(cur, mine), ...(cur.값 ?? {}) }); if (unitOf("손료계수") !== machineUnit("손료계수")) delete vals.손료계수; // 일 단위 손료(임업) = 시간당 범위 아님 @@ -541,6 +560,7 @@ export function createMachineSheet(o: MachineSheetOptions) { }; const drawLive = (): void => { drawCtl(); + drawWork(); drawRight(); drawWarn(); }; @@ -552,7 +572,7 @@ export function createMachineSheet(o: MachineSheetOptions) { notice.replaceChildren(...(t ? [createText(t, "warning")] : [])); }; - left.append(...similarBox(), notice, warn, ctlBox, baseBox); + left.append(...similarBox(), notice, warn, ctlBox, workBox, baseBox); drawAll(); ask(true); diff --git a/A00_Common/line_stack_machine_sheet_work.ts b/A00_Common/line_stack_machine_sheet_work.ts new file mode 100644 index 000000000..832a85941 --- /dev/null +++ b/A00_Common/line_stack_machine_sheet_work.ts @@ -0,0 +1,105 @@ +/* ============================================================================= + * line_stack_machine_sheet_work.ts + * 기계 보기 시간당 작업량(PLAN 846-2 · 요구 883 「시간당 작업량 = 기계의 능력 → 기계에 둠」) + * 왼쪽 = 「시간당 작업량」 접이 카드 — 기계 줄 `값.작업량` 식마다 이름 + 변수 칸(기본 = 기계 값 › 식 문서 기본 · + * 고친 칸 「고침」 + [원래 값] = 처음 줄 값으로) · 고침 = 지금 줄(저장은 부르는 쪽 — M01 기계 상세 · 내 기계) + * 오른쪽 = 시간당 경비 풀이 아래 「시간당 작업량」 — 그 식 풀이(값 줄 + 결과 식 · `hourlyRows` 산출근거와 같음) + * 식 없는 기계 = 「시간당 작업량 식 없는 기계」 · 작업량 틀 · 표 연결 = 기계 보기 서버(`M02_MyMachine_Sheet._work`) + * ========================================================================== */ + +import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; +import { el } from "@ui/ui_template_elements"; +import type { FormulaDoc } from "../common_util/common_util_formula_doc"; +import { calcDoc, type DocLinks } from "../common_util/common_util_formula_doc_calc"; +import { specsOf, workDefaults, type WorkSpec } from "../common_util/common_util_formula_doc_work"; +import type { MachineRow } from "../common_util/common_util_machine_sheet"; +import { lt } from "./line_stack_text"; +import { workVarRows } from "./line_stack_machine_work"; +import { hourlyRows, workTag } from "./line_stack_work"; + +export interface SheetWorkOpts { + cur: MachineRow; + orig: MachineRow; + ro: boolean; + links: DocLinks; + /** 캐시에 없는 연결 — 뒤에서 서버 */ + miss: () => void; + /** 지금 줄을 고친 뒤 */ + changed: () => void; +} + +const specsOfRow = (row: MachineRow): WorkSpec[] => specsOf(row.값?.작업량); + +const docOf = (o: SheetWorkOpts, key: string): FormulaDoc | null => { + try { + return o.links.doc(key) ?? null; + } catch { + o.miss(); + return null; + } +}; + +const note = (text: string): HTMLElement => el("div", { className: "lstk-empty", text }); + +/** 왼쪽 카드 — `keep` = 접힘 유지 */ +export function sheetWorkCard(o: SheetWorkOpts, keep: boolean): HTMLElement { + const card = createPanelCard(lt("Work_Head"), keep); + card.dataset.machineWork = "1"; + const specs = specsOfRow(o.cur); + if (!specs.length) card.append(note(lt("Work_None"))); + const was = specsOfRow(o.orig); + specs.forEach((spec, i) => { + const tpl = docOf(o, spec.식); + if (!tpl) return void card.append(note(`${spec.식} — ${lt("Work_Wait")}`)); + const first = was.find((s) => s.식 === spec.식) ?? null; + const box = el("div", { + className: "lstk-mctl", + children: [ + el("div", { className: "lstk-fhead", children: [workTag("시간당", spec.식), el("span", { className: "lstk-hint", text: tpl.이름 ?? spec.식 })] }), // prettier-ignore + ...workVarRows({ + tpl, + now: workDefaults(tpl, spec), + base: workDefaults(tpl, first), + ro: o.ro, + set: (k, v) => { + const vars = { ...(spec.변수 ?? {}) }; + const back = first?.변수?.[k]; + if (v !== null) vars[k] = v; + else if (back !== undefined) vars[k] = back; + else delete vars[k]; + const list = specsOfRow(o.cur).map((s, j) => (j === i ? { ...s, 변수: vars } : s)); + if (!Object.keys(vars).length) delete list[i].변수; + (o.cur.값 ??= {}).작업량 = list; + o.changed(); + }, + }), + ], + }); + card.append(box); + }); + attachCollapsible(card); + return card; +} + +/** 오른쪽 풀이 — 식마다 머리 + 값 줄 + 결과 식 */ +export function sheetWorkOut(o: SheetWorkOpts): HTMLElement { + const specs = specsOfRow(o.cur); + const box = el("div", { className: "lstk-msheet-work", attrs: { "data-machine-work-out": "1" } }); + box.append(el("strong", { text: lt("Work_Head") })); + if (!specs.length) return (box.append(note(lt("Work_None"))), box); + for (const spec of specs) { + const tpl = docOf(o, spec.식); + if (!tpl) { + box.append(note(`${spec.식} — ${lt("Work_Wait")}`)); + continue; + } + const a = calcDoc(tpl, o.links, workDefaults(tpl, spec), [], new Map(), {}); + box.append( + el("div", { className: "lstk-work", children: [workTag("시간당", spec.식), el("span", { className: "lstk-hint", text: tpl.이름 ?? spec.식 })] }), // prettier-ignore + ...hourlyRows(tpl, a, (p) => o.links.table(p)).map((r) => + el("div", { className: `lstk-work ${r.cls ?? ""}`.trim(), children: r.h }), + ), + ); + } + return box; +} diff --git a/A00_Common/line_stack_machine_work.ts b/A00_Common/line_stack_machine_work.ts new file mode 100644 index 000000000..91f024f65 --- /dev/null +++ b/A00_Common/line_stack_machine_work.ts @@ -0,0 +1,216 @@ +/* ============================================================================= + * line_stack_machine_work.ts + * 기계 시간당 작업량 묶음(PLAN 846-2 · 요구 881 · 882 · 883) — 왼쪽 기계 제어 구역 · 기계 보기가 같이 씀 + * 제어 구역(`buildWorkGroup`) = 방식 고르기(「품셈 일당」 · 「문서 식」 | 「시간당 식」 — 기계에 맞는 식 없으면 잠김 + 까닭) + * 일당 = 그 풀이 읽기 글(산출근거와 같은 줄) · 시간당 = 식 고르기(여럿일 때) + 그 식 변수 칸(기본 = 기계 값 › + * 식 문서 기본 · 고친 칸 「고침」 + [원래 값]) + 시간당 작업량 값 + * 고침 = `기계덮음.<기계>.작업량 {방식 시간당, 식, 변수?}`(기본과 같은 변수 뺌 · 일당 = 칸 지움) · 읽기 = 잠김 + * 기계 보기(`workVarRows`) = 같은 변수 칸으로 기계 작업량 줄 변수(기계 정본 값)를 고침 + * ========================================================================== */ + +import { createBadge } from "@ui/ui_template_badge"; +import { createButton } from "@ui/ui_template_button"; +import { eqText } from "@ui/ui_template_equation"; +import { el } from "@ui/ui_template_elements"; +import { createSelectField } from "@ui/ui_template_input"; +import { createSegment } from "@ui/ui_template_segment"; +import type { FormulaDoc } from "../common_util/common_util_formula_doc"; +import { isNum } from "../common_util/common_util_formula_doc_calc"; +import { + HOURLY, + workDefaults, + type WorkCover, + type WorkInfo, + type WorkSpec, +} from "../common_util/common_util_formula_doc_work"; +import type { Values } from "../common_util/common_util_formula_doc_view"; +import { varUnit } from "../common_util/common_util_line_stack_unit"; +import { workUnit } from "../common_util/common_util_line_stack_work"; +import { numBox } from "./line_stack_machine_ctl"; +import { lt } from "./line_stack_text"; +import { workRows } from "./line_stack_work"; + +const KINDS = ["입력", "선택", "숫자"]; +const same = (a: unknown, b: unknown): boolean => + typeof a === "number" && typeof b === "number" + ? Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a)) + : String(a ?? "") === String(b ?? ""); + +/** 기본과 같은 변수 뺌 */ +export const pruneVars = ( + vals: Record, + base: Record, +): Record => + Object.fromEntries(Object.entries(vals).filter(([k, v]) => !(k in base && same(v, base[k])))); + +export interface VarRowsOpts { + tpl: FormulaDoc; + /** 지금 값 */ + now: Record; + /** 원래 값(고침 표지 · [원래 값]) */ + base: Record; + ro: boolean; + /** null = 원래대로 */ + set: (key: string, v: number | string | null) => void; +} + +/** 작업량 틀 변수 칸(입력 · 선택) — 이름(품셈 기호는 칸 풍선) = 칸 단위 */ +export function workVarRows(o: VarRowsOpts): HTMLElement[] { + return (o.tpl.변수 ?? []) + .filter((v) => v.열쇠 && KINDS.includes(v.연결?.갈래 ?? "")) + .map((v) => { + const k = v.열쇠!; + const now = k in o.now ? o.now[k] : o.base[k]; + const name = v.이름 || k; + const list = v.연결?.목록; + const box = + v.연결?.갈래 === "선택" && list?.length + ? createSelectField({ + options: list.map((x) => ({ value: String(x), text: String(x) })), + value: String(now ?? ""), + compact: true, + size: 24, + disabled: o.ro, + ariaLabel: name, + onChange: (x) => o.set(k, (list.find((y) => String(y) === x) ?? x) as number | string), // prettier-ignore + }).root + : numBox(name, now, varUnit(v), o.ro, (n) => o.set(k, n)); + const changed = !same(now, o.base[k]); + const sym = [v.기호, v.아래].filter(Boolean).join(" "); + const row = el("div", { + className: "lstk-frow", + attrs: { "data-work-var": k }, + children: [ + el("b", { + className: "lstk-sym", + attrs: { + title: [name, sym, (v as { 설명?: string }).설명].filter(Boolean).join(" — "), + }, + // 품셈 기호(q · f)는 그대로 곁에 + children: [name, ...(v.기호 && v.기호 !== "S" && !v.아래 ? [" ", el("span", { className: "lstk-hint", text: v.기호 })] : [])], // prettier-ignore + }), + "=", + box, + ], + }); + if (changed) { + const tag = createBadge(lt("Ctl_Changed"), "info", { size: 18 }); + tag.title = `${lt("Ctl_Was")} ${o.base[k] === undefined ? "—" : eqText(o.base[k])}`; + row.append(tag); + if (!o.ro) + row.append( + createButton({ label: lt("Ctl_Back"), variant: "ghost", size: 24, iconStart: "undo", onClick: () => o.set(k, null) }), // prettier-ignore + ); + } + return row; + }); +} + +export interface WorkGroupOpts { + doc: FormulaDoc; + /** 지금 셈 답 작업량(그 기계 첫 줄) */ + info: WorkInfo; + /** 덮음 없을 때 꼴(일당 · 식) — 고르기 첫 칸 이름 */ + kind: string; + entry: WorkCover | undefined; + /** 그 기계 작업량 줄(기계 값 칸) */ + specs: WorkSpec[]; + subDoc: (key: string) => FormulaDoc | null; + /** 문서 값(일당 풀이 글) */ + vals: Values; + ro: boolean; + put: (w: WorkCover | undefined) => void; +} + +/** 제어 구역 「시간당 작업량」 묶음 */ +export function buildWorkGroup(o: WorkGroupOpts): HTMLElement[] { + const hourly = o.entry?.방식 === HOURLY; + const formulas = o.info.식들 ?? []; + const first = o.kind === "식" ? "식" : "일당"; + const seg = createSegment({ + options: [ + { value: "일당", label: lt(first === "식" ? "Work_식" : "Work_일당") }, + { + value: HOURLY, + label: lt("Work_시간당"), + disabled: !formulas.length && !hourly, + ...(formulas.length ? {} : { title: lt("Work_None") }), + }, + ], + value: hourly ? HOURLY : "일당", + size: 24, + ariaLabel: lt("Work_Way"), + onChange: (v) => o.put(v === HOURLY ? { 방식: HOURLY, 식: formulas[0] } : undefined), + }); + if (o.ro) seg.root.querySelectorAll("button").forEach((b) => (b.disabled = true)); + seg.root.dataset.workWay = hourly ? HOURLY : first; + const head = el("div", { + className: "lstk-fhead", + children: [ + el("span", { className: "lstk-kind", text: lt("Work_Head") }), + ...(o.entry ? [createBadge(lt("Ctl_Changed"), "info", { size: 18 })] : []), + ...(o.entry && !o.ro + ? [createButton({ label: lt("Ctl_Back"), variant: "ghost", size: 24, iconStart: "undo", onClick: () => o.put(undefined) })] // prettier-ignore + : []), + ], + }); + const note = (text: string, cls = "lstk-empty"): HTMLElement => el("div", { className: cls, text }); // prettier-ignore + const locked = !formulas.length && !hourly ? [note(lt("Work_None"))] : []; // 식 없는 기계 = 잠김 까닭 + const out: HTMLElement[] = [head, el("div", { className: "lstk-work", children: [seg.root, ...locked] })]; // prettier-ignore + if (!hourly) { + const rows = workRows(o.info, { doc: o.doc, vals: o.vals, miss: () => undefined }); + // 방식 작은 글은 고르기가 보임 — 풀이만 + const bare = (r: { h: (string | HTMLElement)[] }) => r.h.filter((x) => typeof x === "string" || !x.dataset.work); // prettier-ignore + out.push(...rows.map((r) => el("div", { className: "lstk-work", children: bare(r) }))); + return out; + } + const pick = o.entry?.식 || o.info.율 || formulas[0] || ""; + if (formulas.length > 1) + out.push( + el("div", { + className: "lstk-frow", + children: [ + el("b", { className: "lstk-sym", text: lt("Work_Pick") }), + "=", + createSelectField({ + options: formulas.map((k) => ({ value: k, text: o.subDoc(k)?.이름 ?? k })), + value: pick, + compact: true, + size: 24, + disabled: o.ro, + ariaLabel: lt("Work_Pick"), + onChange: (k) => o.put({ 방식: HOURLY, 식: k }), + }).root, + ], + }), + ); + if (o.info.까닭) out.push(note(o.info.까닭, "lstk-miss")); + const tpl = pick ? o.subDoc(pick) : null; + if (!tpl) return [...out, note(lt("Work_Wait"))]; + const base = workDefaults(tpl, o.specs.find((s) => s.식 === pick) ?? null); + const mine = o.entry?.변수 ?? {}; + out.push( + el("div", { className: "lstk-work", children: [el("span", { className: "lstk-hint", text: tpl.이름 ?? pick })] }), // prettier-ignore + ...workVarRows({ + tpl, + now: { ...base, ...mine }, + base, + ro: o.ro, + set: (k, v) => { + const next = { ...mine }; + if (v === null) delete next[k]; + else next[k] = v; + const kept = pruneVars(next, base); + o.put({ 방식: HOURLY, 식: pick, ...(Object.keys(kept).length ? { 변수: kept } : {}) }); + }, + }), + el("div", { + className: "lstk-work", + children: [ + el("b", { text: `${lt("Work_Head")} = ` }), + isNum(o.info.값) ? `${eqText(o.info.값)} ${workUnit(o.info.단위)}`.trim() : "—", + ], + }), + ); + return out; +} diff --git a/A00_Common/line_stack_out.ts b/A00_Common/line_stack_out.ts index 9544e7a34..45c87e43a 100644 --- a/A00_Common/line_stack_out.ts +++ b/A00_Common/line_stack_out.ts @@ -6,6 +6,7 @@ * 기계 · 단가산출 줄 = 비목마다 「노무비 : 59,020 × … = 금액」 — 제목 줄(「굴착기 0.6㎥」) 누름 = 그 문서(기계 시간당 경비 등)를 * 같은 계산기로 셈해 같은 꼴로 펼침(손료 식 · 연료 · 운전사 · 노 · 재 · 경 · 계) · 비목 수는 글(링크 꼴 없음 · 요구 687-11) · * 공식 줄 단가산출 결과 = 수 누름(▸) · 접으면 한 줄 · 출력엔 펼친 것만 + * 기계 줄 작업량(846-2) = 「시간당 작업량 = …」 줄 먼저 · 비목 줄 「÷ 70 ㎥/시간」(`line_stack_work`) * 값 = 계산기 셈 답 그대로(`calcDoc` · 줄 `answer.줄` · 합 `노무비` …) — 여기서 다시 셈 안 함 · 조판 = KaTeX(`line_stack_tex.ts`) * ========================================================================== */ @@ -50,9 +51,11 @@ import { unitText, shownUnitOf, } from "../common_util/common_util_line_stack_unit"; +import { workAt } from "../common_util/common_util_line_stack_work"; import { partName } from "../common_util/common_util_machine_symbol_name"; import { katexNode, symTex, tex, txt, valTex, type TexCtx } from "./line_stack_tex"; import { lt } from "./line_stack_text"; +import { workQty, workRows } from "./line_stack_work"; type Cols = Partial>; interface Row { @@ -318,18 +321,30 @@ function rows( // 값 · 단위 한 덩이(좁은 폭에서 줄이 바뀌어도 단위가 떨어지지 않게) const glue = (...xs: (string | HTMLElement)[]): HTMLElement => el("span", { className: "lstk-pack", children: xs.filter((x) => x !== "") }); + // 846-2 작업량 — 「시간당 작업량」 줄 먼저 · 수량 = 「÷ 시간당 작업량」 + const w = workAt(a, i); + const wq = (): HTMLElement[] | null => workQty(doc, qty, w, scope, alias); // 줄마다 새 요소 + const byWork = wq() !== null; + const workHead = (): void => { + if (!w) return; + const got = workRows(w, { doc, vals: scope, links: h.cache.links, miss: () => undefined, look }); // prettier-ignore + if (h.cache.takeMissing()) h.miss(); + for (const r of got) push(r); + }; const qtyNode = (cost?: string): (string | HTMLElement)[] => qty === null ? [] - : [ - glue( - " × ", - mu.wrap && qn - ? katexNode(`\\left(${tex(qn, { side: "val", vals: scope, inline: true, cost, alias })}\\right)`) // prettier-ignore - : exprNode(qty, "val", scope, true, cost, { alias }), - mu.qty ? ` ${mu.qty}` : "", - ), - ]; + : byWork + ? [glue(" ", ...wq()!)] + : [ + glue( + " × ", + mu.wrap && qn + ? katexNode(`\\left(${tex(qn, { side: "val", vals: scope, inline: true, cost, alias })}\\right)`) // prettier-ignore + : exprNode(qty, "val", scope, true, cost, { alias }), + mu.qty ? ` ${mu.qty}` : "", + ), + ]; const priceUnit = mu.price ? ` ${mu.price}` : ""; if (r.까닭 && r.금액 === null) { push({ h: [`${name} : — `, el("span", { className: "lstk-miss", text: r.까닭 })] }); @@ -341,6 +356,7 @@ function rows( const cols: Cols = {}; const title = name || (s ? (subOf(s)?.이름 ?? s) : ""); push({ h: s && subOf(s) ? fold(s, title) : [title], cls: "lname" }); + workHead(); for (const c of COSTS) { const part = r.비목?.[c]; if (!isNum(part) || (!part && !bundle[c])) continue; @@ -364,6 +380,7 @@ function rows( : [glue(exprNode(line.단가, "val", scope, true, undefined, { alias }), priceUnit)]; const c = String(line.비목); const bare = !!line.합밖 && !line.비목; // 합 밖 소계(810-2) = 글에만 금액 · 비목 칸 빈칸 + workHead(); push({ h: [`${name} : `, ...price, ...qtyNode(), ` = ${one(amt)} ${AMOUNT_UNIT}`], cols: bare ? {} : { sum: amt, [c]: amt }, diff --git a/A00_Common/line_stack_screen.ts b/A00_Common/line_stack_screen.ts index c54f1fdb3..3185cda49 100644 --- a/A00_Common/line_stack_screen.ts +++ b/A00_Common/line_stack_screen.ts @@ -56,6 +56,7 @@ import { askRenew } from "./formula_doc_renew"; import { buildOptions } from "./formula_doc_options"; import { ft } from "./formula_doc_text"; import { addTaken, type Taken } from "../common_util/common_util_line_stack_copy"; +import { machineWork } from "../common_util/common_util_line_stack_work"; import type { Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import { buildEdit } from "./line_stack_edit"; import { openMachineWindow, type CopyAnswer, type MachineCtx } from "./line_stack_machine"; @@ -378,6 +379,7 @@ export function createStackScreen(opt: StackScreenOptions) { calc: calcSub, lists: (g) => listsOf((q) => cache.links.element(q), g), look: (p) => cache.links.table(p), + work: (m) => machineWork((q) => cache.links.element(q), m), before: saved ? lineMap(JSON.parse(saved) as StackDoc) : undefined, }), ] diff --git a/A00_Common/line_stack_text.ts b/A00_Common/line_stack_text.ts index aa76b2d43..1677e0fd8 100644 --- a/A00_Common/line_stack_text.ts +++ b/A00_Common/line_stack_text.ts @@ -90,7 +90,10 @@ const TEXT = { "이 값의 줄은 위 옵션이 고름(다른 줄은 옵션에서 바꿈 · 여기선 칸만)", "Row follows the option above (change it there; pick a column here)", ], - Ctl_Open: ["기계 값 · 조종원 · 잡품 고치기", "Edit machine values · operator · sundries"], + Ctl_Open: [ + "기계 값 · 조종원 · 잡품 · 작업량 고치기", + "Edit machine values · operator · sundries · output", + ], Ctl_Values: ["기계 값", "Machine values"], Ctl_Crew: ["조종원", "Operator"], Ctl_Misc: ["잡품", "Sundries"], @@ -104,6 +107,18 @@ const TEXT = { Ctl_Rate: ["율", "Rate"], Ctl_Follow: ["(따름)", "(follows)"], Ctl_None: ["없음", "None"], + Work_Head: ["시간당 작업량", "Output per hour"], + Work_일당: ["품셈 일당", "Daily output"], + Work_식: ["문서 식", "Doc formula"], + Work_시간당: ["시간당 식", "Hourly formula"], + Work_Way: ["작업량 방식", "Output method"], + Work_Pick: ["작업량 식", "Output formula"], + Work_Day: ["시공량", "Daily output"], + Work_Unit: ["대", "unit"], + Work_Hours: ["시간", "h"], + Work_None: ["시간당 작업량 식 없는 기계", "No hourly output formula for this machine"], + Work_Wait: ["작업량 식 불러오는 중", "Loading output formula"], + Work_Vars: ["작업량 변수", "Output variables"], Win_Ctl_Note: [ "기계 내부 값(가격 · 연료 · 조종원 · 잡품)은 단가산출 왼쪽 기계 줄의 ▸ 에서 고침", "Edit machine internals from the ▸ on the left machine line", diff --git a/A00_Common/line_stack_work.ts b/A00_Common/line_stack_work.ts new file mode 100644 index 000000000..179fd06c3 --- /dev/null +++ b/A00_Common/line_stack_work.ts @@ -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, +): 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)?.이름 ?? ""; diff --git a/M02_MasterTemplete/M02_MyMachine_Sheet.py b/M02_MasterTemplete/M02_MyMachine_Sheet.py index 0d9367dd5..d9dafc9f7 100644 --- a/M02_MasterTemplete/M02_MyMachine_Sheet.py +++ b/M02_MasterTemplete/M02_MyMachine_Sheet.py @@ -9,6 +9,7 @@ - 바탕 = 처음 줄 인력 · 잡품 목록(842-3 · `master_doc_cover.row_entry`) — 덮음(지금 줄 목록)이 그 위에 · 값 박음은 목록 → 조종원 수 · 직종 · 잡재료율(`master_machine_crew.view`) - 직종 = 노임 요소 코드 → 인력 원문번호(인력 줄 키 `LB:원문번호`) +- 작업량(846-2) = 지금 줄 `값.작업량` 식마다 작업량 틀 문서 · 표를 연결에 더함(오른쪽 풀이) """ from __future__ import annotations @@ -59,6 +60,19 @@ def _jobs() -> dict[str, str]: return {str(r["키"]): str(r.get("원문번호")) for r in rows if r.get("키")} +def _work(cur: dict, rec, mc) -> None: + """기계 작업량 줄 → 작업량 틀 문서 · 표 연결(못 읽는 식 = 건너뜀 · 화면이 「불러오는 중」).""" + for spec in (cur.get("값") or {}).get("작업량") or []: + key = str(spec.get("식") or "") if isinstance(spec, dict) else "" + try: + tpl = rec.doc(key) if key else None + if tpl: + rec.prefetch(tpl) + mc.calc(tpl, rec, dict(spec.get("변수") or {})) + except Exception: # noqa: BLE001 — 그림 연결만(셈 정본 아님) + continue + + def sheet(row: Any, base: Any = None, cover: Any = None, mine: bool = False) -> dict: import master_doc_calc as mc import master_doc_cover as mcv @@ -91,6 +105,7 @@ def sheet(row: Any, base: Any = None, cover: Any = None, mine: bool = False) -> rec = mr.Rec(links) rec.prefetch(shaped) mc.calc(shaped, rec, given) + _work(cur, rec, mc) got = {"키": logic["키"], "이름": logic.get("이름"), "까닭": mh.why(cur, logic), "문서": doc, "값": vals, "연결": rec.out()} # fmt: skip return json.loads(json.dumps(out | got, ensure_ascii=False, default=_plain)) diff --git a/common_util/common_util_line_stack_work.ts b/common_util/common_util_line_stack_work.ts new file mode 100644 index 000000000..3236a699a --- /dev/null +++ b/common_util/common_util_line_stack_work.ts @@ -0,0 +1,103 @@ +/* ============================================================================= + * common_util_line_stack_work.ts + * 시간당 작업량 그리기 자료(PLAN 846-2 · 요구 881 · 882 · 883) — 산출근거 · 기계 제어 구역 · 기계 보기가 같이 씀 + * 셈 답 기계 줄 `줄[i].작업량`(846-1 계산기 `common_util_formula_doc_work`) → 그림 자료 + * ㉠ 일당 풀이 = 율 식 「D ÷ (8 × 대수…)」 → 시공량 D · 나눔 항(8 = 시간 · 그 밖 = 대수) + * ㉡ 비목 줄 = 수량 식의 작업량 자리를 바꾼 글(`swap` — 「나머지 ÷ Q_NEW」) → 나머지(1 이면 없음) · + * 꼴이 다르면 null(지금 꼴 그대로) + * ㉢ 단위 — 작업량 「㎥/hr」 → 「㎥/시간」 · 시공량 = 「㎥/일」 + * ㉣ 시간당 식 풀이 = 작업량 틀 결과 식이 읽는 기호(q · K · f · E · cm — 품셈 기호 그대로) + * 그리기만 — 식 · 값 · 금액 불변 · 화면 없음(시험이 Node 로 돌림) + * ========================================================================== */ + +import { names, type FormulaDoc, type Node } from "./common_util_formula_doc"; +import type { DocAnswer } from "./common_util_formula_doc_calc"; +import { + Q_NEW, + specsOf, + swap, + WORK, + type WorkInfo, + type WorkSpec, +} from "./common_util_formula_doc_work"; +import { nodeOf } from "./common_util_line_stack"; +import { unitText } from "./common_util_line_stack_unit"; + +/** 셈 답 i 번째 금액 줄의 작업량(없으면 null) */ +export function workAt(a: DocAnswer | null | undefined, i: number): WorkInfo | null { + const got = (a?.줄?.[i] as Record | undefined)?.[WORK]; + return got && typeof got === "object" ? (got as WorkInfo) : null; +} + +/** 그 기계의 첫 작업량 줄(같은 기계 두 줄 = 덮음 하나) */ +export function workOfMachine(a: DocAnswer | null | undefined, machine: string): WorkInfo | null { + for (let i = 0; i < (a?.줄?.length ?? 0); i++) { + const w = workAt(a, i); + if (w && w.기계 === machine) return w; + } + return null; +} + +/** 기계 작업량 줄(기계 값 칸 `작업량` — 캐시 `links.element`) · 없음 · 못 읽음 = 빈 목록 */ +export function machineWork( + element: (p: Record) => unknown, + machine: string, +): WorkSpec[] { + try { + return specsOf(element({ 키: `EQ:${machine}`, 칸: WORK })); + } catch { + return []; + } +} + +/** 작업량 단위 → 화면(「/hr」 = 「/시간」) */ +export const workUnit = (u: unknown): string => + unitText(u).replace(/\/\s*(hr|h|Hr|HR|시간)$/, "/시간"); + +/** 시공량(하루) 단위 — 작업량 단위 분자 + 「/일」 */ +export const dayUnit = (u: unknown): string => { + const top = workUnit(u).replace(/\/시간$/, ""); + return top ? `${top}/일` : ""; +}; + +const factors = (n: Node): Node[] => + n[0] === "bin" && n[1] === "*" ? [...factors(n[2]), ...factors(n[3])] : [n]; + +export interface DailyParts { + /** 하루 시공량 식 */ + day: Node; + /** 나눔 항 — 8 = 시간 · 그 밖 = 대수 */ + per: { node: Node; hours: boolean }[]; +} + +/** ㉠ 일당 율 식 「D ÷ (8 × …)」 → 시공량 · 나눔 항 · 꼴이 다르면 null */ +export function dailyParts(rate: unknown): DailyParts | null { + const n = nodeOf(rate); + if (!n || n[0] !== "bin" || n[1] !== "/") return null; + const per = factors(n[3]).map((x) => ({ node: x, hours: x[0] === "num" && x[1] === 8 })); + if (!per.some((p) => p.hours)) return null; + // 대수 먼저 · 8 시간 끝(「1 대 × 8 시간」 — 경쟁사 「대수 × 8hr」 꼴) + return { day: n[2], per: [...per.filter((p) => !p.hours), ...per.filter((p) => p.hours)] }; +} + +/** ㉡ 수량 식 → 「나머지 ÷ Q」 의 나머지(없으면 rest null) · 꼴이 다르면 null */ +export function restOfQty(doc: FormulaDoc, qty: unknown): { rest: Node | null } | null { + let text: string; + try { + text = swap(doc, qty); + } catch { + return null; + } + const n = nodeOf(text); + if (!n || n[0] !== "bin" || n[1] !== "/" || n[3][0] !== "var" || n[3][1] !== Q_NEW) return null; + const rest = n[2]; + return { rest: rest[0] === "num" && rest[1] === 1 ? null : rest }; +} + +/** ㉣ 작업량 틀 결과 식 · 그 식이 읽는 기호(차례 그대로 · 겹침 뺌) */ +export function hourlyParts(tpl: FormulaDoc): { id: string; node: Node; uses: string[] } | null { + const id = String(tpl.결과 ?? ""); + const line = (tpl.식 ?? []).find((l) => String(l.기호 ?? "") === id); + const node = line ? nodeOf(line.식) : null; + return node ? { id, node, uses: [...new Set(names(node))] } : null; +} diff --git a/resources/tester/helper_845_2_unit_show.cjs b/resources/tester/helper_845_2_unit_show.cjs index 9754c146d..2b957b4e8 100644 --- a/resources/tester/helper_845_2_unit_show.cjs +++ b/resources/tester/helper_845_2_unit_show.cjs @@ -41,6 +41,9 @@ class FakeNode { append(...xs) { for (const x of xs) { if (x === null || x === undefined) continue; + // 진짜 DOM 처럼 — 이미 다른 곳에 붙은 요소는 옮김(같은 요소를 두 줄에 쓰면 앞 줄에서 빠짐) + if (x instanceof FakeNode && x.parentNode) x.parentNode.childNodes = x.parentNode.childNodes.filter((c) => c !== x); + if (x instanceof FakeNode) x.parentNode = this; this.childNodes.push(x instanceof FakeNode ? x : new FakeText(String(x))); } } @@ -283,7 +286,7 @@ function left(c) { const rows = []; const walk = (n) => { for (const ch of n.children) { - if (ch.cls().includes("lstk-frow")) rows.push({ sym: ch.dataset.sym ?? ch.dataset.mctlField ?? "", t: textOf(ch) }); + if (ch.cls().includes("lstk-frow") || ch.cls().includes("lstk-work")) rows.push({ sym: ch.dataset.sym ?? ch.dataset.mctlField ?? "", t: textOf(ch) }); walk(ch); } }; @@ -291,6 +294,14 @@ function left(c) { return [...opts.buildOptions(doc, {}, true, noop).map((x) => ({ sym: `opt:${x.dataset.option}`, t: textOf(x) })), ...rows]; } +/** 셈 답 요약(846-2 — 계 · 줄마다 작업량 · 금액) */ +function sums(c) { + const cache = cacheMod.createDocCache(); + cache.merge(c.연결 || {}); + const a = calc.calcDoc(c.문서, cache.links, {}); + return { 계: a.계 ?? null, 줄: (a.줄 || []).map((r) => ({ 금액: r.금액 ?? null, 작업량: r.작업량 ?? null })) }; +} + /** 전수 지킴 — 줄 글에서 규칙 어긋남만 */ const MONEY = /^(.+?) : (?!—)(.*\S)$/; function guard(c, machine) { @@ -319,7 +330,7 @@ let res; if (input.할일 === "전수") { res = input.문서들.map((c) => { const g = guard(c, machineNames); - return { 키: c.키, ...g, ...(show.has(c.키) ? { 오른쪽: right(c), 왼쪽: left(c) } : {}) }; + return { 키: c.키, ...g, ...(show.has(c.키) ? { 오른쪽: right(c), 왼쪽: left(c), 셈: sums(c) } : {}) }; }); } else if (input.할일 === "단위") { res = { diff --git a/resources/tester/test_845_2_unit_show.py b/resources/tester/test_845_2_unit_show.py index c542054d1..0d641f48a 100644 --- a/resources/tester/test_845_2_unit_show.py +++ b/resources/tester/test_845_2_unit_show.py @@ -4,7 +4,7 @@ ① 전수 1,520 — 산출근거(펼침 모두)를 진짜 코드로 차린 줄 글: 금액 · 소계 · 합계 = 「원」 · 수량이 식이면 괄호로 식 전체에 단위(「÷ 560 시간」 꼴 없음) · 메타 단위(「… 따름」 · 「수(…)」) 글 0 · 기계경비식(맨 위 · 펼침) 분수 한 층 · 문서 자료 안 바뀜 -② GC000088 — 사용자 그림 자리: 「59,020 원/시간 × (1 × 8 ÷ 560) 시간 = 843.1 원」 · 펼친 굴착기 = 상여 · 휴지 · 손료계수 중간 줄 +② GC000088 — 사용자 그림 자리: 「59,020 원/시간 ÷ 70 ㎥/시간 = 843.1 원」(846-2) · 펼친 굴착기 = 상여 · 휴지 · 손료계수 중간 줄 ③ 왼쪽 — 기계 제어 구역 값 칸 단위 = 오른쪽 펼침과 같은 글(천원 · 10⁻⁷/시간 · ℓ/hr) · 단위는 칸 밖 바로 뒤 ④ 단위 규칙 낱개 — 메타 · 설명 괄호 · 10⁻⁷ · 기계 칸 연결 · 금액 줄 자리 """ @@ -71,11 +71,9 @@ def test_전수_규칙_어긋남_0(world): def test_GC000088_비목_줄_단위_자리(world): rows = [r["t"] for r in world["GC000088"]["오른쪽"]] - labor = next(t for t in rows if t.startswith("노무비 : ") and "×" in t) - assert labor.startswith( - "노무비 : 59,020 원/시간 × ⟦\\left(1 \\times 8 \\div 560\\right)⟧ 시간 = " - ) - assert labor.endswith(" 원") + # 846-2 기계 줄 = 「÷ 시간당 작업량」(시간 수량 식 「× (1 × 8 ÷ 560) 시간」 대신) + labor = next(t for t in rows if t.startswith("노무비 : 59,020")) + assert labor == "노무비 : 59,020 원/시간 ⟦{}\\div 70\\ \\text{㎥/시간}⟧ = 843.1 원" assert any(t.startswith("소 계 : ") and t.endswith(" 원") for t in rows) assert any(t.startswith("계 : ") and t.endswith(" 원") for t in rows) # 펼친 굴착기 시간당 경비 — 겹분수 대신 중간 줄 diff --git a/resources/tester/test_846_2_work_view.py b/resources/tester/test_846_2_work_view.py new file mode 100644 index 000000000..d6a24ec7d --- /dev/null +++ b/resources/tester/test_846_2_work_view.py @@ -0,0 +1,201 @@ +# -*- coding: utf-8 -*- +"""PLAN 846-2(요구 879 · 881 · 882 · 883) — 기계 줄 시간당 작업량 그리기 · 제어 구역 · 기계 보기. + +① 일당(GC000088) — 「시간당 작업량 = 시공량 560 ㎥/일 ÷ (1 대 × 8 시간) = 70 ㎥/시간」 줄 먼저 · + 비목 줄 「59,020 원/시간 ÷ 70 ㎥/시간 = 843.1 원」 · 화면 셈 = 서버 셈(금액 그대로) +② 시간당 식 덮음 — 작업량 틀 풀이(q · K · f · E · cm 값 줄 + 한 층 분수 Q 식) · 비목 ÷ Q · + 변수 E 고침 → Q 그만큼 · 화면 셈 = 서버 셈 +③ 왼쪽 제어 구역 — 방식 고르기 · 시간당 = 그 식 변수 칸 · 식 없는 기계 = 잠김 글 +④ 전수 — 기계 작업량 식이 있는 문서를 모두 시간당 식으로 바꿔 그려도 안 멈춤 · + 겹분수 · 단위 규칙 어긋남 0 · 화면 셈 = 서버 셈 +⑤ 기계 보기 서버 — 작업량 틀 문서 · 표 연결을 실음 +""" + +from __future__ import annotations + +import copy +import importlib +import json +import os +import subprocess +from decimal import Decimal + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 # noqa: F401 (scripts 길) +from M02_MasterTemplete import M02_Doc_Copy as dc + +mr = importlib.import_module("master_doc_record") +HERE = os.path.dirname(os.path.abspath(__file__)) +WORK = "작업량" + + +def _js(payload: dict): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_845_2_unit_show.cjs")], + input=json.dumps(dc.ml._norm(payload), ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + ) + assert proc.returncode == 0, proc.stderr[-3000:] + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module") +def base(): + docs = dc.folder() + return docs, dc.ml.Links(dc.skc.whole_(), docs) + + +def _case(base, key: str, doc: dict): + rec = mr.Rec(base[1]) + rec.prefetch(doc) + ans = dc.mc.calc(doc, rec) + return {"키": key, "문서": doc, "연결": rec.out(), "기계": False}, ans + + +def _hourly(doc: dict, machine: str, key: str, vals: dict | None = None) -> dict: + out = copy.deepcopy(doc) + w = {"방식": "시간당", "식": key, **({"변수": vals} if vals else {})} + out["기계덮음"] = {machine: {WORK: w}} + return out + + +def _num(v): + return None if v is None else float(Decimal(str(v))) + + +@pytest.fixture(scope="module") +def gc88(base): + d = base[0]["GC000088"] + cases, py = [], {} + for key, doc in [ + ("일당", d), + ("시간당", _hourly(d, "0201-0100", "GC001147")), + ("E", _hourly(d, "0201-0100", "GC001147", {"E입력": 0.8})), + ]: + c, a = _case(base, key, doc) + cases.append(c) + py[key] = a + got = _js({"할일": "전수", "문서들": cases, "보기": [c["키"] for c in cases]}) + return {g["키"]: g for g in got}, py + + +def test_일당_시간당_작업량_줄_먼저_비목_나눔(gc88): + g, py = gc88 + rows = [r["t"] for r in g["일당"]["오른쪽"]] + work = ( + r"품셈 일당⟦\text{시간당 작업량}⟧ = ⟦\text{시공량}\ 560\ \text{㎥/일} \div " + r"\left(1\ \text{대} \times 8\ \text{시간}\right)⟧ = ⟦70\ \text{㎥/시간}⟧" + ) + assert work in rows + labor = "노무비 : 59,020 원/시간 ⟦{}\\div 70\\ \\text{㎥/시간}⟧ = 843.1 원" + assert labor in rows + assert rows.index(work) < rows.index(labor) # 작업량 줄 먼저 + assert g["일당"]["bad"] == [] + assert _num(py["일당"]["계"]) == g["일당"]["셈"]["계"] == 1893 # 금액 그대로 · 화면 = 서버 + + +def test_시간당_식_풀이_비목_나눔_금액(gc88): + g, py = gc88 + rows = [r["t"] for r in g["시간당"]["오른쪽"]] + text = "\n".join(rows) + assert ( + "⟦q = 1\\ \\text{㎥}⟧⟦K = 0.9⟧⟦f = 0.7⟧⟦E = 0.75⟧⟦\\mathit{cm} = 21\\ \\text{sec}⟧" in text + ) + assert ( + r"⟦Q⟧ = ⟦\dfrac{3{,}600 \times q \times K \times f \times E}{\mathit{cm}}⟧ = " + r"⟦\dfrac{3{,}600 \times 1 \times 0.9 \times 0.7 \times 0.75}{21}⟧ = ⟦81\ \text{㎥/시간}⟧" + ) in text + assert "노무비 : 59,020 원/시간 ⟦{}\\div 81\\ \\text{㎥/시간}⟧ = 728.6 원" in rows + assert any(t.startswith("시간당 식") for t in rows) # 머리 작은 글 = 방식 + assert g["시간당"]["bad"] == [] + assert _num(py["시간당"]["계"]) == g["시간당"]["셈"]["계"] != g["일당"]["셈"]["계"] + + +def test_시간당_변수_E_고침_그만큼(gc88): + g, py = gc88 + text = "\n".join(r["t"] for r in g["E"]["오른쪽"]) + assert "⟦E = 0.8⟧" in text and r"= ⟦86.4\ \text{㎥/시간}⟧" in text # 81 × 0.8 / 0.75 + assert "노무비 : 59,020 원/시간 ⟦{}\\div 86.4\\ \\text{㎥/시간}⟧ = 683.1 원" in text + assert _num(py["E"]["계"]) == g["E"]["셈"]["계"] + w = next(r["작업량"] for r in g["E"]["셈"]["줄"] if r["작업량"]) + assert w["꼴"] == "시간당" and w["값"] == 86.4 and w["변수"]["E입력"] == 0.8 + + +def test_왼쪽_제어_구역_방식_변수_칸(gc88): + g, _ = gc88 + day = [r["t"] for r in g["일당"]["왼쪽"]] + hour = [r["t"] for r in g["E"]["왼쪽"]] + assert "품셈 일당시간당 식" in [t.strip() for t in day] # 고르기 두 칸 + assert not any(t.strip().startswith("버킷용량") for t in day) # 일당 = 변수 칸 없음 + assert "버킷용량 q=[1]㎥" in [t.strip() for t in hour] + assert "체적환산계수 f=[0.7]" in [t.strip() for t in hour] + e = next(t.strip() for t in hour if t.strip().startswith("E입력")) + assert e.startswith("E입력=[0.8]고침") # 고친 칸 표지 + + +@pytest.fixture(scope="module") +def every(base): + """작업량 자리가 있는 문서 전수 — 일당 그대로 + 기계 식이 있으면 시간당 식으로.""" + docs = base[0] + plain, hourly, kinds, nofit = [], [], {}, [] + for key, doc in sorted(docs.items()): + c, a = _case(base, key, doc) + works = [r[WORK] for r in a.get("줄") or [] if isinstance(r, dict) and r.get(WORK)] + if not works: + continue + plain.append(c) + for w in works: + kinds.setdefault(w["꼴"], set()).add(key) + cover = { + w["기계"]: {WORK: {"방식": "시간당", "식": w["식들"][0]}} for w in works if w["식들"] + } + if not cover: + nofit.append(key) + continue + h = copy.deepcopy(doc) + h["기계덮음"] = cover + hc, ha = _case(base, f"{key}~시간당", h) + hc["py계"] = _num(ha.get("계")) + hourly.append(hc) + return plain, hourly, kinds, nofit + + +def test_전수_일당_시간당_차림_어긋남_0(every): + plain, hourly, kinds, nofit = every + assert len(kinds.get("일당", ())) >= 200 and len(kinds.get("식", ())) >= 40 + want = {c["키"]: c.get("py계") for c in hourly} + got = _js({"할일": "전수", "문서들": plain + hourly, "보기": list(want)}) + assert len(got) == len(plain) + len(hourly) + bad = {g["키"]: g["bad"][:2] for g in got if g["bad"]} + assert bad == {} + assert all(g["같음"] for g in got) + off = { + g["키"]: (want[g["키"]], g["셈"]["계"]) + for g in got + if g["키"] in want and want[g["키"]] != g["셈"]["계"] + } + assert off == {} # 시간당 식 화면 셈 = 서버 셈 + + +def test_식_없는_기계_잠김_글(base, every): + _, _, _, nofit = every + assert nofit + c, _ = _case(base, nofit[0], base[0][nofit[0]]) + g = _js({"할일": "전수", "문서들": [c], "보기": [c["키"]]})[0] + assert any("시간당 작업량 식 없는 기계" in r["t"] for r in g["왼쪽"]) + + +def test_기계_보기_서버_작업량_연결(): + from M02_MasterTemplete import M02_MyMachine_Sheet as sheet + + folder = os.path.join(HERE, "..", "master_data", "기계") + rows = (json.load(open(os.path.join(folder, f), encoding="utf-8")) for f in os.listdir(folder)) + row = next(r for r in rows if r.get("원문번호") == "0201-0100") + got = sheet.sheet(row) + keys = [s["식"] for s in row["값"][WORK]] + assert "GC001147" in keys + assert all(k in got["연결"]["문서"] for k in keys) + assert got["연결"]["표"] # 작업량 틀이 읽는 표(버킷계수 · 작업효율 · 싸이클)