Merge remote-tracking branch 'origin/dev' into sub_laptop_3
This commit is contained in:
@@ -61,6 +61,8 @@ export interface SvgZoomPanOptions {
|
||||
maxScale?: number;
|
||||
/** 이전 상태 — 다시 그려도 배율이 살아남게. */
|
||||
initial?: ZoomPanState;
|
||||
/** 처음 · 원복 배율(기본 1) — 플롯 중앙을 붙잡음 · 이보다 작게도 줄일 수 있음(266-1 B06 상세). */
|
||||
homeScale?: number;
|
||||
/** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */
|
||||
onChange?: (state: ZoomPanState) => void;
|
||||
/** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */
|
||||
@@ -85,9 +87,15 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
||||
height: Math.max(heightPx - pad.top - pad.bottom, 1),
|
||||
};
|
||||
const maxScale = options.maxScale ?? 8;
|
||||
let scale = initial?.scale ?? 1;
|
||||
let tx = initial?.tx ?? 0;
|
||||
let ty = initial?.ty ?? 0;
|
||||
const homeScale = Math.min(Math.max(options.homeScale ?? 1, 1), maxScale);
|
||||
const home: ZoomPanState = {
|
||||
scale: homeScale,
|
||||
tx: (plot.x + plot.width / 2) * (1 - homeScale),
|
||||
ty: (plot.y + plot.height / 2) * (1 - homeScale),
|
||||
};
|
||||
let scale = initial?.scale ?? home.scale;
|
||||
let tx = initial?.tx ?? home.tx;
|
||||
let ty = initial?.ty ?? home.ty;
|
||||
const applyTransform = (): void => {
|
||||
layer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
|
||||
onChange?.({ scale, tx, ty });
|
||||
@@ -182,7 +190,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
||||
/* 이미 해제됨 */
|
||||
}
|
||||
};
|
||||
if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) {
|
||||
if (scale !== 1 || tx !== 0 || ty !== 0) {
|
||||
clampPan();
|
||||
applyTransform();
|
||||
}
|
||||
@@ -209,9 +217,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
||||
}
|
||||
const currentScale = (): number => scale;
|
||||
const reset = (): void => {
|
||||
scale = 1;
|
||||
tx = 0;
|
||||
ty = 0;
|
||||
({ scale, tx, ty } = home);
|
||||
applyTransform();
|
||||
};
|
||||
// 더블클릭 원복.
|
||||
|
||||
@@ -45,12 +45,12 @@ export interface CrossDetailHost {
|
||||
projectId: string;
|
||||
routeId: () => number | null;
|
||||
detail: () => SectionDetailResponse | null;
|
||||
/** 칸 크기(W × H px)로 새로 그린 카드 svg + 축 옵션(173-4 · 179-3 · 뷰 `detailChart`). */
|
||||
/** 칸 크기(W × H px)로 새로 그린 카드 svg + 축 옵션 + 처음 배율(173-4 · 179-3 · 266-1 · 뷰 `detailChart`). */
|
||||
chart: (
|
||||
stationId: string,
|
||||
widthPx: number,
|
||||
heightPx: number,
|
||||
) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;
|
||||
) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null;
|
||||
}
|
||||
|
||||
let host: CrossDetailHost | null = null;
|
||||
@@ -187,12 +187,14 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT
|
||||
/* ── 그림 줌 · 팬(164-1) ─────────────────────────────────────────────── */
|
||||
|
||||
/** 도형 층에만 공용 줌 · 팬(179-3 · 축 · 눈금 · 축선 · clip 제자리) · 휠 = 커서 고정 줌 ·
|
||||
* 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴). */
|
||||
* 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴) ·
|
||||
* 처음 · 원복 = `homeScale`(266-1 · 가로 = 카드 반폭 · 줄이면 칸 맞춤 전체). */
|
||||
export function attachDetailZoom(
|
||||
clone: SVGSVGElement,
|
||||
axes: CrossAxesOptions | null,
|
||||
initial: ZoomPanState | undefined,
|
||||
onChange: (state: ZoomPanState) => void,
|
||||
homeScale = 1,
|
||||
): ZoomPanHandle | null {
|
||||
const layer = clone.querySelector<SVGGElement>("g.b06-chart__plot-layer");
|
||||
if (!layer) return null;
|
||||
@@ -223,6 +225,7 @@ export function attachDetailZoom(
|
||||
content: { x: -width, y: -height, width: 3 * width, height: 3 * height },
|
||||
wheelZoom: true,
|
||||
initial,
|
||||
homeScale,
|
||||
onChange: (state) => {
|
||||
follow(state);
|
||||
onChange(state);
|
||||
@@ -251,7 +254,7 @@ function drawChart(
|
||||
section: CrossSection,
|
||||
widthPx: number,
|
||||
heightPx: number,
|
||||
): { clone: SVGSVGElement; axes: CrossAxesOptions | null } | null {
|
||||
): { clone: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null {
|
||||
drawing = true;
|
||||
try {
|
||||
const drawn = host?.chart(section.station_id, widthPx, heightPx);
|
||||
@@ -264,7 +267,7 @@ function drawChart(
|
||||
.querySelectorAll(`[clip-path="url(#${id})"]`)
|
||||
.forEach((el) => el.setAttribute("clip-path", `url(#${clip.id})`));
|
||||
});
|
||||
return { clone, axes: drawn.axes };
|
||||
return { clone, axes: drawn.axes, homeScale: drawn.homeScale };
|
||||
} finally {
|
||||
drawing = false;
|
||||
}
|
||||
@@ -359,7 +362,13 @@ export function openCrossDetail(section: CrossSection): void {
|
||||
else {
|
||||
const { clone } = drawn;
|
||||
axes = drawn.axes;
|
||||
const handle = attachDetailZoom(clone, axes, zoom, (next) => (zoom = next));
|
||||
const handle = attachDetailZoom(
|
||||
clone,
|
||||
axes,
|
||||
zoom,
|
||||
(next) => (zoom = next),
|
||||
drawn.homeScale,
|
||||
);
|
||||
cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []));
|
||||
tablePick.redraw(); // 선 조각 고르기(209-1)
|
||||
}
|
||||
|
||||
@@ -579,7 +579,9 @@ export function createSectionView(
|
||||
elevationMid: plot.elevationMid,
|
||||
exaggeration: Math.max(currentExaggeration, 0.1),
|
||||
};
|
||||
return { svg, axes };
|
||||
// 칸 맞춤이 넓힌 가로 범위를 처음 배율로 되좁힘 = 카드 반폭(266-1)
|
||||
const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1;
|
||||
return { svg, axes, homeScale };
|
||||
},
|
||||
focusStation(stationId) {
|
||||
if (stationId === null) clearSelection();
|
||||
|
||||
@@ -39,12 +39,12 @@ export interface SectionViewController {
|
||||
/** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */
|
||||
refreshCards: (chainages: ReadonlyArray<number>) => void;
|
||||
/** [상세] 모달 그림(173-4) — 카드 그리기 함수로 칸 크기(W × H px)에 맞춰 새로 그린 svg(없으면 null)
|
||||
* · 축 다시 그리기 옵션(179-3 · 기하 없으면 null). */
|
||||
* · 축 다시 그리기 옵션(179-3 · 기하 없으면 null) · 처음 배율(266-1 · 가로 = 카드 반폭). */
|
||||
detailChart: (
|
||||
stationId: string,
|
||||
widthPx: number,
|
||||
heightPx: number,
|
||||
) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;
|
||||
) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null;
|
||||
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
|
||||
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */
|
||||
focusStation: (stationId: string | null) => void;
|
||||
|
||||
@@ -20,7 +20,6 @@ import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
import type { Row } from "../B09_Estimation/B09_Estimation_Calc_Base";
|
||||
import { openHaulModal } from "../B09_Estimation/B09_Estimation_UI_Haul_Modal";
|
||||
import { openPriceLink } from "../B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link";
|
||||
import { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
|
||||
import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram";
|
||||
import {
|
||||
@@ -163,7 +162,6 @@ function tableView(projectId: string): MilestoneView {
|
||||
dist: at && { L1: at.L1, L2: at.L2 },
|
||||
name: route.rows[at?.index ?? 0]?.item || route.name || "운반",
|
||||
apply: link,
|
||||
other: () => void openPriceLink({ apply: link }),
|
||||
}),
|
||||
],
|
||||
...(route.ref ? [["단가연결 풀기", () => link(null)] as [string, () => void]] : []),
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Haul_Modal.ts
|
||||
* 「운반 단가」 모달(PLAN 253-3 · 요구 162-1) — 운반공 줄 하나(거리이정표 행 하나)의 단가연결.
|
||||
* 위 = 단가산출 이름 · [변경](`openLogicPicker`) · [다른 종류](옛 단가연결 창) ·
|
||||
* 왼 = 옵션 값 — 로직 입력(`fetchLogicInputs`) + 사이클 입력(L1 · L2 · V1~V4 · t1 · t3~t6 · t1 셈 값) ·
|
||||
* 오른 = 계산 절차 — 사이클 식 줄(TS 짝 `B08_Quantity_Haul_Cycle`) + 로직 산출 줄
|
||||
* (서버 `POST …/estimation/logic-preview` · 값 바꾸면 다시 · 최신 응답만).
|
||||
* 로직 입력 Cm · 사이클시간 · t2 · 운반거리 = 거리에서 채움(「거리에서」) · 손으로 고치면 덮음(`own`) ·
|
||||
* 빈칸으로 지우면 다시 거리에서.
|
||||
* 연결 없는 줄도 같은 모달(단가산출 빈칸 → [고르기]) · [적용] = 부르는 쪽 `apply`(캐시 → [저장] 때 서버 정본).
|
||||
* 「운반 단가」 모달(PLAN 253-3 · 263-1 · 요구 162-1 · 164-1) — 운반공 줄 하나(거리이정표 행 하나)의 단가연결.
|
||||
* 위 = 고른 항목 이름 · [변경](단가연결 한 목록 `openPriceLink` — 단가산출 · 사전조합 · 구조물 조합 · 호표) ·
|
||||
* 왼 = 고른 항목이 요구하는 입력만(단가산출 = 로직 입력 `fetchLogicInputs` · 호표 = 입력 없음) ·
|
||||
* 오른 = 계산 절차 — 거리 줄 · 사이클 식 줄(TS 짝 `B08_Quantity_Haul_Cycle` · 8-2-8 기본값 · 거리 칸 있을 때만) +
|
||||
* 로직 산출 줄(서버 `POST …/estimation/logic-preview` · 값 바꾸면 다시 · 최신 응답만).
|
||||
* 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 거리에서 채움(「거리에서」) · 손으로 고치면 덮음(`own`) ·
|
||||
* 빈칸으로 지우면 다시 거리에서. 옛 사이클 손 값(`cycle`) = 칸 없음 · [적용]하면 지움(기본값).
|
||||
* 연결 없는 줄도 같은 모달(빈칸 → [고르기]) · [적용] = 부르는 쪽 `apply`(캐시 → [저장] 때 서버 정본).
|
||||
* 여는 곳 = B08 거리이정표 표 우클릭 · B09 설계내역서 운반공 줄 단가연결.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -22,65 +22,28 @@ import {
|
||||
fillGiven,
|
||||
type Dist,
|
||||
} from "../B08_Quantity/B08_Quantity_Haul_Cycle";
|
||||
import { loadTable, type TableRow } from "../M01_MasterData/M01_MasterData_UI_Logic_Note";
|
||||
import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
|
||||
import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick";
|
||||
import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import {
|
||||
importLib,
|
||||
openPriceLink,
|
||||
type Picked,
|
||||
type PriceLinkOpts,
|
||||
} from "./B09_Estimation_UI_Tabs_Bill_Link";
|
||||
|
||||
type Apply = (ref: Row | null, from?: Row) => void;
|
||||
export interface HaulModalOpts {
|
||||
export interface HaulModalOpts extends PriceLinkOpts {
|
||||
/** 지금 연결(없으면 null) */
|
||||
ref: Row | null;
|
||||
/** 그 행 거리 — L1 포장 계 · L2 비포장(km) */
|
||||
dist: Dist;
|
||||
/** 줄 이름(제목) */
|
||||
name: string;
|
||||
apply: Apply;
|
||||
/** 옛 단가연결 창(일위대가 · 구조물 조합 등 다른 종류) */
|
||||
other?: () => void;
|
||||
}
|
||||
|
||||
/** 사이클 입력 칸 — [이름, 글, 단위] · 무리 = 화면 묶음 */
|
||||
const CYCLE_FIELDS: Array<[string, string, string][]> = [
|
||||
[
|
||||
["L1", "포장 운반거리 L1", "km"],
|
||||
["L2", "비포장 운반거리 L2", "km"],
|
||||
],
|
||||
[
|
||||
["V1", "포장 적재 V1", "km/hr"],
|
||||
["V2", "포장 공차 V2", "km/hr"],
|
||||
["V3", "비포장 적재 V3", "km/hr"],
|
||||
["V4", "비포장 공차 V4", "km/hr"],
|
||||
],
|
||||
[
|
||||
["t1", "적재 t1", "분"],
|
||||
["T", "적재량 T", "ton"],
|
||||
["rt", "단위중량 rt", "t/㎥"],
|
||||
["f", "체적환산 f", ""],
|
||||
["qs", "버킷용량 qs", "㎥"],
|
||||
["k", "버킷계수 k", ""],
|
||||
["Es", "작업효율 Es", ""],
|
||||
["Cms", "적재기계 Cms", "초"],
|
||||
],
|
||||
[
|
||||
["t3", "적하 t3", "분"],
|
||||
["t4", "대기 t4", "분"],
|
||||
["t5", "덮개 t5", "분"],
|
||||
["t6", "세륜 t6", "분"],
|
||||
],
|
||||
];
|
||||
/** 표 고르기(건설품셈 8-2-8 · master_data 읽기만) — [표 키, 채울 칸(값칸 → 입력), 조건 칸들] */
|
||||
const PICKS: Array<[string, Record<string, string>, string]> = [
|
||||
["CC000054", { 적재: "V1", 공차: "V2" }, "포장 도로상태"],
|
||||
["CC000054", { 적재: "V3", 공차: "V4" }, "비포장 도로상태"],
|
||||
["CC000055", { 시간: "t3" }, "적하(토질 · 작업조건)"],
|
||||
["CC000056", { 시간: "t4" }, "대기(적재장소)"],
|
||||
["CC000057", { 시간: "t5" }, "덮개"],
|
||||
["CC000058", { 시간: "t6" }, "세륜"],
|
||||
];
|
||||
const OWNABLE = new Set(["L1", "L2", "t1"]);
|
||||
/** 옛 사이클 손 칸(253-3) — 새 모달은 받지 않음 */
|
||||
const OLD_OWN = new Set(["L1", "L2", "t1"]);
|
||||
const FROM_DIST = "거리에서";
|
||||
|
||||
const fmt = (v: number | null | undefined): string =>
|
||||
@@ -91,15 +54,11 @@ 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);
|
||||
const t1 =
|
||||
typeof input.t1 === "number"
|
||||
? `t1 = ${fmt(r.t1)} 분(손 입력)`
|
||||
: `t1 = Cms × n / (60 × Es) = ${v.Cms} × ${r.n} / (60 × ${v.Es}) = ${r.t1} 분`;
|
||||
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,
|
||||
`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`,
|
||||
@@ -127,21 +86,33 @@ async function preview(code: string, given: Row, rate: unknown, picks: unknown):
|
||||
}
|
||||
|
||||
const label = (text: string, unit: string): string => (unit ? `${text} (${unit})` : text);
|
||||
const KIND_TEXT: Record<string, string> = {
|
||||
logic: "단가산출",
|
||||
precombo: "일위대가 사전조합",
|
||||
combo: "구조물 조합",
|
||||
dg: "일위대가 호표",
|
||||
sg: "단가산출 호표",
|
||||
};
|
||||
|
||||
/** 「운반 단가」 모달 */
|
||||
export function openHaulModal(o: HaulModalOpts): void {
|
||||
const logicRef = o.ref?.tab === "logic" ? o.ref : null;
|
||||
const old = (logicRef?.cycle as Row) || {};
|
||||
const st = {
|
||||
/** 고른 것 — 단가산출 아닌 것(조합 · 호표) */
|
||||
other: (o.ref && !logicRef
|
||||
? { kind: o.ref.tab, ref: o.ref, from: { name: o.ref.name ?? o.ref.key } }
|
||||
: null) as Picked | null,
|
||||
code: String(logicRef?.code ?? ""),
|
||||
name: String(logicRef?.name ?? ""),
|
||||
given: { ...((logicRef?.given as Row) || {}) } as Row,
|
||||
cycle: { ...((logicRef?.cycle as Row) || {}) } as Row,
|
||||
own: new Set<string>((logicRef?.own as string[]) || []),
|
||||
own: new Set<string>(((logicRef?.own as string[]) || []).filter((n) => !OLD_OWN.has(n))),
|
||||
rate: logicRef?.rate ?? null,
|
||||
picks: logicRef?.picks ?? null,
|
||||
inputs: [] as ItemInput[],
|
||||
unit: "",
|
||||
};
|
||||
const oldCycle = Object.keys(old).length > 0;
|
||||
let seq = 0;
|
||||
let timer = 0;
|
||||
openModal({
|
||||
@@ -159,65 +130,46 @@ export function openHaulModal(o: HaulModalOpts): void {
|
||||
attrs: { style: "margin:0;white-space:pre-wrap;font-size:12px;line-height:1.6" },
|
||||
});
|
||||
const right = el("div", { attrs: { style: "overflow:auto;min-width:0" }, children: [steps] });
|
||||
const ref = (): Row => ({ own: [...st.own], cycle: st.cycle, given: st.given });
|
||||
// 사이클 = 8-2-8 기본값 + 거리(옛 손 값 안 씀 · [적용]하면 지움)
|
||||
const ref = (): Row => ({ own: [...st.own], cycle: {}, given: st.given });
|
||||
const names = (): string[] => st.inputs.map((i) => i.이름);
|
||||
const filled = (): Row => fillGiven(ref(), names(), o.dist) as Row;
|
||||
|
||||
const redraw = (): void => {
|
||||
const fills = st.code !== "" && names().some((n) => n in CYCLE_FILL);
|
||||
const input = cycleInputs(ref(), o.dist);
|
||||
const head2 = cycleLines(input);
|
||||
steps.textContent = [...head2, "", st.code ? "단가산출 — 셈 중…" : "단가산출 — 없음"].join(
|
||||
"\n",
|
||||
);
|
||||
const head2 = fills
|
||||
? [
|
||||
`거리 — 포장 ${fmt(o.dist?.L1)} km + 비포장 ${fmt(o.dist?.L2)} km`,
|
||||
...cycleLines(input),
|
||||
"",
|
||||
]
|
||||
: [];
|
||||
window.clearTimeout(timer);
|
||||
if (!st.code) return;
|
||||
const my = ++seq;
|
||||
if (!st.code) {
|
||||
const what = st.other
|
||||
? `${KIND_TEXT[st.other.kind] ?? ""} — 입력 없음 · 그대로 연결`
|
||||
: "없음";
|
||||
steps.textContent = `연결 — ${what}`;
|
||||
return;
|
||||
}
|
||||
steps.textContent = [...head2, "단가산출 — 셈 중…"].join("\n");
|
||||
timer = window.setTimeout(() => {
|
||||
preview(st.code, filled(), st.rate, st.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");
|
||||
steps.textContent = [...head2, ...got.lines, "", `단가 = ${price}`].join("\n");
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (my === seq)
|
||||
steps.textContent = [...head2, "", `못 셈 — ${String(error)}`].join("\n");
|
||||
if (my === seq) steps.textContent = [...head2, `못 셈 — ${String(error)}`].join("\n");
|
||||
},
|
||||
);
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const numberField = (name: string, text: string, unit: string): HTMLElement => {
|
||||
const input = el("input", {
|
||||
className: "ui-input m02-inputval__control",
|
||||
attrs: { type: "number", step: "any", "data-haul-cycle": name },
|
||||
}) as HTMLInputElement;
|
||||
const tag = el("span", { className: "m02-labin__muted" });
|
||||
const show = (): void => {
|
||||
const now = cycleInputs(ref(), o.dist);
|
||||
const auto =
|
||||
name === "t1" ? cycleTime(now).t1 : ((now[name] as number) ?? CYCLE_DEFAULTS[name]);
|
||||
const mine = OWNABLE.has(name) ? st.own.has(name) : name in st.cycle;
|
||||
if (document.activeElement !== input)
|
||||
input.value = mine ? String(st.cycle[name] ?? "") : "";
|
||||
input.placeholder = String(auto ?? "");
|
||||
tag.textContent = (name === "L1" || name === "L2") && !mine && o.dist ? FROM_DIST : "";
|
||||
};
|
||||
input.addEventListener("change", () => {
|
||||
const v = input.value.trim() === "" ? null : Number(input.value);
|
||||
if (v === null || !Number.isFinite(v)) {
|
||||
delete st.cycle[name];
|
||||
st.own.delete(name);
|
||||
} else {
|
||||
st.cycle[name] = v;
|
||||
if (OWNABLE.has(name)) st.own.add(name);
|
||||
}
|
||||
update();
|
||||
});
|
||||
shows.push(show);
|
||||
return row(label(text, unit), [input, tag]);
|
||||
};
|
||||
const row = (text: string, cells: HTMLElement[]): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m02-inputval__row",
|
||||
@@ -228,33 +180,6 @@ export function openHaulModal(o: HaulModalOpts): void {
|
||||
});
|
||||
const shows: Array<() => void> = [];
|
||||
|
||||
const tablePick = (key: string, map: Record<string, string>, text: string): HTMLElement => {
|
||||
const select = el("select", {
|
||||
className: "ui-select ui-select--sm m02-inputval__control",
|
||||
attrs: { "data-haul-pick": Object.values(map).join(",") },
|
||||
}) as HTMLSelectElement;
|
||||
select.append(el("option", { text: "표에서 고르기", attrs: { value: "" } }));
|
||||
void loadTable(key).then((t: TableRow | null) => {
|
||||
const conds = Object.keys(t?.조건 || {});
|
||||
(t?.줄 || []).forEach((line, i) => {
|
||||
const what = conds.map((c) => String(line[c] ?? "")).join(" · ");
|
||||
const vals = Object.keys(map)
|
||||
.map((col) => `${map[col]} ${String(line[col] ?? "")}`)
|
||||
.join(" · ");
|
||||
select.append(el("option", { text: `${what} — ${vals}`, attrs: { value: String(i) } }));
|
||||
});
|
||||
select.onchange = () => {
|
||||
const line = t?.줄?.[Number(select.value)];
|
||||
if (!line) return;
|
||||
for (const [col, name] of Object.entries(map))
|
||||
if (typeof line[col] === "number") st.cycle[name] = line[col] as number;
|
||||
select.value = "";
|
||||
update();
|
||||
};
|
||||
});
|
||||
return row(text, [select]);
|
||||
};
|
||||
|
||||
const logicRows = (): HTMLElement[] =>
|
||||
st.inputs.map((input) => {
|
||||
const fill = input.이름 in CYCLE_FILL && !st.own.has(input.이름);
|
||||
@@ -291,29 +216,36 @@ export function openHaulModal(o: HaulModalOpts): void {
|
||||
shows.forEach((show) => show());
|
||||
redraw();
|
||||
};
|
||||
/** 로직 바뀜 — 왼쪽 칸을 새로 */
|
||||
const muted = (text: string): HTMLElement => el("p", { className: "m02-labin__muted", text });
|
||||
/** 고른 것 바뀜 — 왼쪽 칸을 새로 */
|
||||
const refresh = (): void => {
|
||||
shows.splice(0);
|
||||
const sec = (text: string): HTMLElement =>
|
||||
el("h4", { text, attrs: { style: "margin:12px 0 4px" } });
|
||||
left.replaceChildren(
|
||||
sec("단가산출 옵션 값"),
|
||||
...(st.code
|
||||
const lines = st.code
|
||||
? st.inputs.length
|
||||
? logicRows()
|
||||
: [el("p", { className: "m02-labin__muted", text: "단가산출 없음 — [고르기]" })]),
|
||||
sec("사이클시간 입력"),
|
||||
...CYCLE_FIELDS.flatMap((group) => group.map(([n, t, u]) => numberField(n, t, u))),
|
||||
sec("표에서 고르기(건설품셈 8-2-8)"),
|
||||
...PICKS.map(([key, map, text]) => tablePick(key, map, text)),
|
||||
: [muted("입력 없음")]
|
||||
: [muted(st.other ? "입력 없음 — 그대로 연결" : "연결 없음 — [고르기]")];
|
||||
left.replaceChildren(
|
||||
el("h4", { text: "옵션 값", attrs: { style: "margin:12px 0 4px" } }),
|
||||
...lines,
|
||||
...(oldCycle && st.code
|
||||
? [muted("옛 사이클 손 값 — 셈은 8-2-8 기본값 · [적용]하면 지움")]
|
||||
: []),
|
||||
);
|
||||
title.textContent = st.code ? `단가산출 ${st.code} ${st.name}` : "단가산출 — 없음";
|
||||
change.querySelector(".ui-btn__label")!.textContent = st.code ? "변경" : "고르기";
|
||||
const p = st.other;
|
||||
title.textContent = st.code
|
||||
? `단가산출 ${st.code} ${st.name}`
|
||||
: p
|
||||
? `${KIND_TEXT[p.kind] ?? p.kind} ${String("from" in p ? (p.from.name ?? "") : "name" in p ? p.name : "")}`
|
||||
: "연결 — 없음";
|
||||
change.querySelector(".ui-btn__label")!.textContent = st.code || p ? "변경" : "고르기";
|
||||
update();
|
||||
};
|
||||
|
||||
const useLogic = (code: string, name: string): void => {
|
||||
fetchLogicInputs([code]).then(
|
||||
(got) => {
|
||||
st.other = null;
|
||||
st.code = code;
|
||||
st.name = name;
|
||||
st.inputs = got.로직[0]?.입력 ?? [];
|
||||
@@ -324,41 +256,40 @@ export function openHaulModal(o: HaulModalOpts): void {
|
||||
(error: unknown) => showToast(`로직 입력을 못 읽음 — ${String(error)}`, "error"),
|
||||
);
|
||||
};
|
||||
const usePicked = (p: Picked): void => {
|
||||
if (p.kind === "logic") return useLogic(p.code, p.name);
|
||||
st.other = p;
|
||||
st.code = "";
|
||||
st.inputs = [];
|
||||
refresh();
|
||||
};
|
||||
const change = createButton({
|
||||
label: "변경",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => openLogicPicker((logic) => useLogic(logic.키, logic.이름)),
|
||||
onClick: () => void openPriceLink({ ...o, onPick: usePicked }),
|
||||
});
|
||||
linkBox.append(title, change);
|
||||
if (o.other)
|
||||
linkBox.append(
|
||||
createButton({
|
||||
label: "다른 종류",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
close();
|
||||
o.other?.();
|
||||
},
|
||||
}),
|
||||
);
|
||||
const ok = createButton({
|
||||
label: "적용",
|
||||
onClick: () => {
|
||||
if (!st.code) return showToast("단가산출을 먼저 고르세요", "error");
|
||||
const next: Row = {
|
||||
tab: "logic",
|
||||
code: st.code,
|
||||
name: st.name,
|
||||
given: filled(),
|
||||
rate: st.rate,
|
||||
picks: st.picks,
|
||||
cycle: st.cycle,
|
||||
own: [...st.own],
|
||||
};
|
||||
o.apply(next, { name: st.name, unit: st.unit.replace(/^원\//, "") });
|
||||
showToast("적용함 — [저장]하면 서버가 거리로 다시 셈", "success");
|
||||
const p = st.other;
|
||||
if (!st.code && !p) return showToast("연결할 것을 먼저 고르세요", "error");
|
||||
if (p && (p.kind === "combo" || p.kind === "precombo")) void importLib(o, p);
|
||||
else if (p && "ref" in p) o.apply(p.ref, p.from);
|
||||
else {
|
||||
const next: Row = {
|
||||
tab: "logic",
|
||||
code: st.code,
|
||||
name: st.name,
|
||||
given: filled(),
|
||||
rate: st.rate,
|
||||
picks: st.picks,
|
||||
own: [...st.own],
|
||||
};
|
||||
o.apply(next, { name: st.name, unit: st.unit.replace(/^원\//, "") });
|
||||
showToast("적용함 — [저장]하면 서버가 거리로 다시 셈", "success");
|
||||
}
|
||||
close();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -370,7 +370,7 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
actions.append(save, reset);
|
||||
const left = document.createElement("div");
|
||||
left.className = "ui-sidebar-panel b09-options";
|
||||
left.append(section, actions);
|
||||
left.append(section, ...DRAFT.panels.map((make) => make(() => user, setUser)), actions);
|
||||
attachCollapsible(left);
|
||||
|
||||
const layout = createWorkflowLayout({
|
||||
|
||||
@@ -122,11 +122,13 @@ export interface TabTable {
|
||||
export type TabBuild = (ctx: TabContext) => TabTable | string;
|
||||
|
||||
/** 초안 탭(252 · `draft/*_UI.ts` 가 TABS · TAB_GROUPS 와 함께 넣음 · 폴더째 지우면 빈 채로) —
|
||||
* builders = 탭 짜기 · extend = 브라우저 셈 곁(셈 결과에 더함) */
|
||||
* builders = 탭 짜기 · extend = 브라우저 셈 곁(셈 결과에 더함) ·
|
||||
* panels = 왼쪽 패널 카드(264 · 「데이터 점검」 아래 · 지금 사용자 값 · 고침 → 캐시 · 다시 셈) */
|
||||
export const DRAFT: {
|
||||
builders: Record<string, TabBuild>;
|
||||
extend: Array<(payload: Row, user: Row, sheets: Row) => void>;
|
||||
} = { builders: {}, extend: [] };
|
||||
panels: Array<(user: () => Row, setUser: (next: Row) => void) => HTMLElement>;
|
||||
} = { builders: {}, extend: [], panels: [] };
|
||||
|
||||
/** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */
|
||||
export const blockLevel = (key: string, top: (index: number) => boolean): TabTable["level"] => ({
|
||||
|
||||
@@ -61,13 +61,26 @@ export function askInputs(apply: ApplyRef, code: string, name: string, old?: Row
|
||||
);
|
||||
}
|
||||
|
||||
/** 단가연결 목록에서 고른 것(263-1 · 「운반 단가」 모달이 받아 보임) */
|
||||
export type LibPick = {
|
||||
kind: "combo" | "precombo";
|
||||
layer: string;
|
||||
owner: string | null;
|
||||
key: string;
|
||||
};
|
||||
export type Picked =
|
||||
| { kind: "logic"; code: string; name: string }
|
||||
| (LibPick & { name: string; unit: string })
|
||||
| { kind: "dg" | "sg"; ref: Row; from: Row };
|
||||
|
||||
/** 서고 것 → 프로젝트 사본 · 새 대가 호표(204-2) — 서버가 들여옴 → 받은 원가 자료(대가 블록 · 산근)만
|
||||
* 바꿔 끼우고(사용자 캐시 그대로) 줄 연결 `{tab: dg, key: 블록 id, lib: 사본 폴더}` */
|
||||
async function importLib(
|
||||
export async function importLib(
|
||||
o: { apply: ApplyRef; onPayload?: (payload: Row) => void },
|
||||
pick: { kind: "combo" | "precombo"; layer: string; owner: string | null; key: string },
|
||||
lib: LibPick,
|
||||
): Promise<void> {
|
||||
const apply = o.apply;
|
||||
const pick = { kind: lib.kind, layer: lib.layer, owner: lib.owner, key: lib.key };
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
if (!projectId) return;
|
||||
showToast("가져오는 중…", "info");
|
||||
@@ -98,7 +111,7 @@ async function importLib(
|
||||
}
|
||||
|
||||
/** 항목 줄 단가연결 메뉴(251-6) — [단가연결 모달, 그 밖(로직 입력값 · 단가연결 풀기)] · ref = 지금 연결 ·
|
||||
* haul = 운반공 줄(253-3 · 그 행 거리 · 이름) → 「운반 단가」 모달(옛 창은 [다른 종류]) */
|
||||
* haul = 운반공 줄(253-3 · 그 행 거리 · 이름) → 「운반 단가」 모달([변경] = 같은 목록 · 263-1) */
|
||||
export function linkItems(
|
||||
ctx: TabContext,
|
||||
ref: Row | null,
|
||||
@@ -106,14 +119,14 @@ export function linkItems(
|
||||
haul?: { dist: Dist; name: string },
|
||||
): [Leaf[], Leaf[]] {
|
||||
const more: Leaf[] = [];
|
||||
const priceLink = (): void =>
|
||||
void openPriceLink({
|
||||
apply,
|
||||
hopyo: { dg: ctx.sheets.daega || [], sg: ctx.sheets.sg_list || [] },
|
||||
onPayload: (p) => Object.assign(ctx.payload, p),
|
||||
});
|
||||
const link: PriceLinkOpts = {
|
||||
apply,
|
||||
hopyo: { dg: ctx.sheets.daega || [], sg: ctx.sheets.sg_list || [] },
|
||||
onPayload: (p) => Object.assign(ctx.payload, p),
|
||||
};
|
||||
const priceLink = (): void => void openPriceLink(link);
|
||||
const haulModal = (): void =>
|
||||
openHaulModal({ ref, dist: haul?.dist, name: haul?.name || "", apply, other: priceLink });
|
||||
openHaulModal({ ...link, ref, dist: haul?.dist, name: haul?.name || "" });
|
||||
if (ref?.tab === "logic" && !haul)
|
||||
more.push(["로직 입력값 고치기", () => askInputs(apply, ref.code, ref.name || "", ref)]);
|
||||
if (ref) more.push(["단가연결 풀기", () => apply(null)]);
|
||||
@@ -129,9 +142,18 @@ export interface PriceLinkOpts {
|
||||
hopyo?: { dg: Row[]; sg: Row[] };
|
||||
/** 서고 사본 들여온 뒤 받은 원가 자료(B09 = 화면 자료에 끼움) */
|
||||
onPayload?: (payload: Row) => void;
|
||||
/** 고른 것 받기(263-1 「운반 단가」 모달) — 없으면 단가산출 = 옵션 값 창 · 조합 = 사본 들여옴 · 호표 = 연결 */
|
||||
onPick?: (picked: Picked) => void;
|
||||
}
|
||||
|
||||
export async function openPriceLink(o: PriceLinkOpts): Promise<void> {
|
||||
const pick =
|
||||
o.onPick ??
|
||||
((p: Picked): void => {
|
||||
if (p.kind === "logic") askInputs(o.apply, p.code, p.name);
|
||||
else if ("ref" in p) o.apply(p.ref, p.from);
|
||||
else void importLib(o, p);
|
||||
});
|
||||
const hop = (tab: "dg" | "sg") =>
|
||||
((tab === "dg" ? o.hopyo?.dg : o.hopyo?.sg) ?? []).map((r) => ({
|
||||
kind: tab,
|
||||
@@ -140,7 +162,7 @@ export async function openPriceLink(o: PriceLinkOpts): Promise<void> {
|
||||
name: String(r.name || ""),
|
||||
sub: String(r.spec || ""),
|
||||
unit: String(r.unit || ""),
|
||||
onPick: () => o.apply({ tab, key: r.key }, r),
|
||||
onPick: () => pick({ kind: tab, ref: { tab, key: r.key }, from: r }),
|
||||
}));
|
||||
// 구조물 조합 = 마스터 · 회사 · 개인 한 목록(B08 [선택] 나무와 같은 원천)
|
||||
const combos = (await loadRows().catch(() => [])).map((r) => ({
|
||||
@@ -151,14 +173,16 @@ export async function openPriceLink(o: PriceLinkOpts): Promise<void> {
|
||||
sub: `${LAYER_TEXT[r.layer ?? "system"]} · ${r.key}`,
|
||||
unit: String(r.단위 || ""),
|
||||
onPick: () =>
|
||||
void importLib(o, {
|
||||
pick({
|
||||
kind: "combo",
|
||||
layer: r.layer ?? "system",
|
||||
owner: r.owner ?? null,
|
||||
key: r.key,
|
||||
name: itemName(r),
|
||||
unit: String(r.단위 || ""),
|
||||
}),
|
||||
}));
|
||||
openLogicPicker((logic) => askInputs(o.apply, logic.키, logic.이름), {
|
||||
openLogicPicker((logic) => pick({ kind: "logic", code: logic.키, name: logic.이름 }), {
|
||||
layer: "personal",
|
||||
owner: null,
|
||||
title: "단가연결",
|
||||
@@ -174,7 +198,7 @@ export async function openPriceLink(o: PriceLinkOpts): Promise<void> {
|
||||
]
|
||||
: []),
|
||||
],
|
||||
pick: (ref) =>
|
||||
void importLib(o, { kind: "precombo", layer: ref.층, owner: ref.주인, key: ref.키 }),
|
||||
pick: (ref, name) =>
|
||||
pick({ kind: "precombo", layer: ref.층, owner: ref.주인, key: ref.키, name, unit: "" }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""B09 공기 셈 짝(PLAN 252-1 · 사용자 161번 · 초안 — draft 폴더째 지우면 사라짐).
|
||||
|
||||
짝 = `B09_Draft_Schedule_Calc.ts`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py`).
|
||||
짝 = `B09_Draft_Schedule_Calc.ts`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py` · `test_264_2_b09_schedule_options.py`).
|
||||
근거 = 실무 설계서 공기 탭 식(거창 25 · 울진 24 · 울진소광 25) — 값 베끼기가 아니라 식만:
|
||||
|
||||
공기산출 설계내역서 항목 줄마다 작업일수 = ROUNDUP(시공량 × 반영률 ÷ 일작업량, 1)
|
||||
@@ -11,13 +11,19 @@
|
||||
단위당 자원 = 서버 역전개(`…_Source.unit_resources` · 호표 구성 1 단위)
|
||||
반영률 = 다른 공종과 겹침(100 = 혼자 · 50 = 반 겹침 · 0 = 다 겹침) · 운반공 기본 0
|
||||
소계 = ROUND(Σ 작업일수 + 기타, 0) · 총작업일수 = ROUND(준비 + 소계 + 정리, 0)
|
||||
공기집계 착공 연월부터 달마다 — 달력일수 · 기후여건 A = ROUNDUP(강수일 + 혹서일, 1) ·
|
||||
공기집계 착공일부터 달마다 — 달력일수 · 기후여건 A = ROUNDUP(강수일 + 혹서일, 1) ·
|
||||
법정공휴일 B = 일요일 + 공휴일(대체 · 선거일 포함 · 사용자 덮개) ·
|
||||
중복 C = A × B ÷ 달력 ·
|
||||
비작업일수 = ROUNDDOWN(A + B − C, 0) · 작업가능일수 = 달력 − 비작업
|
||||
준비 → 작업 → 정리 차례로 작업가능일수를 채움 · 다 쓴 달 = 그 달 비작업 전부 ·
|
||||
마지막 달(덜 쓴 달) = ROUNDUP(비작업 × 쓴 날 ÷ 작업가능일, 0)
|
||||
공사기간 = Σ(쓴 날 + 비작업) · 준공 예정일 = 착공(그 달 1일) + 공사기간 − 1
|
||||
공사기간 = Σ(쓴 날 + 비작업) · 준공 예정일 = 착공일 + 공사기간 − 1
|
||||
옵션(264 · user.schedule · 기본값 = 위 식 그대로) — 착공일(1일 아니면 첫 달 = 그날부터 ·
|
||||
A = 날수 비례) · 기후 자료(원천 climates 고르기 · 달마다 덮개가 이김) ·
|
||||
쉬는 날(일 · 토일) · 공휴일 빼기(대체 · 선거일 · 노동절 · 제헌절 — 이름으로 가름) ·
|
||||
투입 기본(장비 대 · 인력 인) · 반영률 기본(운반공 · 그 밖) ·
|
||||
주 장비 기준(가장 긴 장비 · 장비 시간 합) · 중복 C 끝수(그대로 · 소수 1자리 반올림) ·
|
||||
마지막 달 비작업(쓴 비율 올림 · 전부)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,12 +33,26 @@ from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import num, round_half, rounddown, roundup
|
||||
|
||||
HOURS = 8
|
||||
CREW = 10
|
||||
PREP_DOCS = 2
|
||||
PREP_SETUP = 2
|
||||
TIDY = 4
|
||||
#: 공사 아닌 공종(자재대 · 관급자재대) · 기본 반영률 0(운반 = 다른 공종과 겹침)
|
||||
#: 사용자 값 기본(264 패널 · 표 칸 같은 값)
|
||||
DEFAULTS = {
|
||||
"hours": 8,
|
||||
"units": 1,
|
||||
"crew": 10,
|
||||
"prep_docs": 2,
|
||||
"prep_setup": 2,
|
||||
"tidy": 4,
|
||||
"etc": 0,
|
||||
"rate_haul": 0,
|
||||
"rate_base": 100,
|
||||
}
|
||||
#: 공휴일 빼기 갈래 — 이름에 든 글
|
||||
HOLIDAY_KINDS = {
|
||||
"substitute": ("대체",),
|
||||
"election": ("선거",),
|
||||
"labor": ("노동절", "근로자"),
|
||||
"constitution": ("제헌절",),
|
||||
}
|
||||
#: 공사 아닌 공종(자재대 · 관급자재대) · 기본 반영률 운반공(= 다른 공종과 겹침)
|
||||
SKIP_TOP = ("4", "G")
|
||||
OVERLAP_TOP = ("3",)
|
||||
MAX_MONTHS = 120
|
||||
@@ -42,18 +62,23 @@ def _top(section: Any) -> str:
|
||||
return str(section or "").split(".")[0]
|
||||
|
||||
|
||||
def _cfg(user: dict[str, Any], key: str, default: float) -> float:
|
||||
def _cfg(user: dict[str, Any], key: str) -> float:
|
||||
value = num(user.get(key))
|
||||
return default if value is None or value < 0 else value
|
||||
return DEFAULTS[key] if value is None or value < 0 else value
|
||||
|
||||
|
||||
def _work_row(item: dict[str, Any], unit: dict[str, Any] | None, cfg: dict, hours: float) -> dict:
|
||||
def _work_row(item: dict[str, Any], unit: dict[str, Any] | None, cfg: dict, base: dict) -> dict:
|
||||
hours = base["hours"]
|
||||
qty = num(item.get("qty")) or 0.0
|
||||
rate = num(cfg.get("rate"))
|
||||
if rate is None:
|
||||
rate = 0.0 if _top(item.get("section")) in OVERLAP_TOP else 100.0
|
||||
rate = base["rate_haul"] if _top(item.get("section")) in OVERLAP_TOP else base["rate_base"]
|
||||
daily_in = num(cfg.get("daily"))
|
||||
machine = (unit or {}).get("machine")
|
||||
total = (unit or {}).get("machine_sum")
|
||||
if machine and base["sum"] and total and (num(total.get("hr")) or 0) > 0:
|
||||
many = f" 외 {total['n'] - 1}종" if (total.get("n") or 1) > 1 else ""
|
||||
machine = {**machine, "name": f"{machine.get('name') or ''}{many}", "hr": total["hr"]}
|
||||
labor = num((unit or {}).get("labor"))
|
||||
out = {
|
||||
"kind": "item",
|
||||
@@ -78,12 +103,12 @@ def _work_row(item: dict[str, Any], unit: dict[str, Any] | None, cfg: dict, hour
|
||||
need = qty * rate / 100 / daily_in
|
||||
elif machine and (num(machine.get("hr")) or 0) > 0:
|
||||
hr = num(machine["hr"])
|
||||
units = num(cfg.get("units")) or 1.0
|
||||
units = num(cfg.get("units")) or base["units"]
|
||||
out.update(basis="장비", who=machine.get("name") or "", per=hr, put=units)
|
||||
out.update(q=1 / hr, daily=hours * units / hr)
|
||||
need = qty * rate / 100 * hr / (hours * units)
|
||||
elif labor is not None and labor > 0:
|
||||
crew = num(cfg.get("crew")) or float(CREW)
|
||||
crew = num(cfg.get("crew")) or base["crew"]
|
||||
out.update(basis="인력", who="인력", per=labor, put=crew, daily=crew / labor)
|
||||
need = qty * rate / 100 * labor / crew
|
||||
else:
|
||||
@@ -96,7 +121,15 @@ def _work_row(item: dict[str, Any], unit: dict[str, Any] | None, cfg: dict, hour
|
||||
|
||||
def work_table(src: dict[str, Any], bill_rows: list[dict[str, Any]], user: dict) -> dict:
|
||||
"""공기산출 — 설계내역서 차례(묶음 줄 = 그 아래 셀 줄이 있을 때만) · 준비 · 기타 · 정리."""
|
||||
hours = _cfg(user, "hours", HOURS) or HOURS
|
||||
hours = _cfg(user, "hours") or DEFAULTS["hours"]
|
||||
base = {
|
||||
"hours": hours,
|
||||
"units": _cfg(user, "units") or DEFAULTS["units"],
|
||||
"crew": _cfg(user, "crew") or DEFAULTS["crew"],
|
||||
"rate_haul": min(_cfg(user, "rate_haul"), 100),
|
||||
"rate_base": min(_cfg(user, "rate_base"), 100),
|
||||
"sum": user.get("machine_basis") == "sum",
|
||||
}
|
||||
units = src.get("units") or {}
|
||||
cfgs = user.get("rows") or {}
|
||||
rows: list[dict[str, Any]] = []
|
||||
@@ -129,11 +162,11 @@ def work_table(src: dict[str, Any], bill_rows: list[dict[str, Any]], user: dict)
|
||||
"lv": head.get("level") or 1,
|
||||
} # fmt: skip
|
||||
)
|
||||
rows.append(_work_row(row, units.get(key), cfgs.get(key) or {}, hours))
|
||||
docs = _cfg(user, "prep_docs", PREP_DOCS)
|
||||
setup = _cfg(user, "prep_setup", PREP_SETUP)
|
||||
etc = _cfg(user, "etc", 0)
|
||||
tidy = _cfg(user, "tidy", TIDY)
|
||||
rows.append(_work_row(row, units.get(key), cfgs.get(key) or {}, base))
|
||||
docs = _cfg(user, "prep_docs")
|
||||
setup = _cfg(user, "prep_setup")
|
||||
etc = _cfg(user, "etc")
|
||||
tidy = _cfg(user, "tidy")
|
||||
days = sum(r["days"] or 0 for r in rows if r["kind"] == "item")
|
||||
subtotal = round_half(days + etc, 0)
|
||||
missing = sum(1 for r in rows if r["kind"] == "item" and r["days"] is None)
|
||||
@@ -156,45 +189,70 @@ def _month_days(year: int, month: int) -> int:
|
||||
return (nxt - datetime.date(year, month, 1)).days
|
||||
|
||||
|
||||
def _holidays(src: dict[str, Any], year: int, month: int) -> tuple[int, list[str]]:
|
||||
"""그 달 법정공휴일 수(일요일 + 공휴일 겹침 한 번) · 공휴일 이름(「일 이름」)."""
|
||||
def _counted(name: str, off: list[str]) -> bool:
|
||||
"""공휴일 넣음 — 「;」 로 묶인 이름 중 하나라도 뺀 갈래가 아니면 넣음."""
|
||||
for part in name.split(";"):
|
||||
kinds = [k for k, words in HOLIDAY_KINDS.items() if any(w in part for w in words)]
|
||||
if not any(k in off for k in kinds):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _holidays(
|
||||
src: dict[str, Any], year: int, month: int, first: int, user: dict[str, Any]
|
||||
) -> tuple[int, list[str]]:
|
||||
"""그 달(first 일부터) 법정공휴일 수(쉬는 요일 + 공휴일 겹침 한 번) · 공휴일 이름(「일 이름」)."""
|
||||
head = f"{year:04d}-{month:02d}-"
|
||||
off = user.get("holiday_off") or []
|
||||
named = {
|
||||
d: n for d, n in (src.get("holidays") or {}).get(str(year)) or [] if d.startswith(head)
|
||||
d: n
|
||||
for d, n in (src.get("holidays") or {}).get(str(year)) or []
|
||||
if d.startswith(head) and int(d[-2:]) >= first and _counted(n, off)
|
||||
}
|
||||
rest = (5, 6) if user.get("rest") == "satsun" else (6,)
|
||||
count = 0
|
||||
for day in range(1, _month_days(year, month) + 1):
|
||||
for day in range(first, _month_days(year, month) + 1):
|
||||
iso = f"{head}{day:02d}"
|
||||
if datetime.date(year, month, day).weekday() == 6 or iso in named:
|
||||
if datetime.date(year, month, day).weekday() in rest or iso in named:
|
||||
count += 1
|
||||
return count, [f"{int(d[-2:])}일 {n}" for d, n in sorted(named.items())]
|
||||
|
||||
|
||||
def climate_base(src: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]:
|
||||
"""고른 기후 자료(원천 climates) · 없으면 원천 기본값."""
|
||||
return (src.get("climates") or {}).get(user.get("climate_set")) or src.get("climate") or {}
|
||||
|
||||
|
||||
def _climate(src: dict[str, Any], user: dict[str, Any], kind: str, month: int) -> float:
|
||||
mine = ((user.get("climate") or {}).get(kind) or [None] * 12)[month - 1]
|
||||
if num(mine) is not None:
|
||||
return num(mine)
|
||||
return num(((src.get("climate") or {}).get(kind) or [0] * 12)[month - 1]) or 0.0
|
||||
return num((climate_base(src, user).get(kind) or [0] * 12)[month - 1]) or 0.0
|
||||
|
||||
|
||||
def month_table(src: dict[str, Any], user: dict[str, Any], parts: list[float]) -> dict[str, Any]:
|
||||
"""공기집계 — parts = [준비, 작업, 정리] 일수 · 착공 연월부터 채움."""
|
||||
"""공기집계 — parts = [준비, 작업, 정리] 일수 · 착공일부터 채움."""
|
||||
start = {**(src.get("start") or {}), **(user.get("start") or {})}
|
||||
year, month = int(start.get("year") or 2000), int(start.get("month") or 1)
|
||||
first = min(max(int(start.get("day") or 1), 1), _month_days(year, month))
|
||||
begin = datetime.date(year, month, first)
|
||||
left = [max(p, 0) for p in parts]
|
||||
months: list[dict[str, Any]] = []
|
||||
total = 0
|
||||
while sum(left) > 0 or not months:
|
||||
if len(months) >= MAX_MONTHS:
|
||||
break
|
||||
days = _month_days(year, month)
|
||||
full = _month_days(year, month)
|
||||
days = full - first + 1
|
||||
rain = _climate(src, user, "rain", month)
|
||||
heat = _climate(src, user, "heat", month)
|
||||
a = roundup(rain + heat, 1)
|
||||
auto, names = _holidays(src, year, month)
|
||||
a = roundup(rain + heat if first == 1 else (rain + heat) * days / full, 1)
|
||||
auto, names = _holidays(src, year, month, first, user)
|
||||
mine = num((user.get("holiday") or {}).get(f"{year:04d}-{month:02d}"))
|
||||
b = mine if mine is not None else auto
|
||||
c = a * b / days
|
||||
if user.get("c_round") == "round1":
|
||||
c = round_half(c, 1)
|
||||
off = rounddown(a + b - c, 0)
|
||||
avail = days - off
|
||||
cap = max(avail, 0)
|
||||
@@ -208,7 +266,7 @@ def month_table(src: dict[str, Any], user: dict[str, Any], parts: list[float]) -
|
||||
used = sum(use)
|
||||
if cap <= 0:
|
||||
off_used = days
|
||||
elif used >= cap:
|
||||
elif used >= cap or user.get("last_off") == "all":
|
||||
off_used = off
|
||||
else:
|
||||
off_used = roundup(off * used / cap, 0)
|
||||
@@ -237,11 +295,10 @@ def month_table(src: dict[str, Any], user: dict[str, Any], parts: list[float]) -
|
||||
} # fmt: skip
|
||||
)
|
||||
year, month = (year + 1, 1) if month == 12 else (year, month + 1)
|
||||
first = months[0]
|
||||
begin = datetime.date(first["year"], first["month"], 1)
|
||||
first = 1
|
||||
end = begin + datetime.timedelta(days=max(total, 1) - 1)
|
||||
return {
|
||||
"start": {"year": first["year"], "month": first["month"]},
|
||||
"start": {"year": begin.year, "month": begin.month, "day": begin.day},
|
||||
"months": months,
|
||||
"days": total,
|
||||
"months_n": round_half(total / 30, 1),
|
||||
|
||||
@@ -1,35 +1,59 @@
|
||||
/* =============================================================================
|
||||
* B09_Draft_Schedule_Calc.ts
|
||||
* B09 공기 셈 짝(PLAN 252-1 · 사용자 161번 · 초안 — draft 폴더째 지우면 사라짐).
|
||||
* 짝 = `B09_Draft_Schedule_Calc.py`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py`) — 식 설명은 그쪽 머리.
|
||||
* 짝 = `B09_Draft_Schedule_Calc.py`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py` ·
|
||||
* `test_264_2_b09_schedule_options.py`) — 식 설명은 그쪽 머리.
|
||||
* 공기산출 = 설계내역서 항목 줄마다 ROUNDUP(시공량 × 반영률 ÷ 일작업량, 1) · 일작업량 = 장비 · 인력 · 입력
|
||||
* 공기집계 = 착공 연월부터 달마다 작업가능일수(달력 − (A + B − A×B÷달력))로 준비 → 작업 → 정리를 채움
|
||||
* 공기집계 = 착공일부터 달마다 작업가능일수(달력 − (A + B − A×B÷달력))로 준비 → 작업 → 정리를 채움
|
||||
* 옵션(264) = 착공일 · 기후 자료 · 쉬는 날 · 공휴일 빼기 · 투입 기본 · 반영률 기본 · 주 장비 기준 ·
|
||||
* 중복 C 끝수 · 마지막 달 비작업(기본값 = 위 식 그대로)
|
||||
* ========================================================================== */
|
||||
|
||||
import { num, roundHalf, rounddown, roundup, type Row } from "../B09_Estimation_Calc_Base";
|
||||
|
||||
const HOURS = 8;
|
||||
const CREW = 10;
|
||||
const PREP_DOCS = 2;
|
||||
const PREP_SETUP = 2;
|
||||
const TIDY = 4;
|
||||
/** 사용자 값 기본(264 패널 · 표 칸 같은 값) */
|
||||
export const DEFAULTS: Record<string, number> = {
|
||||
hours: 8,
|
||||
units: 1,
|
||||
crew: 10,
|
||||
prep_docs: 2,
|
||||
prep_setup: 2,
|
||||
tidy: 4,
|
||||
etc: 0,
|
||||
rate_haul: 0,
|
||||
rate_base: 100,
|
||||
};
|
||||
/** 공휴일 빼기 갈래 — 이름에 든 글 */
|
||||
export const HOLIDAY_KINDS: Record<string, string[]> = {
|
||||
substitute: ["대체"],
|
||||
election: ["선거"],
|
||||
labor: ["노동절", "근로자"],
|
||||
constitution: ["제헌절"],
|
||||
};
|
||||
const SKIP_TOP = ["4", "G"];
|
||||
const OVERLAP_TOP = ["3"];
|
||||
const MAX_MONTHS = 120;
|
||||
|
||||
const top = (section: unknown): string => String(section ?? "").split(".")[0];
|
||||
|
||||
function cfg(user: Row, key: string, fallback: number): number {
|
||||
function cfg(user: Row, key: string): number {
|
||||
const value = num(user[key]);
|
||||
return value === null || value < 0 ? fallback : value;
|
||||
return value === null || value < 0 ? DEFAULTS[key] : value;
|
||||
}
|
||||
|
||||
function workRow(item: Row, unit: Row | undefined, c: Row, hours: number): Row {
|
||||
function workRow(item: Row, unit: Row | undefined, c: Row, base: Row): Row {
|
||||
const hours: number = base.hours;
|
||||
const qty = num(item.qty) ?? 0;
|
||||
let rate = num(c.rate);
|
||||
if (rate === null) rate = OVERLAP_TOP.includes(top(item.section)) ? 0 : 100;
|
||||
if (rate === null)
|
||||
rate = OVERLAP_TOP.includes(top(item.section)) ? base.rate_haul : base.rate_base;
|
||||
const dailyIn = num(c.daily);
|
||||
const machine = unit?.machine as Row | null | undefined;
|
||||
let machine = unit?.machine as Row | null | undefined;
|
||||
const total = unit?.machine_sum as Row | null | undefined;
|
||||
if (machine && base.sum && total && (num(total.hr) ?? 0) > 0) {
|
||||
const many = (total.n || 1) > 1 ? ` 외 ${total.n - 1}종` : "";
|
||||
machine = { ...machine, name: `${machine.name || ""}${many}`, hr: total.hr };
|
||||
}
|
||||
const labor = num(unit?.labor);
|
||||
const out: Row = {
|
||||
kind: "item",
|
||||
@@ -55,12 +79,12 @@ function workRow(item: Row, unit: Row | undefined, c: Row, hours: number): Row {
|
||||
need = (qty * rate) / 100 / dailyIn;
|
||||
} else if (machine && (num(machine.hr) ?? 0) > 0) {
|
||||
const hr = num(machine.hr) as number;
|
||||
const units = num(c.units) || 1;
|
||||
const units = num(c.units) || base.units;
|
||||
Object.assign(out, { basis: "장비", who: machine.name || "", per: hr, put: units });
|
||||
Object.assign(out, { q: 1 / hr, daily: (hours * units) / hr });
|
||||
need = (((qty * rate) / 100) * hr) / (hours * units);
|
||||
} else if (labor !== null && labor > 0) {
|
||||
const crew = num(c.crew) || CREW;
|
||||
const crew = num(c.crew) || base.crew;
|
||||
Object.assign(out, { basis: "인력", who: "인력", per: labor, put: crew, daily: crew / labor });
|
||||
need = (((qty * rate) / 100) * labor) / crew;
|
||||
} else {
|
||||
@@ -74,7 +98,15 @@ function workRow(item: Row, unit: Row | undefined, c: Row, hours: number): Row {
|
||||
|
||||
/** 공기산출 — 설계내역서 차례(묶음 줄 = 그 아래 셀 줄이 있을 때만) · 준비 · 기타 · 정리 */
|
||||
export function workTable(src: Row, billRows: Row[], user: Row): Row {
|
||||
const hours = cfg(user, "hours", HOURS) || HOURS;
|
||||
const hours = cfg(user, "hours") || DEFAULTS.hours;
|
||||
const base: Row = {
|
||||
hours,
|
||||
units: cfg(user, "units") || DEFAULTS.units,
|
||||
crew: cfg(user, "crew") || DEFAULTS.crew,
|
||||
rate_haul: Math.min(cfg(user, "rate_haul"), 100),
|
||||
rate_base: Math.min(cfg(user, "rate_base"), 100),
|
||||
sum: user.machine_basis === "sum",
|
||||
};
|
||||
const units: Row = src.units || {};
|
||||
const cfgs: Row = user.rows || {};
|
||||
const rows: Row[] = [];
|
||||
@@ -101,12 +133,12 @@ export function workTable(src: Row, billRows: Row[], user: Row): Row {
|
||||
p[1] = true;
|
||||
rows.push({ kind: "head", key: p[0].key, name: p[0].name || "", lv: p[0].level || 1 });
|
||||
}
|
||||
rows.push(workRow(row, units[key], cfgs[key] || {}, hours));
|
||||
rows.push(workRow(row, units[key], cfgs[key] || {}, base));
|
||||
}
|
||||
const docs = cfg(user, "prep_docs", PREP_DOCS);
|
||||
const setup = cfg(user, "prep_setup", PREP_SETUP);
|
||||
const etc = cfg(user, "etc", 0);
|
||||
const tidy = cfg(user, "tidy", TIDY);
|
||||
const docs = cfg(user, "prep_docs");
|
||||
const setup = cfg(user, "prep_setup");
|
||||
const etc = cfg(user, "etc");
|
||||
const tidy = cfg(user, "tidy");
|
||||
const items = rows.filter((r) => r.kind === "item");
|
||||
const days = items.reduce((s: number, r) => s + (r.days || 0), 0);
|
||||
const subtotal = roundHalf(days + etc, 0);
|
||||
@@ -129,16 +161,38 @@ const monthDays = (year: number, month: number): number =>
|
||||
|
||||
const pad = (n: number): string => String(n).padStart(2, "0");
|
||||
|
||||
/** 그 달 법정공휴일 수(일요일 + 공휴일 겹침 한 번) · 공휴일 이름 */
|
||||
function holidays(src: Row, year: number, month: number): [number, string[]] {
|
||||
/** 공휴일 넣음 — 「;」 로 묶인 이름 중 하나라도 뺀 갈래가 아니면 넣음 */
|
||||
function counted(name: string, off: string[]): boolean {
|
||||
for (const part of name.split(";")) {
|
||||
const kinds = Object.keys(HOLIDAY_KINDS).filter((k) =>
|
||||
HOLIDAY_KINDS[k].some((w) => part.includes(w)),
|
||||
);
|
||||
if (!kinds.some((k) => off.includes(k))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 그 달(first 일부터) 법정공휴일 수(쉬는 요일 + 공휴일 겹침 한 번) · 공휴일 이름 */
|
||||
function holidays(
|
||||
src: Row,
|
||||
year: number,
|
||||
month: number,
|
||||
first: number,
|
||||
user: Row,
|
||||
): [number, string[]] {
|
||||
const head = `${String(year).padStart(4, "0")}-${pad(month)}-`;
|
||||
const off: string[] = user.holiday_off || [];
|
||||
const named = new Map<string, string>(
|
||||
((src.holidays || {})[String(year)] || []).filter(([d]: [string]) => d.startsWith(head)),
|
||||
((src.holidays || {})[String(year)] || []).filter(
|
||||
([d, n]: [string, string]) =>
|
||||
d.startsWith(head) && Number(d.slice(-2)) >= first && counted(n, off),
|
||||
),
|
||||
);
|
||||
const rest = user.rest === "satsun" ? [6, 0] : [0];
|
||||
let count = 0;
|
||||
for (let day = 1; day <= monthDays(year, month); day += 1) {
|
||||
const sunday = new Date(Date.UTC(year, month - 1, day)).getUTCDay() === 0;
|
||||
if (sunday || named.has(`${head}${pad(day)}`)) count += 1;
|
||||
for (let day = first; day <= monthDays(year, month); day += 1) {
|
||||
const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
|
||||
if (rest.includes(weekday) || named.has(`${head}${pad(day)}`)) count += 1;
|
||||
}
|
||||
const names = [...named.entries()]
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
@@ -146,30 +200,38 @@ function holidays(src: Row, year: number, month: number): [number, string[]] {
|
||||
return [count, names];
|
||||
}
|
||||
|
||||
/** 고른 기후 자료(원천 climates) · 없으면 원천 기본값 */
|
||||
export const climateBase = (src: Row, user: Row): Row =>
|
||||
(src.climates || {})[user.climate_set] || src.climate || {};
|
||||
|
||||
function climate(src: Row, user: Row, kind: string, month: number): number {
|
||||
const mine = num(((user.climate || {})[kind] || [])[month - 1]);
|
||||
if (mine !== null) return mine;
|
||||
return num(((src.climate || {})[kind] || [])[month - 1]) ?? 0;
|
||||
return num((climateBase(src, user)[kind] || [])[month - 1]) ?? 0;
|
||||
}
|
||||
|
||||
/** 공기집계 — parts = [준비, 작업, 정리] 일수 · 착공 연월부터 채움 */
|
||||
/** 공기집계 — parts = [준비, 작업, 정리] 일수 · 착공일부터 채움 */
|
||||
export function monthTable(src: Row, user: Row, parts: number[]): Row {
|
||||
const start: Row = { ...(src.start || {}), ...(user.start || {}) };
|
||||
let year = Math.trunc(Number(start.year) || 2000);
|
||||
let month = Math.trunc(Number(start.month) || 1);
|
||||
let first = Math.min(Math.max(Math.trunc(Number(start.day) || 1), 1), monthDays(year, month));
|
||||
const begin = Date.UTC(year, month - 1, first);
|
||||
const left = parts.map((p) => Math.max(p, 0));
|
||||
const months: Row[] = [];
|
||||
let total = 0;
|
||||
while (left.reduce((s, v) => s + v, 0) > 0 || !months.length) {
|
||||
if (months.length >= MAX_MONTHS) break;
|
||||
const days = monthDays(year, month);
|
||||
const full = monthDays(year, month);
|
||||
const days = full - first + 1;
|
||||
const rain = climate(src, user, "rain", month);
|
||||
const heat = climate(src, user, "heat", month);
|
||||
const a = roundup(rain + heat, 1);
|
||||
const [auto, names] = holidays(src, year, month);
|
||||
const a = roundup(first === 1 ? rain + heat : ((rain + heat) * days) / full, 1);
|
||||
const [auto, names] = holidays(src, year, month, first, user);
|
||||
const mine = num((user.holiday || {})[`${String(year).padStart(4, "0")}-${pad(month)}`]);
|
||||
const b = mine !== null ? mine : auto;
|
||||
const c = (a * b) / days;
|
||||
let c = (a * b) / days;
|
||||
if (user.c_round === "round1") c = roundHalf(c, 1);
|
||||
const off = rounddown(a + b - c, 0);
|
||||
const avail = days - off;
|
||||
const cap = Math.max(avail, 0);
|
||||
@@ -182,7 +244,12 @@ export function monthTable(src: Row, user: Row, parts: number[]): Row {
|
||||
room -= take;
|
||||
}
|
||||
const used = use[0] + use[1] + use[2];
|
||||
const offUsed = cap <= 0 ? days : used >= cap ? off : roundup((off * used) / cap, 0);
|
||||
const offUsed =
|
||||
cap <= 0
|
||||
? days
|
||||
: used >= cap || user.last_off === "all"
|
||||
? off
|
||||
: roundup((off * used) / cap, 0);
|
||||
const span = used + offUsed;
|
||||
total += span;
|
||||
months.push({
|
||||
@@ -209,11 +276,12 @@ export function monthTable(src: Row, user: Row, parts: number[]): Row {
|
||||
year += 1;
|
||||
month = 1;
|
||||
} else month += 1;
|
||||
first = 1;
|
||||
}
|
||||
const first = months[0];
|
||||
const end = new Date(Date.UTC(first.year, first.month - 1, 1) + (Math.max(total, 1) - 1) * 864e5);
|
||||
const from = new Date(begin);
|
||||
const end = new Date(begin + (Math.max(total, 1) - 1) * 864e5);
|
||||
return {
|
||||
start: { year: first.year, month: first.month },
|
||||
start: { year: from.getUTCFullYear(), month: from.getUTCMonth() + 1, day: from.getUTCDate() },
|
||||
months,
|
||||
days: total,
|
||||
months_n: roundHalf(total / 30, 1),
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/* =============================================================================
|
||||
* B09_Draft_Schedule_Panel.ts
|
||||
* B09 왼쪽 패널 「공기 옵션」 카드(PLAN 264-1 · 사용자 164-2 · 초안 — draft 폴더째 지우면 사라짐).
|
||||
* 등록 = `B09_Draft_Schedule_UI` 가 DRAFT.panels(카드) · DRAFT.extend(값 맞춤)에 넣음 —
|
||||
* 본 코드 훅 = `…_UI_Shell` 이 「데이터 점검」 아래 · [저장] 묶음 위에 붙이는 한 줄
|
||||
* 값 = user.schedule(캐시 → [저장] 한 번에) · 공기산출 · 공기집계 표 칸과 같은 값(어디서 고쳐도 한 벌)
|
||||
* 꼴 = 좌측 패널 공용(B05/B06 「구조물 배치」 칸 — 라벨 위 · 입력 아래 · 두 칸 줄 · 체크)
|
||||
* ========================================================================== */
|
||||
|
||||
import "../../B05_Profile/B05_Profile_UI_Style.css";
|
||||
import { num, type Row } from "../B09_Estimation_Calc_Base";
|
||||
import { parseNumber } from "../B09_Estimation_UI_Tabs";
|
||||
import { DEFAULTS } from "./B09_Draft_Schedule_Calc";
|
||||
|
||||
type Sync = (mine: Row, src: Row) => void;
|
||||
|
||||
const HOLIDAY_LABELS: Array<[string, string]> = [
|
||||
["substitute", "대체공휴일"],
|
||||
["election", "선거일"],
|
||||
["labor", "근로자의 날(노동절)"],
|
||||
["constitution", "제헌절"],
|
||||
];
|
||||
const CHOICES: Record<string, Array<[string, string]>> = {
|
||||
rest: [
|
||||
["sun", "일요일만"],
|
||||
["satsun", "토 · 일"],
|
||||
],
|
||||
machine_basis: [
|
||||
["max", "단위당 시간이 가장 긴 장비"],
|
||||
["sum", "장비 시간 합(차례로 일하는 장비)"],
|
||||
],
|
||||
c_round: [
|
||||
["none", "그대로"],
|
||||
["round1", "소수 1자리 반올림"],
|
||||
],
|
||||
last_off: [
|
||||
["ratio", "쓴 비율만큼 올림"],
|
||||
["all", "그 달 비작업 전부"],
|
||||
],
|
||||
};
|
||||
|
||||
/** 지금 화면의 카드 맞춤 — 셈마다 DRAFT.extend 가 부름(카드를 다시 만들면 새 카드로) */
|
||||
let syncNow: Sync | null = null;
|
||||
|
||||
export function syncSchedulePanel(payload: Row, user: Row): void {
|
||||
syncNow?.(user.schedule || {}, payload.inputs?.schedule || {});
|
||||
}
|
||||
|
||||
function field(label: string, input: HTMLElement): HTMLLabelElement {
|
||||
const wrapper = document.createElement("label");
|
||||
wrapper.className = "b05-route__field";
|
||||
const caption = document.createElement("span");
|
||||
caption.textContent = label;
|
||||
wrapper.append(caption, input);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function pair(...items: HTMLElement[]): HTMLDivElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "b05-route__field-row";
|
||||
row.append(...items);
|
||||
return row;
|
||||
}
|
||||
|
||||
export function createSchedulePanel(user: () => Row, setUser: (next: Row) => void): HTMLElement {
|
||||
const syncs: Sync[] = [];
|
||||
const change = (fn: (s: Row) => void): void => {
|
||||
const next: Row = structuredClone(user().schedule || {});
|
||||
fn(next);
|
||||
setUser({ ...user(), schedule: next });
|
||||
};
|
||||
|
||||
/** 수 칸 — 빈칸 · 기본값 = 지움(기본) · 범위 밖 = 되돌림 */
|
||||
const number = (key: string, label: string, min: number, max: number): HTMLLabelElement => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.min = String(min);
|
||||
input.max = String(max);
|
||||
input.step = "any";
|
||||
input.dataset.b09Schedule = key;
|
||||
let last: Row = {};
|
||||
syncs.push((mine) => {
|
||||
last = mine;
|
||||
input.value = String(num(mine[key]) ?? DEFAULTS[key]);
|
||||
});
|
||||
input.addEventListener("change", () => {
|
||||
const v = parseNumber(input.value);
|
||||
if (v === undefined || (v !== null && (v < min || v > max))) {
|
||||
input.value = String(num(last[key]) ?? DEFAULTS[key]);
|
||||
return;
|
||||
}
|
||||
change((s) => {
|
||||
if (v === null || v === DEFAULTS[key]) delete s[key];
|
||||
else s[key] = v;
|
||||
});
|
||||
});
|
||||
return field(label, input);
|
||||
};
|
||||
|
||||
/** 고르기 칸 — 첫째 = 기본(고르면 지움) · options 없으면 원천에서(기후 자료) */
|
||||
const choose = (
|
||||
key: string,
|
||||
label: string,
|
||||
options?: Array<[string, string]>,
|
||||
): HTMLLabelElement => {
|
||||
const select = document.createElement("select");
|
||||
select.dataset.b09Schedule = key;
|
||||
let values: string[] = [];
|
||||
syncs.push((mine, src) => {
|
||||
const list =
|
||||
options ??
|
||||
Object.entries((src.climates || {}) as Row).map(([id, c]): [string, string] => [
|
||||
id,
|
||||
c.name || id,
|
||||
]);
|
||||
if (list.map(([v]) => v).join() !== values.join()) {
|
||||
values = list.map(([v]) => v);
|
||||
select.replaceChildren(...list.map(([v, text]) => new Option(text, v)));
|
||||
}
|
||||
select.value = values.includes(mine[key]) ? mine[key] : (values[0] ?? "");
|
||||
});
|
||||
select.addEventListener("change", () =>
|
||||
change((s) => {
|
||||
if (select.value === values[0]) delete s[key];
|
||||
else s[key] = select.value;
|
||||
}),
|
||||
);
|
||||
return field(label, select);
|
||||
};
|
||||
|
||||
const start = document.createElement("input");
|
||||
start.type = "date";
|
||||
start.dataset.b09Schedule = "start";
|
||||
syncs.push((mine, src) => {
|
||||
const at: Row = { ...(src.start || {}), ...(mine.start || {}) };
|
||||
const pad = (n: unknown): string => String(Number(n) || 1).padStart(2, "0");
|
||||
start.value = at.year ? `${at.year}-${pad(at.month)}-${pad(at.day)}` : "";
|
||||
});
|
||||
start.addEventListener("change", () => {
|
||||
const hit = /^(\d{4})-(\d{2})-(\d{2})$/.exec(start.value);
|
||||
change((s) => {
|
||||
if (!hit) return void delete s.start;
|
||||
const day = Number(hit[3]);
|
||||
s.start = { year: Number(hit[1]), month: Number(hit[2]), ...(day > 1 ? { day } : {}) };
|
||||
});
|
||||
});
|
||||
|
||||
const checks = HOLIDAY_LABELS.map(([kind, label]) => {
|
||||
const wrapper = document.createElement("label");
|
||||
wrapper.className = "b05-route__check";
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.dataset.b09Holiday = kind;
|
||||
syncs.push((mine) => {
|
||||
input.checked = !(mine.holiday_off || []).includes(kind);
|
||||
});
|
||||
input.addEventListener("change", () =>
|
||||
change((s) => {
|
||||
const off = HOLIDAY_LABELS.map(([k]) => k).filter((k) =>
|
||||
k === kind ? !input.checked : (s.holiday_off || []).includes(k),
|
||||
);
|
||||
if (off.length) s.holiday_off = off;
|
||||
else delete s.holiday_off;
|
||||
}),
|
||||
);
|
||||
wrapper.append(input, document.createTextNode(label));
|
||||
return wrapper;
|
||||
});
|
||||
const holidayBox = document.createElement("div");
|
||||
holidayBox.className = "b05-route__field";
|
||||
const holidayCaption = document.createElement("span");
|
||||
holidayCaption.textContent = "공휴일 넣기(그 밖 법정공휴일은 늘 넣음)";
|
||||
holidayBox.append(holidayCaption, ...checks);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "b05-route__panel-body";
|
||||
body.append(
|
||||
field("착공일", start),
|
||||
choose("climate_set", "기후 자료(달마다 덮개는 공기집계 표)"),
|
||||
choose("rest", "쉬는 날", CHOICES.rest),
|
||||
holidayBox,
|
||||
number("hours", "일 작업시간(시간)", 0.5, 24),
|
||||
pair(
|
||||
number("units", "장비 기본 대수", 0.001, 1e4),
|
||||
number("crew", "인력 기본 인원", 0.001, 1e4),
|
||||
),
|
||||
pair(
|
||||
number("prep_docs", "서류 · 착수 준비(일)", 0, 1000),
|
||||
number("prep_setup", "공종별 준비(일)", 0, 1000),
|
||||
),
|
||||
pair(number("tidy", "정리(일)", 0, 1000), number("etc", "기타 · 현장여건(일)", 0, 1000)),
|
||||
pair(
|
||||
number("rate_haul", "반영률 운반공(%)", 0, 100),
|
||||
number("rate_base", "반영률 그 밖(%)", 0, 100),
|
||||
),
|
||||
choose("machine_basis", "주 장비 기준", CHOICES.machine_basis),
|
||||
choose("c_round", "중복일수 C 끝수", CHOICES.c_round),
|
||||
choose("last_off", "마지막 달 비작업일", CHOICES.last_off),
|
||||
);
|
||||
const section = document.createElement("section");
|
||||
section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card";
|
||||
section.dataset.b09SchedulePanel = "";
|
||||
section.hidden = true; // 원천(서버 공기 자료)이 오면 보임
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "ui-collapsible__title";
|
||||
heading.textContent = "공기 옵션";
|
||||
section.append(heading, body);
|
||||
|
||||
syncNow = (mine, src) => {
|
||||
section.hidden = !src.climates;
|
||||
for (const sync of syncs) sync(mine, src);
|
||||
};
|
||||
syncNow(user().schedule || {}, {});
|
||||
return section;
|
||||
}
|
||||
@@ -9,12 +9,14 @@
|
||||
공휴일 = `B09_Draft_Schedule_Holidays.json`(2024~2040 · 법정 · 대체 · 확정 선거일 — 공개 `holidays`
|
||||
목록 0.106 에서 뽑음 · 2027 이후 선거일은 날짜가 안 정해져 뺌 · 달마다 사용자 덮개)
|
||||
기후 기본값 = 참고 설계서(25 거창) 월별 강수일(일강수량 5mm 이상) · 혹서일(33℃ 이상) —
|
||||
지역마다 사용자 고침
|
||||
사용자 값 = user["schedule"] — 다듬기 `clean_schedule`(모르는 칸 버림 · 수 칸은 수)
|
||||
고르기(264) = 참고 설계서 셋(거창 25 · 울진 24 · 울진 소광 25 「공사기간산정표」) · 0에서 직접 ·
|
||||
달마다 사용자 덮개가 이김
|
||||
사용자 값 = user["schedule"] — 다듬기 `clean_schedule`(모르는 칸 버림 · 수 칸은 수 · 고르기 칸은 목록 안)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
@@ -22,7 +24,7 @@ from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import num
|
||||
from B09_Estimation.B09_Estimation_Summary import expand, hopyo_lines
|
||||
from B09_Estimation.draft.B09_Draft_Schedule_Calc import compute_schedule
|
||||
from B09_Estimation.draft.B09_Draft_Schedule_Calc import HOLIDAY_KINDS, compute_schedule
|
||||
|
||||
HOLIDAYS = Path(__file__).with_name("B09_Draft_Schedule_Holidays.json")
|
||||
CLIMATE = {
|
||||
@@ -30,8 +32,50 @@ CLIMATE = {
|
||||
"rain": [1.2, 1.4, 3.6, 4.5, 3.6, 4.4, 7.7, 6.9, 5.5, 3.1, 2.2, 1.2],
|
||||
"heat": [0, 0, 0, 0, 0.5, 1.2, 5.9, 8.4, 0, 0, 0, 0],
|
||||
}
|
||||
#: 기후 자료 고르기(264) — 첫 줄 = 기본 · rain_note · heat_note = 공기집계 비고
|
||||
CLIMATES = {
|
||||
"geochang25": {
|
||||
**CLIMATE,
|
||||
"name": "거창 25 참고",
|
||||
"rain_note": "일강수량 5mm 이상",
|
||||
"heat_note": "33℃ 이상",
|
||||
},
|
||||
"uljin24": {
|
||||
"name": "울진 24 참고",
|
||||
"label": "경북 울진(참고 설계서 24 · 강수 = 일강수량 5mm 이상 · 혹서 없음)",
|
||||
"rain": [0.6, 1.3, 2.9, 4.3, 3.2, 3.9, 7.4, 8.3, 4, 2.8, 2.6, 1.4],
|
||||
"heat": [0] * 12,
|
||||
"rain_note": "일강수량 5mm 이상",
|
||||
"heat_note": "혹서 없음(참고 설계서)",
|
||||
},
|
||||
"sogwang25": {
|
||||
"name": "울진 소광 25 참고",
|
||||
"label": "경북 울진 소광(참고 설계서 25 · 강수 = 일강수량 10mm 이상 · 혹서 없음)",
|
||||
"rain": [2.1, 2, 3.3, 4, 2.1, 3.7, 6.3, 7.9, 5, 4.2, 3.6, 1.6],
|
||||
"heat": [0] * 12,
|
||||
"rain_note": "일강수량 10mm 이상",
|
||||
"heat_note": "혹서 없음(참고 설계서)",
|
||||
},
|
||||
"zero": {
|
||||
"name": "0에서 직접",
|
||||
"label": "직접 입력(달마다 강수 · 혹서 칸)",
|
||||
"rain": [0] * 12,
|
||||
"heat": [0] * 12,
|
||||
"rain_note": "직접 입력",
|
||||
"heat_note": "직접 입력",
|
||||
},
|
||||
}
|
||||
START_MONTH = 3
|
||||
NUMBERS = ("hours", "prep_docs", "prep_setup", "etc", "tidy")
|
||||
NUMBERS = ("hours", "prep_docs", "prep_setup", "etc", "tidy", "rate_haul", "rate_base")
|
||||
POSITIVE = ("units", "crew")
|
||||
#: 고르기 칸 — 기본값이 첫째
|
||||
CHOICES = {
|
||||
"climate_set": tuple(CLIMATES),
|
||||
"rest": ("sun", "satsun"),
|
||||
"machine_basis": ("max", "sum"),
|
||||
"c_round": ("none", "round1"),
|
||||
"last_off": ("ratio", "all"),
|
||||
}
|
||||
|
||||
|
||||
def unit_resources(sheets: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
@@ -54,7 +98,13 @@ def unit_resources(sheets: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
name = " ".join(t for t in (src.get("name"), src.get("spec")) if t) or mkey
|
||||
machine = {"key": mkey, "name": name, "hr": hr}
|
||||
labor = sum((used.get("labor") or {}).values())
|
||||
out[row["key"]] = {"machine": machine, "labor": labor if labor > 0 else None}
|
||||
# 264 주 장비 기준 「장비 시간 합」 = 차례로 일하는 장비 · n = 장비 수
|
||||
spent = [hr for hr in hours.values() if hr > 0]
|
||||
out[row["key"]] = {
|
||||
"machine": machine,
|
||||
"labor": labor if labor > 0 else None,
|
||||
"machine_sum": {"hr": sum(spent), "n": len(spent)} if machine else None,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
@@ -71,6 +121,7 @@ def schedule_inputs(sheets: dict[str, Any], today: datetime.date | None = None)
|
||||
"units": unit_resources(sheets),
|
||||
"holidays": holiday_table(),
|
||||
"climate": CLIMATE,
|
||||
"climates": CLIMATES,
|
||||
"start": {"year": today.year + 1, "month": START_MONTH},
|
||||
}
|
||||
|
||||
@@ -100,12 +151,25 @@ def clean_schedule(raw: Any) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
for field in NUMBERS:
|
||||
v = num(raw.get(field))
|
||||
if v is not None and 0 <= v <= 1000:
|
||||
if v is not None and 0 <= v <= (100 if field.startswith("rate") else 1000):
|
||||
out[field] = v
|
||||
for field in POSITIVE:
|
||||
if (v := _positive(raw.get(field))) is not None and v <= 1e4:
|
||||
out[field] = v
|
||||
for field, allowed in CHOICES.items():
|
||||
if raw.get(field) in allowed:
|
||||
out[field] = raw[field]
|
||||
off = raw.get("holiday_off") if isinstance(raw.get("holiday_off"), list) else []
|
||||
kinds = [k for k in HOLIDAY_KINDS if k in off]
|
||||
if kinds:
|
||||
out["holiday_off"] = kinds
|
||||
start = raw.get("start") if isinstance(raw.get("start"), dict) else {}
|
||||
year, month = num(start.get("year")), num(start.get("month"))
|
||||
if year and month and 2000 <= year <= 2100 and 1 <= month <= 12:
|
||||
out["start"] = {"year": int(year), "month": int(month)}
|
||||
day = num(start.get("day"))
|
||||
if day and 1 < day <= calendar.monthrange(int(year), int(month))[1]:
|
||||
out["start"]["day"] = int(day)
|
||||
climate = raw.get("climate") if isinstance(raw.get("climate"), dict) else {}
|
||||
kept = {k: v for k in ("rain", "heat") if (v := _twelve(climate.get(k)))}
|
||||
if kept:
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* 등록 = 이 파일을 `…_UI_Shell` 이 `import.meta.glob("./draft/*_UI.ts")` 로 불러올 때 TABS · TAB_GROUPS ·
|
||||
* DRAFT(탭 짜기 · 셈 곁)에 넣음 — draft 폴더째 지우면 탭 · 셈이 함께 사라짐
|
||||
* 셈 = `B09_Draft_Schedule_Calc`(TS 짝 · 조작 즉시) · 원천 = 서버 `inputs.schedule` · 값 = `user.schedule`
|
||||
* 왼쪽 「공기 옵션」 카드 = `B09_Draft_Schedule_Panel`(264-1 · 같은 값 · 비고는 고른 옵션을 따름)
|
||||
* 고칠 칸(더블클릭) — 공기산출: 준비 · 일 작업시간 · 투입 · 일작업량 · 반영률 · 기타 · 정리 /
|
||||
* 공기집계: 착공 연월 · 강수 · 혹서(달마다 · 해 무관) · 법정공휴일(그 해 그 달 덮개 · 빈칸 = 자동)
|
||||
* ========================================================================== */
|
||||
@@ -21,7 +22,8 @@ import {
|
||||
type TabId,
|
||||
type TabTable,
|
||||
} from "../B09_Estimation_UI_Tabs";
|
||||
import { computeSchedule } from "./B09_Draft_Schedule_Calc";
|
||||
import { climateBase, computeSchedule } from "./B09_Draft_Schedule_Calc";
|
||||
import { createSchedulePanel, syncSchedulePanel } from "./B09_Draft_Schedule_Panel";
|
||||
|
||||
const SUM = "schedule_sum" as TabId;
|
||||
const CALC = "schedule_calc" as TabId;
|
||||
@@ -185,6 +187,35 @@ const LINES: Array<[string, string, string, string]> = [
|
||||
["span", "공사기간", "공사일수", "작업일수 계 + 비작업일수"],
|
||||
];
|
||||
|
||||
/** 264 고른 옵션을 따르는 비고 — 기본과 같으면 LINES 글 그대로(rain 은 늘 기후 자료 이름) */
|
||||
function optionNotes(src: Row, mine: Row, midMonth: boolean): Record<string, string> {
|
||||
const base = climateBase(src, mine);
|
||||
const out: Record<string, string> = {};
|
||||
if (base.label)
|
||||
out.rain = `${base.rain_note ?? "일강수량 5mm 이상"}(달마다 · 해 무관) · 기본값 = ${base.label}`;
|
||||
if (base.heat_note) out.heat = base.heat_note;
|
||||
if (midMonth) {
|
||||
out.days = "첫 달 = 착공일부터";
|
||||
out.a = "ROUNDUP(강수 + 혹서, 1) · 첫 달 = 날수 비례";
|
||||
}
|
||||
const off: string[] = mine.holiday_off || [];
|
||||
if (mine.rest === "satsun" || off.length) {
|
||||
const rest = mine.rest === "satsun" ? "토 · 일" : "일요일";
|
||||
const cut = HOLIDAY_NAMES.filter(([k]) => off.includes(k)).map(([, n]) => n);
|
||||
out.b = `${rest} + 공휴일${cut.length ? `(뺌 = ${cut.join(" · ")})` : ""} · 풍선 = 이름 · 고치면 그 달 덮개`;
|
||||
}
|
||||
if (mine.c_round === "round1") out.c = "A × B ÷ 달력일수 · 소수 1자리 반올림";
|
||||
if (mine.last_off === "all") out.off_used = "다 쓴 달 · 마지막 달 = 그 달 비작업 전부";
|
||||
return out;
|
||||
}
|
||||
|
||||
const HOLIDAY_NAMES: Array<[string, string]> = [
|
||||
["substitute", "대체"],
|
||||
["election", "선거일"],
|
||||
["labor", "노동절"],
|
||||
["constitution", "제헌절"],
|
||||
];
|
||||
|
||||
export function buildScheduleSum(ctx: TabContext): TabTable | string {
|
||||
const sch: Row | undefined = ctx.sheets.schedule;
|
||||
if (!sch) return "공기 자료 없음 — 서버가 공기 원천을 아직 주지 않음([업데이트] 뒤 다시)";
|
||||
@@ -192,7 +223,9 @@ export function buildScheduleSum(ctx: TabContext): TabTable | string {
|
||||
const months: Row[] = cal.months;
|
||||
const src: Row = ctx.payload.inputs?.schedule || {};
|
||||
const mine: Row = ctx.user.schedule || {};
|
||||
const start = `${cal.start.year}.${String(cal.start.month).padStart(2, "0")}`;
|
||||
const day = cal.start.day > 1 ? `.${String(cal.start.day).padStart(2, "0")}` : "";
|
||||
const start = `${cal.start.year}.${String(cal.start.month).padStart(2, "0")}${day}`;
|
||||
const notes = optionNotes(src, mine, Boolean(day));
|
||||
const columns: DataColumn[] = [
|
||||
text("label", "구분", 96),
|
||||
text("sub", "세부", 96),
|
||||
@@ -212,15 +245,13 @@ export function buildScheduleSum(ctx: TabContext): TabTable | string {
|
||||
note: `준공 예정 ${cal.end} · 공사기간 ${cal.days}일(≒ ${pct(cal.months_n)}개월)`,
|
||||
},
|
||||
...LINES.map(([key, label, sub, note]) => {
|
||||
const row: DataRow = { key, label, sub, note };
|
||||
const row: DataRow = { key, label, sub, note: notes[key] ?? note };
|
||||
let sum = 0;
|
||||
months.forEach((m, i) => {
|
||||
row[`m${i}`] = m[key];
|
||||
sum += Number(m[key]) || 0;
|
||||
});
|
||||
row.sum = sum;
|
||||
if (key === "rain" && src.climate?.label)
|
||||
row.note = `${note} · 기본값 = ${src.climate.label}`;
|
||||
return row;
|
||||
}),
|
||||
];
|
||||
@@ -232,7 +263,7 @@ export function buildScheduleSum(ctx: TabContext): TabTable | string {
|
||||
frozen: 2,
|
||||
mergeSame: ["label"],
|
||||
rowClass: (i) => (rows[i].key === "span" || rows[i].key === "start" ? "is-title" : null),
|
||||
hint: "공사기간 = 준비기간 + 작업일수 + 비작업일수 + 정리기간 · 착공 = 그 달 1일 · 강수 · 혹서 · 공휴일 칸은 더블클릭으로 고침(빈칸 = 기본값)",
|
||||
hint: "공사기간 = 준비기간 + 작업일수 + 비작업일수 + 정리기간 · 착공 = 「연.월」 또는 「연.월.일」(왼쪽 「공기 옵션」과 같은 값) · 강수 · 혹서 · 공휴일 칸은 더블클릭으로 고침(빈칸 = 기본값)",
|
||||
tip: (i, key) => {
|
||||
const m = months[monthOf(key)];
|
||||
if (!m || rows[i].key !== "b") return null;
|
||||
@@ -266,10 +297,13 @@ export function buildScheduleSum(ctx: TabContext): TabTable | string {
|
||||
const k = String(rows[i].key);
|
||||
change(ctx, (s) => {
|
||||
if (k === "start") {
|
||||
const hit = /(\d{4})\D+(\d{1,2})/.exec(value);
|
||||
const hit = /(\d{4})\D+(\d{1,2})(?:\D+(\d{1,2}))?/.exec(value);
|
||||
if (!value.trim()) delete s.start;
|
||||
else if (hit && Number(hit[2]) >= 1 && Number(hit[2]) <= 12)
|
||||
s.start = { year: Number(hit[1]), month: Number(hit[2]) };
|
||||
else if (hit && Number(hit[2]) >= 1 && Number(hit[2]) <= 12) {
|
||||
const [year, month, day] = [Number(hit[1]), Number(hit[2]), Number(hit[3] ?? 1)];
|
||||
const last = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
s.start = { year, month, ...(day > 1 && day <= last ? { day } : {}) };
|
||||
}
|
||||
return;
|
||||
}
|
||||
const m = months[monthOf(key)];
|
||||
@@ -303,4 +337,7 @@ if (!TABS.some(([id]) => id === SUM)) {
|
||||
const src = payload.inputs?.schedule;
|
||||
if (src) sheets.schedule = computeSchedule(src, sheets.bill?.rows || [], user.schedule || {});
|
||||
});
|
||||
// 264-1 왼쪽 「공기 옵션」 카드 — 셈마다 지금 값으로 맞춤
|
||||
DRAFT.panels.push(createSchedulePanel);
|
||||
DRAFT.extend.push(syncSchedulePanel);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/* PLAN 266-1 단위검증 헬퍼 — [상세] 처음 가로 범위 = 카드 반폭.
|
||||
* 실제 TS(`_Cross_View_Metrics` 칸 맞춤 · `b_svg_zoom_pan` homeScale)를 Node 로 돌림.
|
||||
* 처음 배율 = 뷰 `detailChart` 와 같은 식(base.ppm / fitted.ppm · 짝 시험이 원본 줄을 확인).
|
||||
* 결과 = 표준출력 JSON 한 벌. 짝 = `test_266_detail_home.py`.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const B06 = path.join(ROOT, "B06_Section");
|
||||
const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8");
|
||||
const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)();
|
||||
const CROSS_HEIGHT = Number(/export const CROSS_HEIGHT = (\d+)/.exec(common)[1]);
|
||||
const STUBS = {
|
||||
"./B06_Section_UI_Section_Common": {
|
||||
CROSS_PAD,
|
||||
CROSS_HEIGHT,
|
||||
validElevation: (s) => s.valid !== false && Number.isFinite(s.elevation_m),
|
||||
},
|
||||
};
|
||||
function loadTs(full) {
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
const localRequire = (request) => STUBS[request] ?? {};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
const metrics = loadTs(path.join(B06, "B06_Section_UI_Cross_View_Metrics.ts"));
|
||||
const { attachSvgZoomPan } = loadTs(path.join(ROOT, "A00_Common", "b_svg_zoom_pan.ts"));
|
||||
|
||||
function fakeSvg(width, height) {
|
||||
const listeners = {};
|
||||
const attrs = {};
|
||||
return {
|
||||
attrs,
|
||||
setAttribute: (name, value) => (attrs[name] = String(value)),
|
||||
getAttribute: (name) => attrs[name] ?? null,
|
||||
classList: { add() {}, remove() {} },
|
||||
addEventListener: (type, fn) => (listeners[type] ??= []).push(fn),
|
||||
fire: (type, event) =>
|
||||
(listeners[type] ?? []).forEach((fn) =>
|
||||
fn({ preventDefault() {}, stopPropagation() {}, pointerId: 1, ...event }),
|
||||
),
|
||||
setPointerCapture() {},
|
||||
releasePointerCapture() {},
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width, height }),
|
||||
};
|
||||
}
|
||||
|
||||
// 가파른 비탈 — ±30 m 샘플 · 1 m 마다 · 1 m 에 0.6 m 오름(칸 맞춤이 가로를 넓히게)
|
||||
const section = {
|
||||
samples: Array.from({ length: 61 }, (_, i) => ({
|
||||
offset_m: i - 30,
|
||||
elevation_m: 100 + 0.6 * (i - 30),
|
||||
})),
|
||||
};
|
||||
|
||||
/** 반폭 · 칸 크기로 상세 창을 연 셈 → 처음 · 줌 · 팬 · 원복 뒤 보이는 가로 범위(m). */
|
||||
function open(halfWidth, W, H) {
|
||||
const base = metrics.crossPlotBase(section, 1, W, halfWidth, 100);
|
||||
const fitted = metrics.fitPlotBase(base, H);
|
||||
const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1;
|
||||
const svg = fakeSvg(W, H);
|
||||
const layer = fakeSvg(W, H);
|
||||
let state = null;
|
||||
const handle = attachSvgZoomPan({
|
||||
svg,
|
||||
layer,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
pad: CROSS_PAD,
|
||||
maxScale: 16,
|
||||
content: { x: -W, y: -H, width: 3 * W, height: 3 * H },
|
||||
wheelZoom: true,
|
||||
homeScale,
|
||||
onChange: (next) => (state = next),
|
||||
});
|
||||
// 보이는 플롯 가로 끝(svg x) → 도형 층 x → 측점 거리(m)
|
||||
const range = () => {
|
||||
const s = state ?? { scale: 1, tx: 0, ty: 0 };
|
||||
const toM = (x) =>
|
||||
fitted.minOffset + ((x - s.tx) / s.scale - CROSS_PAD.left) / fitted.pixelsPerMeter;
|
||||
return [toM(CROSS_PAD.left), toM(W - CROSS_PAD.right)].map((v) => Math.round(v * 1000) / 1000);
|
||||
};
|
||||
const out = { homeScale, widened: fitted !== base, first: range() };
|
||||
svg.fire("wheel", { clientX: W / 2, clientY: H / 2, deltaY: 100 });
|
||||
out.wheelScale = state.scale / homeScale;
|
||||
svg.fire("pointerdown", { button: 1, clientX: 100, clientY: 100 });
|
||||
svg.fire("pointermove", { clientX: 160, clientY: 100 });
|
||||
svg.fire("pointerup", {});
|
||||
out.panned = range()[0] !== out.first[0];
|
||||
handle.reset(); // [⤢]
|
||||
out.reset = range();
|
||||
for (let i = 0; i < 40; i++) svg.fire("wheel", { clientX: W / 2, clientY: H / 2, deltaY: -100 });
|
||||
out.minScale = state.scale; // 줄이면 칸 맞춤 전체(1배)까지
|
||||
svg.fire("dblclick", {});
|
||||
out.dblclick = range();
|
||||
return out;
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
global15: open(15, 1200, 300),
|
||||
station10: open(10, 1200, 300),
|
||||
tall15: open(15, 1200, 900),
|
||||
}),
|
||||
);
|
||||
@@ -84,13 +84,16 @@ def test_모달_배선_단추_칸_비율():
|
||||
css = _read("B06_Section_UI_Style_Cross_Detail.css")
|
||||
assert "wheelZoom: true" in src and "attachSvgZoomPan({" in src
|
||||
assert "cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []))" in src
|
||||
assert "attachDetailZoom(clone, axes, zoom, (next) => (zoom = next))" in src
|
||||
flat = " ".join(src.split())
|
||||
assert (
|
||||
"attachDetailZoom( clone, axes, zoom, (next) => (zoom = next), drawn.homeScale, )" in flat
|
||||
)
|
||||
assert "layer.append(...clone.childNodes)" not in src # 감싸기 지움(179-3)
|
||||
assert 'clone.querySelector<SVGGElement>("g.b06-chart__plot-layer")' in src
|
||||
assert "createCrossAxes(axisLayer, axes)" in src
|
||||
view = _read("B06_Section_UI_Section_View.ts")
|
||||
assert "const plot = fitted && crossPlotFromBase(fitted, heightPx);" in view
|
||||
assert "return { svg, axes };" in view
|
||||
assert "return { svg, axes, homeScale };" in view
|
||||
assert "position: relative;" in css
|
||||
# 173-4 — 그림 = 칸 크기로 새로 그림 · svg 칸 가득
|
||||
assert ".b06-detail__cad > svg {\n display: block;\n width: 100%;\n height: 100%;" in css
|
||||
|
||||
@@ -100,7 +100,10 @@ def test_상세_칸_크기로_새로_그림_배선():
|
||||
assert "cardZoomStates.delete(stationId);" in view
|
||||
types = _read("B06_Section_UI_Section_View_Types.ts")
|
||||
# 179-3 — svg + 축 옵션
|
||||
assert " ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;" in types
|
||||
assert (
|
||||
" ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null;"
|
||||
in types
|
||||
)
|
||||
assert (
|
||||
" detailChart: (\n stationId: string,\n widthPx: number,\n heightPx: number,"
|
||||
in types
|
||||
|
||||
@@ -139,6 +139,6 @@ console.log(JSON.stringify([refText(null),
|
||||
]
|
||||
# 램프 = 항목 칸 · 우클릭 = [단가연결] · 연결 있으면 [단가연결 풀기] · 창 = B09 와 같은 것
|
||||
assert 'key !== "item" ? null : routeAt(i)?.ref ? `${LAMP} is-linked` : LAMP' in src
|
||||
assert "openPriceLink({ apply: link })" in src and '"단가연결 풀기"' in src
|
||||
assert "openHaulModal({" in src and '"단가연결 풀기"' in src # 263-1 = 운반 단가 모달 하나
|
||||
diagram = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Milestone_Diagram.ts").read_text("utf-8")
|
||||
assert "Math.abs(h - lastH) > 1" in diagram # 틀 높이 바뀜 = 다시 그림(아래 잘림 없음)
|
||||
|
||||
@@ -48,8 +48,8 @@ def test_내역_줄_거리():
|
||||
|
||||
def test_모달_여는_곳():
|
||||
modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts")
|
||||
for want in ("/estimation/logic-preview", "openLogicPicker", "fetchLogicInputs", "fillGiven",
|
||||
"cycleInputs", "my !== seq", "CC000054", "거리에서", "[고르기]"): # fmt: skip
|
||||
for want in ("/estimation/logic-preview", "openPriceLink", "fetchLogicInputs", "fillGiven",
|
||||
"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
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""263-1 「운반 단가」 모달 = 고른 항목 입력만 + 거리 자동 — 사이클 손 칸 · 8-2-8 표 고르기 없음 ·
|
||||
[변경] = 단가연결 한 목록 · 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 거리에서 · 손 값 이김 ·
|
||||
옛 `cycle` = 서버 읽기 그대로(모달 [적용]하면 지움)."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
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, cycle_time, fill_given # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Sangeun import _input_names, hand_sources # noqa: E402
|
||||
|
||||
DIST = {"L1": 23.8, "L2": 10.5}
|
||||
|
||||
|
||||
def _src(name: str) -> str:
|
||||
return (ROOT / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_왼쪽_칸_고른_로직_입력만():
|
||||
modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts")
|
||||
for gone in ("data-haul-cycle", "CC000054", "사이클시간 입력", "표에서 고르기", "다른 종류",
|
||||
"numberField", "loadTable"): # fmt: skip
|
||||
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
|
||||
# 거리 받는 마스터 로직 = 거리 칸 하나씩
|
||||
for code, want in (("GF000284", "운반거리"), ("GF000134", "Cm"), ("GF000283", "사이클시간")):
|
||||
names = _input_names(code)
|
||||
assert [n for n in names if n in FILL] == [want], (code, names)
|
||||
|
||||
|
||||
def test_변경_한_목록():
|
||||
modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts")
|
||||
assert "openPriceLink({ ...o, onPick: usePicked })" in modal and "importLib(o, p)" in modal
|
||||
link = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts")
|
||||
assert "o.onPick ??" in link and "openHaulModal({ ...link, ref" in link
|
||||
milestone = _src("B08_Quantity/B08_Quantity_UI_Milestone.ts")
|
||||
assert "other:" not in milestone and "openPriceLink" not in milestone
|
||||
|
||||
|
||||
def test_거리_칸_자동_손_값_이김():
|
||||
ref = {"given": {"재료": "토사"}, "cycle": {}, "own": []}
|
||||
got = fill_given(ref, _input_names("GF000284"), DIST)
|
||||
assert got["운반거리"] == 34.3 # L1 + L2
|
||||
cm = cycle_time(DIST)["cm"]
|
||||
assert fill_given(ref, ["Cm"], DIST)["Cm"] == cm
|
||||
assert fill_given(ref, ["사이클시간", "t2"], DIST) == {
|
||||
"재료": "토사", "사이클시간": cm, "t2": cycle_time(DIST)["t2"]} # fmt: skip
|
||||
mine = {"given": {"운반거리": 5}, "own": ["운반거리"]}
|
||||
assert fill_given(mine, ["운반거리"], DIST)["운반거리"] == 5
|
||||
|
||||
|
||||
def test_ts_짝_사이클_비움():
|
||||
"""모달 = cycle {} · 옛 own L1 · L2 · t1 뺌 → TS fillGiven = 파이썬과 같음."""
|
||||
calc = ROOT / "B08_Quantity" / "B08_Quantity_Haul_Cycle.ts"
|
||||
ref = {"given": {"qt": 15}, "cycle": {}, "own": []}
|
||||
body = (
|
||||
f"\nconsole.log(JSON.stringify(fillGiven({json.dumps(ref)}, ['Cm', '운반거리'],"
|
||||
f" {json.dumps(DIST)})));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", calc.read_text(encoding="utf-8") + body],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == fill_given(ref, ["Cm", "운반거리"], DIST)
|
||||
modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts")
|
||||
assert (
|
||||
"cycle: {}, given: st.given" in modal and 'OLD_OWN = new Set(["L1", "L2", "t1"])' in modal
|
||||
)
|
||||
assert "cycle: st.cycle" not in modal # [적용] 새 연결에 cycle 없음
|
||||
|
||||
|
||||
def test_옛_cycle_서버_읽기_그대로():
|
||||
old = {"tab": "logic", "code": "GF000134", "given": {"Cm": 1}, "cycle": {"V1": 20}}
|
||||
user = {"haul_refs": {"haul:r-u1": old}, "haul_dist": {"haul:r-u1": DIST}}
|
||||
((_k, _u, src),) = hand_sources(user)
|
||||
assert src["given"]["Cm"] == cycle_time({**DIST, "V1": 20})["cm"]
|
||||
new = {**old, "cycle": None}
|
||||
user["haul_refs"]["haul:r-u1"] = new
|
||||
((_k, _u, src),) = hand_sources(user)
|
||||
assert src["given"]["Cm"] == cycle_time(DIST)["cm"]
|
||||
@@ -0,0 +1,210 @@
|
||||
"""PLAN 264 — B09 공기 옵션(착공일 · 기후 자료 · 쉬는 날 · 공휴일 빼기 · 투입 · 준비 정리 · 반영률 ·
|
||||
주 장비 기준 · 중복 C 끝수 · 마지막 달) — 옵션마다 결과가 바뀜 · 다듬기 · 거울 TS = Py · 패널 훅."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from B09_Estimation.draft.B09_Draft_Schedule_Calc import compute_schedule # noqa: E402
|
||||
from B09_Estimation.draft.B09_Draft_Schedule_Source import ( # noqa: E402
|
||||
CLIMATES,
|
||||
clean_schedule,
|
||||
schedule_inputs,
|
||||
unit_resources,
|
||||
)
|
||||
from test_252_1_b09_schedule import GEOCHANG, SRC, _bill, _ref_user, _ts_schedule # noqa: E402
|
||||
|
||||
SRC2 = {**SRC, "climates": CLIMATES}
|
||||
UNITS = {
|
||||
"a": {
|
||||
"machine": {"key": "M1", "name": "굴착기 0.7㎥", "hr": 0.0066},
|
||||
"labor": 0.01,
|
||||
"machine_sum": {"hr": 0.0086, "n": 2},
|
||||
},
|
||||
"b": {"machine": None, "labor": 1.5},
|
||||
}
|
||||
BILL = [
|
||||
{"kind": "item", "key": "a", "qty": 1000, "section": "1.2", "level": 2},
|
||||
{"kind": "item", "key": "b", "qty": 80, "section": "2.1", "level": 2},
|
||||
{"kind": "item", "key": "h", "qty": 9, "section": "3", "level": 2},
|
||||
]
|
||||
SRC3 = {**SRC2, "units": UNITS}
|
||||
YEAR_2026 = {"start": {"year": 2026, "month": 1}, "tidy": 400}
|
||||
|
||||
|
||||
def _plain():
|
||||
"""거창 재현 사용자 값에서 법정공휴일 덮개만 뺌(자동 B 를 봄)."""
|
||||
user = _ref_user()
|
||||
del user["holiday"]
|
||||
return user
|
||||
|
||||
|
||||
def _cal(user, src=SRC2, bill=None):
|
||||
return compute_schedule(src, _bill(GEOCHANG) if bill is None else bill, user)["calendar"]
|
||||
|
||||
|
||||
def _rows(user):
|
||||
return {r["key"]: r for r in compute_schedule(SRC3, BILL, user)["work"]["rows"]}
|
||||
|
||||
|
||||
def test_기본값_그대로():
|
||||
base = compute_schedule(SRC, _bill(GEOCHANG), _ref_user())
|
||||
assert compute_schedule(SRC2, _bill(GEOCHANG), _ref_user()) == base
|
||||
picked = {**_ref_user(), "climate_set": "geochang25", "rest": "sun", "machine_basis": "max",
|
||||
"c_round": "none", "last_off": "ratio", "holiday_off": [], "units": 1, "crew": 10,
|
||||
"rate_haul": 0, "rate_base": 100, "hours": 8, "prep_docs": 2, "prep_setup": 2,
|
||||
"tidy": 4} # fmt: skip
|
||||
assert compute_schedule(SRC2, _bill(GEOCHANG), picked) == base
|
||||
assert (base["work"]["total"], base["calendar"]["days"]) == (95, 136)
|
||||
|
||||
|
||||
def test_1_착공일_그날부터():
|
||||
user = {**_plain(), "start": {"year": 2026, "month": 3, "day": 15}}
|
||||
cal = _cal(user)
|
||||
first = cal["months"][0]
|
||||
# 3.15 ~ 3.31 = 17일 · A = ROUNDUP(3.6 × 17 ÷ 31, 1) = 2.0 · 일요일 15 · 22 · 29
|
||||
assert (first["days"], first["a"], first["b_auto"]) == (17, 2.0, 3)
|
||||
assert cal["start"] == {"year": 2026, "month": 3, "day": 15}
|
||||
assert cal["end"] > _cal(_plain())["end"]
|
||||
assert cal["months"][1]["days"] == 30 # 둘째 달부터 달 전체
|
||||
|
||||
|
||||
def test_2_기후_자료_고르기():
|
||||
m = _cal({**_plain(), "climate_set": "uljin24"})["months"][0]
|
||||
assert (m["rain"], m["heat"]) == (2.9, 0)
|
||||
m = _cal({**_plain(), "climate_set": "sogwang25"})["months"][5 - 3]
|
||||
assert (m["rain"], m["heat"]) == (2.1, 0) # 5월
|
||||
assert all(m["a"] == 0 for m in _cal({**_plain(), "climate_set": "zero"})["months"])
|
||||
user = {**_plain(), "climate_set": "uljin24", "climate": {"rain": [9] * 12}}
|
||||
assert _cal(user)["months"][0]["rain"] == 9 # 달마다 덮개가 이김
|
||||
|
||||
|
||||
def test_3_쉬는_날_토일():
|
||||
base = _cal(_plain())["months"][0]["b_auto"]
|
||||
assert _cal({**_plain(), "rest": "satsun"})["months"][0]["b_auto"] == base + 4 # 2026.3 토 4번
|
||||
|
||||
|
||||
def test_4_공휴일_빼기():
|
||||
def auto(off):
|
||||
cal = _cal({**YEAR_2026, "holiday_off": off}, bill=[])
|
||||
return [m["b_auto"] for m in cal["months"][:12]]
|
||||
|
||||
full = auto([])
|
||||
assert full == [5, 7, 6, 4, 8, 6, 5, 7, 7, 7, 5, 5]
|
||||
assert auto(["labor"])[4] == 7 # 5.1 노동절
|
||||
assert auto(["substitute"])[4] == 7 # 5.25 부처님오신날 대체
|
||||
assert auto(["election"])[5] == 5 # 6.3 지방선거
|
||||
assert auto(["constitution"])[6] == 4 # 7.17 제헌절
|
||||
may = _cal({**YEAR_2026, "holiday_off": ["labor"]}, bill=[])["months"][4]["names"]
|
||||
assert not any("노동절" in n for n in may)
|
||||
|
||||
|
||||
def test_5_일_작업시간():
|
||||
assert _rows({})["a"]["days"] == 0.9
|
||||
assert _rows({"hours": 4})["a"]["days"] == 1.7 # 1000 × 0.0066 ÷ 4 = 1.65
|
||||
|
||||
|
||||
def test_6_투입_기본():
|
||||
rows = _rows({"units": 2, "crew": 5})
|
||||
assert (rows["a"]["put"], rows["a"]["days"]) == (2, 0.5) # 0.4125 → 0.5
|
||||
assert (rows["b"]["put"], rows["b"]["days"]) == (5, 24)
|
||||
rows = _rows({"crew": 5, "rows": {"b": {"crew": 20}}})
|
||||
assert rows["b"]["days"] == 6 # 줄마다 덮개가 이김
|
||||
|
||||
|
||||
def test_7_준비_정리_기타():
|
||||
work = compute_schedule(SRC3, BILL, {"prep_docs": 3, "prep_setup": 1, "tidy": 6, "etc": 2})
|
||||
w = work["work"]
|
||||
assert (w["prep"], w["tidy"], w["etc"]) == (4, 6, 2)
|
||||
assert w["total"] == 4 + round(0.9 + 12 + 0 + 2) + 6
|
||||
|
||||
|
||||
def test_8_반영률_기본():
|
||||
rows = _rows({"rate_haul": 100, "rate_base": 50})
|
||||
assert (rows["h"]["rate"], rows["a"]["rate"], rows["b"]["rate"]) == (100, 50, 50)
|
||||
assert rows["b"]["days"] == 6
|
||||
assert _rows({"rate_base": 50, "rows": {"b": {"rate": 100}}})["b"]["days"] == 12
|
||||
|
||||
|
||||
def test_9_주_장비_장비_시간_합():
|
||||
a = _rows({"machine_basis": "sum"})["a"]
|
||||
assert (a["per"], a["who"], a["days"]) == (0.0086, "굴착기 0.7㎥ 외 1종", 1.1) # 8.6 ÷ 8
|
||||
assert _rows({})["a"]["per"] == 0.0066
|
||||
|
||||
|
||||
def test_10_중복일수_끝수():
|
||||
base = _cal(_plain())["months"]
|
||||
got = _cal({**_plain(), "c_round": "round1"})["months"]
|
||||
assert all(m["c"] == round(m["c"], 1) for m in got)
|
||||
assert [m["c"] for m in got] != [m["c"] for m in base]
|
||||
|
||||
|
||||
def test_11_마지막_달_비작업_전부():
|
||||
base = _cal(_ref_user())["months"][-1]
|
||||
last = _cal({**_ref_user(), "last_off": "all"})
|
||||
assert (base["off_used"], base["off"]) == (7, 15)
|
||||
assert last["months"][-1]["off_used"] == 15 and last["days"] == 136 + 8
|
||||
|
||||
|
||||
def test_다듬기_새_칸():
|
||||
raw = {
|
||||
"units": 2, "crew": 0, "rate_haul": 50, "rate_base": 120,
|
||||
"climate_set": "uljin24", "rest": "sat", "machine_basis": "sum",
|
||||
"c_round": "round1", "last_off": "x", "holiday_off": ["labor", "junk", "election"],
|
||||
"start": {"year": 2027, "month": 2, "day": 30},
|
||||
} # fmt: skip
|
||||
assert clean_schedule(raw) == {
|
||||
"rate_haul": 50,
|
||||
"units": 2,
|
||||
"climate_set": "uljin24",
|
||||
"machine_basis": "sum",
|
||||
"c_round": "round1",
|
||||
"holiday_off": ["election", "labor"],
|
||||
"start": {"year": 2027, "month": 2},
|
||||
}
|
||||
got = clean_schedule({"start": {"year": 2027, "month": 3, "day": 15}, "holiday_off": "x"})
|
||||
assert got == {"start": {"year": 2027, "month": 3, "day": 15}}
|
||||
|
||||
|
||||
def test_원천_기후_셋_장비_합():
|
||||
sheets = {
|
||||
"bill": {"rows": [{"kind": "item", "key": "earth:4", "ref": {"tab": "sg", "key": "S1"}}]},
|
||||
"sg": [{"key": "S1", "lines": [
|
||||
{"kind": "machine", "ref": {"key": "M1"}, "qty": 0.0066},
|
||||
{"kind": "machine", "ref": {"key": "M2"}, "qty": 0.002},
|
||||
]}],
|
||||
"machine": [],
|
||||
} # fmt: skip
|
||||
unit = unit_resources(sheets)["earth:4"]
|
||||
assert unit["machine_sum"]["n"] == 2 and round(unit["machine_sum"]["hr"], 6) == 0.0086
|
||||
got = schedule_inputs(sheets)
|
||||
assert list(got["climates"]) == ["geochang25", "uljin24", "sogwang25", "zero"]
|
||||
assert got["climates"]["geochang25"]["rain"] == got["climate"]["rain"]
|
||||
|
||||
|
||||
def test_거울_TS_옵션():
|
||||
cases = [
|
||||
(SRC2, _bill(GEOCHANG), {**_plain(), "start": {"year": 2026, "month": 3, "day": 15},
|
||||
"climate_set": "sogwang25", "rest": "satsun",
|
||||
"holiday_off": ["substitute", "constitution"],
|
||||
"c_round": "round1", "last_off": "all"}),
|
||||
(SRC3, BILL, {"machine_basis": "sum", "units": 3, "crew": 7, "rate_haul": 30,
|
||||
"rate_base": 80, "hours": 9, "prep_docs": 1, "tidy": 2, "etc": 1,
|
||||
"climate_set": "zero", "start": {"year": 2027, "month": 12, "day": 31}}),
|
||||
] # fmt: skip
|
||||
for src, bill, user in cases:
|
||||
py = json.loads(json.dumps(compute_schedule(src, bill, user), ensure_ascii=False))
|
||||
assert _ts_schedule(src, bill, user) == py
|
||||
|
||||
|
||||
def test_패널_훅_draft_없으면_빈칸():
|
||||
tabs = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs.ts").read_text("utf-8")
|
||||
shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
ui = (ROOT / "B09_Estimation/draft/B09_Draft_Schedule_UI.ts").read_text("utf-8")
|
||||
assert "{ builders: {}, extend: [], panels: [] }" in tabs # draft 없으면 카드 없음
|
||||
assert "...DRAFT.panels.map((make) => make(() => user, setUser))" in shell
|
||||
assert "DRAFT.panels.push(" in ui
|
||||
@@ -0,0 +1,85 @@
|
||||
"""PLAN 266-1 — B06 [상세] 처음 가로 범위 = 좌측 「횡단 보기 설정」 반폭(개별 표시 반폭이면 그것).
|
||||
|
||||
짝: `B06_Section/B06_Section_UI_Section_View.ts`(`detailChart` 처음 배율) ·
|
||||
`B06_Section_UI_Cross_Detail.ts`(`attachDetailZoom` homeScale) · `A00_Common/b_svg_zoom_pan.ts`.
|
||||
실제 TS 를 `helper_266_detail_home.cjs` 가 Node 로 돌린다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
ROOT = os.path.join(HERE, "..", "..")
|
||||
B06 = os.path.join(ROOT, "B06_Section")
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
|
||||
reason="프론트엔드 의존성 미설치",
|
||||
)
|
||||
|
||||
|
||||
def _read(name: str) -> str:
|
||||
with open(os.path.join(B06, name), encoding="utf-8") as f:
|
||||
return f.read().replace(chr(13), "")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", os.path.join(HERE, "helper_266_detail_home.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_처음_가로_반폭_15(out):
|
||||
# 칸 맞춤이 가로를 넓혀도 처음 배율로 되좁힘 = −15 ~ +15
|
||||
case = out["global15"]
|
||||
assert case["widened"] is True and case["homeScale"] > 1
|
||||
assert case["first"] == [-15, 15]
|
||||
|
||||
|
||||
def test_개별_반폭_측점(out):
|
||||
assert out["station10"]["first"] == [-10, 10]
|
||||
|
||||
|
||||
def test_안_넓힌_칸_그대로(out):
|
||||
case = out["tall15"]
|
||||
assert case["widened"] is False and case["homeScale"] == 1
|
||||
assert case["first"] == [-15, 15]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["global15", "station10", "tall15"])
|
||||
def test_처음으로_같은_범위_줌_이동_그대로(out, name):
|
||||
case = out[name]
|
||||
assert case["reset"] == case["first"] # [⤢]
|
||||
assert case["dblclick"] == case["first"] # 더블클릭
|
||||
assert case["wheelScale"] == pytest.approx(1.15) # 휠 줌
|
||||
assert case["panned"] is True # 가운데 단추 끌기
|
||||
assert case["minScale"] == 1 # 줄이면 칸 맞춤 전체까지
|
||||
|
||||
|
||||
def test_배선():
|
||||
view = _read("B06_Section_UI_Section_View.ts")
|
||||
assert (
|
||||
"const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1;"
|
||||
in view
|
||||
)
|
||||
assert "return { svg, axes, homeScale };" in view
|
||||
# 반폭 = 개별 표시 반폭 › 좌측 「횡단 보기 설정」(카드와 같은 길)
|
||||
assert (
|
||||
"effectiveCardHalfWidth(section, stationWidth?.widthFor(section) ?? currentCrossHalfWidth)"
|
||||
in view
|
||||
)
|
||||
src = " ".join(_read("B06_Section_UI_Cross_Detail.ts").split())
|
||||
assert "drawn.homeScale" in src and "homeScale, onChange" in src
|
||||
assert "homeScale: drawn.homeScale" in src
|
||||
Reference in New Issue
Block a user