feat(b09): 운반 단가 창 오른쪽 계산 칸 나눔 · 운반거리 칸 자동계산 단추 (277-1~4)

- 오른쪽 글 상자 → 칸 차례(거리 · 사이클 8-2-8 · 단가산출) · 칸마다 제목 · 몸 · 바닥 결과
- 사이클 칸 = Cm · 사이클시간 · t2 받는 로직만 · 조합 = 담은 로직 줄마다 미리보기 · 하위 산근 이어 붙임
- logic-preview 응답에 blocks(머리 · 호표 줄 · 계 · 출처) 더함 · 셈 · lines 그대로
- 값 칸 숫자 칸 = ui-input--sm(고르기 칸과 같은 높이) · 「거리에서」 → 「자동계산」 단추

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
This commit is contained in:
2026-10-12 18:25:05 +09:00
co-authored by Claude Opus 5.5
parent 9413b6c69a
commit f43a36aa39
8 changed files with 497 additions and 72 deletions
+19 -2
View File
@@ -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 []],
}
+104 -66
View File
@@ -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, unknown>): string[] {
const v: Record<string, number> = { ...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<Preview> {
@@ -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()
@@ -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);
}
@@ -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<string, number | null>;
why: string | null;
lines: StepLine[];
}
interface StepRow {
name: string;
value: string;
formula?: string;
bad?: boolean;
}
const PART_TEXT: Record<string, string> = { 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<string, unknown>): 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<string, unknown>): HTMLElement {
const v: Record<string, number> = { ...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,
);
}
+1 -1
View File
@@ -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) } : {}),
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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)
+79
View File
@@ -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