diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index f52e17869..786470ddb 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -8,7 +8,7 @@ [단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 POST /api/projects/{id}/estimation/logic-preview {code, given, rate, picks} 「운반 단가」 모달 계산 절차(253-3) — 그 로직 한 산근을 프로젝트 단가로 풀어 글 줄 · 단가 · - 저장 안 함(`render_sangeun_text` 를 `Sangeun.Solver` 로) + 저장 안 함(`render_sangeun_text` 를 `Sangeun.Solver` 로) · `blocks` = 칸 나눔(277-2) POST /api/projects/{id}/estimation/library {kind, layer, owner, key} 설계내역서 연결 고르기(204-2) — 서고 구조물조합 · 사전조합을 프로젝트 사본으로 (있으면 그대로) → {폴더, 만듦, key = 대가 블록 id, payload} · @@ -236,7 +236,8 @@ def logic_preview(root: Path, body: PreviewBody) -> dict[str, Any]: keys = sangeun.hand_resource_keys({"haul_refs": {"preview": {"tab": "logic", **src}}}) user = store.read_user(root) base = compute_base(prices.build_inputs(store.read_book(root), keys), user) - block = sangeun.Solver(base).add(sangeun.hand_whole(hands), src, None) + solver = sangeun.Solver(base) + block = solver.add(sangeun.hand_whole(hands), src, None) text = [ *block["head"], *(t for ln in block["lines"] for t in ln.get("text") or []), @@ -249,6 +250,22 @@ def logic_preview(root: Path, body: PreviewBody) -> dict[str, Any]: "price": block["sum"], "parts": {p: block[p] for p in sangeun.PARTS}, "why": block["why"], + # 277-2 칸 나눔 — 맨 위 + 하위 산근 블록(차례) · 셈 그대로 · 화면이 머리 · 줄 · 계를 칸으로 + "blocks": [_block_shown(b) for b in sorted(solver.blocks.values(), key=lambda b: b["no"])], + } + + +SHOWN_LINE = ("name", "spec", "qty", "unit", "kind", *sangeun.PARTS, "why", "text") + + +def _block_shown(block: dict[str, Any]) -> dict[str, Any]: + """미리보기 칸 하나 — Solver 블록에서 화면이 쓰는 것만(머리 · 호표 줄 · 계 · 출처).""" + return { + **{k: block.get(k) for k in ("no", "logic", "name", "unit", "source", "head", "foot")}, + "price": block.get("sum"), + "parts": {p: block.get(p) for p in sangeun.PARTS}, + "why": block.get("why"), + "lines": [{k: ln.get(k) for k in SHOWN_LINE} for ln in block.get("lines") or []], } diff --git a/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts b/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts index a1f6f97bb..650dd7d32 100644 --- a/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts +++ b/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts @@ -4,10 +4,11 @@ * 위 = 고른 항목 이름 · [변경](단가연결 한 목록 `openPriceLink` — 단가산출 · 사전조합 · 구조물 조합 · 호표) · * 왼 = 고른 항목이 요구하는 입력만(단가산출 = 로직 입력 `fetchLogicInputs` · 호표 = 입력 없음 · * 사전조합 · 구조물 조합 = 담은 로직 이름별 입력 `fetchLibInputs` · 263-2) · - * 오른 = 계산 절차 — 거리 줄 · 사이클 식 줄(TS 짝 `B08_Quantity_Haul_Cycle` · 8-2-8 기본값 · 거리 칸 있을 때만) + - * 로직 산출 줄(서버 `POST …/estimation/logic-preview` · 값 바꾸면 다시 · 최신 응답만). - * 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 거리에서 채움(「거리에서」) · 손으로 고치면 덮음(`own`) · - * 빈칸으로 지우면 다시 거리에서. 옛 사이클 손 값(`cycle`) = 칸 없음 · [적용]하면 지움(기본값). + * 오른 = 계산 칸 차례(277 · `B09_Estimation_UI_Haul_Steps`) — ① 거리(거리 칸 받을 때) · + * ② 사이클 8-2-8(Cm · 사이클시간 · t2 받을 때만) · ③ 단가산출 블록(서버 `POST …/estimation/logic-preview` + * `blocks` · 하위 산근 이어 붙임 · 조합 = 담은 로직 줄마다 · 값 바꾸면 다시 · 최신 응답만 · 셈 중 = 옛 칸 흐리게). + * 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 거리에서 채움(「자동계산」 켜짐) · 손으로 고치면 덮음(`own` · 꺼짐) · + * 단추 누름 · 빈칸으로 지우면 다시 거리에서. 옛 사이클 손 값(`cycle`) = 칸 없음 · [적용]하면 지움(기본값). * 조합 [적용] = 거리 · 손 값 사본(서버 · 같은 값 = 같은 사본) · 연결 {tab dg, key, lib, given, own}. * [적용] = 부르는 쪽 `apply`(캐시 → [저장] 때 서버 정본) · 크기 = 큰 창보다 작게(276). * 여는 곳 = B08 거리이정표 표 우클릭 · B09 설계내역서 운반공 줄 단가연결 — 연결 있는 줄만(276 · @@ -18,10 +19,8 @@ import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { - CYCLE_DEFAULTS, CYCLE_FILL, cycleInputs, - cycleTime, fillGiven, type Dist, } from "../B08_Quantity/B08_Quantity_Haul_Cycle"; @@ -39,6 +38,13 @@ import { type Picked, type PriceLinkOpts, } from "./B09_Estimation_UI_Tabs_Bill_Link"; +import { + cycleBox, + distBox, + logicBox, + noteBox, + type StepBlock, +} from "./B09_Estimation_UI_Haul_Steps"; export interface HaulModalOpts extends PriceLinkOpts { /** 지금 연결(없으면 null) */ @@ -51,32 +57,16 @@ export interface HaulModalOpts extends PriceLinkOpts { /** 옛 사이클 손 칸(253-3) — 새 모달은 받지 않음 */ const OLD_OWN = new Set(["L1", "L2", "t1"]); -const FROM_DIST = "거리에서"; - -const fmt = (v: number | null | undefined): string => - v == null || !Number.isFinite(v) ? "—" : String(v); - -/** 계산 절차 — 사이클 식 줄(거창 단가산출 꼴) */ -export function cycleLines(input: Record): string[] { - const v: Record = { ...CYCLE_DEFAULTS }; - for (const k of Object.keys(v)) if (typeof input[k] === "number") v[k] = input[k] as number; - const r = cycleTime(input); - return [ - "사이클시간 — 건설품셈 8-2-8 덤프트럭", - `q = T / rt × f = ${v.T} / ${v.rt} × ${v.f} = ${r.q} ㎥`, - `n = q / (qs × k) = ${r.q} / (${v.qs} × ${v.k}) = ${r.n} 회`, - `t1 = Cms × n / (60 × Es) = ${v.Cms} × ${r.n} / (60 × ${v.Es}) = ${r.t1} 분`, - `t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 = (${v.L1}/${v.V1} + ${v.L1}/${v.V2} + ${v.L2}/${v.V3} + ${v.L2}/${v.V4}) × 60 = ${r.t2} 분`, - `Cm = t1 + t2 + t3 + t4 + t5 + t6 = ${r.t1} + ${r.t2} + ${v.t3} + ${v.t4} + ${v.t5} + ${v.t6} = ${r.cm} 분`, - `운반거리 = L1 + L2 = ${r.L} km`, - ]; -} +const AUTO = "자동계산"; +/** ② 사이클 칸 = 이 입력을 받는 로직만(운반거리만 받는 로직은 자기 식으로 셈) */ +const CYCLE_ONLY = new Set(["Cm", "사이클시간", "t2"]); interface Preview { lines: string[]; unit: string; price: number | null; why: string | null; + blocks: StepBlock[]; } async function preview(code: string, given: Row, rate: unknown, picks: unknown): Promise { @@ -145,9 +135,9 @@ export function openHaulModal(o: HaulModalOpts): void { attrs: { style: "display:flex;gap:8px;align-items:center;margin-bottom:8px" }, }); const left = el("div", { attrs: { style: "overflow:auto;min-width:0" } }); - const steps = el("pre", { - attrs: { style: "margin:0;white-space:pre-wrap;font-size:12px;line-height:1.6" }, - }); + const steps = el("div", { className: "b09-haulsteps" }); + /** 마지막 단가산출 칸들 — 셈 중엔 흐리게 둠(고른 것 바뀌면 비움) */ + let last: HTMLElement[] = []; const right = el("div", { attrs: { style: "overflow:auto;min-width:0" }, children: [steps] }); // 사이클 = 8-2-8 기본값 + 거리(옛 손 값 안 씀 · [적용]하면 지움) const ref = (): Row => ({ own: [...st.own], cycle: {}, given: st.given }); @@ -160,48 +150,66 @@ export function openHaulModal(o: HaulModalOpts): void { const fromDist = (name: string): unknown => o.dist ? fillGiven({ given: {}, own: [], cycle: {} }, [name], o.dist)[name] : undefined; + /** 조합 줄 거리 칸 — 따르면 거리 값(아니면 undefined) */ + const libAuto = (lid: string, name: string, id = `${lid}|${name}`): unknown => + name in CYCLE_FILL && !st.libOwn.has(id) ? fromDist(name) : undefined; + /** 조합 줄 미리보기 값 = 원값 + 손 값 + 거리 값(서버 짝 `haul_values`) */ + const libFilled = (line: LibLine): Row => { + const out: Row = { ...line.값, ...(st.libGiven[line.id] ?? {}) }; + for (const { 이름 } of line.입력) { + const value = libAuto(line.id, 이름); + if (value !== undefined) out[이름] = value; + } + return out; + }; + const redraw = (): void => { - const fills = - (st.code !== "" && names().some((n) => n in CYCLE_FILL)) || - (isLib(st.other) && - !!o.dist && - st.lines.some((l) => l.입력.some((i) => i.이름 in CYCLE_FILL))); + const lib = isLib(st.other); + const want = !o.dist + ? [] + : lib + ? st.lines.flatMap((l) => l.입력.map((i) => i.이름)) + : st.code + ? names() + : []; const input = cycleInputs(ref(), o.dist); - const head2 = fills - ? [ - `거리 — 포장 ${fmt(o.dist?.L1)} km + 비포장 ${fmt(o.dist?.L2)} km`, - ...cycleLines(input), - "", - ] - : []; + const head2 = [ + ...(want.some((n) => n in CYCLE_FILL) ? [distBox(o.dist, input)] : []), + ...(want.some((n) => CYCLE_ONLY.has(n)) ? [cycleBox(input)] : []), + ]; + const show = (...boxes: HTMLElement[]): void => steps.replaceChildren(...head2, ...boxes); window.clearTimeout(timer); const my = ++seq; - if (isLib(st.other)) { - steps.textContent = [ - ...head2, - `${KIND_TEXT[st.other.kind]} — [적용]하면 거리 · 손 값 사본(같은 값 = 같은 사본)`, - "단가 = [저장] 뒤 서버 셈", - ].join("\n"); - return; + const jobs = lib + ? st.lines.map((l) => ({ code: l.로직, given: libFilled(l), rate: null, picks: null })) + : st.code + ? [{ code: st.code, given: filled(), rate: st.rate, picks: st.picks }] + : []; + if (!jobs.length) { + const what = lib + ? `${KIND_TEXT[st.other!.kind]} — 담은 로직 없음` + : st.other + ? `${KIND_TEXT[st.other.kind] ?? ""} — 입력 없음 · 그대로 연결` + : "없음"; + return show(noteBox(`연결 — ${what}`)); } - if (!st.code) { - const what = st.other - ? `${KIND_TEXT[st.other.kind] ?? ""} — 입력 없음 · 그대로 연결` - : "없음"; - steps.textContent = `연결 — ${what}`; - return; - } - steps.textContent = [...head2, "단가산출 — 셈 중…"].join("\n"); + const tail = lib ? [noteBox("조합 단가 = [적용] · [저장] 뒤 서버 셈")] : []; + last.forEach((b) => b.classList.add("is-stale")); + show(...(last.length ? last : [noteBox("단가산출 — 셈 중…")]), ...tail); timer = window.setTimeout(() => { - preview(st.code, filled(), st.rate, st.picks).then( + Promise.all(jobs.map((j) => preview(j.code, j.given, j.rate, j.picks))).then( (got) => { if (my !== seq) return; // 최신 응답만 - st.unit = got.unit; - const price = got.price == null ? `빈칸 — ${got.why ?? ""}` : `${got.price} 원`; - steps.textContent = [...head2, ...got.lines, "", `단가 = ${price}`].join("\n"); + if (!lib) st.unit = got[0].unit; + last = got.flatMap((g) => + g.blocks.map((b, i) => logicBox(b, i ? "하위 산근" : "단가산출")), + ); + show(...last, ...tail); }, (error: unknown) => { - if (my === seq) steps.textContent = [...head2, `못 셈 — ${String(error)}`].join("\n"); + if (my !== seq) return; + last = []; + show(noteBox(`못 셈 — ${String(error)}`, true), ...tail); }, ); }, 250); @@ -217,12 +225,14 @@ export function openHaulModal(o: HaulModalOpts): void { }); const shows: Array<() => void> = []; - /** 입력 칸 하나 — auto = 거리에서 채울 값(아니면 undefined) · set(null = 비움) */ + /** 입력 칸 하나 — auto = 거리에서 채울 값(아니면 undefined) · set(null = 비움) · + * unown = 「자동계산」 누름(덮음 풀기 · 거리 칸 + 거리 있을 때만 단추) */ const valueRow = ( input: ItemInput, auto: () => unknown, hand: () => unknown, set: (value: unknown) => void, + unown?: () => void, ): HTMLElement => { const now = auto(); const field = createValueControl( @@ -237,14 +247,29 @@ export function openHaulModal(o: HaulModalOpts): void { }, ); field.control.setAttribute("data-labor-input", input.이름); - const tag = el("span", { className: "m02-labin__muted" }); + const button = + unown && o.dist + ? createButton({ + label: AUTO, + variant: "pill", + size: "sm", + onClick: () => { + unown(); + update(); + }, + }) + : null; shows.push(() => { const value = auto(); - tag.textContent = value === undefined ? "" : FROM_DIST; + if (button) { + button.classList.toggle("is-active", value !== undefined); + button.setAttribute("aria-pressed", String(value !== undefined)); + } if (value !== undefined && document.activeElement !== field.control) field.control.value = String(value ?? ""); }); - return row(label(input.이름, input.단위 || ""), [field.control, tag, field.error]); + const cells = button ? [field.control, button, field.error] : [field.control, field.error]; + return row(label(input.이름, input.단위 || ""), cells); }; const logicRows = (): HTMLElement[] => st.inputs.map((input) => { @@ -260,6 +285,12 @@ export function openHaulModal(o: HaulModalOpts): void { if (fill && value !== null) st.own.add(name); else st.own.delete(name); }, + fill + ? () => { + st.own.delete(name); + delete st.given[name]; + } + : undefined, ); }); /** 조합 = 담은 로직 이름별 입력(263-2) */ @@ -272,7 +303,7 @@ export function openHaulModal(o: HaulModalOpts): void { const fill = name in CYCLE_FILL; return valueRow( input, - () => (fill && !st.libOwn.has(id) ? fromDist(name) : undefined), + () => libAuto(line.id, name), () => st.libGiven[line.id]?.[name] ?? line.값[name], (value) => { const mine = (st.libGiven[line.id] ??= {}); @@ -281,6 +312,12 @@ export function openHaulModal(o: HaulModalOpts): void { if (fill && value !== null) st.libOwn.add(id); else st.libOwn.delete(id); }, + fill + ? () => { + st.libOwn.delete(id); + delete st.libGiven[line.id]?.[name]; + } + : undefined, ); }), ]); @@ -294,6 +331,7 @@ export function openHaulModal(o: HaulModalOpts): void { /** 고른 것 바뀜 — 왼쪽 칸을 새로 */ const refresh = (): void => { shows.splice(0); + last = []; const lines = st.code ? st.inputs.length ? logicRows() diff --git a/B09_Estimation/B09_Estimation_UI_Haul_Steps.css b/B09_Estimation/B09_Estimation_UI_Haul_Steps.css new file mode 100644 index 000000000..75b5d2191 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Haul_Steps.css @@ -0,0 +1,81 @@ +/* 「운반 단가」 창 오른쪽 계산 칸(PLAN 277) — 칸마다 제목 줄 · 몸 · 바닥 결과(굵게) · 테마 변수만 */ +.b09-haulsteps { + display: flex; + flex-direction: column; + gap: var(--spacing-12); +} +.b09-haulstep { + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background-color: var(--color-surface-raised); + color: var(--color-text-body); + font-size: var(--text-body-sm); +} +.b09-haulstep.is-stale { + opacity: 0.5; +} +.b09-haulstep__head { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-8); + align-items: baseline; + padding: var(--spacing-8) var(--spacing-12); + border-bottom: 1px solid var(--color-border); +} +.b09-haulstep__title { + color: var(--color-text); + font-weight: 600; +} +.b09-haulstep__sub, +.b09-haulstep__formula, +.b09-haulstep__group { + color: var(--color-text-secondary); + font-size: var(--text-caption); +} +.b09-haulstep__sub { + margin-left: auto; +} +.b09-haulstep__body { + padding: var(--spacing-4) var(--spacing-12); +} +.b09-haulstep__group { + margin: var(--spacing-8) 0 var(--spacing-4); +} +.b09-haulstep__row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0 var(--spacing-12); + padding: 2px 0; +} +.b09-haulstep__value { + text-align: right; + font-variant-numeric: tabular-nums; + color: var(--color-text); +} +.b09-haulstep__formula { + grid-column: 1 / -1; + white-space: pre-line; + word-break: break-all; +} +.b09-haulstep__foot { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-12); + justify-content: space-between; + align-items: baseline; + padding: var(--spacing-8) var(--spacing-12); + border-top: 1px solid var(--color-border); + font-weight: 700; + color: var(--color-text); +} +.b09-haulstep__foot .b09-haulstep__value { + margin-left: auto; +} +.b09-haulstep__foot .b09-haulstep__formula { + flex-basis: 100%; + text-align: right; + font-weight: 400; +} +.b09-haulstep__bad { + color: var(--color-danger); +} diff --git a/B09_Estimation/B09_Estimation_UI_Haul_Steps.ts b/B09_Estimation/B09_Estimation_UI_Haul_Steps.ts new file mode 100644 index 000000000..ed474d691 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Haul_Steps.ts @@ -0,0 +1,210 @@ +/* ============================================================================= + * B09_Estimation_UI_Haul_Steps.ts + * 「운반 단가」 창 오른쪽 계산 칸(PLAN 277) — 칸 차례 · 칸마다 제목 줄 · 몸 · 바닥 결과(굵게). + * ① 거리(바닥 운반거리) ② 사이클시간 건설품셈 8-2-8(TS 짝 `B08_Quantity_Haul_Cycle` · 바닥 Cm) + * ③ 단가산출 = 서버 `logic-preview` 의 `blocks` 하나(277-2) — 머리 글 → 입력값 · 중간값(이름 · 값 크게, + * 식 작게 흐리게) · 호표 줄(수량 · 금액) · 바닥 단가 + 노무 · 재료 · 경비(빈칸 = 까닭 빨강). + * 셈은 서버 그대로 — 여기는 그리기만 · 테마 변수(`B09_Estimation_UI_Haul_Steps.css`). + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { CYCLE_DEFAULTS, cycleTime, type Dist } from "../B08_Quantity/B08_Quantity_Haul_Cycle"; +import "./B09_Estimation_UI_Haul_Steps.css"; + +/** 호표 줄 하나(서버 `SHOWN_LINE`) */ +export interface StepLine { + name: string; + spec: string; + qty: number | null; + unit: string; + kind: string; + labor: number | null; + material: number | null; + expense: number | null; + why: string | null; + text?: string[] | null; +} + +/** 산근 블록 하나(서버 `_block_shown`) */ +export interface StepBlock { + no: number; + logic: string; + name: string; + unit: string; + source: string; + head: string[]; + foot: string; + price: number | null; + parts: Record; + why: string | null; + lines: StepLine[]; +} + +interface StepRow { + name: string; + value: string; + formula?: string; + bad?: boolean; +} + +const PART_TEXT: Record = { labor: "노무", material: "재료", expense: "경비" }; + +/** 값 — 천 단위 쉼표 · 빈 값 「—」 */ +export const num = (v: number | null | undefined, digits = 4): string => + v == null || !Number.isFinite(v) + ? "—" + : v.toLocaleString("ko-KR", { maximumFractionDigits: digits }); + +const rowEl = (r: StepRow): HTMLElement => + el("div", { + className: "b09-haulstep__row", + children: [ + el("span", { text: r.name }), + el("span", { + className: `b09-haulstep__value${r.bad ? " b09-haulstep__bad" : ""}`, + text: r.value, + }), + ...(r.formula ? [el("span", { className: "b09-haulstep__formula", text: r.formula })] : []), + ], + }); + +const group = (label: string, rows: StepRow[]): HTMLElement[] => + rows.length + ? [el("div", { className: "b09-haulstep__group", text: label }), ...rows.map(rowEl)] + : []; + +/** 칸 하나 — 제목 줄(제목 · 곁글) · 몸 · 바닥(굵게) */ +function box(title: string, sub: string, body: HTMLElement[], foot: HTMLElement[]): HTMLElement { + return el("section", { + className: "b09-haulstep", + children: [ + el("div", { + className: "b09-haulstep__head", + children: [ + el("span", { className: "b09-haulstep__title", text: title }), + ...(sub ? [el("span", { className: "b09-haulstep__sub", text: sub })] : []), + ], + }), + ...(body.length ? [el("div", { className: "b09-haulstep__body", children: body })] : []), + ...(foot.length ? [el("div", { className: "b09-haulstep__foot", children: foot })] : []), + ], + }); +} + +const footOf = (name: string, value: string, bad = false): HTMLElement[] => [ + el("span", { text: name }), + el("span", { className: `b09-haulstep__value${bad ? " b09-haulstep__bad" : ""}`, text: value }), +]; + +/** 알림 칸(제목 줄만) — 셈 중 · 못 셈 · 조합 안내 · 연결 없음 */ +export function noteBox(text: string, bad = false): HTMLElement { + const got = box(text, "", [], []); + if (bad) got.querySelector(".b09-haulstep__title")?.classList.add("b09-haulstep__bad"); + return got; +} + +/** ① 거리 — 거리이정표 포장 · 비포장 → 운반거리 */ +export function distBox(dist: Dist, input: Record): HTMLElement { + const rows = [ + { name: "포장 L1", value: `${num(dist?.L1)} km` }, + { name: "비포장 L2", value: `${num(dist?.L2)} km` }, + ]; + return box( + "거리", + "거리이정표", + rows.map(rowEl), + footOf("운반거리", `${num(cycleTime(input).L)} km`), + ); +} + +/** ② 사이클시간 — 건설품셈 8-2-8 덤프트럭(기본값 + 거리) */ +export function cycleBox(input: Record): HTMLElement { + const v: Record = { ...CYCLE_DEFAULTS }; + for (const k of Object.keys(v)) if (typeof input[k] === "number") v[k] = input[k] as number; + const r = cycleTime(input); + const rows: StepRow[] = [ + { name: "q", value: `${num(r.q)} ㎥`, formula: `T / rt × f = ${v.T} / ${v.rt} × ${v.f}` }, + { name: "n", value: `${num(r.n)} 회`, formula: `q / (qs × k) = ${r.q} / (${v.qs} × ${v.k})` }, + { + name: "t1", + value: `${num(r.t1)} 분`, + formula: `Cms × n / (60 × Es) = ${v.Cms} × ${r.n} / (60 × ${v.Es})`, + }, + { + name: "t2", + value: `${num(r.t2)} 분`, + formula: `(L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 = (${v.L1}/${v.V1} + ${v.L1}/${v.V2} + ${v.L2}/${v.V3} + ${v.L2}/${v.V4}) × 60`, + }, + { + name: "Cm", + value: `${num(r.cm)} 분`, + formula: `t1 + t2 + t3 + t4 + t5 + t6 = ${r.t1} + ${r.t2} + ${v.t3} + ${v.t4} + ${v.t5} + ${v.t6}`, + }, + ]; + return box( + "사이클시간", + "건설품셈 8-2-8 덤프트럭", + rows.map(rowEl), + footOf("Cm", `${num(r.cm)} 분`), + ); +} + +/** 머리 글 → 입력값(「이름 : 뜻 = 값」) · 중간값(「이름 = 식 = 값」 · 「└」 = 앞 줄 식에 붙임) */ +export function splitHead(b: StepBlock): { inputs: StepRow[]; mids: StepRow[] } { + const inputs: StepRow[] = []; + const mids: StepRow[] = []; + let last: StepRow | null = null; + for (const line of b.head) { + if (line.startsWith(`${b.name} / `) || (b.source && line === `${b.source} 적용`)) continue; + if (line.startsWith("└") && last) { + last.formula = [last.formula, line].filter(Boolean).join("\n"); + continue; + } + const colon = line.indexOf(" : "); + const eq = line.indexOf(" = "); + const end = line.lastIndexOf(" = "); + if (colon >= 0 && (eq < 0 || colon < eq)) { + const value = end > colon ? line.slice(end + 3) : line.slice(colon + 3); + const note = end > colon ? line.slice(colon + 3, end) : ""; + inputs.push((last = { name: line.slice(0, colon), value, formula: note })); + } else if (eq >= 0) { + const formula = end > eq ? line.slice(eq + 3, end) : ""; + mids.push((last = { name: line.slice(0, eq), value: line.slice(end + 3), formula })); + } else mids.push((last = { name: line, value: "" })); + } + return { inputs, mids }; +} + +/** ③ 단가산출 칸 — 블록 하나(맨 위 = 「단가산출」 · 하위 = 「하위 산근」) */ +export function logicBox(b: StepBlock, label = "단가산출"): HTMLElement { + const unit = b.unit ? `원/${b.unit.replace(/^원\//, "")}` : "원"; + const { inputs, mids } = splitHead(b); + const lines: StepRow[] = b.lines.map((ln) => { + const parts = [ln.labor, ln.material, ln.expense]; + const money = parts.some((p) => p == null) ? null : parts.reduce((a, p) => a! + p!, 0); + return { + name: ln.spec ? `${ln.name} ${ln.spec}` : ln.name, + value: ln.why ? `빈칸 — ${ln.why}` : `${num(ln.qty)} ${ln.unit} · ${num(money, 1)} 원`, + formula: (ln.text || []).join("\n"), + bad: !!ln.why, + }; + }); + const foot = + b.price == null + ? footOf("단가", `빈칸 — ${b.why ?? ""}`, true) + : [ + ...footOf("단가", `${num(b.price, 1)} ${unit}`), + el("span", { + className: "b09-haulstep__formula", + text: Object.entries(b.parts) + .map(([p, v]) => `${PART_TEXT[p] ?? p} ${num(v, 1)}`) + .join(" · "), + }), + ]; + return box( + `${label} ${b.logic} ${b.name}`, + [unit, b.source].filter(Boolean).join(" · "), + [...group("입력값", inputs), ...group("중간값", mids), ...group("호표", lines)], + foot, + ); +} diff --git a/M02_MasterTemplete/M02_Labor_Values.ts b/M02_MasterTemplete/M02_Labor_Values.ts index b83422733..e99da649e 100644 --- a/M02_MasterTemplete/M02_Labor_Values.ts +++ b/M02_MasterTemplete/M02_Labor_Values.ts @@ -51,7 +51,7 @@ export function createValueControl( choices.length ? el("select", { className: "ui-select ui-select--sm m02-inputval__control" }) : el("input", { - className: "ui-input m02-inputval__control", + className: "ui-input ui-input--sm m02-inputval__control", // 고르기 칸과 같은 높이(277-4) attrs: { type: range ? "number" : "text", ...(range?.min !== undefined ? { min: String(range.min) } : {}), diff --git a/resources/tester/test_253_3_haul_preview.py b/resources/tester/test_253_3_haul_preview.py index 424dece0d..159563ee9 100644 --- a/resources/tester/test_253_3_haul_preview.py +++ b/resources/tester/test_253_3_haul_preview.py @@ -49,7 +49,7 @@ def test_내역_줄_거리(): def test_모달_여는_곳(): modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts") for want in ("/estimation/logic-preview", "openPriceLink", "fetchLogicInputs", "fillGiven", - "cycleInputs", "my !== seq", "거리에서", "[고르기]"): # fmt: skip + "cycleInputs", "my !== seq", "자동계산", "[고르기]"): # fmt: skip assert want in modal, want milestone = _src("B08_Quantity/B08_Quantity_UI_Milestone.ts") assert "openHaulModal({" in milestone and "L1: paved" in milestone diff --git a/resources/tester/test_263_1_haul_modal.py b/resources/tester/test_263_1_haul_modal.py index e3fb6c099..c27af2d81 100644 --- a/resources/tester/test_263_1_haul_modal.py +++ b/resources/tester/test_263_1_haul_modal.py @@ -27,8 +27,8 @@ def test_왼쪽_칸_고른_로직_입력만(): assert gone not in modal, gone # 칸 = 로직 입력 하나에 한 줄(createValueControl) · 사이클 줄 = 거리 칸 있을 때만 assert "st.inputs.map((input) =>" in modal and "createValueControl(" in modal - assert "names().some((n) => n in CYCLE_FILL)" in modal - assert "포장 ${fmt(o.dist?.L1)} km + 비포장 ${fmt(o.dist?.L2)} km" in modal + # 277 — 거리 칸 · 사이클 칸 = 거리 칸 받는 로직만(test_277_1) + assert "want.some((n) => n in CYCLE_FILL) ? [distBox(o.dist, input)]" in modal # 거리 받는 마스터 로직 = 거리 칸 하나씩 for code, want in (("GF000284", "운반거리"), ("GF000134", "Cm"), ("GF000283", "사이클시간")): names = _input_names(code) diff --git a/resources/tester/test_277_1_haul_steps.py b/resources/tester/test_277_1_haul_steps.py new file mode 100644 index 000000000..aa3f17cd7 --- /dev/null +++ b/resources/tester/test_277_1_haul_steps.py @@ -0,0 +1,79 @@ +"""277 「운반 단가」 창 오른쪽 계산 칸 — 미리보기 `blocks`(머리 · 호표 줄 · 계 · 출처 · 하위 산근) · +옛 `lines` · 단가 그대로 · 칸 고르기(운반거리만 = 거리 칸만 · Cm 받음 = 사이클 칸까지) · +운반거리 칸 높이(`ui-input--sm`) · 「자동계산」 단추.""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Haul_Cycle import FILL # noqa: E402 +from B09_Estimation.B09_Estimation_Router import PreviewBody, logic_preview # noqa: E402 +from B09_Estimation.B09_Estimation_Sangeun import _input_names # noqa: E402 + +GIVEN = {"재료": "토사", "운반거리": 23.877, "지역": "전국평균", "보정작업": "해당 없음"} +CYCLE_ONLY = {"Cm", "사이클시간", "t2"} + + +def _src(name: str) -> str: + return (ROOT / name).read_text(encoding="utf-8") + + +def test_미리보기_칸_GF000284(tmp_path): + got = logic_preview(tmp_path, PreviewBody(code="GF000284", given=GIVEN)) + (block,) = got["blocks"] # 하위 산근 없음 + assert block["logic"] == "GF000284" and block["source"] == "산림품셈 10-12-1" + assert block["unit"] == got["unit"] == "㎥" + assert block["price"] == got["price"] and got["price"] > 0 + assert block["parts"] == got["parts"] + assert block["head"][0].startswith(block["name"]) and block["foot"].startswith("계 :") + assert any(h.startswith("Cmt = ") for h in block["head"]) # 중간값 + names = [ln["name"] for ln in block["lines"]] + assert names == ["굴착기(무한궤도) 0.7㎥(적재)", "덤프트럭 15ton(운반)"] + truck = block["lines"][1] + assert truck["qty"] == 1.25 and truck["unit"] == "시간" and len(truck["text"]) == 3 + assert set(truck) == {"name", "spec", "qty", "unit", "kind", "labor", "material", "expense", + "why", "text"} # fmt: skip + # 옛 글 줄 그대로 = 머리 + 줄 글 + 계 + texts = [t for ln in block["lines"] for t in ln["text"]] + assert got["lines"] == [*block["head"], *texts, block["foot"]] + + +def test_칸_고르기_규칙(): + def boxes(code: str) -> tuple[bool, bool]: + names = _input_names(code) + return any(n in FILL for n in names), any(n in CYCLE_ONLY for n in names) + + assert boxes("GF000284") == (True, False) # 운반거리만 = 거리 칸만 + assert boxes("GF000134") == (True, True) # Cm = 사이클 칸까지 + modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts") + assert 'CYCLE_ONLY = new Set(["Cm", "사이클시간", "t2"])' in modal + assert "want.some((n) => CYCLE_ONLY.has(n)) ? [cycleBox(input)]" in modal + assert 'el("pre"' not in modal and "cycleLines" not in modal + # 조합 = 담은 로직 줄마다 미리보기 · 최신 응답만 · 하위 산근 이어 붙임 · 셈 중 옛 칸 흐리게 + for want in ("st.lines.map((l) => ({ code: l.로직, given: libFilled(l)", "my !== seq", + '"하위 산근"', 'classList.add("is-stale")', + "조합 단가 = [적용] · [저장] 뒤 서버 셈"): # fmt: skip + assert want in modal, want + + +def test_칸_그리기_테마(): + steps = _src("B09_Estimation/B09_Estimation_UI_Haul_Steps.ts") + for want in ("export function distBox", "export function cycleBox", "export function logicBox", + "export function splitHead", 'toLocaleString("ko-KR"', '"입력값"', '"중간값"', + '"호표"', "빈칸 — "): # fmt: skip + assert want in steps, want + css = _src("B09_Estimation/B09_Estimation_UI_Haul_Steps.css") + assert "#" not in css.replace("/*", "").split("*/", 1)[1] # 색 = 테마 변수만 + assert "var(--color-danger)" in css and "text-align: right" in css + + +def test_운반거리_칸_높이_자동계산_단추(): + values = _src("M02_MasterTemplete/M02_Labor_Values.ts") + assert '"ui-input ui-input--sm m02-inputval__control"' in values + modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts") + assert '"거리에서"' not in modal and 'AUTO = "자동계산"' in modal + for want in ("unown && o.dist", 'variant: "pill"', '"aria-pressed"', '"is-active"', + "st.own.delete(name);", "st.libOwn.delete(id);"): # fmt: skip + assert want in modal, want