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:
@@ -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 []],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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) } : {}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user