From b40a129cb55a1938e5a902b467641c55ce3bdc88 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 14:51:50 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(B08):=20=EC=97=B0=EA=B2=B0=EB=90=9C=20?= =?UTF-8?q?=EC=82=AC=EB=B3=B8=20=EC=9B=90=EC=B2=9C=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=C2=B7=20=EC=A7=91=EA=B3=84=ED=91=9C=20=EC=82=AC=EB=B3=B8=20?= =?UTF-8?q?=EC=9E=88=EB=8A=94=20=EC=B9=B8=EB=A7=8C=20=C2=B7=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=EB=8F=84=20=EC=82=AC=EB=B3=B8=EC=97=90=EC=84=9C(143-1?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_Item_Snapshot.linked_snapshots — 지정 ∩ 같은 항목 사본 {구조물 id: 항목 · 폴더 · 부분 · 경로} · B08 · B09 공용 - 집계표 서버 — 잇기 목록을 사본으로 거름 · 조합 모두 없는 칸은 안 셈 · 세월교 날 잇기 · 관 몫 본체 잇기 · 「조합 사본 없는 시설 n곳」 알림 · 응답 linked - 화면 — 조합 이름 = 사본 경로(층 목록 GET 없음) · 측점 · 조합 열 = 사본 있는 것만 · 구조물도 모달 = 사본만(없으면 사본 없음) - 시험 — test_98 · 120 · 131 픽스처 잇기 · test_61_2 사본만 · 새 test_143_1 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_Router_Summary.py | 61 ++++-- B08_Quantity/B08_Quantity_Summary_Data.ts | 45 +++- B08_Quantity/B08_Quantity_UI_Drawing.ts | 88 +++----- B08_Quantity/B08_Quantity_UI_Summary.ts | 52 ++--- M02_MasterTemplete/M02_Item_Snapshot.py | 27 +++ .../tester/test_120_b08_drain_summary.py | 32 ++- resources/tester/test_131_b08_material_fix.py | 4 +- .../tester/test_143_1_b08_linked_snapshots.py | 199 ++++++++++++++++++ .../tester/test_61_2_b08_structure_sheets.py | 12 +- resources/tester/test_98_b08_pipe_summary.py | 18 +- ui_template/ui_template_locale_b4.ts | 11 +- 11 files changed, 421 insertions(+), 128 deletions(-) create mode 100644 resources/tester/test_143_1_b08_linked_snapshots.py diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py index 95e78bd6a..fe16b794a 100644 --- a/B08_Quantity/B08_Quantity_Router_Summary.py +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -46,6 +46,7 @@ from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file from common_util.common_util_summary_item import GROUP_JOIN from config.config_db import get_db_pool +from M02_MasterTemplete import M02_Item_Snapshot as snapshots from M02_MasterTemplete import M02_Table_Fill as fill from M02_MasterTemplete import M02_Template_Layers as layers from M02_MasterTemplete.M02_Table_Fill_Spec import soil_tiers_from_designs @@ -159,6 +160,8 @@ class _Sheet: def __init__(self) -> None: self.columns: dict[str, dict[str, Any]] = {} self.rows: dict[int, dict[str, Any]] = {} + self.owner = "" # 지금 셈하는 시설 — 사본 없어 안 센 칸의 시설을 모음(143-1) + self.unlinked: set[str] = set() def add( self, @@ -170,6 +173,9 @@ class _Sheet: value: float | None, combos: list[str | None], ) -> None: + if not any(combos): # 그 칸을 낸 조합 사본이 없음 — 안 셈(143-1) + self.unlinked.add(self.owner) + return cid = f"pipe:{key}" column = self.columns.setdefault( cid, {"id": cid, "group": group, "label": label, "combos": [], "_sort": sort} @@ -367,15 +373,17 @@ def _ford_pipes( one_m: float | None, names: dict[str, str], pipes: list[dict[str, Any]], + body: str | None, ) -> None: """세월교 관 몫 — 한 련 길이 × 련 수를 관매설 · 커플링 열에 더함(관 공과 같은 열 · 131). - `pipes` 에는 련마다 한 줄(좌측 패널 1본 길이로 커플링을 다시 셀 때 련 수만큼 셈). + `pipes` 에는 련마다 한 줄(좌측 패널 1본 길이로 커플링을 다시 셀 때 련 수만큼 셈) · + 조합 = 시설 본체 + 본체 잇기(`body` · 143-1). """ diameter = int(_num(base["options"].get("pipe_diameter_mm")) or 1000) count = int(_num(base["options"].get("pipe_count")) or 1) head = ["관 공", f"Φ{names.get(f'pipe.diameter={diameter}', diameter)}m/m"] - combos = [_combo(base.get("summary_item"))] + combos = [_combo(base.get("summary_item")), body] each = couplings(length, one_m) sheet.add( at, @@ -395,7 +403,8 @@ def _ford_pipes( None if each is None else each * count, combos, ) - pipes += [{"chainage_m": at, "column": f"pipe:cp|{diameter}", "length_m": length}] * count + if any(combos): + pipes += [{"chainage_m": at, "column": f"pipe:cp|{diameter}", "length_m": length}] * count def _pavement( @@ -467,12 +476,15 @@ def pipe_summary( names: dict[str, str], one_m: float | None, kinds: dict[str, Any] | None = None, + linked: dict[str, dict[str, Any]] | None = None, ) -> dict[str, Any]: """횡단배수 한 곳마다 컨테이너 값 → 집계표 열 · 줄 · 계(저장 안 함). - `names` = 관 정의 이름(공용 흙막이 · 돌붙임 머리) · `kinds` = M02 정의(비면 읽음). + `names` = 관 정의 이름(공용 흙막이 · 돌붙임 머리) · `kinds` = M02 정의(비면 읽음) · + `linked` = 연결된 사본(비면 읽음) — 칸마다 그 칸을 낸 컨테이너 사본이 있을 때만 셈(143-1). """ kinds = kinds or definition.read() + linked = snapshots.linked_snapshots(project_root) if linked is None else linked items = fill.collect_items( project_root, fill.pipe_lengths_from_designs(designs), @@ -488,15 +500,19 @@ def pipe_summary( box_lengths = _station_values(designs, "box_length_m") ford_lengths = _station_values(designs, "ford_pipe_length_m") by_owner: dict[tuple[str, str], dict[str, Any]] = {} + others: dict[str, bool] = {} # 집계표 밖 구조물 — 사본 하나라도 있나(조합 열은 화면이 그림) for item in items: - if item["type_id"] not in DRAIN_TYPES: - continue owner, _, link = item["복사본"].partition(GROUP_JOIN) + if item["type_id"] not in DRAIN_TYPES: + others[owner] = others.get(owner, False) or item["복사본"] in linked + continue entry = by_owner.setdefault((item["type_id"], owner), {"links": {}}) + # 거름 한 곳 — 사본 없는 잇기는 조합 없음으로(그 칸만 안 셈 · 143-1) + combo = _combo(item.get("summary_item")) if item["복사본"] in linked else None if link: - entry["links"][link] = _combo(item.get("summary_item")) + entry["links"][link] = combo else: - entry["base"] = item + entry["base"] = {**item, "summary_item": item.get("summary_item") if combo else None} sheet = _Sheet() pipes: list[dict[str, Any]] = [] @@ -506,24 +522,32 @@ def pipe_summary( if base is None: continue links = entry["links"] + sheet.owner = owner point = raw.get((type_id, owner)) options = dict(point.options or {}) if point else {} at = float(base["chainage_m"]) if type_id != "pipe": box_length = fill._nearest(box_lengths, at) if type_id == "box_culvert" else None - no_box += type_id == "box_culvert" and not box_length - group = (point.group_items if point else None) or {} + group = { # 세월교 날 잇기(저장된 잇기)도 사본 있는 것만 + gid: chosen + for gid, chosen in ((point.group_items if point else None) or {}).items() + if f"{owner}{GROUP_JOIN}{gid}" in linked + } _drain(sheet, at, type_id, base, options, links, group, kinds, names, box_length) + body_link = BOX_LINKS["body"] if type_id == "box_culvert" else FORD_LINKS["body"] + body = links.get(body_link) or _combo(group.get(body_link)) + counted = bool(base["summary_item"] or body) # 본체 칸을 셈(사본 있음) + no_box += type_id == "box_culvert" and counted and not box_length if type_id == "ford_bridge": ford_length = fill._nearest(ford_lengths, at) - no_ford += not ford_length - _ford_pipes(sheet, at, base, ford_length, one_m, names, pipes) + no_ford += counted and not ford_length + _ford_pipes(sheet, at, base, ford_length, one_m, names, pipes, body) continue diameter = int(_num(base["options"].get("pipe_diameter_mm")) or 1000) pipe_head = ["관 공", f"Φ{names.get(f'pipe.diameter={diameter}', diameter)}m/m"] pipe_combos = [_combo(base.get("summary_item")), links.get(LINKS["pipe"])] length = base.get("관연장") - no_length += not length + no_length += any(pipe_combos) and not length sheet.add( at, f"len|{diameter}", @@ -542,9 +566,10 @@ def pipe_summary( couplings(length, one_m), pipe_combos, ) - pipes.append({"chainage_m": at, "column": f"pipe:cp|{diameter}", "length_m": length}) + if any(pipe_combos): + pipes.append({"chainage_m": at, "column": f"pipe:cp|{diameter}", "length_m": length}) shape = fill._nearest(basins, at) - no_basin += shape is None + no_basin += bool(links.get(LINKS["inlet"])) and shape is None if shape: sheet.add( at, @@ -563,6 +588,11 @@ def pipe_summary( out = sheet.result() notes = [] + unlinked = len(sheet.unlinked) + sum(not found for found in others.values()) + if unlinked: + notes.append( + f"조합 사본 없는 시설 {unlinked}곳 — B05 · B06 좌측 패널에서 조합 연결 · [저장]" + ) if no_length: notes.append(f"관 연장 없음 {no_length}곳 — B06 횡단 [저장] 뒤 채워짐") if no_basin: @@ -579,6 +609,7 @@ def pipe_summary( "one_length_m": one_m, "types": list(DRAIN_TYPES), "pipes": pipes, + "linked": linked, } diff --git a/B08_Quantity/B08_Quantity_Summary_Data.ts b/B08_Quantity/B08_Quantity_Summary_Data.ts index e228d23be..028295f9a 100644 --- a/B08_Quantity/B08_Quantity_Summary_Data.ts +++ b/B08_Quantity/B08_Quantity_Summary_Data.ts @@ -4,12 +4,14 @@ * * 줄 = 구조물 · 배관이 놓인 측점(같은 측점은 한 줄 · 오름차순) * 열 = 저장된 구조물 조합(본체 `summary_item` + 그룹 `group_items`) — 같은 층 · 주인 · 키는 한 열 + * 조합 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · 서버 `linked_snapshots` · PLAN 143-1) * * 읽기만 — B05/B06 저장 자료(structures.json · pipe_points.json)를 고치지 않는다. * 런타임 import 는 `common_util_summary_item` 하나 — 시험이 두 파일을 이어 붙여 돌림. * ========================================================================== */ import { + GROUP_JOIN, readGroupItems, readSummaryItem, type SummaryItem, @@ -25,6 +27,35 @@ export interface PlacedItem { group_items?: unknown; } +/** 연결된 사본 한 건(서버 `M02_Item_Snapshot.linked_snapshots` · 143-1) — `경로` = 가져온 때 M02 경로. */ +export interface LinkedSnapshot { + 항목: SummaryItem; + 폴더: string; + /** 있는 부분(도면 · 면적 · 일위대가) */ + 부분: string[]; + 경로: string[]; +} + +/** 사본 있는 조합만 남김 — 본체 = `{구조물 id}` · 그룹 = `{구조물 id}__{그룹}` 사본 · 하나도 없으면 뺌. */ +export function linkedOnly( + things: readonly T[], + linked: Readonly>, +): T[] { + const out: T[] = []; + for (const thing of things) { + const id = thing.structure_id ?? ""; + const own = id in linked ? readSummaryItem(thing.summary_item) : null; + const groups = Object.fromEntries( + Object.entries(readGroupItems(thing.group_items)).filter( + ([gid]) => `${id}${GROUP_JOIN}${gid}` in linked, + ), + ); + if (own || Object.keys(groups).length) + out.push({ ...thing, summary_item: own, group_items: groups }); + } + return out; +} + /** M02 조합 정보에서 읽은 구분 · 이름 — 못 찾으면 null(저장된 이름으로 보임). */ export interface ComboLabel { group: string; @@ -84,11 +115,12 @@ export function summaryColumns( ); } -/** 한 측점에 놓인 조합 한 건 — 어느 구조물 것인지(복사본 찾기용)까지. */ +/** 한 측점에 놓인 조합 한 건 — 어느 구조물 것인지 · 사본 id(본체 = 구조물 id · 그룹 = `{id}__{그룹}`). */ export interface StationCombo { id: string; item: SummaryItem; structureId: string | null; + copyId: string | null; } /** 측점(1cm 안 같은 측점)에 놓인 조합들 — 구조물마다 본체 → 그룹 차례 · 같은 구조물의 같은 조합은 한 건. */ @@ -99,16 +131,17 @@ export function combosAt(things: readonly PlacedItem[], station: number): Statio const at = stationOf(thing); if (at === null || Math.round(at * 100) !== Math.round(station * 100)) continue; const own = readSummaryItem(thing.summary_item); - for (const item of [ - ...(own ? [own] : []), - ...Object.values(readGroupItems(thing.group_items)), + const structureId = thing.structure_id ?? null; + for (const [gid, item] of [ + ...(own ? [["", own] as const] : []), + ...Object.entries(readGroupItems(thing.group_items)), ]) { - const structureId = thing.structure_id ?? null; const key = `${structureId} ${comboId(item)}`; + const copyId = structureId && (gid ? `${structureId}${GROUP_JOIN}${gid}` : structureId); if (!seen.has(key)) { seen.add(key); - found.push({ id: comboId(item), item, structureId }); + found.push({ id: comboId(item), item, structureId, copyId }); } } } diff --git a/B08_Quantity/B08_Quantity_UI_Drawing.ts b/B08_Quantity/B08_Quantity_UI_Drawing.ts index b3356214a..60dc1a94e 100644 --- a/B08_Quantity/B08_Quantity_UI_Drawing.ts +++ b/B08_Quantity/B08_Quantity_UI_Drawing.ts @@ -4,7 +4,7 @@ * 조합 한 개 = M02 항목 카드(`M02_MasterTemplete_ItemCards`) 그대로 — 도면(도각 포함 · 보기 전용 CAD · * 여러 장이면 장 탭) + 면적산출. * - * 원천 = 그 구조물의 프로젝트 복사본(같은 항목일 때) · 없으면 M02 원본 층 — 읽기만 + * 원천 = 그 구조물의 프로젝트 사본만(연결된 사본 · PLAN 143-1) — 없으면 「사본 없음」 · 읽기만 * 조합 목록 = 집계표와 같음(`B08_Quantity_Summary_Data` `combosAt`) * 카드 · CAD · 스프레드시트는 모달을 열 때만 `import()` · 닫을 때 카드를 모두 정리 * ========================================================================== */ @@ -12,19 +12,11 @@ import "./B08_Quantity_UI_Drawing.css"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { openModal } from "@ui/ui_template_modal"; -import { layerOf, type PickerRow } from "@ui/ui_template_item_picker_data"; -import { - PART_OF, - projectItems, - sameItem, - structureFolder, - type ProjectItemInfo, -} from "@ui/ui_template_item_picker_project"; -import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import { PART_OF, projectItems } from "@ui/ui_template_item_picker_project"; import type { ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards"; import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource"; import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item"; -import type { ComboLabel, StationCombo } from "./B08_Quantity_Summary_Data"; +import type { ComboLabel, LinkedSnapshot, StationCombo } from "./B08_Quantity_Summary_Data"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -42,48 +34,21 @@ export interface SheetsRequest { /** 모달 제목에 붙는 측점 글 */ station: string; combos: StationCombo[]; - /** `comboId` → M02 줄 */ - rows: Map; + /** 연결된 사본 `{구조물 id: 사본}`(집계 응답 · 143-1) */ + linked: Record; describe: (item: SummaryItem) => ComboLabel | null; } -/** 그 구조물의 복사본 목록 — 프로젝트 복사본을 못 읽으면 원본만(모달은 그대로 열림). */ -const loadCopies = (projectId: string): Promise => - projectItems(projectId) - .list() - .catch(() => []); - -function sourceOf( - request: SheetsRequest, - combo: StationCombo, - row: PickerRow, - copies: ProjectItemInfo[], -): { source: ItemSource; copy: boolean } { - const own = combo.structureId - ? copies.find((info) => info.폴더 === structureFolder(combo.structureId!)) - : undefined; - if (own && sameItem(own, row)) { - const items = projectItems(request.projectId); - return { - copy: true, - source: { - layer: "project", - projectId: request.projectId, - read: async (part) => { - const doc = await items.read(own.폴더, PART_OF[part]); - if (doc == null) throw new Error(L("B08_Quantity_Sheets_NoCopy")); - return { 문서: doc }; - }, - }, - }; - } - const layer = layerOf(row); +/** 사본 폴더 읽기 — 그 부분 파일이 없으면 던짐(카드가 「없음」). */ +function sourceOf(projectId: string, folder: string): ItemSource { + const items = projectItems(projectId); return { - copy: false, - source: { - layer, - projectId: null, - read: (part, column) => readTemplate(layer, part, column, null), + layer: "project", + projectId, + read: async (part) => { + const doc = await items.read(folder, PART_OF[part]); + if (doc == null) throw new Error(L("B08_Quantity_Sheets_NoCopy")); + return { 문서: doc }; }, }; } @@ -92,7 +57,6 @@ function sourceOf( function comboSection( request: SheetsRequest, combo: StationCombo, - copies: ProjectItemInfo[], createItemCards: typeof import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards").createItemCards, handles: ItemCardsHandle[], ): HTMLElement { @@ -100,7 +64,7 @@ function comboSection( const name = label ? [label.group, label.name].filter(Boolean).join(" ") : combo.item.name || combo.item.key; - const row = request.rows.get(combo.id) ?? null; + const copy = combo.copyId ? request.linked[combo.copyId] : undefined; const section = document.createElement("section"); section.className = "b08-sheets__combo"; section.dataset.b08Combo = combo.id; @@ -109,27 +73,25 @@ function comboSection( title.textContent = name; title.title = summaryTooltip(combo.item); section.append(title); - if (!row?.열) { + if (!copy) { section.append(note(L("B08_Quantity_Drawing_Missing"))); return section; } - const picked = sourceOf(request, combo, row, copies); + const source = sourceOf(request.projectId, copy.폴더); const from = document.createElement("span"); from.className = "b08-sheets__from"; - from.textContent = L( - picked.copy ? "B08_Quantity_Sheets_FromCopy" : "B08_Quantity_Sheets_FromMaster", - ); + from.textContent = L("B08_Quantity_Sheets_FromCopy"); title.append(from); - if (!row.도번) section.append(note(L("B08_Quantity_Drawing_NoDrawing"))); + if (!copy.부분.includes("도면")) section.append(note(L("B08_Quantity_Drawing_NoDrawing"))); const cards = createItemCards({ - source: () => picked.source, + source: () => source, editable: () => false, onEdit: () => undefined, warnOnFail: () => false, // 못 읽으면 카드에 「없음」 }); handles.push(cards); section.append(cards.root); - void cards.load({ id: row.열, label: name }); + void cards.load({ id: copy.폴더, label: name }); return section; } @@ -151,14 +113,12 @@ export function openStructureSheets(request: SheetsRequest): void { body.append(note(L("B08_Quantity_Drawing_Loading"))); void (async () => { try { - const [{ createItemCards }, copies] = await Promise.all([ - import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"), - loadCopies(request.projectId), - ]); + const { createItemCards } = + await import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"); if (closed) return; body.replaceChildren( ...request.combos.map((combo) => - comboSection(request, combo, copies, createItemCards, handles), + comboSection(request, combo, createItemCards, handles), ), ); } catch (error) { diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index 4a4e13ff8..5038a7c92 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -4,7 +4,8 @@ * 표는 공용 많은 자료 표(`ui_template_data_table` · PLAN 70-2) — 머리 · 측점 열 고정. * * 구조물 = GET …/route/structures · 배관 = GET …/drainage/pipe-points (읽기만) - * 머리 두 줄 = 조합 구분(M02 경로 첫 칸) · 조합 이름(나머지 경로) — `GET /api/m02/items?all=1` + * 머리 두 줄 = 조합 구분(경로 첫 칸) · 조합 이름(나머지 경로) — 사본 manifest 경로(집계 응답 `linked`) + * 조합 · 측점 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · PLAN 143-1) — 없으면 그 시설은 없음 * 횡단배수 열 · 칸 = GET …/quantity/summary(서버 단독 · PLAN 98 · 120) — 머리 구조물 · 형식 · 규격 * 3단 + 단위 줄 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(있으면) · 서버가 펼친 종류(`types`)는 * 조합 열에서 뺌 · 다른 구조물 열은 조합 그대로 @@ -15,8 +16,6 @@ import { API_BASE_URL } from "@config/config_frontend"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { loadRows as loadPickerRows } from "@ui/ui_template_item_picker"; -import { pathOf, rowId, type PickerRow } from "@ui/ui_template_item_picker_data"; import { showToast } from "@ui/ui_template_elements"; import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures"; import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; @@ -28,10 +27,12 @@ import { summaryTooltip, type SummaryItem } from "../common_util/common_util_sum import { combosAt, comboId, + linkedOnly, summaryColumns, summaryStations, type ComboColumn, type ComboLabel, + type LinkedSnapshot, type PlacedItem, type StationCombo, widthOf, @@ -72,6 +73,8 @@ interface PipeSummary { types: string[]; one_length_m: number | null; pipes: PipeLength[]; + /** 연결된 사본 `{구조물 id: 사본}`(143-1) */ + linked: Record; } async function fetchPipeSummary(projectId: string): Promise { @@ -88,7 +91,7 @@ const stationKey = (station: number): number => Math.round(station * 100); const placesOf = (values: number[]): number => Math.max(0, ...values.map((v) => (String(Math.round(v * 100) / 100).split(".")[1] ?? "").length)); -/** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(M02 에 있으면 · 조합 잇기 98). */ +/** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(사본 경로 · 조합 잇기 98). */ function pipeColumns(summary: PipeSummary, comboPath: (id: string) => string | null): DataColumn[] { const top = summary.columns @@ -168,12 +171,12 @@ async function openSheets( projectId: string, station: string, combos: StationCombo[], - rows: Map, + linked: Record, describe: (item: SummaryItem) => ComboLabel | null, ): Promise { try { const { openStructureSheets } = await import("./B08_Quantity_UI_Drawing"); - openStructureSheets({ projectId, station, combos, rows, describe }); + openStructureSheets({ projectId, station, combos, linked, describe }); } catch (error) { showToast(`${L("B08_Quantity_Drawing_Failed")} — ${String(error)}`, "error"); } @@ -183,9 +186,8 @@ async function openSheets( export async function renderStructureSummary(host: HTMLElement, projectId: string): Promise { host.replaceChildren(note(L("B08_Quantity_Summary_Loading"))); try { - const [structures, pickerRows, pipes, interval, pipe] = await Promise.all([ + const [structures, pipes, interval, pipe] = await Promise.all([ fetchStructures(projectId), - loadPickerRows(), // 배수 분석 전이면 관이 없음 — 구조물만으로 그림 fetchDetailPipePoints(projectId).then( (response) => response.pipe_points, @@ -195,28 +197,28 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin // 배관 집계 실패 = 까닭 한 줄 · 나머지 표는 그대로 fetchPipeSummary(projectId).catch((error: unknown) => String(error)), ]); - const m02 = new Map(pickerRows.map((row) => [rowId(row), row])); + const saved = typeof pipe === "string" ? null : pipe; + // 조합 이름 = 사본 manifest 경로(층 목록을 안 읽음 · 143-1) + const linked = saved?.linked ?? {}; + const paths = new Map( + Object.values(linked).map((copy) => [comboId(copy.항목), copy.경로] as const), + ); const describe = (item: SummaryItem): ComboLabel | null => { - const row = m02.get(comboId(item)); - if (!row) return null; - const [group = "", ...rest] = pathOf(row); - return { group, name: rest.join(" ") || group || row.key }; + const [group = "", ...rest] = paths.get(comboId(item)) ?? []; + return group ? { group, name: rest.join(" ") || group } : null; }; - const comboPath = (id: string): string | null => { - const row = m02.get(id); - return row ? pathOf(row).join(" ") : null; - }; - const placed = pipes.map((point) => ({ - ...point, - structure_id: pipeStructureId(point.chainage_m), - })); - const things: PlacedItem[] = [...structures.structures, ...placed]; + const comboPath = (id: string): string | null => paths.get(id)?.join(" ") || null; + const placed = linkedOnly( + pipes.map((point) => ({ ...point, structure_id: pipeStructureId(point.chainage_m) })), + linked, + ); + const mine = linkedOnly(structures.structures, linked); + const things: PlacedItem[] = [...mine, ...placed]; const stations = summaryStations(things); // 횡단배수는 서버 집계 열(98 · 120) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부) - const saved = typeof pipe === "string" ? null : pipe; const served = new Set(saved?.types ?? []); const others = [ - ...structures.structures.filter((item) => !served.has(item.type_id)), + ...mine.filter((item) => !served.has(item.type_id)), ...placed.filter((point) => !served.has(point.facility ?? "pipe")), ]; const columns = summaryColumns(others, describe); @@ -231,7 +233,7 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin const draw = (): HTMLElement => stations.length ? buildTable(stations, columns, live(), comboPath, interval, (station, label) => - openSheets(projectId, label, combosAt(things, station), m02, describe), + openSheets(projectId, label, combosAt(things, station), linked, describe), ) : note(L("B08_Quantity_Summary_NoStation")); let table = draw(); diff --git a/M02_MasterTemplete/M02_Item_Snapshot.py b/M02_MasterTemplete/M02_Item_Snapshot.py index 2270275bb..3e750d7cc 100644 --- a/M02_MasterTemplete/M02_Item_Snapshot.py +++ b/M02_MasterTemplete/M02_Item_Snapshot.py @@ -304,6 +304,33 @@ def complete_old(folder: Path) -> bool: return True +#: 「연결된 사본」 부분 이름 → 파일(있는 것만 `부분` 에 담음) +LINKED_PARTS = (("도면", "도면.json"), ("면적", "면적.json"), ("일위대가", LABOR_FILE)) + + +def linked_snapshots(project_root: str | Path) -> dict[str, dict[str, Any]]: + """연결된 사본 — 지정 ∩ 같은 항목 사본 `{구조물 id: {항목, 폴더, 부분, 경로}}`(PLAN 143-1). + + 구조물 id = `assignments` 키(관 지점 `pipe@측점` · 옵션 그룹 `{id}__{그룹}`) · 미지정 · 사본 없음 · + 다른 항목 사본은 안 담음(M02 `assign_items` 규칙) · 원본 층과 대조 안 함(빠른 읽기 · 쓰기 없음) · + `경로` = manifest 원본 경로(가져온 때 M02 구분 · 이름) · B08 · B09 공용. + """ + base = structures_dir(project_root) + found: dict[str, dict[str, Any]] = {} + for owner, item in assignments(project_root).items(): + folder = base / folder_name(owner) + manifest = read_json(folder / MANIFEST_NAME) + if not same_item(manifest, item): + continue + found[owner] = { + "항목": item, + "폴더": folder.name, + "부분": [part for part, name in LINKED_PARTS if (folder / name).is_file()], + "경로": list(manifest["원본"].get("경로") or []), + } + return found + + def read_snapshot(project_root: str | Path, owner_id: str) -> dict[str, Any] | None: """그 구조물 복사본의 `집계.json`(`{줄, 열}`) — 없으면 None(옛 항목 폴더는 먼저 옮겨 받음).""" if items_dir(project_root).is_dir(): diff --git a/resources/tester/test_120_b08_drain_summary.py b/resources/tester/test_120_b08_drain_summary.py index ec364dfaf..2ae572e72 100644 --- a/resources/tester/test_120_b08_drain_summary.py +++ b/resources/tester/test_120_b08_drain_summary.py @@ -8,6 +8,8 @@ 400 세월교 Φ1000×2련 월류 폭 12 · 유입 날개벽 · 유출 흙막이 찰(높이 빈칸 → 미입력) 3 + 4 500 물넘이 포장 월류 폭 8 · H=1.0 600 개거 + 노출형 횡단수로 · 700 맹암거(그룹 조합) +143-1 — 칸은 그 칸을 낸 조합 사본이 있을 때만 셈: 견본은 칸을 내는 잇기를 모두 두고 연결된 사본 = +지정 전부 · 사본 없으면 없음은 test_143_1 """ from __future__ import annotations @@ -22,6 +24,7 @@ import pytest from B06_Section.B06_Section_Schema import CrossSectionPatch from B08_Quantity import B08_Quantity_Router_Summary as summary +from M02_MasterTemplete import M02_Item_Snapshot as snapshots from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition ROOT = Path(__file__).resolve().parents[2] @@ -36,7 +39,7 @@ def _item(key: str) -> dict: POINTS = [ - {"chainage_m": 300.0, "options": {"pipe_diameter_mm": 800}}, + {"chainage_m": 300.0, "options": {"pipe_diameter_mm": 800}, "summary_item": _item("SS000100")}, { "chainage_m": 300.0, "facility": "box_culvert", @@ -50,7 +53,11 @@ POINTS = [ "outlet_revet_after_m": 5, }, "summary_item": _item("SS000101"), - "group_items": {"gboxbody": _item("SS000102")}, + "group_items": { + "gboxbody": _item("SS000102"), + "gboxin": _item("SS000106"), + "gboxout": _item("SS000107"), + }, }, { "chainage_m": 340.0, @@ -61,6 +68,8 @@ POINTS = [ "wing_in": "없음", "wing_out": "날개벽", }, + "summary_item": _item("SS000101"), + "group_items": {"gboxout": _item("SS000107")}, }, { "chainage_m": 400.0, @@ -77,21 +86,33 @@ POINTS = [ "outlet_revet_before_m": 3, "outlet_revet_after_m": 4, }, - "group_items": {"gford": _item("SS000103"), "gfordin": _item("SS000104")}, + "group_items": { + "gford": _item("SS000103"), + "gfordin": _item("SS000104"), + "gfordout": _item("SS000108"), + }, }, { "chainage_m": 500.0, "facility": "ford_pavement", "options": {"ford_width_m": 8, "ford_height_m": 1.0}, + "summary_item": _item("SS000109"), }, ] STRUCTURES = [ - {"structure_id": "s-ditch", "type_id": "open_ditch", "placement": "point", "chainage_m": 600}, + { + "structure_id": "s-ditch", + "type_id": "open_ditch", + "placement": "point", + "chainage_m": 600, + "summary_item": _item("SS000110"), + }, { "structure_id": "s-exposed", "type_id": "cross_drain_exposed", "placement": "point", "chainage_m": 600, + "summary_item": _item("SS000111"), }, { "structure_id": "s-ud", @@ -123,7 +144,8 @@ def out(tmp_path: Path) -> dict: encoding="utf-8", ) names = definition.read()["종류"]["pipe"]["이름"] - return summary.pipe_summary(root, DESIGNS, names, 6.0) + linked = {owner: {} for owner in snapshots.assignments(root)} # 지정 전부 사본 있음 + return summary.pipe_summary(root, DESIGNS, names, 6.0, linked=linked) def _cell(out: dict, at: float, cid: str): diff --git a/resources/tester/test_131_b08_material_fix.py b/resources/tester/test_131_b08_material_fix.py index fa1fb00d1..5f96af366 100644 --- a/resources/tester/test_131_b08_material_fix.py +++ b/resources/tester/test_131_b08_material_fix.py @@ -20,6 +20,7 @@ from test_121_b08_material_sheets import SUMMARY, UNITS, _run_ts from B08_Quantity import B08_Quantity_Router_Material as router_mod from B08_Quantity import B08_Quantity_Router_Summary as summary_mod from B08_Quantity.B08_Quantity_Material_Calc import clean_overrides, compute, finish +from M02_MasterTemplete import M02_Item_Snapshot as snapshots from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition ROOT = Path(__file__).resolve().parents[2] @@ -107,7 +108,8 @@ def _ford_summary(tmp_path: Path, designs: list[dict]) -> dict: encoding="utf-8", ) names = definition.read()["종류"]["pipe"]["이름"] - return summary_mod.pipe_summary(root, designs, names, 6.0) + linked = {owner: {} for owner in snapshots.assignments(root)} # 143-1 지정 전부 사본 있음 + return summary_mod.pipe_summary(root, designs, names, 6.0, linked=linked) def _cell(out: dict, at: float, cid: str): diff --git a/resources/tester/test_143_1_b08_linked_snapshots.py b/resources/tester/test_143_1_b08_linked_snapshots.py new file mode 100644 index 000000000..6fb1cda99 --- /dev/null +++ b/resources/tester/test_143_1_b08_linked_snapshots.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +"""B08 구조물 집계표 — 조합 사본 없으면 없음(PLAN 143-1). + +견본(관 지점 넷 + 세월교 둘 + 개거 하나): + 100 관 Φ800 L16 · 사본 있음 · 유출 흙막이 잇기 사본 없음 → 관 칸만 · 유출 흙막이 칸 없음 + 200 관 Φ800 L18 · 지정 있으나 사본 폴더가 다른 항목 → 없음 + 300 관 Φ800 L20 · 지정 있으나 사본 폴더 없음 → 없음 + 400 관 Φ800 L22 · 지정 없음 → 없음 + 500 세월교 Φ1000×2련 · 본체 잇기(gford) 사본 있음 → 관 몫 · 월류 폭 셈 + 600 세월교 · 잇기 없음 → 관 몫 · pipes 에도 없음 + 700 개거 · 지정 없음 → 「사본 없는 시설」 에 셈 +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from B08_Quantity import B08_Quantity_Router_Summary as summary +from M02_MasterTemplete import M02_Item_Snapshot as snapshots +from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition + +ROOT = Path(__file__).resolve().parents[2] + + +def _item(key: str) -> dict: + return {"layer": "system", "owner": None, "key": key, "name": key} + + +def _soil() -> dict: + return { + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(메)", + "outlet_revet_height_m": 2, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + } + + +POINTS = [ + { + "chainage_m": 100.0, + "options": {"pipe_diameter_mm": 800, **_soil()}, + "summary_item": _item("SS000001"), + "group_items": {"g9lq9uk": _item("SS000002")}, + }, + {"chainage_m": 200.0, "options": {"pipe_diameter_mm": 800}, "summary_item": _item("SS000001")}, + {"chainage_m": 300.0, "options": {"pipe_diameter_mm": 800}, "summary_item": _item("SS000001")}, + {"chainage_m": 400.0, "options": {"pipe_diameter_mm": 800}}, + { + "chainage_m": 500.0, + "facility": "ford_bridge", + "options": {"pipe_diameter_mm": 1000, "pipe_count": 2, "ford_width_m": 12}, + "group_items": {"gford": _item("SS000003")}, + }, + { + "chainage_m": 600.0, + "facility": "ford_bridge", + "options": {"pipe_diameter_mm": 1000, "pipe_count": 1, "ford_width_m": 8}, + }, +] +STRUCTURES = [ + {"structure_id": "s-ditch", "type_id": "open_ditch", "placement": "point", "chainage_m": 700}, +] +DESIGNS = [ + {"chainage_m": 100.0, "design": {"pipe_length_m": 16}}, + {"chainage_m": 200.0, "design": {"pipe_length_m": 18}}, + {"chainage_m": 300.0, "design": {"pipe_length_m": 20}}, + {"chainage_m": 400.0, "design": {"pipe_length_m": 22}}, + {"chainage_m": 500.0, "design": {"ford_pipe_length_m": 9}}, + {"chainage_m": 600.0, "design": {"ford_pipe_length_m": 7}}, +] + + +def _snapshot(root: Path, owner: str, item: dict, parts: tuple[str, ...] = ()) -> None: + folder = snapshots.structures_dir(root) / snapshots.folder_name(owner) + folder.mkdir(parents=True) + origin = {**item, "경로": ["횡단배수", item["key"], "이름"]} + (folder / snapshots.MANIFEST_NAME).write_text( + json.dumps({"원본": origin}, ensure_ascii=False), encoding="utf-8" + ) + for name in parts: + (folder / name).write_text("{}", encoding="utf-8") + + +@pytest.fixture +def root(tmp_path: Path) -> Path: + found = tmp_path / "proj" + edits = found / "B04_PreProcess/drainage/edits" + edits.mkdir(parents=True) + points = [{**p, "source": "user"} for p in POINTS] + (edits / "pipe_points.json").write_text( + json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8" + ) + route = found / "B05_Profile/route" + route.mkdir(parents=True) + (route / "structures.json").write_text( + json.dumps({"revision": 1, "structures": STRUCTURES}, ensure_ascii=False), + encoding="utf-8", + ) + _snapshot(found, "pipe@100.0", _item("SS000001"), ("도면.json", "일위대가.json")) + _snapshot(found, "pipe@200.0", _item("SS000009")) # 다른 항목 사본 + _snapshot(found, "pipe@500.0__gford", _item("SS000003")) + return found + + +def _run(root: Path) -> dict: + names = definition.read()["종류"]["pipe"]["이름"] + return summary.pipe_summary(root, DESIGNS, names, 6.0) + + +def _cell(out: dict, at: float, cid: str): + row = next((r for r in out["rows"] if abs(r["chainage_m"] - at) < 0.01), None) + return None if row is None else row["values"].get(cid) + + +def test_연결된_사본은_지정_교집합_같은_항목(root): + linked = snapshots.linked_snapshots(root) + assert set(linked) == {"pipe@100.0", "pipe@500.0__gford"} + one = linked["pipe@100.0"] + assert one["항목"]["key"] == "SS000001" and one["폴더"] == "pipe@100.0" + assert one["부분"] == ["도면", "일위대가"] # 있는 파일만 + assert one["경로"] == ["횡단배수", "SS000001", "이름"] + + +def test_사본_없는_칸은_안_셈(root): + out = _run(root) + assert _cell(out, 100, "pipe:len|800") == 16 + assert _cell(out, 100, "pipe:cp|800") == 2 + # 유출 흙막이 잇기는 사본 없음 → 그 칸만 없음(열도 안 섬) + assert not any(c["id"].startswith("pipe:revet") for c in out["columns"]) + # 다른 항목 사본 · 사본 없음 · 지정 없음 → 줄 없음 + assert [round(r["chainage_m"]) for r in out["rows"]] == [100, 500] + assert out["total"]["pipe:len|800"] == 16 + + +def test_세월교_관_몫은_본체_잇기_사본으로(root): + out = _run(root) + assert _cell(out, 500, "pipe:len|1000") == 18 # 9 × 2련 + assert _cell(out, 500, "pipe:ford|1000|2|width") == 12 + assert _cell(out, 600, "pipe:len|1000") is None + assert [(round(p["chainage_m"]), p["column"]) for p in out["pipes"]] == [ + (100, "pipe:cp|800"), + (500, "pipe:cp|1000"), + (500, "pipe:cp|1000"), + ] + width = next(c for c in out["columns"] if c["id"] == "pipe:ford|1000|2|width") + assert width["combos"] == ["system||SS000003"] + + +def test_사본_없는_시설_알림_응답에_사본(root): + out = _run(root) + # 100(유출 흙막이) · 200 · 300 · 400 · 500(기본 날개벽 잇기) · 600 · 개거 700 = 7곳 + assert not any("wing" in c["id"] for c in out["columns"]) + assert "조합 사본 없는 시설 7곳 — B05 · B06 좌측 패널에서 조합 연결 · [저장]" in out["notes"] + assert not any("관 연장 없음" in n for n in out["notes"]) + assert set(out["linked"]) == {"pipe@100.0", "pipe@500.0__gford"} + + +def test_화면_조합_이름_구조물도는_사본에서(): + ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + assert "loadPickerRows" not in ui # 층 목록 GET 안 씀 + assert "linkedOnly(" in ui + drawing = (ROOT / "B08_Quantity/B08_Quantity_UI_Drawing.ts").read_text(encoding="utf-8") + assert "readTemplate" not in drawing # 층 폴백 없음 + assert "B08_Quantity_Sheets_NoCopy" in drawing + + +def test_화면_사본_있는_조합만_사본_id(): + """`linkedOnly` — 본체 · 그룹마다 사본 있는 것만 · 하나도 없으면 시설째 뺌 · `combosAt` 사본 id.""" + import re + import subprocess + + imports = re.compile(r"^import[^;]*;\n", re.MULTILINE) + sources = [ + ROOT / "common_util" / "common_util_summary_item.ts", + ROOT / "B08_Quantity" / "B08_Quantity_Summary_Data.ts", + ] + things = [ + {"structure_id": "s1", "chainage_m": 40, "summary_item": _item("SS1"), + "group_items": {"g1": _item("SS3"), "g2": _item("SS4")}}, + {"structure_id": "s2", "chainage_m": 80, "summary_item": _item("SS2")}, + ] # fmt: skip + linked = {"s1__g1": {}, "s1": {}} + body = ( + f"\nconst kept = linkedOnly({json.dumps(things)}, {json.dumps(linked)});" + "\nconsole.log(JSON.stringify([kept.map((t) => [t.structure_id, Object.keys(t.group_items)])," + " combosAt(kept, 40).map((c) => c.copyId)]));" + ) + script = "\n".join(imports.sub("", p.read_text(encoding="utf-8")) for p in sources) + body + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [[["s1", ["g1"]]], ["s1", "s1__g1"]] diff --git a/resources/tester/test_61_2_b08_structure_sheets.py b/resources/tester/test_61_2_b08_structure_sheets.py index 05333c28e..a098d8555 100644 --- a/resources/tester/test_61_2_b08_structure_sheets.py +++ b/resources/tester/test_61_2_b08_structure_sheets.py @@ -32,10 +32,14 @@ def test_read_only(): assert "editable: () => false" in DRAWING -def test_copy_first_then_master(): - # 같은 항목의 프로젝트 복사본이 있으면 복사본 · 없으면 M02 원본 층 - assert "sameItem(own, row)" in DRAWING and "structureFolder(" in DRAWING - assert "readTemplate(layer, part, column, null)" in DRAWING +def test_copy_only(): + # 143-1 — 연결된 사본만(그룹은 `{id}__{그룹}` 사본) · 없으면 「사본 없음」 · M02 층 폴백 없음 + assert ( + "request.linked[combo.copyId]" in DRAWING + and "sourceOf(request.projectId, copy.폴더)" in DRAWING + ) + assert "readTemplate" not in DRAWING and "FromMaster" not in DRAWING + assert "사본 없음" in LOCALE def test_locale_keys_exist(): diff --git a/resources/tester/test_98_b08_pipe_summary.py b/resources/tester/test_98_b08_pipe_summary.py index feaa73c5a..c65fb5991 100644 --- a/resources/tester/test_98_b08_pipe_summary.py +++ b/resources/tester/test_98_b08_pipe_summary.py @@ -7,6 +7,8 @@ 78+7 Φ800 L18 · 집수정 ㄷ(U) · 유출 메 H2.0 10 m · 메붙임 20 ㎡ → 커플링 2 84+0 Φ800 L16 · 집수정 ㄴ(L) · 유출 메 H2.0 → 커플링 올림(16/6)−1 = 2 · 1본 8 m 면 1 89+10 Φ800 L(없음) · 유출 높이 빈칸 → 관경 최소 1.8 · 집수정 기록 없음 → 알림 +143-1 — 칸은 그 칸을 낸 조합 사본이 있을 때만 셈: 픽스처는 관 · 유입 · 유출 · 흙막이 1 · 돌붙임 1 을 +모두 잇고 연결된 사본 = 지정 전부(`_run`) · 사본 없으면 없음은 test_143_1 """ from __future__ import annotations @@ -17,12 +19,15 @@ from pathlib import Path import pytest from B08_Quantity import B08_Quantity_Router_Summary as summary +from M02_MasterTemplete import M02_Item_Snapshot as snapshots from M02_MasterTemplete import M02_Template_Layers as layers from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition COMBO = {"layer": "system", "owner": None, "key": "SS000011", "name": "횡단배수 배관"} OUT = {"layer": "company", "owner": "3", "key": "SC3-000001", "name": "기슭막이 메쌓기"} WALL = {"side": "outlet", "key": "extra0"} +#: 컨테이너 잇기 전부(143-1 — 잇지 않은 칸은 안 셈) · 흙막이 칸 조합 = OUT +LINKED = {"gnbf9m9": COMBO, "g9lq9uk": OUT, "gsoiladd1": OUT, "gchuteadd1": COMBO} def _revet(side: str, form: str, height: float | None) -> dict: @@ -101,7 +106,15 @@ def root(tmp_path: Path) -> Path: found = tmp_path / "proj" edits = found / "B04_PreProcess/drainage/edits" edits.mkdir(parents=True) - points = [{**p, "source": "user"} for p in POINTS] + points = [ + { + **p, + "source": "user", + "summary_item": COMBO, + "group_items": {**LINKED, **(p.get("group_items") or {})}, + } + for p in POINTS + ] (edits / "pipe_points.json").write_text( json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8" ) @@ -110,7 +123,8 @@ def root(tmp_path: Path) -> Path: def _run(root: Path, one_m: float | None = 6.0) -> dict: names = definition.read()["종류"]["pipe"]["이름"] - return summary.pipe_summary(root, DESIGNS, names, one_m) + linked = {owner: {} for owner in snapshots.assignments(root)} # 지정 전부 사본 있음 + return summary.pipe_summary(root, DESIGNS, names, one_m, linked=linked) def _cell(out: dict, at: float, cid: str): diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index 8f2f5cab4..962572564 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -28,7 +28,6 @@ export const ui_locales_b4 = { B08_Quantity_Tab_MaterialQuantity: ["자재수량집계표", "Material Quantity Table"], B08_Quantity_Sheets_Title: ["구조물도", "Structure Sheets"], B08_Quantity_Sheets_FromCopy: ["프로젝트 복사본", "Project copy"], - B08_Quantity_Sheets_FromMaster: ["M02 원본", "M02 original"], B08_Quantity_Sheets_NoCopy: ["복사본에 없음", "Not in the project copy"], B08_Quantity_Drawing_Loading: ["구조물도를 불러오는 중…", "Loading structure sheets…"], B08_Quantity_Drawing_Failed: [ @@ -36,15 +35,15 @@ export const ui_locales_b4 = { "Failed to load structure sheets", ], B08_Quantity_Drawing_Missing: [ - "M02 에 없는 조합입니다 — 도면 · 면적산출을 보일 수 없습니다.", - "This combination is not in M02 — no drawing or area sheet to show.", + "사본 없음 — B05 · B06 좌측 패널에서 조합 연결 · [저장] 뒤 보입니다.", + "No project copy — link the combination in the B05 · B06 panel and save.", ], B08_Quantity_Drawing_NoDrawing: [ - "이 조합은 도면이 없습니다 — M02 구조물 조합에서 그립니다.", - "This combination has no drawing — draw it in M02 structure combinations.", + "이 조합 사본은 도면이 없습니다 — M02 구조물 조합에서 그린 뒤 [재적용].", + "This copy has no drawing — draw it in M02 structure combinations, then reapply.", ], B08_Quantity_Summary_Station: ["측점", "Station"], - B08_Quantity_Summary_MissingGroup: ["M02 에 없음", "Not in M02"], + B08_Quantity_Summary_MissingGroup: ["경로 없음", "No path"], B08_Quantity_Summary_Loading: ["구조물 집계표를 불러오는 중…", "Loading the structure summary…"], B08_Quantity_Summary_Failed: [ "구조물 집계표를 불러오지 못했습니다", From 14c3f01f11d420077da4b114de193da309808264 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 14:56:57 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix(B08):=20=EC=9E=84=EC=9D=98=20=C2=B7=20?= =?UTF-8?q?=EC=8B=A4=EB=AC=B4=20=EA=B0=92=20=EB=B9=88=EC=B9=B8=20=E2=80=94?= =?UTF-8?q?=20=EA=B4=80=EA=B2=BD=20=C2=B7=20=EB=A0=A8=20=EB=AF=B8=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=C2=B7=201=EB=B3=B8=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EA=B0=92=EB=A7=8C=20=C2=B7=20=EA=B1=B0=EC=B0=BD=20?= =?UTF-8?q?=EA=B3=84=EC=88=98=20=EB=B9=84=EC=9C=A8=20=EB=AF=B8=EC=A0=95=20?= =?UTF-8?q?=C2=B7=20=EC=88=98=EC=8B=AC=EB=B9=84=20=EB=B9=88=EC=B9=B8(143-2?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 집계표 — 관경 · 련 빈칸 = 「미입력」 열(or 1000 · or 1 지움) · 관경 빈칸 흙막이 빈 높이 미입력 · 1본 = 프로젝트 층 변수만(시스템 층 폴백 지움) - 토공집계표 — 성토면다짐 · 초류종자살포 · 지장목제거 = 빈칸 + 「비율 미정」 · 구조물 쪽 늘 빈 줄 8 뺌(나머지 7 유지) - 수리 박스 — 수심비 처음 값 빈칸 · 빈칸이면 수심 · 유출량 멈춤 · 칸 옆 알림 - 좌측 패널 1본 읽기 전 빈칸 · 시험 test_69 · 98 · 122 · 73_11 고침 + 새 test_143_2 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- .../B08_Quantity_Engine_Earthwork_Summary.py | 28 ++---- B08_Quantity/B08_Quantity_Engine_Hydro.py | 4 +- B08_Quantity/B08_Quantity_Hydro_Defaults.py | 5 +- B08_Quantity/B08_Quantity_Router_Summary.py | 88 ++++++++++++------- .../B08_Quantity_UI_Earthwork_Sheets.ts | 2 +- B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts | 6 +- B08_Quantity/B08_Quantity_UI_Options.ts | 3 +- M02_MasterTemplete/M02_Item_Snapshot.py | 5 +- resources/tester/test_122_b08_options.py | 9 +- .../tester/test_143_2_b08_blank_values.py | 80 +++++++++++++++++ .../tester/test_69_b08_earthwork_tabs.py | 13 +-- .../tester/test_73_11_b08_hydro_box_ford.py | 21 ++++- resources/tester/test_98_b08_pipe_summary.py | 2 +- 13 files changed, 189 insertions(+), 77 deletions(-) create mode 100644 resources/tester/test_143_2_b08_blank_values.py diff --git a/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py b/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py index 080ea669c..406f49d41 100644 --- a/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py +++ b/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py @@ -12,7 +12,9 @@ Q · L 출처 = 저장된 유토곡선 배분(`mass_haul.haul_plan` — B07 토 (`free_haul` = 무대 · `dozer` = 불도우저 · `dump_truck` = 덤프트럭). 암 갈래 — 「연암」 · 「경암」 줄 = 암 전체 × 비율(좌측 패널 · 기본 90 : 10 · PLAN 122). -풍화암 · 보통암 · 구조물 쪽 줄(su2 시트)은 값이 없어 비움(None). +풍화암 · 보통암 · 표토제거 · 법면보호공 · 운반성토 줄은 값이 없어 비움(None) · 구조물 쪽 줄(su2 시트 +구조물터파기 · 토사다짐 · 되메우기 · 잔토)은 뺌 · 거창 계수(성토면다짐 0.8 · 초류종자 성토 0.5 · +지장목 0.8)는 근거가 없어 값 빈칸 + 「비율 미정」(PLAN 143-2). 짝 — `rock_split` · `split_rock_columns` ↔ `B08_Quantity_Options_Calc.ts` `rockSplit` · `splitRockColumns` · `splitRockSummary`(거울 시험 `test_122_b08_options_mirror`). @@ -99,8 +101,8 @@ def summary_rows( r0 = excel_round free_q, dump_q, dozer_q = haul["free_haul"]["q"], haul["dump_truck"]["q"], haul["dozer"]["q"] diverted = t["diverted_m3"] - tree = r0(t["tree_removal_fill_area_m2"] * 0.5 + t["tree_removal_cut_area_m2"], 2) m3 = "㎥" + unsure = "비율 미정" # 거창 계수 자리 — 값을 확인한 뒤 다시 잇기(143-2) return [ _row("흙깍기", "토사", "기계(굴삭기)", m3, r0(t["cut_soil_volume_m3"])), # 토적표!E137 _row("흙깍기", "풍화암", "굴삭기 + 브레카", m3, None), @@ -121,26 +123,10 @@ def summary_rows( _row("토사운반", "", "19 TON B/D", m3, dozer_q), # 평균운반거리 불도우저 ΣQ _row("잔토처리", "", "백호우", m3, r0(t["balance_m3"])), # 토적표!T137 _row("층따기", "", "백호우", m3, t["benching_area_m2"]), # 토적표!W137 - _row("성토면다짐", "", "", "㎡", r0(t["grading_fill_area_m2"] * 0.8), "성토면80%반영"), + _row("성토면다짐", "", "", "㎡", None, unsure), # 거창 = 성토면 × 0.8 _row("표토제거", "", "", "㎡", None), - _row( - "초류종자살포", - "", - "씨드스프레이", - "㎡", - r0(t["grading_fill_area_m2"] * 0.5 + t["grading_cut_area_m2"]), - "성토면50%반영+절토면", - ), + _row("초류종자살포", "", "씨드스프레이", "㎡", None, unsure), # 거창 = 성토 × 0.5 + 절토 _row("법면보호공", "", "", "㎡", None), - # 토적표!AI138 = ROUND(성토 × 0.5 + 절토, 2) → × 0.8 - _row("지장목제거", "", "", "㎡", r0(tree * 0.8), "80%반영"), - _row("구조물터파기", "토사", "기계(굴삭기)", m3, None, "구조물 수량"), - _row("구조물터파기", "풍화암", "굴삭기 + 브레카", m3, None), - _row("구조물터파기", "연암", "굴삭기 + 브레카", m3, None, "구조물 수량"), - _row("구조물터파기", "보통암", "굴삭기 + 브레카", m3, None), - _row("구조물터파기", "경암", "발파 + 굴삭기", m3, None), - _row("토사다짐", "", "", m3, None, "구조물 수량"), - _row("되메우기", "", "", m3, None, "구조물 수량"), - _row("잔토", "", "", m3, None, "현장내 성토(유용토)"), + _row("지장목제거", "", "", "㎡", None, unsure), # 거창 = (성토 × 0.5 + 절토) × 0.8 _row("운반성토", "", "BH+DT+BH", m3, None), ] diff --git a/B08_Quantity/B08_Quantity_Engine_Hydro.py b/B08_Quantity/B08_Quantity_Engine_Hydro.py index fd70ed906..adf8ae043 100644 --- a/B08_Quantity/B08_Quantity_Engine_Hydro.py +++ b/B08_Quantity/B08_Quantity_Engine_Hydro.py @@ -102,8 +102,8 @@ def compute(inputs: dict[str, Any]) -> dict[str, Any]: props = section_props(inputs["section"], diameter_mm / 1000.0) if diameter_mm else {} elif sheet == "box": width, height = _num(inputs, "box_width_m"), _num(inputs, "box_height_m") - ratio = _num(inputs, "depth_ratio") or SHEET_DEFAULTS["box"]["depth_ratio"] - if width and height: + ratio = _num(inputs, "depth_ratio") # 빈칸 = 수심 · 단면 · 유출량 멈춤(143-2) + if width and height and ratio: depth = ratio * height props = rect_props(width, depth) else: diff --git a/B08_Quantity/B08_Quantity_Hydro_Defaults.py b/B08_Quantity/B08_Quantity_Hydro_Defaults.py index 311c4e4e0..c3065da53 100644 --- a/B08_Quantity/B08_Quantity_Hydro_Defaults.py +++ b/B08_Quantity/B08_Quantity_Hydro_Defaults.py @@ -56,10 +56,11 @@ SHEETS = { "ford_pavement": ("ford", "물넘이포장"), } -#: 장별 처음 값 — 박스 수심비 0.8H · n 0.013(울진 산출서 · 조사 69-7) · 내공 · 경사는 빈칸 +#: 장별 처음 값 — 박스 n 0.013(KDS 콘크리트관) · 수심비 · 내공 · 경사는 빈칸(수심비 0.8 = 울진 산출서 — +#: 근거 없어 안 채움 · 빈칸이면 단면 계산 멈춤 · 143-2) #: 세월교 · 물넘이 월류 폭 10 m(사용자 10-7) · n · 경사 = B05 세월교 높이 계산과 같은 값 SHEET_DEFAULTS: dict[str, dict[str, Any]] = { - "box": {"n": 0.013, "depth_ratio": 0.8, "box_width_m": None, "box_height_m": None}, + "box": {"n": 0.013, "depth_ratio": None, "box_width_m": None, "box_height_m": None}, "ford": { "n": FORD_MANNING_N, "overflow_width_m": 10.0, diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py index fe16b794a..4e840da35 100644 --- a/B08_Quantity/B08_Quantity_Router_Summary.py +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -8,7 +8,8 @@ · 길이 = 전 + 후 · 빈 높이 = 횡단도가 그리는 관경 최소 높이 돌붙임 › 찰붙임 · 메붙임 ㎡ 도수로/돌붙임 붙임 형식 · 면적(빈칸이면 안 셈 · 98-3) 관 공 › Φ{관경}m/m › 관매설 m B06 관 길이(1 m 올림 그대로) - › 커플링 ea 올림(L ÷ 1본) − 1 · 1본 = 프로젝트 층 M02 구조물집계표 변수 + › 커플링 ea 올림(L ÷ 1본) − 1 · 1본 = 프로젝트 층 M02 구조물집계표 변수만 + 관경 · 련 빈칸 = 「미입력」 열(임의 값으로 안 채움 · 143-2) › 관보호공 모양 실제로 선 집수정 모양마다 1 개소(98-2 `inlet_basin`) BOX암거 › {가로×세로} › 본체 m B06 새 키 `box_length_m`(두 끝 offset 차 · 120) › 날개벽 › 유입 · 유출 개소 @@ -86,6 +87,23 @@ def _num(value: Any) -> float | None: return None +def _count(options: dict[str, Any], key: str) -> int | None: + """관경 · 련 수 — 빈칸이면 None(「미입력」 열 · 임의 값으로 안 채움 · 143-2).""" + value = _num(options.get(key)) + return int(value) if value else None + + +def _pipe_head(names: dict[str, str], diameter: int | None) -> list[str]: + """「관 공」 머리 — 관경 빈칸이면 「미입력」.""" + if diameter is None: + return ["관 공", fill.EMPTY_TEXT] + return ["관 공", f"Φ{names.get(f'pipe.diameter={diameter}', diameter)}m/m"] + + +def _key(value: int | None) -> str: + return fill.EMPTY_LABEL if value is None else str(value) + + def _combo(item: Any) -> str | None: """집계 항목 → 조합 id(화면 `comboId` 와 같은 꼴) · 없으면 None.""" if not isinstance(item, dict) or not item.get("key"): @@ -94,13 +112,10 @@ def _combo(item: Any) -> str | None: def one_pipe_length(project_root: str | Path) -> float | None: - """1본 길이(m) — 프로젝트 층 구조물집계표 변수 · 없으면 시스템 층 · 빈칸이면 None.""" - for folder in (layers.project_dir(project_root), layers.system_dir()): - found = layers.read_template(folder, "table", TABLE_NAME) - if found is not None: - value = _num((found["문서"].get("변수") or {}).get(ONE_LENGTH_VAR)) - return value if value and value > 0 else None - return None + """1본 길이(m) — 프로젝트 층 구조물집계표 변수만(시스템 층으로 안 채움 · 143-2) · 빈칸 None.""" + found = layers.read_template(layers.project_dir(project_root), "table", TABLE_NAME) + value = _num((found["문서"].get("변수") or {}).get(ONE_LENGTH_VAR)) if found else None + return value if value and value > 0 else None def couplings(length_m: float | None, one_m: float | None) -> int | None: @@ -344,10 +359,11 @@ def _ford( combos: dict[str, str | None], names: dict[str, str], ) -> None: - """세월교 — Φ관경×n련마다 월류 폭 m · 본체 개소.""" - diameter = int(_num(base["options"].get("pipe_diameter_mm")) or 1000) - count = int(_num(base["options"].get("pipe_count")) or 1) - spec = f"Φ{names.get(f'ford.diameter={diameter}', diameter)}×{count}련" + """세월교 — Φ관경×n련마다 월류 폭 m · 본체 개소(관경 · 련 빈칸 = 「미입력」).""" + diameter = _count(base["options"], "pipe_diameter_mm") + count = _count(base["options"], "pipe_count") + size = fill.EMPTY_TEXT if diameter is None else names.get(f"ford.diameter={diameter}", diameter) + spec = f"Φ{size}×{fill.EMPTY_TEXT if count is None else count}련" width_name = names.get("ford.width", "월류 폭").split(" (")[0] cells = ( ("width", width_name, "m", _num(base["options"].get("ford_width_m"))), @@ -356,8 +372,8 @@ def _ford( for index, (key, label, unit, value) in enumerate(cells): sheet.add( at, - f"ford|{diameter}|{count}|{key}", - (4, 0, diameter, count, index), + f"ford|{_key(diameter)}|{_key(count)}|{key}", + (4, 0, diameter or math.inf, count or math.inf, index), [names["종류"], spec, label], unit, value, @@ -380,31 +396,32 @@ def _ford_pipes( `pipes` 에는 련마다 한 줄(좌측 패널 1본 길이로 커플링을 다시 셀 때 련 수만큼 셈) · 조합 = 시설 본체 + 본체 잇기(`body` · 143-1). """ - diameter = int(_num(base["options"].get("pipe_diameter_mm")) or 1000) - count = int(_num(base["options"].get("pipe_count")) or 1) - head = ["관 공", f"Φ{names.get(f'pipe.diameter={diameter}', diameter)}m/m"] + diameter = _count(base["options"], "pipe_diameter_mm") + count = _count(base["options"], "pipe_count") + head = _pipe_head(names, diameter) combos = [_combo(base.get("summary_item")), body] each = couplings(length, one_m) sheet.add( at, - f"len|{diameter}", - (2, diameter, 0), + f"len|{_key(diameter)}", + (2, diameter or math.inf, 0), [*head, "관매설"], "m", - length * count if length else None, + length * count if length and count else None, combos, ) sheet.add( at, - f"cp|{diameter}", - (2, diameter, 1), + f"cp|{_key(diameter)}", + (2, diameter or math.inf, 1), [*head, "커플링"], "ea", - None if each is None else each * count, + None if each is None or count is None else each * count, combos, ) if any(combos): - pipes += [{"chainage_m": at, "column": f"pipe:cp|{diameter}", "length_m": length}] * count + column = f"pipe:cp|{_key(diameter)}" + pipes += [{"chainage_m": at, "column": column, "length_m": length}] * (count or 0) def _pavement( @@ -543,15 +560,15 @@ def pipe_summary( no_ford += counted and not ford_length _ford_pipes(sheet, at, base, ford_length, one_m, names, pipes, body) continue - diameter = int(_num(base["options"].get("pipe_diameter_mm")) or 1000) - pipe_head = ["관 공", f"Φ{names.get(f'pipe.diameter={diameter}', diameter)}m/m"] + diameter = _count(base["options"], "pipe_diameter_mm") + pipe_head = _pipe_head(names, diameter) pipe_combos = [_combo(base.get("summary_item")), links.get(LINKS["pipe"])] length = base.get("관연장") no_length += any(pipe_combos) and not length sheet.add( at, - f"len|{diameter}", - (2, diameter, 0), + f"len|{_key(diameter)}", + (2, diameter or math.inf, 0), [*pipe_head, "관매설"], "m", length, @@ -559,22 +576,23 @@ def pipe_summary( ) sheet.add( at, - f"cp|{diameter}", - (2, diameter, 1), + f"cp|{_key(diameter)}", + (2, diameter or math.inf, 1), [*pipe_head, "커플링"], "ea", couplings(length, one_m), pipe_combos, ) if any(pipe_combos): - pipes.append({"chainage_m": at, "column": f"pipe:cp|{diameter}", "length_m": length}) + column = f"pipe:cp|{_key(diameter)}" + pipes.append({"chainage_m": at, "column": column, "length_m": length}) shape = fill._nearest(basins, at) no_basin += bool(links.get(LINKS["inlet"])) and shape is None if shape: sheet.add( at, - f"basin|{diameter}|{shape}", - (2, diameter, 2 + _order(SHAPES, shape), shape), + f"basin|{_key(diameter)}|{shape}", + (2, diameter or math.inf, 2 + _order(SHAPES, shape), shape), [*pipe_head, "관보호공"], names.get(f"inlet.form={shape}", shape), 1.0, @@ -583,7 +601,9 @@ def pipe_summary( sides = [side for side in ("inlet", "outlet") if options.get(f"{side}_type") == SOIL] sources = [(side, links.get(LINKS[side])) for side in sides] - _revets(sheet, at, options, links, sources, pipe_wall_min_height_m(diameter / 1000), names) + # 관경 빈칸 = 빈 높이도 「미입력」(관경 최소 높이를 모름) + wall = pipe_wall_min_height_m(diameter / 1000) if diameter else None + _revets(sheet, at, options, links, sources, wall, names) _chutes(sheet, at, options, links, names) out = sheet.result() diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts index 5f09a2827..ba97716f5 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts @@ -28,7 +28,7 @@ const SUMMARY_COLUMNS: DataColumn[] = [ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable): void { const info = note( "토적표 합계 · 평균운반거리 · 종방향 무대운반거리를 실무 토공집계표 식대로 묶음 · " + - "연암 · 경암 = 암 × 좌측 패널 비율 · 구조물 쪽 줄은 구조물 수량에서(- = 아직 값 없음)", + "연암 · 경암 = 암 × 좌측 패널 비율 · 「비율 미정」 = 반영 비율 확인 뒤 채움(- = 아직 값 없음)", ); const grid = createDataTable({ columns: SUMMARY_COLUMNS, diff --git a/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts b/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts index bc0e84a06..e47659aee 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts +++ b/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts @@ -430,7 +430,11 @@ export function buildHydroSheet( dims.append( rowF("내공 폭 B (m)", null, numberInput("box_width_m", "입력")), rowF("내공 높이 H (m)", null, numberInput("box_height_m", "입력")), - rowF("수심비 h / H", hint(`기본 ${sheetDefaults.depth_ratio}`), numberInput("depth_ratio")), + rowF( + "수심비 h / H", + hint("빈칸이면 계산 멈춤 — 값 넣기"), + numberInput("depth_ratio", "입력"), + ), rowF("수심 h (m)", formula("h = 수심비 × H"), outCell("depth")), rowF("단면적", formula("A = B × h"), outCell("section_area")), rowF("윤변", formula("P = B + 2h"), outCell("perimeter")), diff --git a/B08_Quantity/B08_Quantity_UI_Options.ts b/B08_Quantity/B08_Quantity_UI_Options.ts index 2016d91ce..599fe4035 100644 --- a/B08_Quantity/B08_Quantity_UI_Options.ts +++ b/B08_Quantity/B08_Quantity_UI_Options.ts @@ -142,8 +142,9 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void rockRow.className = "b08-options__row"; rockRow.append(soft.root, hard.root); + // 1본 = 프로젝트 값만(읽기 전 빈칸 · 143-2) let saved: { one: number | null; soft: number; surcharge: Cells } = { - one: 8, + one: null, soft: 90, surcharge: PANEL_SURCHARGE, }; diff --git a/M02_MasterTemplete/M02_Item_Snapshot.py b/M02_MasterTemplete/M02_Item_Snapshot.py index 3e750d7cc..a85122e7d 100644 --- a/M02_MasterTemplete/M02_Item_Snapshot.py +++ b/M02_MasterTemplete/M02_Item_Snapshot.py @@ -311,8 +311,9 @@ LINKED_PARTS = (("도면", "도면.json"), ("면적", "면적.json"), ("일위 def linked_snapshots(project_root: str | Path) -> dict[str, dict[str, Any]]: """연결된 사본 — 지정 ∩ 같은 항목 사본 `{구조물 id: {항목, 폴더, 부분, 경로}}`(PLAN 143-1). - 구조물 id = `assignments` 키(관 지점 `pipe@측점` · 옵션 그룹 `{id}__{그룹}`) · 미지정 · 사본 없음 · - 다른 항목 사본은 안 담음(M02 `assign_items` 규칙) · 원본 층과 대조 안 함(빠른 읽기 · 쓰기 없음) · + 구조물 id = `assignments` 키(관 지점 `pipe@측점` · 옵션 그룹 `{id}__{그룹}`) · + 미지정 · 사본 없음 · 다른 항목 사본은 안 담음(M02 `assign_items` 규칙) · + 원본 층과 대조 안 함(빠른 읽기 · 쓰기 없음) · `경로` = manifest 원본 경로(가져온 때 M02 구분 · 이름) · B08 · B09 공용. """ base = structures_dir(project_root) diff --git a/resources/tester/test_122_b08_options.py b/resources/tester/test_122_b08_options.py index 7b38b50f6..ddb457020 100644 --- a/resources/tester/test_122_b08_options.py +++ b/resources/tester/test_122_b08_options.py @@ -40,9 +40,9 @@ def root(tmp_path, monkeypatch) -> Path: return project -def test_기본값은_8_m_와_90_10_파일에_안_씀(root): +def test_1본_프로젝트_값_없으면_빈칸_90_10_파일에_안_씀(root): assert options.read_options(root) == { - "one_pipe_length_m": 8, + "one_pipe_length_m": None, # 143-2 — 시스템 층 8 m 로 안 채움 "rock_ratios_pct": {"연암": 90.0, "경암": 10.0}, "material_surcharge_pct": {"sand": 12, "gravel": 5, "cement": 3}, # 124 } @@ -51,7 +51,7 @@ def test_기본값은_8_m_와_90_10_파일에_안_씀(root): def test_1본_6_m_저장_뒤_커플링을_다시_셈(root): - assert summary.couplings(22, summary.one_pipe_length(root)) == 2 # 올림(22/8) − 1 + assert summary.couplings(22, summary.one_pipe_length(root)) is None # 프로젝트 값 없음 out = options.save_options(root, options.OptionsSave(one_pipe_length_m=6)) assert out["one_pipe_length_m"] == 6 == summary.one_pipe_length(root) assert summary.couplings(22, 6) == 3 and summary.couplings(16, 6) == 2 @@ -63,6 +63,7 @@ def test_1본_6_m_저장_뒤_커플링을_다시_셈(root): # 다시 저장 = 같은 사본 판 위에 씀 options.save_options(root, options.OptionsSave(one_pipe_length_m=8)) assert summary.one_pipe_length(root) == 8 + assert summary.couplings(22, summary.one_pipe_length(root)) == 2 # 올림(22/8) − 1 def test_비율_70_30_저장_뒤_남고_다른_칸은_그대로(root): @@ -79,7 +80,7 @@ def test_비율_70_30_저장_뒤_남고_다른_칸은_그대로(root): assert stored["rock_methods"] == {"연암": "ripping"} assert options.read_options(root)["rock_ratios_pct"]["연암"] == 70.0 # 1본 길이는 안 보냈으니 그대로 - assert options.read_options(root)["one_pipe_length_m"] == 8 + assert options.read_options(root)["one_pipe_length_m"] is None def test_비율_읽기_자름_경암만_있을_때(): diff --git a/resources/tester/test_143_2_b08_blank_values.py b/resources/tester/test_143_2_b08_blank_values.py new file mode 100644 index 000000000..31fcd2f31 --- /dev/null +++ b/resources/tester/test_143_2_b08_blank_values.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +"""B08 구조물 집계표 — 관경 · 련 빈칸 = 「미입력」(임의 1000 · 1 로 안 채움 · PLAN 143-2). + +견본: 100 관 관경 빈칸 L16 · 유출 흙막이 높이 빈칸 / 200 세월교 Φ1000 련 빈칸 · 관 길이 9. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from B08_Quantity import B08_Quantity_Router_Summary as summary +from M02_MasterTemplete import M02_Item_Snapshot as snapshots +from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition + +ITEM = {"layer": "system", "owner": None, "key": "SS000001", "name": "견본"} +POINTS = [ + { + "chainage_m": 100.0, + "options": { + "pipe_diameter_mm": "", + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(메)", + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + }, + "summary_item": ITEM, + "group_items": {"g9lq9uk": ITEM}, + }, + { + "chainage_m": 200.0, + "facility": "ford_bridge", + "options": {"pipe_diameter_mm": 1000, "pipe_count": "", "ford_width_m": 12}, + "summary_item": ITEM, + }, +] +DESIGNS = [ + {"chainage_m": 100.0, "design": {"pipe_length_m": 16}}, + {"chainage_m": 200.0, "design": {"ford_pipe_length_m": 9}}, +] + + +def _run(tmp_path: Path) -> dict: + root = tmp_path / "proj" + edits = root / "B04_PreProcess/drainage/edits" + edits.mkdir(parents=True) + points = [{**p, "source": "user"} for p in POINTS] + (edits / "pipe_points.json").write_text( + json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8" + ) + names = definition.read()["종류"]["pipe"]["이름"] + linked = {owner: {} for owner in snapshots.assignments(root)} + return summary.pipe_summary(root, DESIGNS, names, 6.0, linked=linked) + + +def _cell(out: dict, at: float, cid: str): + row = next(r for r in out["rows"] if abs(r["chainage_m"] - at) < 0.01) + return row["values"].get(cid) + + +def test_관경_빈칸은_미입력_열(tmp_path): + out = _run(tmp_path) + cols = {c["id"]: c for c in out["columns"]} + assert cols["pipe:len|-"]["group"] == ["관 공", "미입력", "관매설"] + assert _cell(out, 100, "pipe:len|-") == 16 + assert _cell(out, 100, "pipe:cp|-") == 2 # 1본 6 m — 관경과 무관 + assert not any(c.startswith("pipe:len|1000") and _cell(out, 100, c) for c in cols) + # 흙막이 빈 높이 = 관경 최소 높이를 모름 → 「미입력」 + assert cols["pipe:revet|돌쌓기(메)|-"]["group"][2] == "미입력" + assert _cell(out, 100, "pipe:revet|돌쌓기(메)|-") == 10 + + +def test_세월교_련_빈칸은_미입력_관_몫_빈칸(tmp_path): + out = _run(tmp_path) + cols = {c["id"]: c for c in out["columns"]} + assert cols["pipe:ford|1000|-|width"]["group"][1] == "Φ1000×미입력련" + assert _cell(out, 200, "pipe:ford|1000|-|width") == 12 + assert _cell(out, 200, "pipe:len|1000") is None # 련 수를 모르면 관 몫도 빈칸 + assert _cell(out, 200, "pipe:cp|1000") is None + assert not [p for p in out["pipes"] if p["column"] == "pipe:cp|1000"] diff --git a/resources/tester/test_69_b08_earthwork_tabs.py b/resources/tester/test_69_b08_earthwork_tabs.py index f29e0890a..63ea0b737 100644 --- a/resources/tester/test_69_b08_earthwork_tabs.py +++ b/resources/tester/test_69_b08_earthwork_tabs.py @@ -195,12 +195,15 @@ def test_토공집계표_식() -> None: assert value[("토사운반", "", "19 TON B/D")] == 20 assert value[("유용토 소계", "", "")] == pytest.approx(t["diverted_m3"] + 82) assert value[("잔토처리", "", "백호우")] == -98 - assert value[("성토면다짐", "", "")] == excel_round(101.7 * 0.8) - assert value[("초류종자살포", "", "씨드스프레이")] == excel_round(101.7 * 0.5 + 15.3) - tree = excel_round(133.2 * 0.5 + 82.8, 2) - assert value[("지장목제거", "", "")] == excel_round(tree * 0.8) + # 143-2 — 거창 계수(0.8 · 0.5) 자리는 빈칸 + 「비율 미정」 · 구조물 쪽 줄 8 뺌 · 나머지 7 빈칸 + notes = {r["group"]: r["note"] for r in rows} + for group in ("성토면다짐", "초류종자살포", "지장목제거"): + assert next(r["value"] for r in rows if r["group"] == group) is None + assert notes[group] == "비율 미정" assert value[("표토제거", "", "")] is None - assert len(rows) == 33 # 시트 C5:C37 + assert not {"구조물터파기", "토사다짐", "되메우기", "잔토"} & {r["group"] for r in rows} + assert sum(r["value"] is None and r["note"] != "비율 미정" for r in rows) == 7 + assert len(rows) == 25 # 시트 C5:C37 33 − 구조물 쪽 8 def test_토적표_머리_글자는_사용자_견본대로(): diff --git a/resources/tester/test_73_11_b08_hydro_box_ford.py b/resources/tester/test_73_11_b08_hydro_box_ford.py index 1d5f6a96a..6593ffd1d 100644 --- a/resources/tester/test_73_11_b08_hydro_box_ford.py +++ b/resources/tester/test_73_11_b08_hydro_box_ford.py @@ -56,15 +56,27 @@ def test_장은_시설이_정함() -> None: def test_박스_울진_산출서_단면() -> None: - r = compute(_inputs("box", box_width_m=3.0, box_height_m=1.5, slope_denominator=50)) - assert SHEET_DEFAULTS["box"]["n"] == 0.013 and SHEET_DEFAULTS["box"]["depth_ratio"] == 0.8 + # 143-2 — 수심비 처음 값 빈칸(울진 0.8 은 손으로 넣은 값으로만) + r = compute( + _inputs("box", box_width_m=3.0, box_height_m=1.5, slope_denominator=50, depth_ratio=0.8) + ) + assert SHEET_DEFAULTS["box"]["n"] == 0.013 and SHEET_DEFAULTS["box"]["depth_ratio"] is None assert r["n"] == 0.013 assert r["depth"] == pytest.approx(1.2) assert r["section_area"] == pytest.approx(3.6) assert r["perimeter"] == pytest.approx(5.4) assert r["radius"] == pytest.approx(3.6 / 5.4) # 실무 29.236 = 1/n 75 · R 0.67 반올림 — 같은 n 이면 R 반올림 차(0.3 %)만 남음 - r75 = compute(_inputs("box", box_width_m=3.0, box_height_m=1.5, slope_denominator=50, n=1 / 75)) + r75 = compute( + _inputs( + "box", + box_width_m=3.0, + box_height_m=1.5, + slope_denominator=50, + n=1 / 75, + depth_ratio=0.8, + ) + ) assert r75["capacity"] == pytest.approx(29.236, rel=0.004) assert 75 * 0.67 ** (2 / 3) * math.sqrt(1 / 50) * 3.6 == pytest.approx(29.236, abs=0.001) assert r["capacity"] > r75["capacity"] # n 고치면 따라감 @@ -79,6 +91,9 @@ def test_박스_내공_경사_빈칸이면_대시() -> None: r = compute(_inputs("box")) assert r["design_flow"] is not None assert r["depth"] is None and r["capacity"] is None and r["ok"] is None + # 수심비 빈칸 = 내공 · 경사가 있어도 계산 멈춤(143-2) + r = compute(_inputs("box", box_width_m=3.0, box_height_m=1.5, slope_denominator=50)) + assert r["depth"] is None and r["capacity"] is None and r["ok"] is None @pytest.fixture(scope="module") diff --git a/resources/tester/test_98_b08_pipe_summary.py b/resources/tester/test_98_b08_pipe_summary.py index c65fb5991..f851b9b4a 100644 --- a/resources/tester/test_98_b08_pipe_summary.py +++ b/resources/tester/test_98_b08_pipe_summary.py @@ -215,7 +215,7 @@ def test_1본_길이는_프로젝트_층_변수(root, tmp_path, monkeypatch): json.dumps({"변수": {"파형강관_1본_m": 8}}), encoding="utf-8" ) monkeypatch.setattr(layers, "SYSTEM_ROOT", system) - assert summary.one_pipe_length(root) == 8 + assert summary.one_pipe_length(root) is None # 143-2 — 시스템 층으로 안 채움 mine = layers.template_path(layers.project_dir(root), "table", "구조물집계표") mine.parent.mkdir(parents=True) mine.write_text(json.dumps({"변수": {"파형강관_1본_m": 6}}), encoding="utf-8") From 22a54abe998c4f054bb34ddeb9dbf51f748e7624 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 14:59:58 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(B08):=20=EC=9E=90=EC=9E=AC=20=EC=85=8B?= =?UTF-8?q?=20=EA=B0=92=20=EC=97=86=EB=8A=94=20=EC=A4=84=20=EC=88=A8?= =?UTF-8?q?=EA=B9=80=20=C2=B7=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=ED=83=AD=20?= =?UTF-8?q?=ED=91=9C=20=EB=84=B7=20=EA=B3=B5=EC=9A=A9=20=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=20=EC=95=88(143-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 총괄 · 콘크리트 · 자재수량 — 수량 · 금액 · 손 단위수량 없는 줄 숨김(화면만 · 고친 칸은 원래 줄 자리로 저장) - 구조물 집계표 · 자재 셋 — 공용 카드 안(토공 표와 같은 꼴) - 시험 — 새 test_143_3 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_UI_Material.ts | 52 ++++++++++++------- B08_Quantity/B08_Quantity_UI_Summary.ts | 4 +- .../tester/test_143_3_b08_material_cards.py | 30 +++++++++++ 3 files changed, 66 insertions(+), 20 deletions(-) create mode 100644 resources/tester/test_143_3_b08_material_cards.py diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index 7d032c8a9..c0746c266 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -7,11 +7,13 @@ * 고칠 칸 = 단위수량 · ㎥당 배합 · 할증 % — 조작 = 캐시(`b08-materials`) + 짝 계산(`Material_Calc.ts`)으로 바로 * 총괄 모래 · 자갈 · 시멘트 할증 = 좌측 패널 값(PLAN 124 · 표에서 안 고침 · 패널 조작에 바로 다시 셈) * [저장] = PUT …/quantity/materials → 서버가 정본으로 다시 셈 · 캐시 비움 · 자동 저장 없음 + * 값 없는 줄은 숨김(화면만 · 셈 · 열 · 단위수량은 그대로) · 표는 공용 카드 안(PLAN 143-3) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { createButton, + createCard, hideLoadingOverlay, showLoadingOverlay, showToast, @@ -49,6 +51,12 @@ const HINTS: Record = { concrete: "㎥당 = 산림품셈 12-1-3 (B) 배합 · 건축 9-1-1 모르터 기본(무근은 빈칸) — 고칠 수 있음", quantity: "수량 = 구조물 수량 × 단위수량 · 단위수량은 빈칸(사용자 입력) · 관매설은 1", }; +const HIDDEN = "값 없는 줄은 숨김"; + +/** 보일 줄 차례(원래 줄 번호) — 값 없는 줄은 숨김(143-3) */ +const shown = (rows: readonly T[], has: (row: T) => boolean): number[] => + rows.flatMap((row, i) => (has(row) ? [i] : [])); +const anyValue = (cells: Cells): boolean => Object.values(cells).some((v) => v != null); async function request(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { @@ -99,7 +107,9 @@ interface Table { function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table { const { materials, rows, total } = s.quantity; - const rowsOut: DataRow[] = rows.flatMap((row) => { + const keep = shown(rows, (row) => row.qty != null || anyValue(row.units)); + const rowsOut: DataRow[] = keep.flatMap((at) => { + const row = rows[at]; const unit: DataRow = { name: row.name, spec: row.spec, @@ -137,7 +147,7 @@ function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table { total: totalRow, frozen: 5, editable: (i, key) => i % 2 === 0 && key.startsWith("m:"), - target: (i, key) => [o.units, rows[i / 2].id, key.slice(2)], + target: (i, key) => [o.units, rows[keep[i / 2]].id, key.slice(2)], }; } @@ -149,9 +159,11 @@ const MIX_LABEL: Record<(typeof MIX_FIELDS)[number], [string, string, string]> = function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table { const { rows, total } = s.concrete; - const rowsOut: DataRow[] = rows.map((row, i) => { + const keep = shown(rows, (row) => row.qty != null || anyValue(row.amounts)); + const rowsOut: DataRow[] = keep.map((at, i) => { + const row = rows[at]; const out: DataRow = { - name: i > 0 && rows[i - 1].name === row.name ? "" : row.name, + name: i > 0 && rows[keep[i - 1]].name === row.name ? "" : row.name, spec: row.spec, qty: row.qty, }; @@ -187,12 +199,13 @@ function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table { total: totalRow, frozen: 2, editable: (_i, key) => key.startsWith("per:"), - target: (i, key) => [o.mix, rows[i].key, key.slice(4)], + target: (i, key) => [o.mix, rows[keep[i]].key, key.slice(4)], }; } function totalTable(s: MaterialSheets, o: MaterialOverrides): Table { const { rows } = s.material; + const keep = shown(rows, (row) => row.qty != null || row.amount != null); return { columns: [ { key: "group", label: "구분", width: 96 }, @@ -203,19 +216,22 @@ function totalTable(s: MaterialSheets, o: MaterialOverrides): Table { numberColumn({ key: "amount", label: "합계", width: 96 }), { key: "note", label: "비고", width: 96 }, ], - rows: rows.map((row, i) => ({ - group: i > 0 && rows[i - 1].group === row.group ? "" : row.group, - spec: row.spec, - unit: row.unit, - qty: row.qty, - surcharge: row.surcharge, - amount: row.amount, - note: row.panel ? "할증 = 좌측 패널" : "", - })), + rows: keep.map((at, i) => { + const row = rows[at]; + return { + group: i > 0 && rows[keep[i - 1]].group === row.group ? "" : row.group, + spec: row.spec, + unit: row.unit, + qty: row.qty, + surcharge: row.surcharge, + amount: row.amount, + note: row.panel ? "할증 = 좌측 패널" : "", + }; + }), total: null, frozen: 2, - editable: (i, key) => key === "surcharge" && !rows[i].panel, - target: (i) => [o.surcharge, rows[i].key, null], + editable: (i, key) => key === "surcharge" && !rows[keep[i]].panel, + target: (i) => [o.surcharge, rows[keep[i]].key, null], }; } @@ -254,7 +270,7 @@ export async function renderMaterialSheet( const toolbar = document.createElement("div"); toolbar.className = "b08-material__toolbar"; const notes = document.createElement("div"); - notes.append(note(BASIS), note(HINTS[sheet])); + notes.append(note(BASIS), note(`${HINTS[sheet]} · ${HIDDEN}`)); if (sheet !== "quantity") { // 빈 까닭 한 줄(131) — 단위수량 빈 줄 · 서버 알림 · 빈 칸은 빈칸 그대로 const blank = blankUnitRows(sheets); @@ -300,7 +316,7 @@ export async function renderMaterialSheet( built = BUILD[sheet](sheets, overrides); table.setRows(built.rows, built.total); }; - host.replaceChildren(toolbar, table.root); + host.replaceChildren(createCard({ body: [toolbar, table.root] })); onOptionsChange(host, () => { sheets = calc(); redraw(); diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index 5038a7c92..ddb310f21 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -16,7 +16,7 @@ import { API_BASE_URL } from "@config/config_frontend"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { showToast } from "@ui/ui_template_elements"; +import { createCard, showToast } from "@ui/ui_template_elements"; import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures"; import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; @@ -238,7 +238,7 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin : note(L("B08_Quantity_Summary_NoStation")); let table = draw(); parts.push(table); - host.replaceChildren(...parts); + host.replaceChildren(createCard({ body: parts })); // 공용 카드 안(토공 표와 같은 꼴 · 143-3) onOptionsChange(host, () => { const next = draw(); table.replaceWith(next); diff --git a/resources/tester/test_143_3_b08_material_cards.py b/resources/tester/test_143_3_b08_material_cards.py new file mode 100644 index 000000000..9f3b3234b --- /dev/null +++ b/resources/tester/test_143_3_b08_material_cards.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +"""B08 구조물 탭 — 자재 셋 값 없는 줄 숨김 · 표 넷 공용 카드 안(PLAN 143-3). + +숨김은 화면만 — 셈 · 열 · 단위수량은 그대로 · 고친 칸은 원래 줄 번호(`keep`)로 저장 자리를 찾음. +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +MATERIAL = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") +SUMMARY = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + + +def test_자재_셋_값_없는_줄_숨김(): + assert MATERIAL.count("const keep = shown(rows, ") == 3 # 총괄 · 콘크리트 · 자재수량 + assert "row.qty != null || anyValue(row.units)" in MATERIAL # 손 단위수량 있으면 보임 + assert "row.qty != null || anyValue(row.amounts)" in MATERIAL + assert "row.qty != null || row.amount != null" in MATERIAL + # 고친 칸 → 원래 줄(숨긴 줄 건너뜀) + for target in ( + "rows[keep[i / 2]].id", + "rows[keep[i]].key, key.slice(4)", + "rows[keep[i]].panel", + ): + assert target in MATERIAL, target + + +def test_표_넷_공용_카드_안(): + assert "createCard({ body: [toolbar, table.root] })" in MATERIAL + assert "createCard({ body: parts })" in SUMMARY From e1d83398d4392bf1b95fa96eafb208dae63c417d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 15:01:27 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(B09):=20=EC=9B=90=EA=B0=80=20=EB=B0=94?= =?UTF-8?q?=ED=83=95=20=EB=8B=A8=EA=B0=80=20=E2=80=94=20=EB=BC=88=EB=8C=80?= =?UTF-8?q?=20=C2=B7=20=EB=85=B8=EB=AC=B4=20=C2=B7=20=EA=B2=BD=EB=B9=84=20?= =?UTF-8?q?=C2=B7=20=ED=99=98=EC=9C=A8=20=C2=B7=20=EC=9E=90=EC=9E=AC=20?= =?UTF-8?q?=EB=8C=80=EB=B9=84=20=C2=B7=20=EC=A4=91=EA=B8=B0=20=ED=83=AD(14?= =?UTF-8?q?4-1~4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 연결 점검) · 탭 하나 = 공용 표 하나 · 호표 칸 누름 = 그 탭 그 줄로 이동 - 저장 = 프로젝트 B09_Estimation/v1(사용자 값 · 굳힌 단가 한 벌) · 결과는 GET 때 서버 셈 · [저장] · [단가 갱신] 길 - 줄 = 수요 따라(연결된 사본 원천 자리 · 기계 → 운전사 · 연료 · B08 총괄 자재) · 연결 없으면 빈칸 + 연결 점검 목록 - 셈 = 파이썬 · TS 짝 + 거울(운전사 시간 노임 · 자재 최저 자동 + 사용자 고침 · 관급 · 별도 · 중기 손료 + 운전사 + 연료 + 잡품 · 엑셀 끝수) - 공용 표 = 칸 누름 · 줄로 이동(강조) 더함 - 시험 = test_144_b09_skeleton · test_144_b09_base_calc(엑셀 중기 126,000 · 76,436 · 96,829 · 40,659 · 운전사 55,700 · 47,231 · 33,571) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X --- A00_Common/b_page_state.ts | 2 + B09_Estimation/B09_Estimation_Calc_Base.py | 319 ++++++++++++++++++ B09_Estimation/B09_Estimation_Calc_Base.ts | 289 ++++++++++++++++ B09_Estimation/B09_Estimation_Prices.py | 266 +++++++++++++++ B09_Estimation/B09_Estimation_Router.py | 84 +++++ B09_Estimation/B09_Estimation_Store.py | 109 ++++++ B09_Estimation/B09_Estimation_UI_Page.css | 32 ++ B09_Estimation/B09_Estimation_UI_Shell.ts | 228 ++++++++++++- B09_Estimation/B09_Estimation_UI_Tabs.ts | 164 +++++++++ B09_Estimation/B09_Estimation_UI_Tabs_Base.ts | 275 +++++++++++++++ main.py | 2 + resources/tester/test_144_b09_base_calc.py | 170 ++++++++++ resources/tester/test_144_b09_skeleton.py | 114 +++++++ ui_template/ui_template_data_table.css | 6 + ui_template/ui_template_data_table.ts | 26 +- 15 files changed, 2075 insertions(+), 11 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Calc_Base.py create mode 100644 B09_Estimation/B09_Estimation_Calc_Base.ts create mode 100644 B09_Estimation/B09_Estimation_Prices.py create mode 100644 B09_Estimation/B09_Estimation_Router.py create mode 100644 B09_Estimation/B09_Estimation_Store.py create mode 100644 B09_Estimation/B09_Estimation_UI_Page.css create mode 100644 B09_Estimation/B09_Estimation_UI_Tabs.ts create mode 100644 B09_Estimation/B09_Estimation_UI_Tabs_Base.ts create mode 100644 resources/tester/test_144_b09_base_calc.py create mode 100644 resources/tester/test_144_b09_skeleton.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index f2aa42d78..bd0f26bd5 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -178,6 +178,8 @@ export const STATE_REGISTRY = { "b08-hydro": { bucket: "draft", scope: "project", version: 3 }, /** B08 좌측 패널 옵션 `{one_pipe_length_m, soft_rock_pct}` — [저장] 때 M02 표 변수 · project_settings 로(122). */ "b08-options": { bucket: "draft", scope: "project" }, + /** B09 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */ + "b09-estimation": { bucket: "draft", scope: "project" }, /** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c). * 옛 키는 노선 번호만 붙은 `b05-profile-alignment-draft:{노선}` 이었다. */ "alignment-draft": { diff --git a/B09_Estimation/B09_Estimation_Calc_Base.py b/B09_Estimation/B09_Estimation_Calc_Base.py new file mode 100644 index 000000000..0c0677cac --- /dev/null +++ b/B09_Estimation/B09_Estimation_Calc_Base.py @@ -0,0 +1,319 @@ +"""B09 원가 바탕 단가 셈(PLAN 144) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음. + +⚠ 짝 = `B09_Estimation_Calc_Base.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · + 거울 시험 `resources/tester/test_144_b09_base_calc.py`. + 조작 중은 화면 짝이 바로 셈 · [저장] 때 서버가 이 파일로 정본을 다시 셈. + +실무 엑셀(기번3 공통 xlsx) 탭 꼴 · 끝수 = 엑셀 자리 그대로: + 노무비목록표 · 경비목록표 = 굳힌 단가 그대로(사용자 고침 없음 · [단가 갱신] 때만 새로) + 환율및기초자료 = 환율 · 운전사 시간 노임 = ROUNDDOWN(일당 × 분자 ÷ 분모, 0) + (분자 · 분모 = M01 CC000336 1/8 × 16/12 × 25/20 — 서버가 넣어 줌) + 자재단가대비표 = 출처 단가 중 최저(0 · 빈칸 뺌) 자동 · 사용자 적용 단가가 있으면 그 값 + · 관급 · 별도계상 = 사용자 표시 → 재료비목록표 단가 = 적용 단가 + 중기사용료 = 손료(경비 · 기준가 천원 × 손료계수 ÷ 10,000) + 운전사(노무 · 인 × 시간 노임) + + 연료(재료 · ℓ/hr × 적용 단가) + 잡품(재료 · 연료 금액 × % ÷ 100) + 줄 = ROUNDDOWN(단가 × 수량, 1) · 비목 합 = ROUNDDOWN(Σ, 0) → 중기목록표 +빈칸 규칙 = 값을 낸 원천이 없으면 None(0 을 채우지 않음) · 그 칸을 쓰는 합도 None · +「연결 없음」 목록(`gaps`)에 탭 · 줄 키 · 까닭을 남김(목차 탭이 이동 링크로 씀). +""" + +from __future__ import annotations + +import math +from typing import Any + +#: 떠도는 끝수(부동소수) — 이만큼은 자리 올림 · 버림에서 무시 +EPS = 1e-7 + +#: 자재단가대비표 출처 칸 차례 — 엑셀 조달 · 물가정보 · 물가자료 차례(같은 값이면 앞 칸) ↔ M01 재료_자재품목 값 열 · 유가 = 오피넷 +SOURCES = ("관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가") + +#: 자재 줄 사용자 표시 +SUPPLY = ("관급", "별도") + + +def _scaled(value: float, places: int, fn) -> float: + a = abs(value) + if places >= 0: + scale = 10**places + out = fn(a * scale) / scale + else: + unit = 10 ** (-places) + out = fn(a / unit) * unit + return -out if value < 0 else out + + +def rounddown(value: float, places: int = 0) -> float: + """엑셀 ROUNDDOWN — 0 쪽으로 버림.""" + return _scaled(value, places, lambda x: math.floor(x + EPS)) + + +def roundup(value: float, places: int = 0) -> float: + """엑셀 ROUNDUP — 0 에서 먼 쪽으로 올림.""" + return _scaled(value, places, lambda x: math.ceil(x - EPS)) + + +def round_half(value: float, places: int = 0) -> float: + """엑셀 ROUND — 반 올림(0 에서 먼 쪽).""" + return _scaled(value, places, lambda x: math.floor(x + 0.5 + EPS)) + + +def num(value: Any) -> float | None: + """수 · 빈칸 — 수가 아니면 None.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return float(value) if math.isfinite(value) else None + + +def total(values: list[float | None]) -> float | None: + """합 — 하나라도 빈칸이면 빈칸(연결 안 된 값은 비움).""" + out = 0.0 + for v in values: + if v is None: + return None + out += v + return out + + +def gap(tab: str, key: str, name: str, why: str) -> dict[str, str]: + return {"tab": tab, "key": key, "name": name, "why": why} + + +def driver_hour(day: float | None, factor: dict[str, Any] | None) -> float | None: + """운전사 시간 노임 = ROUNDDOWN(일당 × 분자 ÷ 분모, 0).""" + if day is None or not factor or not factor.get("den"): + return None + return rounddown(day * factor["num"] / factor["den"], 0) + + +def labor_sheet(labor: list[dict[str, Any]], gaps: list) -> list[dict[str, Any]]: + rows = [] + for no, row in enumerate(labor, 1): + price = num(row.get("price")) + if price is None: + gaps.append(gap("labor", row["key"], row["name"], "노임 단가 없음")) + rows.append( + { + **_head(row, no), + "code": row.get("code"), + "unit": row.get("unit") or "인", + "price": price, + } + ) + return rows + + +def _head(row: dict[str, Any], no: int) -> dict[str, Any]: + return { + "no": no, + "key": row["key"], + "name": row.get("name") or "", + "spec": row.get("spec") or "", + "source": row.get("source") or "", + } + + +def expense_sheet(machines: list[dict[str, Any]], gaps: list) -> list[dict[str, Any]]: + """경비목록표 — 기계 손료 기준가(천원).""" + rows = [] + for no, m in enumerate(machines, 1): + price = num(m.get("price")) + if price is None: + gaps.append(gap("expense", m["key"], m["name"], "기계 기준가 없음")) + rows.append({**_head(m, no), "unit": "천원", "price": price}) + return rows + + +def lowest(prices: dict[str, Any]) -> tuple[float | None, str | None]: + """출처 단가 중 최저 — 0 · 빈칸 뺌 · 같으면 앞 칸.""" + best: tuple[float | None, str | None] = (None, None) + for source in SOURCES: + v = num(prices.get(source)) + if v is not None and v > 0 and (best[0] is None or v < best[0]): + best = (v, source) + return best + + +def compare_sheet( + materials: list[dict[str, Any]], user: dict[str, Any], gaps: list +) -> list[dict[str, Any]]: + """자재단가대비표 — 출처 · 최저 · 적용(사용자 값이 앞섬) · 관급 · 별도 표시.""" + rows = [] + for no, m in enumerate(materials, 1): + mine = user.get(m["key"]) or {} + prices = m.get("prices") or {} + low, low_from = lowest(prices) + if "applied" in mine: + applied, applied_from = num(mine["applied"]), "사용자" + else: + applied, applied_from = low, low_from + if applied is None: + gaps.append(gap("compare", m["key"], m["name"], "적용 단가 없음(출처 없음)")) + supply = mine.get("supply") if mine.get("supply") in SUPPLY else None + rows.append( + { + **_head(m, no), + "unit": m.get("unit") or "", + "origin": m.get("origin") or "", + "prices": {s: num(prices.get(s)) for s in SOURCES}, + "pages": {s: (m.get("pages") or {}).get(s) for s in SOURCES}, + "lowest": low, + "lowest_from": low_from, + "applied": applied, + "applied_from": applied_from if applied is not None else None, + "supply": supply, + } + ) + return rows + + +def material_sheet(compare: list[dict[str, Any]]) -> list[dict[str, Any]]: + """재료비목록표 — 단가 = 대비표 적용 단가.""" + keep = ("no", "key", "name", "spec", "unit", "supply", "origin") + return [{**{k: row[k] for k in keep}, "price": row["applied"]} for row in compare] + + +def _line(name, spec, qty, unit, kind, price, ref, pct=False) -> dict[str, Any]: + if price is None or qty is None: + amount = None + elif pct: + amount = rounddown(price * qty / 100, 1) + else: + amount = rounddown(price * qty, 1) + return { + "name": name, + "spec": spec, + "qty": qty, + "unit": unit, + "kind": kind, + "price": price, + "amount": amount, + "ref": ref, + } + + +def machine_block( + m: dict[str, Any], + drivers: dict[str, dict[str, Any]], + fuels: dict[str, dict[str, Any]], + gaps: list, +) -> dict[str, Any]: + """중기사용료 한 블록 — 손료 · 운전사 · 연료 · 잡품 줄 + 비목 합.""" + lines = [] + loss = num(m.get("loss")) + coef = loss / 10000 if loss is not None else None + price = num(m.get("price")) + lines.append(_line(m["name"], m.get("spec") or "", coef, "천원", "expense", price, + {"tab": "expense", "key": m["key"]})) # fmt: skip + crew = num(m.get("crew")) + if crew: + code = str(m.get("crew_code") or "") + driver = drivers.get(code) or {} + hour = driver.get("hour") + if hour is None: + gaps.append(gap("machine_blocks", m["key"], m["name"], "운전사 노임 없음")) + lines.append(_line(driver.get("name") or "운전사", "", crew, "인", "labor", hour, + {"tab": "basis", "key": code})) # fmt: skip + lph = num(m.get("fuel_lph")) + if lph: + kind = str(m.get("fuel") or "") + fuel = fuels.get(kind) or {} + fprice = fuel.get("applied") + if fprice is None: + gaps.append(gap("machine_blocks", m["key"], m["name"], f"연료({kind}) 단가 없음")) + fuel_line = _line(fuel.get("name") or kind, fuel.get("spec") or "", lph, "ℓ", "material", + fprice, {"tab": "compare", "key": fuel.get("key") or ""}) # fmt: skip + lines.append(fuel_line) + misc = num(m.get("misc_pct")) + if misc: + lines.append(_line("잡품", "주연료의 %", misc, "%", "material", fuel_line["amount"], + None, pct=True)) # fmt: skip + if price is None or coef is None: + gaps.append(gap("machine_blocks", m["key"], m["name"], "손료 기준가 · 계수 없음")) + sums: dict[str, float | None] = {} + for part in ("labor", "material", "expense"): + s = total([ln["amount"] for ln in lines if ln["kind"] == part]) + sums[part] = None if s is None else rounddown(s, 0) + return { + "key": m["key"], + "name": m["name"], + "spec": m.get("spec") or "", + "unit": "시간", + "lines": lines, + "labor": sums["labor"], + "material": sums["material"], + "expense": sums["expense"], + "sum": total([sums["labor"], sums["material"], sums["expense"]]), + } + + +def basis_sheet( + inputs: dict[str, Any], labor_rows: list[dict[str, Any]] +) -> dict[str, list[dict[str, Any]]]: + """환율및기초자료 — 환율 · 운전사 시간 노임(기계가 부르는 직종만).""" + factor = inputs.get("driver_factor") + by_code = {str(row.get("code")): row for row in labor_rows if row.get("code") is not None} + codes: list[str] = [] + for m in inputs.get("machines") or []: + code = str(m.get("crew_code")) if m.get("crew_code") else None + if code and num(m.get("crew")) and code not in codes: + codes.append(code) + drivers = [] + for code in codes: + row = by_code.get(code) or {} + day = row.get("price") + drivers.append( + { + "key": code, + "labor_key": row.get("key"), + "name": row.get("name") or f"직종 {code}", + "day": day, + "factor": (factor or {}).get("text") or "", + "hour": driver_hour(day, factor), + } + ) + fx = [ + {"key": f["key"], "name": f.get("name") or "", "unit": f.get("unit") or "", + "value": num(f.get("value")), "date": f.get("date") or ""} + for f in inputs.get("fx") or [] + ] # fmt: skip + return {"fx": fx, "drivers": drivers} + + +def compute_base(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]: + """144 탭 일곱 + 「연결 없음」 목록.""" + gaps: list[dict[str, str]] = [] + labor_rows = labor_sheet(inputs.get("labor") or [], gaps) + expense = expense_sheet(inputs.get("machines") or [], gaps) + compare = compare_sheet(inputs.get("materials") or [], user.get("materials") or {}, gaps) + basis = basis_sheet(inputs, labor_rows) + drivers = {d["key"]: d for d in basis["drivers"]} + by_key = {row["key"]: row for row in compare} + fuels = { + kind: by_key[key] for kind, key in (inputs.get("fuel_keys") or {}).items() if key in by_key + } + blocks = [machine_block(m, drivers, fuels, gaps) for m in inputs.get("machines") or []] + machine = [ + { + "no": no, + "key": b["key"], + "name": b["name"], + "spec": b["spec"], + "unit": b["unit"], + "sum": b["sum"], + "labor": b["labor"], + "material": b["material"], + "expense": b["expense"], + } + for no, b in enumerate(blocks, 1) + ] + return { + "labor": labor_rows, + "expense": expense, + "basis": basis, + "compare": compare, + "material": material_sheet(compare), + "machine_blocks": blocks, + "machine": machine, + "gaps": gaps, + } diff --git a/B09_Estimation/B09_Estimation_Calc_Base.ts b/B09_Estimation/B09_Estimation_Calc_Base.ts new file mode 100644 index 000000000..4213f70a3 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Calc_Base.ts @@ -0,0 +1,289 @@ +/* ============================================================================= + * B09_Estimation_Calc_Base.ts + * B09 원가 바탕 단가 셈(PLAN 144) — 화면 없음 · 저장 안 함 · import 없음(시험이 node 로 돌림). + * + * ⚠ 짝 = `B09_Estimation_Calc_Base.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · + * 거울 시험 `resources/tester/test_144_b09_base_calc.py`. + * 조작 중은 이 파일이 바로 셈 · [저장] 때 서버가 파이썬 짝으로 정본을 다시 셈. + * + * 노무비 · 경비목록표 = 굳힌 단가 · 환율및기초자료 = 운전사 시간 노임 ROUNDDOWN(일당 × 분자 ÷ 분모, 0) + * 자재단가대비표 = 출처 중 최저 자동 · 사용자 적용 단가 앞섬 · 관급 · 별도 표시 → 재료비목록표 + * 중기사용료 = 손료 + 운전사 + 연료 + 잡품 · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 → 중기목록표 + * 빈칸 = 원천이 없으면 null · 그 칸을 쓰는 합도 null · 「연결 없음」 목록(gaps)에 남김 + * ========================================================================== */ + +export type Num = number | null; +export type Row = Record; + +export interface Gap { + tab: string; + key: string; + name: string; + why: string; +} + +/** 떠도는 끝수(부동소수) — 이만큼은 자리 올림 · 버림에서 무시 */ +const EPS = 1e-7; + +/** 자재단가대비표 출처 칸 차례 — 엑셀 조달 · 물가정보 · 물가자료(같은 값이면 앞 칸) */ +export const SOURCES = ["관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가"] as const; + +/** 자재 줄 사용자 표시 */ +export const SUPPLY = ["관급", "별도"] as const; + +function scaled(value: number, places: number, fn: (x: number) => number): number { + const a = Math.abs(value); + let out: number; + if (places >= 0) { + const scale = 10 ** places; + out = fn(a * scale) / scale; + } else { + const unit = 10 ** -places; + out = fn(a / unit) * unit; + } + return value < 0 ? -out : out; +} + +/** 엑셀 ROUNDDOWN */ +export const rounddown = (value: number, places = 0): number => + scaled(value, places, (x) => Math.floor(x + EPS)); + +/** 엑셀 ROUNDUP */ +export const roundup = (value: number, places = 0): number => + scaled(value, places, (x) => Math.ceil(x - EPS)); + +/** 엑셀 ROUND(반 올림) */ +export const roundHalf = (value: number, places = 0): number => + scaled(value, places, (x) => Math.floor(x + 0.5 + EPS)); + +export function num(value: unknown): Num { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +/** 합 — 하나라도 빈칸이면 빈칸 */ +export function total(values: Num[]): Num { + let out = 0; + for (const v of values) { + if (v === null) return null; + out += v; + } + return out; +} + +export const gap = (tab: string, key: string, name: string, why: string): Gap => ({ + tab, + key, + name, + why, +}); + +export function driverHour(day: Num, factor: Row | null | undefined): Num { + if (day === null || !factor || !factor.den) return null; + return rounddown((day * factor.num) / factor.den, 0); +} + +const head = (row: Row, no: number): Row => ({ + no, + key: row.key, + name: row.name || "", + spec: row.spec || "", + source: row.source || "", +}); + +function laborSheet(labor: Row[], gaps: Gap[]): Row[] { + return labor.map((row, i) => { + const price = num(row.price); + if (price === null) gaps.push(gap("labor", row.key, row.name, "노임 단가 없음")); + return { ...head(row, i + 1), code: row.code ?? null, unit: row.unit || "인", price }; + }); +} + +function expenseSheet(machines: Row[], gaps: Gap[]): Row[] { + return machines.map((m, i) => { + const price = num(m.price); + if (price === null) gaps.push(gap("expense", m.key, m.name, "기계 기준가 없음")); + return { ...head(m, i + 1), unit: "천원", price }; + }); +} + +export function lowest(prices: Row): [Num, string | null] { + let best: [Num, string | null] = [null, null]; + for (const source of SOURCES) { + const v = num(prices[source]); + if (v !== null && v > 0 && (best[0] === null || v < best[0])) best = [v, source]; + } + return best; +} + +function compareSheet(materials: Row[], user: Row, gaps: Gap[]): Row[] { + return materials.map((m, i) => { + const mine: Row = user[m.key] || {}; + const prices: Row = m.prices || {}; + const [low, lowFrom] = lowest(prices); + let applied: Num; + let appliedFrom: string | null; + if ("applied" in mine) [applied, appliedFrom] = [num(mine.applied), "사용자"]; + else [applied, appliedFrom] = [low, lowFrom]; + if (applied === null) gaps.push(gap("compare", m.key, m.name, "적용 단가 없음(출처 없음)")); + const supply = (SUPPLY as readonly string[]).includes(mine.supply) ? mine.supply : null; + const pages: Row = m.pages || {}; + return { + ...head(m, i + 1), + unit: m.unit || "", + origin: m.origin || "", + prices: Object.fromEntries(SOURCES.map((s) => [s, num(prices[s])])), + pages: Object.fromEntries(SOURCES.map((s) => [s, pages[s] ?? null])), + lowest: low, + lowest_from: lowFrom, + applied, + applied_from: applied !== null ? appliedFrom : null, + supply, + }; + }); +} + +function materialSheet(compare: Row[]): Row[] { + const keep = ["no", "key", "name", "spec", "unit", "supply", "origin"]; + return compare.map((row) => ({ + ...Object.fromEntries(keep.map((k) => [k, row[k]])), + price: row.applied, + })); +} + +function line( + name: string, + spec: string, + qty: Num, + unit: string, + kind: string, + price: Num, + ref: Row | null, + pct = false, +): Row { + let amount: Num; + if (price === null || qty === null) amount = null; + else if (pct) amount = rounddown((price * qty) / 100, 1); + else amount = rounddown(price * qty, 1); + return { name, spec, qty, unit, kind, price, amount, ref }; +} + +function machineBlock(m: Row, drivers: Row, fuels: Row, gaps: Gap[]): Row { + const lines: Row[] = []; + const loss = num(m.loss); + const coef = loss !== null ? loss / 10000 : null; + const price = num(m.price); + lines.push( + line(m.name, m.spec || "", coef, "천원", "expense", price, { tab: "expense", key: m.key }), + ); + const crew = num(m.crew); + if (crew) { + const code = String(m.crew_code || ""); + const driver: Row = drivers[code] || {}; + const hour: Num = driver.hour ?? null; + if (hour === null) gaps.push(gap("machine_blocks", m.key, m.name, "운전사 노임 없음")); + lines.push( + line(driver.name || "운전사", "", crew, "인", "labor", hour, { tab: "basis", key: code }), + ); + } + const lph = num(m.fuel_lph); + if (lph) { + const kind = String(m.fuel || ""); + const fuel: Row = fuels[kind] || {}; + const fprice: Num = fuel.applied ?? null; + if (fprice === null) gaps.push(gap("machine_blocks", m.key, m.name, `연료(${kind}) 단가 없음`)); + const fuelLine = line(fuel.name || kind, fuel.spec || "", lph, "ℓ", "material", fprice, { + tab: "compare", + key: fuel.key || "", + }); + lines.push(fuelLine); + const misc = num(m.misc_pct); + if (misc) + lines.push(line("잡품", "주연료의 %", misc, "%", "material", fuelLine.amount, null, true)); + } + if (price === null || coef === null) + gaps.push(gap("machine_blocks", m.key, m.name, "손료 기준가 · 계수 없음")); + const sums: Record = {}; + for (const part of ["labor", "material", "expense"]) { + const s = total(lines.filter((ln) => ln.kind === part).map((ln) => ln.amount)); + sums[part] = s === null ? null : rounddown(s, 0); + } + return { + key: m.key, + name: m.name, + spec: m.spec || "", + unit: "시간", + lines, + labor: sums.labor, + material: sums.material, + expense: sums.expense, + sum: total([sums.labor, sums.material, sums.expense]), + }; +} + +function basisSheet(inputs: Row, laborRows: Row[]): Row { + const factor = inputs.driver_factor; + const byCode: Row = {}; + for (const row of laborRows) + if (row.code !== null && row.code !== undefined) byCode[String(row.code)] = row; + const codes: string[] = []; + for (const m of inputs.machines || []) { + const code = m.crew_code ? String(m.crew_code) : null; + if (code && num(m.crew) && !codes.includes(code)) codes.push(code); + } + const drivers = codes.map((code) => { + const row: Row = byCode[code] || {}; + const day: Num = row.price ?? null; + return { + key: code, + labor_key: row.key ?? null, + name: row.name || `직종 ${code}`, + day, + factor: (factor || {}).text || "", + hour: driverHour(day, factor), + }; + }); + const fx = (inputs.fx || []).map((f: Row) => ({ + key: f.key, + name: f.name || "", + unit: f.unit || "", + value: num(f.value), + date: f.date || "", + })); + return { fx, drivers }; +} + +/** 144 탭 일곱 + 「연결 없음」 목록 */ +export function computeBase(inputs: Row, user: Row): Row { + const gaps: Gap[] = []; + const laborRows = laborSheet(inputs.labor || [], gaps); + const expense = expenseSheet(inputs.machines || [], gaps); + const compare = compareSheet(inputs.materials || [], user.materials || {}, gaps); + const basis = basisSheet(inputs, laborRows); + const drivers: Row = Object.fromEntries(basis.drivers.map((d: Row) => [d.key, d])); + const byKey: Row = Object.fromEntries(compare.map((row) => [row.key, row])); + const fuels: Row = {}; + for (const [kind, key] of Object.entries(inputs.fuel_keys || {})) + if ((key as string) in byKey) fuels[kind] = byKey[key as string]; + const blocks = (inputs.machines || []).map((m: Row) => machineBlock(m, drivers, fuels, gaps)); + const machine = blocks.map((b: Row, i: number) => ({ + no: i + 1, + key: b.key, + name: b.name, + spec: b.spec, + unit: b.unit, + sum: b.sum, + labor: b.labor, + material: b.material, + expense: b.expense, + })); + return { + labor: laborRows, + expense, + basis, + compare, + material: materialSheet(compare), + machine_blocks: blocks, + machine, + gaps, + }; +} diff --git a/B09_Estimation/B09_Estimation_Prices.py b/B09_Estimation/B09_Estimation_Prices.py new file mode 100644 index 000000000..03ecb38b0 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Prices.py @@ -0,0 +1,266 @@ +"""B09 원가 단가 한 벌 · 수요 모음(PLAN 144) — 서버 단독(M01 을 읽는 곳은 여기 하나). + +원가 단가 = 프로젝트 한 벌 — 원가산출에서 M01 단가를 한 번 복사해 굳힘(`price_book.json`) · +[단가 갱신] 때만 새로 · 대가 · 산근도 이 단가로 셈(한 직종 · 한 자재 = 한 단가). + +줄 = 수요 따라(사용자 승인 ① · 10-9) — 엑셀 목록을 기본으로 깔지 않음: + 연결된 사본이 쓰는 자원 키(`linked_resource_keys` — 143-1 「연결된 사본」 원천 · 145-1 · 2 호표가 + 들어오면 그 결과를 돌려줌 · 그전엔 빈 목록 = 빈 표 「연결 없음」) + + 기계가 부르는 운전사(조종원직종 → 인력 원문번호) · 연료(주연료종류 → 유가 「종류:전국평균」) + + B08 총괄자재수량집계표 자재(M01 짝 없음 → 출처 빈칸 · 적용 단가는 사용자 입력) +운전사 시간 계수 = M01 CC000336(시간환산 · 상여계수 · 휴지계수 분자 · 분모 곱). +연료 지역 = 전국평균(M01 기계경비 로직 지역 첫 값) — 다른 지역 값은 자재 적용 단가를 고쳐 씀. +""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from pathlib import Path +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 + +#: 운전사 시간 노임 계수 표(건설품셈 공통 8-1-3 · 건협기술-2230) +DRIVER_TABLE = "CC000336" +DRIVER_PARTS = ("시간환산", "상여계수", "휴지계수") +#: 연료 지역 — M01 기계경비 로직 지역 고르기 첫 값 +FUEL_REGION = "전국평균" +#: 자재품목 값 열 → 대비표 출처 칸 +MT_SOURCES = ("관급", "물가자료", "유통물가", "물가정보", "거래가격") + + +def linked_resource_keys(project_root: str | Path) -> list[str]: + """연결된 사본(구조물 조합 사본)이 쓰는 자원 키(LB · EQ · MT · MO) — 자리만. + + 143-1 「연결된 사본」 원천 함수 · 145-1 · 2 산근 · 대가 호표가 들어오면 그 자원 키를 돌려줌 · + 그전엔 빈 목록(구조물 조합 사본 없으면 그 구조물은 없음 · 연결 안 된 값은 비움). + """ + return [] + + +def _f(value: Any) -> float | None: + if isinstance(value, Decimal): + return float(value) + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return float(value) + + +def _master(): + return m01.loaded()[1] + + +def _row(master, key: str) -> dict[str, Any] | None: + return master.index.get(key[:2], {}).get(key) + + +def labor_input(row: dict[str, Any]) -> dict[str, Any]: + return { + "key": row["키"], + "code": str(row.get("원문번호") or ""), + "name": row.get("이름") or "", + "spec": row.get("규격") or "", + "unit": "인", + "price": _f(row.get("값")), + "source": row.get("출처") or "", + } + + +def machine_input(row: dict[str, Any]) -> dict[str, Any]: + v = row.get("값") or {} + return { + "key": row["키"], + "code": str(row.get("원문번호") or ""), + "name": row.get("이름") or "", + "spec": row.get("규격") or "", + "price": _f(v.get("가격")), + "loss": _f(v.get("손료계수")), + "fuel": v.get("주연료종류") or None, + "fuel_lph": _f(v.get("주연료")), + "misc_pct": _f(v.get("잡재료율")), + "crew": _f(v.get("조종원")), + "crew_code": str(v["조종원직종"]) if v.get("조종원직종") else None, + "source": (row.get("출처") or {}).get("가격") or row.get("원문") or "", + } + + +def material_input(row: dict[str, Any]) -> dict[str, Any]: + """자재품목(MT) · 유가(MO) 줄 → 대비표 입력 줄.""" + if row["키"].startswith("MO"): + prices = {"유가": _f(row.get("값"))} + pages = {"유가": row.get("규격") or None} + origin = "유가" + spec = f"{row.get('상세구분') or ''} {row.get('규격') or ''}".strip() + unit = (row.get("단위") or "").replace("원/", "") + else: + prices = {s: _f(row.get(s)) for s in MT_SOURCES} + pages = {s: (row.get("면수") or {}).get(s) for s in MT_SOURCES if s != "관급"} + origin = "M01" + spec = row.get("규격") or "" + unit = row.get("단위") or "" + return { + "key": row["키"], + "name": row.get("이름") or "", + "spec": spec, + "unit": unit, + "origin": origin, + "prices": prices, + "pages": pages, + "source": row.get("출처") or "", + } + + +def driver_factor(master) -> dict[str, Any] | None: + table = _row(master, DRIVER_TABLE) + if not table: + return None + parts = {r.get("구분"): r for r in table.get("줄") or []} + if any(p not in parts for p in DRIVER_PARTS): + return None + top = bottom = 1 + text = [] + for p in DRIVER_PARTS: + top *= int(parts[p]["분자"]) + bottom *= int(parts[p]["분모"]) + text.append(f"{parts[p]['분자']}/{parts[p]['분모']}") + return {"num": top, "den": bottom, "text": " × ".join(text)} + + +def fx_inputs(master) -> list[dict[str, Any]]: + return [ + {"key": key, "name": row.get("이름") or "", "unit": row.get("단위") or "", + "value": _f(row.get("값")), "date": row.get("규격") or ""} + for key, row in sorted(master.index.get("FX", {}).items()) + ] # fmt: skip + + +def _fuel_row(master, kind: str) -> dict[str, Any] | None: + return master.numbers.get("MO", {}).get(f"{kind}:{FUEL_REGION}") + + +def demand(master, keys: list[str]) -> dict[str, Any]: + """수요 키 → 줄 차례(노무 · 기계 · 자재) + 연료 짝 · 찾지 못한 키.""" + labor: list[str] = [] + machines: list[str] = [] + materials: list[str] = [] + missing: list[str] = [] + fuel_keys: dict[str, str] = {} + + def add(bucket: list[str], key: str) -> None: + if key not in bucket: + bucket.append(key) + + for key in keys: + if _row(master, key) is None: + missing.append(key) + elif key.startswith("LB"): + add(labor, key) + elif key.startswith("EQ"): + add(machines, key) + elif key.startswith(("MT", "MO")): + add(materials, key) + for key in machines: + v = _row(master, key).get("값") or {} + code = v.get("조종원직종") + driver = master.numbers.get("LB", {}).get(str(code)) if code and v.get("조종원") else None + if driver: + add(labor, driver["키"]) + kind = v.get("주연료종류") + fuel = _fuel_row(master, kind) if kind and v.get("주연료") else None + if fuel: + add(materials, fuel["키"]) + fuel_keys[kind] = fuel["키"] + return { + "labor": labor, + "machines": machines, + "materials": materials, + "fuel_keys": fuel_keys, + "missing": missing, + } + + +def _convert(master, key: str) -> dict[str, Any]: + row = _row(master, key) + if key.startswith("LB"): + return labor_input(row) + if key.startswith("EQ"): + return machine_input(row) + return material_input(row) + + +def refresh_book(keys: list[str]) -> dict[str, Any]: + """[단가 갱신] — 수요 키 전부를 지금 M01 값으로 새로 굳힘.""" + master = _master() + want = demand(master, keys) + rows = { + key: _convert(master, key) + for key in [*want["labor"], *want["machines"], *want["materials"]] + } + return { + "made": datetime.now().isoformat(timespec="seconds"), + "rows": rows, + "driver_factor": driver_factor(master), + "fx": fx_inputs(master), + } + + +def extend_book(book: dict[str, Any] | None, keys: list[str]) -> dict[str, Any]: + """[저장] — 책에 없는 새 줄만 더함(있는 줄 · 계수 · 환율은 안 바꿈) · 책이 없으면 새로 굳힘.""" + if book is None: + return refresh_book(keys) + master = _master() + want = demand(master, keys) + rows = dict(book["rows"]) + for key in [*want["labor"], *want["machines"], *want["materials"]]: + if key not in rows: + rows[key] = _convert(master, key) + return {**book, "rows": rows} + + +def b08_material_rows(b08_sheets: dict[str, Any] | None) -> list[dict[str, Any]]: + """B08 총괄자재수량집계표 줄(수량 있는 줄만) → 자재 입력 줄(M01 짝 없음 · 출처 빈칸).""" + rows = ((b08_sheets or {}).get("material") or {}).get("rows") or [] + out = [] + for row in rows: + if row.get("amount") is None: + continue + out.append( + { + "key": f"b08:{row['key']}", + "name": row.get("group") or "", + "spec": row.get("spec") or "", + "unit": row.get("unit") or "", + "origin": "B08", + "prices": {}, + "pages": {}, + "source": "B08 총괄자재수량집계표", + "qty": row.get("amount"), + } + ) + return out + + +def build_inputs( + book: dict[str, Any] | None, keys: list[str], b08_sheets: dict[str, Any] | None = None +) -> dict[str, Any]: + """표 셈 입력 — 줄 = 수요 · 값 = 굳힌 책(없는 줄은 지금 M01 값 · `fresh` 표시).""" + master = _master() + want = demand(master, keys) + saved = (book or {}).get("rows") or {} + + def pick(key: str) -> dict[str, Any]: + if key in saved: + return saved[key] + return {**_convert(master, key), "fresh": True} + + factor = (book or {}).get("driver_factor") if book else driver_factor(master) + return { + "labor": [pick(k) for k in want["labor"]], + "machines": [pick(k) for k in want["machines"]], + "materials": [pick(k) for k in want["materials"]] + b08_material_rows(b08_sheets), + "fuel_keys": want["fuel_keys"], + "driver_factor": factor, + "fx": (book or {}).get("fx") if book else fx_inputs(master), + "missing": want["missing"], + } diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py new file mode 100644 index 000000000..d46f5fbe9 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Router.py @@ -0,0 +1,84 @@ +"""B09 원가계산 길(PLAN 144 · 145) — 결과는 저장하지 않고 GET 때 서버가 셈(B08 꼴). + + GET /api/projects/{id}/estimation 입력(굳힌 단가 · 수요 · B08 수량) + 사용자 값 → 표 전부 + PUT /api/projects/{id}/estimation {user} [저장] — 사용자 값 · 책에 없는 새 단가 줄만 굳힘 + → 서버가 정본으로 다시 셈 + POST /api/projects/{id}/estimation/prices [단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 + +저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) · 단가 = `B09_Estimation_Prices` · +셈 = `B09_Estimation_Calc`(화면 짝과 같은 식). +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Body + +from B08_Quantity.B08_Quantity_Router_Hydro import _project_root +from B08_Quantity.B08_Quantity_Router_Material import material_sheets +from B09_Estimation import B09_Estimation_Prices as prices +from B09_Estimation import B09_Estimation_Store as store +from B09_Estimation.B09_Estimation_Calc_Base import compute_base + +router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) + + +async def _b08(project_id: UUID, notes: list[str]) -> dict[str, Any]: + """B08 수량 원천 — 못 읽으면 빈칸 + 알림(B09 는 B08 를 고치지 않음).""" + out: dict[str, Any] = {} + try: + out["materials"] = await material_sheets(project_id) + except Exception as error: # noqa: BLE001 — 원천 하나가 막혀도 나머지 표는 그림 + notes.append(f"B08 자재 집계표 못 읽음 — {getattr(error, 'detail', error)}") + out["materials"] = None + return out + + +async def payload(project_id: UUID, root: Path) -> dict[str, Any]: + notes: list[str] = [] + b08 = await _b08(project_id, notes) + keys = prices.linked_resource_keys(root) + if not keys: + notes.append( + "연결된 구조물 조합 사본 없음 — 노무 · 경비 · 중기 단가 줄은 사본이 연결되면 생김" + ) + book = await asyncio.to_thread(store.read_book, root) + inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"]) + user = await asyncio.to_thread(store.read_user, root) + return { + "inputs": inputs, + "user": user, + "book": {"made": book.get("made")} if book else None, + "sheets": compute_base(inputs, user), + "notes": notes, + } + + +@router.get("/{project_id}/estimation") +async def estimation(project_id: UUID) -> dict[str, Any]: + root = await _project_root(project_id) + return await payload(project_id, root) + + +@router.put("/{project_id}/estimation") +async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> dict[str, Any]: + root = await _project_root(project_id) + await asyncio.to_thread(store.write_user, root, body.get("user")) + keys = prices.linked_resource_keys(root) + book = await asyncio.to_thread(store.read_book, root) + book = await asyncio.to_thread(prices.extend_book, book, keys) + await asyncio.to_thread(store.write_book, root, book) + return await payload(project_id, root) + + +@router.post("/{project_id}/estimation/prices") +async def refresh_prices(project_id: UUID) -> dict[str, Any]: + root = await _project_root(project_id) + keys = prices.linked_resource_keys(root) + book = await asyncio.to_thread(prices.refresh_book, keys) + await asyncio.to_thread(store.write_book, root, book) + return await payload(project_id, root) diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py new file mode 100644 index 000000000..a97b14bce --- /dev/null +++ b/B09_Estimation/B09_Estimation_Store.py @@ -0,0 +1,109 @@ +"""B09 원가 저장 자리(PLAN 144-1) — 프로젝트 안 `B09_Estimation/v1/`. + + price_book.json 굳힌 단가 한 벌 {"made": 시각, "rows": {키: 입력 줄}, "driver_factor": …} + — [단가 갱신] 때만 M01 로 새로 · [저장] 때 책에 없는 새 줄만 더함(있는 줄 안 바꿈) + user_values.json 사용자 값 {"materials": {자재 키: {applied?, supply?}}, "estimate": {…}} + — 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본) + +결과(표)는 저장하지 않음 — GET 때 서버가 셈(B08 꼴). +""" + +from __future__ import annotations + +import json +import math +import os +from pathlib import Path +from typing import Any + +from B09_Estimation.B09_Estimation_Calc_Base import SUPPLY + +FOLDER = ("B09_Estimation", "v1") +BOOK = "price_book.json" +USER = "user_values.json" + +#: 원가계산 사용자 입력 칸 — 요율 고르는 값 · 손입력(엑셀 총괄 · 원가계산서 · 내역 손입력 칸) +ESTIMATE_FIELDS = ( + "estimated_price", # 추정가격(원) — 요율 구간 고름 + "duration_days", # 공사기간(일) + "work_type", # 공종(글) + "safety_base", # 산업안전보건관리비 기초액(원) + "profit_adjust", # 이윤 보정(원 · 빼는 값) + "vat_adjust", # 부가가치세 조정(원) + "indirect_material", # 간접재료비(원) + "scrap", # 작업설 부산물(원 · 빼는 값) + "procurement_fee_pct", # 조달수수료율(%) +) + + +def folder(project_root: str | Path) -> Path: + return Path(project_root).joinpath(*FOLDER) + + +def _read(path: Path) -> dict[str, Any] | None: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + return data if isinstance(data, dict) else None + + +def _write(path: Path, data: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temp = path.with_suffix(".tmp") + temp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8") + os.replace(temp, path) + + +def read_book(project_root: str | Path) -> dict[str, Any] | None: + book = _read(folder(project_root) / BOOK) + if book is None or not isinstance(book.get("rows"), dict): + return None + return book + + +def write_book(project_root: str | Path, book: dict[str, Any]) -> None: + _write(folder(project_root) / BOOK, book) + + +def _num(value: Any) -> float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return float(value) if math.isfinite(value) else None + + +def clean_user(raw: Any) -> dict[str, Any]: + """사용자 값 다듬기 — 모르는 칸 버림 · 수 칸은 수 · null(빈칸)만.""" + raw = raw if isinstance(raw, dict) else {} + materials: dict[str, dict[str, Any]] = {} + for key, cell in (raw.get("materials") or {}).items(): + if not isinstance(cell, dict): + continue + out: dict[str, Any] = {} + if "applied" in cell: + out["applied"] = _num(cell["applied"]) + if cell.get("supply") in SUPPLY: + out["supply"] = cell["supply"] + if out: + materials[str(key)] = out + estimate: dict[str, Any] = {} + given = raw.get("estimate") if isinstance(raw.get("estimate"), dict) else {} + for field in ESTIMATE_FIELDS: + if field not in given: + continue + value = given[field] + estimate[field] = (str(value) if value else None) if field == "work_type" else _num(value) + rates = given.get("rates") + if isinstance(rates, dict): + estimate["rates"] = {str(k): _num(v) for k, v in rates.items()} + return {"materials": materials, "estimate": estimate} + + +def read_user(project_root: str | Path) -> dict[str, Any]: + return clean_user(_read(folder(project_root) / USER)) + + +def write_user(project_root: str | Path, user: dict[str, Any]) -> dict[str, Any]: + clean = clean_user(user) + _write(folder(project_root) / USER, clean) + return clean diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css new file mode 100644 index 000000000..f8c01c2d6 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -0,0 +1,32 @@ +/* B09 원가계산 — 탭 줄 · 안내 글. 색은 테마 변수만(밝음 · 어두움 같이). */ + +.b09-estimation { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-16) var(--spacing-24); /* B08 본문과 같은 값 */ +} + +.b09-estimation .ui-general-block__empty, +.b09-estimation__hint { + color: var(--color-text-secondary); +} + +.b09-estimation__hint { + margin: 0 0 var(--spacing-8); + font-size: var(--text-caption); +} + +.b09-estimation__tabs { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4); + padding-bottom: var(--spacing-8); + border-bottom: 1px solid var(--color-border); +} + +/* 표 머리 — 좁은 열에서 말줄임 대신 줄바꿈 */ +[data-b09-table] thead th { + white-space: normal; + overflow-wrap: anywhere; +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index a82ec689d..c90e24ec7 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -1,39 +1,251 @@ /* ============================================================================= * B09_Estimation_UI_Shell.ts - * 로그인 후 09: 6차 워크플로우 (원가계산) — **빈 화면** (PLAN 7-3) + * 로그인 후 09: 6차 워크플로우 (원가계산) — 탭 화면 (PLAN 144-1) * - * - 옛 원가계산 코드는 통째로 `old_code/B09_Estimation/` 에 있음(지우지 않음). - * - 다시 짤 때까지 메뉴와 주소만 남김 — 제목·단계 막대만 뜨고 탭·계산은 없음. + * 원본 = 기번3 공통 원가산출 엑셀 · 탭 하나 = 화면 표 하나(`…_UI_Tabs` 차례) · 옛 원가 코드 안 봄 + * 자료 = GET …/estimation(입력 · 사용자 값 · 서버가 셈한 표) · 조작 = 캐시(`b09-estimation`) + + * 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · [저장] = PUT → 서버 정본 · [단가 갱신] = POST …/prices + * 호표 칸 누름 = 그 탭 그 줄로 이동 · 자동 저장 없음 * ========================================================================== */ +import "@ui/ui_template_general_layout.css"; +import "./B09_Estimation_UI_Page.css"; +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import { + createButton, + hideLoadingOverlay, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { createDataTable } from "@ui/ui_template_data_table"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; +import { computeBase, type Row } from "./B09_Estimation_Calc_Base"; +import { + buildCheck, + rowIndex, + TABS, + type Link, + type TabBuild, + type TabContext, + type TabId, +} from "./B09_Estimation_UI_Tabs"; +import { + buildBasis, + buildCompare, + buildExpense, + buildLabor, + buildMachine, + buildMachineBlocks, + buildMaterial, +} from "./B09_Estimation_UI_Tabs_Base"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } +const LATER: Record = { + dg_list: "일위대가목록표 — 145-2(다른 창)가 들어오면 생김", + dg: "일위대가표 — 145-2(다른 창)가 들어오면 생김", + sg_list: "단가산출근거목록표 — 145-1(다른 창)가 들어오면 생김", + sg: "단가산출근거 — 145-1(다른 창)가 들어오면 생김", +}; + +const BUILDERS: Partial> = { + check: buildCheck, + basis: buildBasis, + machine: buildMachine, + machine_blocks: buildMachineBlocks, + material: buildMaterial, + labor: buildLabor, + expense: buildExpense, + compare: buildCompare, +}; + +async function request(path: string, init: RequestInit = {}): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...init, + }); + const body = (await response.json().catch(() => null)) as { detail?: unknown } | null; + if (!response.ok) { + const detail = body?.detail; + throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); + } + return body as T; +} + +function note(textValue: string): HTMLElement { + const p = document.createElement("p"); + p.className = "ui-general-block__empty"; + p.textContent = textValue; + return p; +} + +/** 브라우저 셈 — 서버 짝과 같은 식(조작 중) */ +export function computeSheets(payload: Row, user: Row): Row { + return computeBase(payload.inputs, user); +} + export function renderB09Estimation(root: HTMLElement): void { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + let payload: Row | null = null; + let user: Row = {}; + let sheets: Row = {}; + let current: TabId = "check"; - const note = document.createElement("p"); - note.className = "b09-estimation__rebuilding"; - note.textContent = L("B09_Estimation_Rebuilding"); + const tabBar = document.createElement("div"); + tabBar.className = "b09-estimation__tabs"; + tabBar.setAttribute("role", "tablist"); + const panel = document.createElement("div"); + panel.className = "b09-estimation__panel"; + panel.setAttribute("role", "tabpanel"); const body = document.createElement("div"); - body.append(note); + body.className = "b09-estimation"; + body.append(tabBar, panel); + + const made = document.createElement("p"); + made.className = "b09-estimation__hint"; + + const recompute = (): void => { + if (payload) sheets = computeSheets(payload, user); + }; + + const setUser = (next: Row): void => { + user = next; + if (projectId) writeState("b09-estimation", user, projectId); + recompute(); + show(current); + }; + + /** 탭 하나 그림 · focus = 이동해 온 줄 키 */ + const show = (tab: TabId, focus?: string): void => { + current = tab; + tabBar.replaceChildren( + ...TABS.map(([id, label]) => { + const button = createButton({ + label, + variant: id === tab ? "filled" : "ghost", + size: "sm", + onClick: () => id !== current && show(id), + }); + button.setAttribute("role", "tab"); + button.setAttribute("aria-selected", String(id === tab)); + button.dataset.b09Tab = id; + return button; + }), + ); + if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요.")); + if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…")); + const build = BUILDERS[tab]; + if (!build) return void panel.replaceChildren(note(LATER[tab] ?? "다음 단계에서 채움")); + const ctx: TabContext = { sheets, payload, user, setUser }; + const spec = build(ctx); + if (typeof spec === "string") return void panel.replaceChildren(note(spec)); + const goto = (link: Link | null): void => { + if (link) show(link.tab, link.key); + }; + const table = createDataTable({ + columns: spec.columns, + rows: spec.rows, + total: spec.total ?? null, + frozen: spec.frozen ?? 0, + empty: "", + caption: TABS.find(([id]) => id === tab)?.[1], + editable: spec.editable, + onEdit: spec.onEdit, + onCellClick: (i, key) => { + if (spec.onCell) spec.onCell(i, key); + if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key)); + }, + }); + table.root.dataset.b09Table = tab; + panel.replaceChildren(...(spec.hint ? [note(spec.hint)] : []), table.root); + if (focus !== undefined) { + const index = rowIndex(spec.rows, focus); + if (index >= 0) requestAnimationFrame(() => table.focusRow(index)); + } + }; + + const apply = (next: Row, fromServer: boolean): void => { + payload = next; + const draft = projectId && !fromServer ? readState("b09-estimation", projectId) : null; + user = draft ?? next.user ?? {}; + recompute(); + made.textContent = next.book?.made + ? `단가 굳힘 ${String(next.book.made).replace("T", " ")} — [단가 갱신] 때만 새로` + : "단가 아직 안 굳힘 — [저장] 이나 [단가 갱신] 때 M01 값을 굳힘"; + show(current); + }; + + const run = async (path: string, method: string, done: string, init?: Row): Promise => { + if (!projectId) return; + showLoadingOverlay(); + try { + const next = await request(`/projects/${projectId}${path}`, { + method, + ...(init ? { body: JSON.stringify(init) } : {}), + }); + clearState("b09-estimation", projectId); + apply(next, true); + showToast(done, "success"); + } catch (error) { + showToast(`하지 못했습니다 — ${String(error)}`, "error"); + } finally { + hideLoadingOverlay(); + } + }; + + const save = createButton({ + label: "저장", + variant: "filled", + onClick: () => void run("/estimation", "PUT", "원가 사용자 값을 저장했습니다.", { user }), + }); + save.dataset.b09Save = ""; + const refresh = createButton({ + label: "단가 갱신", + variant: "ghost", + onClick: () => + void run("/estimation/prices", "POST", "단가를 지금 M01 값으로 새로 굳혔습니다."), + }); + refresh.dataset.b09Refresh = ""; + const section = document.createElement("section"); + section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card"; + const heading = document.createElement("h3"); + heading.className = "ui-collapsible__title"; + heading.textContent = "원가 단가"; + section.append(heading, made, refresh); + const actions = document.createElement("div"); + actions.className = "ui-sidebar-actions"; + actions.append(save); + const left = document.createElement("div"); + left.className = "ui-sidebar-panel b09-options"; + left.append(section, actions); + attachCollapsible(left); const layout = createWorkflowLayout({ title: L("B09_Estimation_Title"), steps: workflowSteps(), activeStep: 6, mainContent: body, + leftPanel: left, routes: WORKFLOW_STEP_ROUTES, onStepClick: (stepIndex: number) => { if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); }, }); root.append(layout.root); + show(current); + + if (projectId) + request(`/projects/${projectId}/estimation`).then( + (next) => apply(next, false), + (error: unknown) => + panel.replaceChildren(note(`원가 자료를 불러오지 못했습니다 — ${String(error)}`)), + ); } diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts new file mode 100644 index 000000000..8ffb8f9c9 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -0,0 +1,164 @@ +/* ============================================================================= + * B09_Estimation_UI_Tabs.ts + * B09 탭 목록 · 표 짜기 공용(PLAN 144-1) — 탭 하나 = 화면 표 하나(실무 엑셀 탭 차례). + * + * 탭 = 연결 점검(목차 · 안내) · 원가 · 총괄 · 내역 · 대가 둘 · 산근 둘 · 기초 · 중기 둘 · 단가 넷 · 집계표 넷 + * 표 = 공용 `createDataTable` · 「연결 없음」 칸 = 빈칸 · 호표 칸 누름 = 그 탭 그 줄로 이동(`link`) + * 탭별 표 = `…_UI_Tabs_Base`(144) · `…_UI_Tabs_Cost`(145) · 여기는 차례 · 꼴 · 연결 점검만 + * ========================================================================== */ + +import type { DataColumn, DataRow } from "@ui/ui_template_data_table"; +import type { Row } from "./B09_Estimation_Calc_Base"; + +export type TabId = + | "check" + | "cost" + | "total" + | "bill" + | "dg_list" + | "dg" + | "sg_list" + | "sg" + | "basis" + | "machine" + | "machine_blocks" + | "material" + | "labor" + | "expense" + | "compare" + | "sum_material" + | "sum_labor" + | "sum_expense" + | "sum_machine"; + +/** 엑셀 탭 차례 — [id, 탭 이름] */ +export const TABS: Array<[TabId, string]> = [ + ["check", "연결 점검"], + ["cost", "공사원가계산서"], + ["total", "총괄설계내역서"], + ["bill", "설계내역서"], + ["dg_list", "일위대가목록표"], + ["dg", "일위대가표"], + ["sg_list", "단가산출근거목록표"], + ["sg", "단가산출근거"], + ["basis", "환율및기초자료"], + ["machine", "중기목록표"], + ["machine_blocks", "중기사용료"], + ["material", "재료비목록표"], + ["labor", "노무비목록표"], + ["expense", "경비목록표"], + ["compare", "자재단가대비표"], + ["sum_material", "재료비수량금액집계표"], + ["sum_labor", "노무비수량금액집계표"], + ["sum_expense", "경비수량금액집계표"], + ["sum_machine", "중기시간금액집계표"], +]; + +export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1] ?? id; + +/** 다른 탭 줄 — 키가 같은 줄로 이동 */ +export interface Link { + tab: TabId; + key: string; +} + +/** 페이지가 탭 짜기에 주는 것 */ +export interface TabContext { + sheets: Row; + payload: Row; + user: Row; + /** 사용자 값 고침 → 캐시 · 바로 다시 셈 */ + setUser: (user: Row) => void; +} + +/** 탭 한 장 = 표 하나 */ +export interface TabTable { + columns: DataColumn[]; + rows: DataRow[]; + total?: DataRow | null; + frozen?: number; + /** 표 위 안내 글(근거 · 끝수) */ + hint?: string; + /** 칸 누름 → 갈 줄(호표 칸) */ + link?: (index: number, key: string) => Link | null; + editable?: (index: number, key: string) => boolean; + onEdit?: (index: number, key: string, text: string) => void; + /** 칸 누름 → 그 자리 동작(예 관급 · 별도 표시 바꿈) */ + onCell?: (index: number, key: string) => void; +} + +export type TabBuild = (ctx: TabContext) => TabTable | string; + +/* ── 열 꼴 ────────────────────────────────────────────────────────────── */ + +export const text = (key: string, label: string, width?: number, group?: string[]): DataColumn => ({ + key, + label, + width, + group, +}); + +export const money = (key: string, label: string, group?: string[], decimals = 0): DataColumn => ({ + key, + label, + group, + kind: "number", + decimals, + width: 96, +}); + +export const qty = (key: string, label: string, decimals = 4, group?: string[]): DataColumn => ({ + key, + label, + group, + kind: "number", + decimals, + trim: true, + width: 76, +}); + +/** 글 → 값 · 빈칸 = null · 수가 아니면 undefined(무시) */ +export function parseNumber(value: string): number | null | undefined { + const t = value.replace(/,/g, "").trim(); + if (!t) return null; + const v = Number(t); + return Number.isFinite(v) ? v : undefined; +} + +/** 줄 키로 줄 번호 찾기(이동) */ +export function rowIndex(rows: DataRow[], key: string): number { + return rows.findIndex((row) => row.key === key); +} + +/* ── 연결 점검(목차 · 안내) ───────────────────────────────────────────── */ + +export const buildCheck: TabBuild = ({ sheets, payload }): TabTable => { + const gaps: Row[] = sheets.gaps || []; + const rows: DataRow[] = gaps.map((g, i) => ({ + no: i + 1, + key: `${g.tab}|${g.key}|${i}`, + tab: tabName(g.tab), + name: g.name, + why: g.why, + })); + const notes: string[] = [...(payload.notes || [])]; + const missing: string[] = payload.inputs?.missing || []; + if (missing.length) notes.push(`M01 에 없는 키 ${missing.length} — ${missing.join(" · ")}`); + return { + columns: [ + qty("no", "번호", 0), + text("tab", "탭", 150), + text("name", "명칭", 260), + text("why", "까닭", 320), + ], + rows, + frozen: 1, + hint: + `연결 없음 ${gaps.length}곳 — 줄을 누르면 그 탭 그 줄로 · 빈칸 = 값을 낸 원천이 없음(0 을 채우지 않음)` + + (notes.length ? ` · ${notes.join(" · ")}` : ""), + link: (i) => { + const g = gaps[i]; + return g ? { tab: g.tab as TabId, key: g.key } : null; + }, + }; +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts new file mode 100644 index 000000000..fef27bb1f --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -0,0 +1,275 @@ +/* ============================================================================= + * B09_Estimation_UI_Tabs_Base.ts + * B09 바탕 단가 탭 일곱(PLAN 144-2~4) — 노무비 · 경비목록표 · 환율및기초자료 · 자재단가대비표 · + * 재료비목록표 · 중기사용료 · 중기목록표(실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Base`). + * + * 고칠 칸 = 자재단가대비표 적용 단가(빈칸으로 지우면 최저 자동) · 관급 / 별도 칸 누름 = 표시 바꿈 + * 이동 = 재료비목록표 줄 → 대비표 · 중기목록표 줄 → 중기사용료 블록 · 블록 줄 → 경비 · 기초 · 대비표 + * ========================================================================== */ + +import type { DataRow } from "@ui/ui_template_data_table"; +import { SOURCES, type Row } from "./B09_Estimation_Calc_Base"; +import { + money, + parseNumber, + qty, + text, + type TabBuild, + type TabId, + type TabTable, +} from "./B09_Estimation_UI_Tabs"; + +const LINK_HINT = "줄을 누르면 단가를 낸 탭 · 줄로 이동"; + +/** 단가 줄 표(노무비 · 경비목록표) — 호표 · 명칭 · 규격 · 단위 · 단가 · 비고(출처) */ +function priceList(rows: Row[], unitPrice: string, hint: string): TabTable { + return { + columns: [ + qty("no", "호표", 0), + text("name", "명칭", 200), + text("spec", "규격", 140), + text("unit", "단위", 56), + money("price", unitPrice), + text("source", "비고", 220), + ], + rows: rows.map((r) => ({ ...r })), + frozen: 2, + hint, + }; +} + +export const buildLabor: TabBuild = ({ sheets, payload }) => + priceList( + sheets.labor || [], + "단가(원/인)", + `노임 = 굳힌 단가(${madeText(payload)}) · M01 인력 · 바꾸려면 [단가 갱신]`, + ); + +export const buildExpense: TabBuild = ({ sheets, payload }) => + priceList( + sheets.expense || [], + "기준가(천원)", + `기계 손료 기준가 = 굳힌 단가(${madeText(payload)}) · M01 기계 가격 · 바꾸려면 [단가 갱신]`, + ); + +function madeText(payload: Row): string { + return payload.book?.made ? `${String(payload.book.made).slice(0, 10)} 굳힘` : "아직 안 굳힘"; +} + +export const buildBasis: TabBuild = ({ sheets }): TabTable => { + const basis: Row = sheets.basis || { fx: [], drivers: [] }; + const rows: DataRow[] = [ + ...basis.fx.map((f: Row) => ({ + part: "1. 환율", + key: f.key, + name: f.name, + spec: f.date, + unit: f.unit, + day: null, + factor: "", + value: f.value, + })), + ...basis.drivers.map((d: Row) => ({ + part: "2. 인건비", + key: d.key, + name: d.name, + spec: `직종 ${d.key}`, + unit: "원/시간", + day: d.day, + factor: d.factor, + value: d.hour, + labor_key: d.labor_key, + })), + ]; + return { + columns: [ + text("part", "구분", 90), + text("name", "명칭", 160), + text("spec", "규격 · 기준일", 120), + text("unit", "단위", 80), + money("day", "일당(원)"), + text("factor", "시간 환산", 160), + money("value", "값", undefined, 2), + ], + rows, + frozen: 2, + hint: "운전사 시간 노임 = ROUNDDOWN(일당 × 1/8 × 16/12 × 25/20, 0)(M01 CC000336 · 건협기술-2230) · 환율 = M01 한국은행 · 인건비 줄 누름 = 노무비목록표", + link: (i) => { + const row = rows[i]; + return row?.labor_key ? { tab: "labor", key: String(row.labor_key) } : null; + }, + }; +}; + +const SUPPLY_NEXT: Record = { "": "관급", 관급: "별도", 별도: null }; + +export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { + const rows: Row[] = sheets.compare || []; + const out: DataRow[] = rows.map((r) => { + const row: DataRow = { + no: r.no, + key: r.key, + name: r.name, + spec: r.spec, + unit: r.unit, + lowest: r.lowest, + applied: r.applied, + applied_from: r.applied_from === "사용자" ? "사용자" : (r.applied_from ?? ""), + supply: r.supply ?? "", + }; + for (const s of SOURCES) { + row[`p:${s}`] = r.prices[s]; + row[`g:${s}`] = r.pages[s] ?? ""; + } + return row; + }); + const mine = (key: string): Row => ({ ...((user.materials || {})[key] || {}) }); + const put = (key: string, cell: Row): void => { + const materials = { ...(user.materials || {}) }; + if (Object.keys(cell).length) materials[key] = cell; + else delete materials[key]; + setUser({ ...user, materials }); + }; + return { + columns: [ + qty("no", "번호", 0), + text("name", "품명", 180), + text("spec", "규격", 140), + text("unit", "단위", 56), + ...SOURCES.flatMap((s) => [money(`p:${s}`, "단가", [s]), text(`g:${s}`, "면", 64, [s])]), + money("lowest", "최저"), + money("applied", "적용 단가", ["적용"]), + text("applied_from", "출처", 80, ["적용"]), + text("supply", "관급 · 별도", 80), + ], + rows: out, + frozen: 2, + hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌) 자동 · 칸에 넣으면 사용자 값 · 지우면 다시 최저 · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도", + editable: (_i, key) => key === "applied", + onEdit: (i, key, value) => { + const row = out[i]; + if (!row || key !== "applied") return; + const v = parseNumber(value); + if (v === undefined) return; + const cell = mine(String(row.key)); + if (v === null) delete cell.applied; + else cell.applied = v; + put(String(row.key), cell); + }, + onCell: (i, key) => { + const row = out[i]; + if (!row || key !== "supply") return; + const cell = mine(String(row.key)); + const next = SUPPLY_NEXT[String(row.supply ?? "")] ?? null; + if (next) cell.supply = next; + else delete cell.supply; + put(String(row.key), cell); + }, + }; +}; + +export const buildMaterial: TabBuild = ({ sheets }): TabTable => { + const rows: DataRow[] = (sheets.material || []).map((r: Row) => ({ + ...r, + supply: r.supply ?? "", + })); + return { + columns: [ + qty("no", "호표", 0), + text("name", "명칭", 200), + text("spec", "규격", 160), + text("unit", "단위", 56), + money("price", "단가"), + text("supply", "관급 · 별도", 80), + text("origin", "비고", 90), + ], + rows, + frozen: 2, + hint: `단가 = 자재단가대비표 적용 단가 · ${LINK_HINT}`, + link: (i) => (rows[i] ? { tab: "compare", key: String(rows[i].key) } : null), + }; +}; + +export const buildMachine: TabBuild = ({ sheets }): TabTable => { + const rows: DataRow[] = (sheets.machine || []).map((r: Row) => ({ ...r })); + return { + columns: [ + qty("no", "호표", 0), + text("name", "명칭", 200), + text("spec", "규격", 120), + text("unit", "단위", 56), + money("sum", "합계"), + money("labor", "노무비"), + money("material", "재료비"), + money("expense", "경비"), + ], + rows, + frozen: 2, + hint: `시간당 = 중기사용료 블록 합계 · ${LINK_HINT}`, + link: (i) => (rows[i] ? { tab: "machine_blocks", key: String(rows[i].key) } : null), + }; +}; + +const KIND_TAB: Record = { + expense: "expense", + labor: "basis", + material: "compare", +}; + +export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => { + const rows: DataRow[] = []; + const links: Array<{ tab: TabId; key: string } | null> = []; + (sheets.machine_blocks || []).forEach((b: Row, n: number) => { + rows.push({ key: b.key, name: `제 ${n + 1} 호표 ${b.name}`, spec: b.spec, unit: b.unit }); + links.push(null); + for (const ln of b.lines) { + const at = (part: string): number | null => (ln.kind === part ? ln.price : null); + const amt = (part: string): number | null => (ln.kind === part ? ln.amount : null); + rows.push({ + key: `${b.key}|${ln.name}`, + name: ` ${ln.name}`, + spec: ln.spec, + qty: ln.qty, + unit: ln.unit, + sum_price: ln.price, + sum: ln.amount, + labor_price: at("labor"), + labor: amt("labor"), + material_price: at("material"), + material: amt("material"), + expense_price: at("expense"), + expense: amt("expense"), + }); + links.push(ln.ref?.key ? { tab: KIND_TAB[ln.kind] ?? "compare", key: ln.ref.key } : null); + } + rows.push({ + key: `${b.key}|합계`, + name: " 합 계", + sum: b.sum, + labor: b.labor, + material: b.material, + expense: b.expense, + }); + links.push(null); + }); + const pair = (key: string, label: string) => [ + money(`${key}_price`, "단가", [label], 1), + money(key, "금액", [label], 1), + ]; + return { + columns: [ + text("name", "명칭", 220), + text("spec", "규격", 120), + qty("qty", "수량"), + text("unit", "단위", 56), + ...pair("sum", "합계"), + ...pair("labor", "노무비"), + ...pair("material", "재료비"), + ...pair("expense", "경비"), + ], + rows, + frozen: 1, + hint: "손료 = 기준가(천원) × 손료계수 ÷ 10,000 · 운전사 = 인 × 시간 노임 · 연료 = ℓ/hr × 적용 단가 · 잡품 = 연료 금액 × % · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 · 줄 누름 = 단가를 낸 탭", + link: (i) => links[i] ?? null, + }; +}; diff --git a/main.py b/main.py index 7f56d5acd..080e5adc0 100644 --- a/main.py +++ b/main.py @@ -68,6 +68,7 @@ from B08_Quantity.B08_Quantity_Router_Hydro import router as b08_hydro_router from B08_Quantity.B08_Quantity_Router_Options import router as b08_options_router from B08_Quantity.B08_Quantity_Router_Summary import router as b08_summary_router from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router +from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router @@ -653,6 +654,7 @@ app.include_router(b08_earthwork_router, dependencies=protected_with_company) app.include_router(b08_summary_router, dependencies=protected_with_company) app.include_router(b08_material_router, dependencies=protected_with_company) app.include_router(b08_options_router, dependencies=protected_with_company) +app.include_router(b09_estimation_router, dependencies=protected_with_company) # B08 수량·B09 원가계산 라우터 13 개는 끊음 — 코드는 `old_code/` 에 있고 화면은 빈 틀만 뜬다(PLAN 7-3). # 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다. system_admin_only = [Depends(verify_session), Depends(require_system_admin)] diff --git a/resources/tester/test_144_b09_base_calc.py b/resources/tester/test_144_b09_base_calc.py new file mode 100644 index 000000000..22cccf081 --- /dev/null +++ b/resources/tester/test_144_b09_base_calc.py @@ -0,0 +1,170 @@ +"""144-2~4 B09 바탕 단가 셈 — 엑셀 검산 대조 · 거울(`B09_Estimation_Calc_Base.py` ↔ `.ts`). + +견본 = 기번3 공통 엑셀(`2024년 간선임도사업(기번3).xlsx`) 같은 입력: + 노무비목록표 E10~E12 · 환율및기초자료 G6~G8 · 경비목록표 E4 · E5 · E7 · E13 · E16 · E21 · + 자재단가대비표 5~12줄 · 중기사용료 1 · 2 · 4 · 11 · 14 · 16 호표(F11 126,000 …) +""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_Calc_Base import ( # noqa: E402 + compute_base, + rounddown, + round_half, + roundup, +) + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +FACTOR = {"num": 400, "den": 1920, "text": "1/8 × 16/12 × 25/20"} + +LABOR = [ + {"key": "LB000048", "code": "1048", "name": "건설기계운전사", "price": 267360}, + {"key": "LB000049", "code": "1049", "name": "화물차운전사", "price": 226709}, + {"key": "LB000050", "code": "1050", "name": "일반기계운전사", "price": 161142}, + {"key": "LB000002", "code": "1002", "name": "보통인부", "price": None}, +] + + +def _m(key, name, price, loss, fuel, lph, misc, crew_code): + return {"key": key, "name": name, "spec": "", "price": price, "loss": loss, "fuel": fuel, + "fuel_lph": lph, "misc_pct": misc, "crew": 1 if crew_code else None, + "crew_code": crew_code} # fmt: skip + + +MACHINES = [ + _m("X00001", "불도우저19ton", 184499, 1811, "경유", 25, 16, "1048"), + _m("X00003", "굴삭기(0.2m3)", 62550, 2085, "경유", 5, 21, "1048"), + _m("X00005", "굴삭기(0.7m3)", 110926, 2085, "경유", 11.6, 22, "1048"), + _m("X00032", "덤프자동덮개", 1546, 2684, None, None, None, None), + _m("X00048", "콘크리트믹서0.45m3", 5409, 3039, "휘발유", 3.9, 2, "1050"), + _m("X00074", "덤프트럭2.5ton(토사)", 20793, 2967, "경유", 2.9, None, "1049"), + _m("X09999", "기준가 없는 기계", None, 2000, "중유", 3, 10, "1048"), +] + + +def _mt(key, name, **prices): + return {"key": key, "name": name, "spec": "", "unit": "", "origin": "M01", "prices": prices} + + +MATERIALS = [ + _mt("MO1", "휘발유", 관급=1369, 물가정보=0, 물가자료=0), + _mt("MO2", "경유", 관급=1272), + _mt("M3", "각재", 관급=500661, 물가정보=829341, 물가자료=748503), + _mt("M4", "못", 관급=1503, 물가정보=1935, 물가자료=1665), + _mt("M8", "화강석", 관급=0, 물가정보=288000, 물가자료=288000), + _mt("M9", "사용자 값 줄", 물가자료=1000), + _mt("b08:sand", "골재"), +] + +INPUTS = { + "labor": LABOR, + "machines": MACHINES, + "materials": MATERIALS, + "fuel_keys": {"경유": "MO2", "휘발유": "MO1"}, + "driver_factor": FACTOR, + "fx": [ + {"key": "FX000001", "name": "USD", "unit": "원/USD", "value": 1414.9, "date": "2026-08-14"} + ], +} +USER = {"materials": {"M9": {"applied": 950, "supply": "관급"}, "M4": {"supply": "별도"}, + "b08:sand": {"applied": None}}} # fmt: skip + + +def _run_ts(inputs, user): + calc = ROOT / "B09_Estimation" / "B09_Estimation_Calc_Base.ts" + body = ( + f"\nconst i = {json.dumps(inputs, ensure_ascii=False)};" + f"\nconst u = {json.dumps(user, ensure_ascii=False)};" + "\nconsole.log(JSON.stringify(computeBase(i, u)));" + ) + script = IMPORTS.sub("", calc.read_text(encoding="utf-8")) + body + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_끝수_엑셀_자리(): + assert rounddown(33412.7689, 1) == 33412.7 and rounddown(126000.9) == 126000 + assert rounddown(-1.25, 1) == -1.2 # 0 쪽으로 + assert roundup(69849380, -3) == 69850000 and roundup(69850000, -3) == 69850000 + assert round_half(2.5) == 3 and round_half(0.125, 2) == 0.13 and round_half(-2.5) == -3 + assert rounddown(0.1 + 0.2, 1) == 0.3 # 떠도는 끝수는 버리지 않음 + + +def test_144_2_노무_경비_환율_기초자료(): + s = compute_base(INPUTS, {}) + assert [r["price"] for r in s["labor"]] == [267360, 226709, 161142, None] + assert ("labor", "LB000002") in {(g["tab"], g["key"]) for g in s["gaps"]} + hours = {d["key"]: d["hour"] for d in s["basis"]["drivers"]} + assert hours == {"1048": 55700, "1050": 33571, "1049": 47231} # 기초자료 G6 · G8 · G7 + assert s["basis"]["drivers"][0]["factor"] == "1/8 × 16/12 × 25/20" + assert [r["price"] for r in s["expense"]][:3] == [ + 184499, + 62550, + 110926, + ] # 경비목록표 E4 · E5 · E7 + assert s["expense"][0]["unit"] == "천원" + assert s["basis"]["fx"][0]["value"] == 1414.9 + + +def test_144_3_자재단가대비_최저_사용자_관급(): + s = compute_base(INPUTS, USER) + rows = {r["key"]: r for r in s["compare"]} + # 엑셀 O 열 = 최저 · P 열 출처(0 은 뺌 · 같은 값이면 앞 칸 — 화강석 「물정」) + assert (rows["MO1"]["applied"], rows["MO1"]["applied_from"]) == (1369, "관급") + assert (rows["M3"]["applied"], rows["M3"]["lowest_from"]) == (500661, "관급") + assert rows["M4"]["applied"] == 1503 and rows["M4"]["supply"] == "별도" + assert (rows["M8"]["applied"], rows["M8"]["applied_from"]) == (288000, "물가정보") + assert (rows["M9"]["lowest"], rows["M9"]["applied"], rows["M9"]["applied_from"]) == ( + 1000, + 950, + "사용자", + ) + assert rows["M9"]["supply"] == "관급" + assert rows["b08:sand"]["applied"] is None # 출처 없음 · 사용자 빈칸 = 빈칸 + material = {r["key"]: r["price"] for r in s["material"]} + assert material == {r["key"]: r["applied"] for r in s["compare"]} # 재료비목록표 = 적용 단가 + gaps = [(g["tab"], g["key"]) for g in s["gaps"] if g["tab"] == "compare"] + assert gaps == [("compare", "b08:sand")] + + +def test_144_4_중기사용료_엑셀_검산(): + s = compute_base(INPUTS, {}) + got = {r["key"]: (r["sum"], r["labor"], r["material"], r["expense"]) for r in s["machine"]} + assert got["X00001"] == (126000, 55700, 36888, 33412) # 중기목록표 4줄 + assert got["X00003"] == (76436, 55700, 7695, 13041) + assert got["X00005"] == (96829, 55700, 18001, 23128) + assert got["X00032"] == (414, 0, 0, 414) # 덮개 — 운전사 · 연료 없음 + assert got["X00048"] == (40659, 33571, 5445, 1643) # 일반기계운전사 · 휘발유 · 잡유 2 % + assert got["X00074"][1:3] == (47231, 3688) # 화물차운전사 · 잡품 없음 + assert got["X09999"] == (None, 55700, None, None) # 기준가 · 중유 단가 없음 = 그 비목 · 합 빈칸 + block = s["machine_blocks"][0] + assert [ln["amount"] for ln in block["lines"]] == [ + 33412.7, + 55700, + 31800, + 5088, + ] # 중기사용료 F7~F10 + assert block["lines"][0]["qty"] == 0.1811 and block["lines"][0]["ref"] == { + "tab": "expense", + "key": "X00001", + } + why = {g["why"] for g in s["gaps"] if g["key"] == "X09999"} + assert why == {"연료(중유) 단가 없음", "기계 기준가 없음", "손료 기준가 · 계수 없음"} + + +def test_거울_TS_가_같은_값(): + for user in ({}, USER): + assert _run_ts(INPUTS, user) == compute_base(INPUTS, user) diff --git a/resources/tester/test_144_b09_skeleton.py b/resources/tester/test_144_b09_skeleton.py new file mode 100644 index 000000000..913befed5 --- /dev/null +++ b/resources/tester/test_144_b09_skeleton.py @@ -0,0 +1,114 @@ +"""144-1 B09 뼈대 — 탭 차례 · 저장 자리 · 길 · 빈칸 규칙 · 「연결 없음」 목록 · 단가 굳히기. + +- 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 「연결 점검」 한 탭 · INITIAL 안 옮김) +- 저장 = 프로젝트 안 `B09_Estimation/v1/`(사용자 값 · 굳힌 단가) · 결과는 저장 안 함 +- 줄 = 수요 따라(사용자 승인 ①) — 연결된 사본 없으면 노무 · 경비 · 중기 빈 표 · B08 자재만 줄 · 출처 빈칸 +""" + +import asyncio +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402 +from B09_Estimation import B09_Estimation_Router as router_mod # noqa: E402 +from B09_Estimation import B09_Estimation_Store as store # noqa: E402 + +#: 엑셀 보이는 탭(목차 · 안내 빼고) — 조사 F 1절 차례 +EXCEL_TABS = [ + "공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가목록표", "일위대가표", + "단가산출근거목록표", "단가산출근거", "환율및기초자료", "중기목록표", "중기사용료", + "재료비목록표", "노무비목록표", "경비목록표", "자재단가대비표", "재료비수량금액집계표", + "노무비수량금액집계표", "경비수량금액집계표", "중기시간금액집계표", +] # fmt: skip + +B08_SHEETS = { + "material": { + "rows": [ + {"key": "sand", "group": "골재", "spec": "모래", "unit": "㎥", "amount": 16}, + {"key": "rubble", "group": "골재", "spec": "막자갈", "unit": "㎥", "amount": None}, + {"key": "cement", "group": "시멘트", "spec": "40kg/대", "unit": "대", "amount": 244}, + ] + } +} + + +def test_탭_차례가_엑셀과_같다(): + source = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs.ts").read_text(encoding="utf-8") + block = source[ + source.index("export const TABS") : source.index("];", source.index("export const TABS")) + ] + names = re.findall(r'\["\w+", "([^"]+)"\]', block) + assert names[0] == "연결 점검" + assert names[1:] == EXCEL_TABS + + +def test_길_셋(): + routes = {(r.path, tuple(sorted(r.methods))) for r in router_mod.router.routes} + assert ("/api/projects/{project_id}/estimation", ("GET",)) in routes + assert ("/api/projects/{project_id}/estimation", ("PUT",)) in routes + assert ("/api/projects/{project_id}/estimation/prices", ("POST",)) in routes + main = (ROOT / "main.py").read_text(encoding="utf-8") + assert "app.include_router(b09_estimation_router" in main + + +def test_사용자_값_다듬기_저장_자리(tmp_path): + raw = { + "materials": {"MT1": {"applied": 1200, "supply": "관급", "x": 1}, "MT2": {"applied": None}, + "MT3": {"supply": "엉뚱"}}, + "estimate": {"estimated_price": "abc", "profit_adjust": 905980, "work_type": "토목", + "rates": {"RT000001": 3.56, "RT000002": None}, "zzz": 1}, + "other": 1, + } # fmt: skip + clean = store.write_user(tmp_path, raw) + assert clean == { + "materials": {"MT1": {"applied": 1200.0, "supply": "관급"}, "MT2": {"applied": None}}, + "estimate": {"estimated_price": None, "profit_adjust": 905980.0, "work_type": "토목", + "rates": {"RT000001": 3.56, "RT000002": None}}, + } # fmt: skip + path = tmp_path / "B09_Estimation" / "v1" / "user_values.json" + assert json.loads(path.read_text(encoding="utf-8")) == clean + assert store.read_user(tmp_path) == clean + assert store.read_book(tmp_path) is None + + +def test_단가_굳히기_운전사_연료_따라옴(): + book = prices.refresh_book(["EQ000004"]) # 불도저(무한궤도) 19ton · 조종원 1048 · 경유 + rows = book["rows"] + assert set(rows) == {"EQ000004", "LB000048", "MO000002"} + assert rows["EQ000004"]["loss"] == 1811 and rows["EQ000004"]["crew_code"] == "1048" + assert rows["LB000048"]["code"] == "1048" and rows["MO000002"]["prices"]["유가"] > 0 + assert book["driver_factor"] == {"num": 400, "den": 1920, "text": "1/8 × 16/12 × 25/20"} + # [저장] = 책에 없는 새 줄만 더함 — 있는 줄 값은 안 바꿈 + stale = {**book, "rows": {**rows, "LB000048": {**rows["LB000048"], "price": 1}}} + grown = prices.extend_book(stale, ["EQ000004", "LB000002"]) + assert grown["rows"]["LB000048"]["price"] == 1 and "LB000002" in grown["rows"] + # 책 밖 줄 = 지금 M01 값 + fresh 표시 + inputs = prices.build_inputs(stale, ["EQ000004", "LB000002"]) + fresh = {row["key"]: row.get("fresh", False) for row in inputs["labor"]} + assert fresh == {"LB000002": True, "LB000048": False} + assert inputs["fuel_keys"] == {"경유": "MO000002"} + + +def test_연결_없으면_빈_표와_연결_없음(tmp_path, monkeypatch): + async def fake_materials(_project_id): + return B08_SHEETS + + monkeypatch.setattr(router_mod, "material_sheets", fake_materials) + body = asyncio.run(router_mod.payload("p", tmp_path)) + sheets = body["sheets"] + assert sheets["labor"] == [] and sheets["expense"] == [] and sheets["machine"] == [] + assert [row["key"] for row in sheets["compare"]] == ["b08:sand", "b08:cement"] # 수량 있는 줄만 + assert all(row["applied"] is None for row in sheets["compare"]) # 출처 없음 = 빈칸(0 아님) + assert [(g["tab"], g["key"]) for g in sheets["gaps"]] == [ + ("compare", "b08:sand"), + ("compare", "b08:cement"), + ] + assert any("연결된 구조물 조합 사본 없음" in n for n in body["notes"]) + assert body["book"] is None # GET 은 아무것도 쓰지 않음 + assert not (tmp_path / "B09_Estimation").exists() diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index d5e5fcbcc..db528c844 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -138,6 +138,12 @@ cursor: pointer; } +/* 이동해 온 줄(PLAN 144 · focusRow) */ +.ui-data-table__table tbody tr.is-focus td { + background: var(--dt-hover); + box-shadow: inset 0 -2px 0 var(--color-accent); +} + /* 고칠 칸(PLAN 121) — 칸 가득 글 상자 · 숫자 오른쪽 맞춤 */ .ui-data-table__table td.is-edit { padding: 0; diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index a23dcfe0c..e9c682480 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -8,6 +8,7 @@ * host.append(t.root); t.setRows(rows2, total2); * 열 = { key: "cut_soil_area", label: "단면적", group: ["절토", "토사"], kind: "number", decimals: 2 } * 고칠 칸(PLAN 121) = editable(줄, 열) 참인 칸만 글 상자 · 바꾸면 onEdit(줄, 열, 글) — 다시 그려도 커서 자리 지킴 + * 칸 누름(PLAN 144) = onCellClick(줄, 열) · 줄로 이동 = t.focusRow(줄)(스크롤 + 강조) * ========================================================================== */ import "./ui_template_data_table.css"; @@ -48,6 +49,10 @@ export interface DataTableOptions { empty?: string; /** 줄 누름(본문 줄만 · 합계 제외) — 주면 줄에 `data-i`(줄 번호)·`is-clickable` 이 붙음 */ onRowClick?: (index: number) => void; + /** 칸 누름(본문 줄만) — 주면 줄에 `data-i`·`is-clickable` 이 붙음 · 줄 누름보다 먼저 부름 */ + onCellClick?: (index: number, key: string) => void; + /** 강조할 줄(`focusRow` 가 정함) */ + focus?: number | null; /** 고칠 칸인지(본문 줄만 · 합계 제외) — 참이면 칸이 글 상자 */ editable?: (index: number, key: string) => boolean; /** 고친 칸 글(바꾼 뒤 · 커서가 옮겨 간 다음에 부름) */ @@ -57,6 +62,8 @@ export interface DataTableOptions { export interface DataTable { root: HTMLElement; setRows: (rows: DataRow[], total?: DataRow | null) => void; + /** 그 줄로 스크롤하고 강조 */ + focusRow: (index: number) => void; } const EMPTY = "-"; @@ -124,6 +131,7 @@ function rowHtml( index: number | null = null, edit: number | null = null, editable?: (index: number, key: string) => boolean, + focus = false, ): string { let tr = ""; for (let j = 0; j < cols.length; j++) { @@ -146,7 +154,9 @@ function rowHtml( (fmts[j] ? ' data-num=""' : ""); tr += `${esc(txt)}`; } - const cls = [alt && "is-alt", index !== null && "is-clickable"].filter(Boolean).join(" "); + const cls = [alt && "is-alt", index !== null && "is-clickable", focus && "is-focus"] + .filter(Boolean) + .join(" "); const data = index !== null ? ` data-i="${index}"` : ""; return cls || data ? `${tr}` : `${tr}`; } @@ -185,7 +195,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin const to = win ? Math.max(from, Math.min(win.to, n)) : n; let body = win ? pad(from * win.rowH, cols.length) : ""; const blank = o.empty ?? EMPTY; - const pick = o.onRowClick !== undefined; + const pick = o.onRowClick !== undefined || o.onCellClick !== undefined; for (let i = from; i < to; i++) body += rowHtml( cols, @@ -197,6 +207,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin pick ? i : null, i, o.editable, + o.focus === i, ); if (win) body += pad((n - to) * win.rowH, cols.length); const foot = o.total ? `${rowHtml(cols, fmts, left, o.total, false, blank)}` : ""; @@ -250,7 +261,11 @@ export function createDataTable(opts: DataTableOptions): DataTable { }); root.addEventListener("click", (event) => { const row = (event.target as Element).closest("tr[data-i]"); - if (row && root.contains(row)) state.onRowClick?.(Number(row.dataset.i)); + if (!row || !root.contains(row)) return; + const cell = (event.target as Element).closest("td"); + const column = cell ? state.columns[cell.cellIndex] : undefined; + if (column) state.onCellClick?.(Number(row.dataset.i), column.key); + state.onRowClick?.(Number(row.dataset.i)); }); draw(true); return { @@ -260,5 +275,10 @@ export function createDataTable(opts: DataTableOptions): DataTable { if (total !== undefined) state.total = total; draw(true); }, + focusRow(index) { + state.focus = index; + root.scrollTop = Math.max(0, index * win.rowH - root.clientHeight / 3); + draw(true); + }, }; } From 83b19f45a5ef65c01fd7d66324251207a2c298fa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 15:04:48 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat(B09):=20=EC=9B=90=EA=B0=80=20=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=20=EC=88=98=EC=9A=94=20=3D=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=EB=90=9C=20=EC=82=AC=EB=B3=B8=EC=9D=B4=20=EB=B6=80=EB=A5=B8=20?= =?UTF-8?q?=EC=9E=90=EC=9B=90(143-1=20=EC=9B=90=EC=B2=9C=20=C2=B7=20144)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B09_Estimation_Linked = linked_snapshots 사본마다 담은 로직을 사본 단가 묶음 · 입력값 · 고른자재로 돌려 부른 LB · EQ · MT · MO 줄만 모음(묶음 통째 · 고르기 후보 안 씀 · 쓰기 없음) - 자리만이던 수요 함수를 바꿈 · 입력 빈 사본은 부른 줄 없음(빈 입력 자동값은 142-1 몫) - 시험 = test_144_b09_skeleton 더함(기계경비 로직 → EQ000004 · MO000002 · LB000048) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X --- B09_Estimation/B09_Estimation_Linked.py | 71 +++++++++++++++++++++++ B09_Estimation/B09_Estimation_Prices.py | 12 +--- B09_Estimation/B09_Estimation_Router.py | 7 ++- resources/tester/test_144_b09_skeleton.py | 19 ++++++ 4 files changed, 95 insertions(+), 14 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Linked.py diff --git a/B09_Estimation/B09_Estimation_Linked.py b/B09_Estimation/B09_Estimation_Linked.py new file mode 100644 index 000000000..8bf747390 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Linked.py @@ -0,0 +1,71 @@ +"""B09 연결된 사본 → 원가 수요 자원 키(PLAN 144 · 143-1 원천) — 서버 단독 · 읽기만. + +연결된 사본 = `M02_Item_Snapshot.linked_snapshots`(지정 ∩ 같은 항목 사본 · B08 · B09 공용) · +사본마다 `일위대가.json` 담은 로직을 그 사본 단가 묶음(`단가.json`) · 입력값 · 고른자재로 돌리며 +실제로 부른 자원 줄(인력 LB · 기계 EQ · 자재 MT · 유가 MO)만 모음 — 단가 묶음 통째(고르기 후보 전부)는 +안 씀 · 값은 쓰지 않음(단가는 원가 한 벌 `price_book.json` 이 맡음). +구조물 조합 사본 없으면 그 구조물은 없음 · 일위대가 없는 사본은 자원 없음. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from M02_MasterTemplete import M02_Item_Prices as item_prices +from M02_MasterTemplete.M02_Item_Snapshot import ( + PICKS_FILE, + VALUES_FILE, + linked_snapshots, + structures_dir, +) +from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json + +#: 원가 단가 목록에 오르는 자원 표 +RESOURCE_TABLES = ("LB", "EQ", "MT", "MO") + +mf = item_prices.mf + + +def used_keys(folder: Path) -> list[str]: + """사본 하나가 담은 로직을 돌릴 때 부른 자원 키 — 부른 차례 · 겹침 없음.""" + labor = read_json(folder / LABOR_FILE) + if not isinstance(labor, dict): + return [] + snapshot = item_prices.load_prices(read_json(folder / PRICE_FILE) or {}) + values = read_json(folder / VALUES_FILE) or {} + picks = read_json(folder / PICKS_FILE) or {} + seen: list[str] = [] + base_get = snapshot.get + + def get(ref: str) -> dict[str, Any]: + row = base_get(ref) + key = str(row.get("키") or "") + if key[:2] in RESOURCE_TABLES and key not in seen: + seen.append(key) + return row + + snapshot.get = get # 이 사본 묶음 한 벌에만 — 마스터 캐시는 안 건드림 + for key in item_prices.mcb.logic_keys(labor.get("줄") or {}): + if not key: + continue + given = {name: (one or {}).get("값") for name, one in (values.get(key) or {}).items()} + try: + with mf.mp.choosing((picks or {}).get(key)): + mf.run(snapshot, key, given) + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): + continue # 못 돈 로직 — 부른 데까지만 + return seen + + +def linked_resource_keys(project_root: str | Path) -> list[str]: + """연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 · 겹침 없음.""" + base = structures_dir(project_root) + out: list[str] = [] + for _owner, linked in sorted(linked_snapshots(project_root).items()): + if "일위대가" not in linked.get("부분", []): + continue + for key in used_keys(base / linked["폴더"]): + if key not in out: + out.append(key) + return out diff --git a/B09_Estimation/B09_Estimation_Prices.py b/B09_Estimation/B09_Estimation_Prices.py index 03ecb38b0..967a0e423 100644 --- a/B09_Estimation/B09_Estimation_Prices.py +++ b/B09_Estimation/B09_Estimation_Prices.py @@ -4,8 +4,7 @@ [단가 갱신] 때만 새로 · 대가 · 산근도 이 단가로 셈(한 직종 · 한 자재 = 한 단가). 줄 = 수요 따라(사용자 승인 ① · 10-9) — 엑셀 목록을 기본으로 깔지 않음: - 연결된 사본이 쓰는 자원 키(`linked_resource_keys` — 143-1 「연결된 사본」 원천 · 145-1 · 2 호표가 - 들어오면 그 결과를 돌려줌 · 그전엔 빈 목록 = 빈 표 「연결 없음」) + 연결된 사본이 부른 자원 키(`B09_Estimation_Linked` — 143-1 「연결된 사본」 원천 · 없으면 빈 표) + 기계가 부르는 운전사(조종원직종 → 인력 원문번호) · 연료(주연료종류 → 유가 「종류:전국평균」) + B08 총괄자재수량집계표 자재(M01 짝 없음 → 출처 빈칸 · 적용 단가는 사용자 입력) 운전사 시간 계수 = M01 CC000336(시간환산 · 상여계수 · 휴지계수 분자 · 분모 곱). @@ -30,15 +29,6 @@ FUEL_REGION = "전국평균" MT_SOURCES = ("관급", "물가자료", "유통물가", "물가정보", "거래가격") -def linked_resource_keys(project_root: str | Path) -> list[str]: - """연결된 사본(구조물 조합 사본)이 쓰는 자원 키(LB · EQ · MT · MO) — 자리만. - - 143-1 「연결된 사본」 원천 함수 · 145-1 · 2 산근 · 대가 호표가 들어오면 그 자원 키를 돌려줌 · - 그전엔 빈 목록(구조물 조합 사본 없으면 그 구조물은 없음 · 연결 안 된 값은 비움). - """ - return [] - - def _f(value: Any) -> float | None: if isinstance(value, Decimal): return float(value) diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index d46f5fbe9..cb94b6692 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -22,6 +22,7 @@ from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Material import material_sheets from B09_Estimation import B09_Estimation_Prices as prices from B09_Estimation import B09_Estimation_Store as store +from B09_Estimation.B09_Estimation_Linked import linked_resource_keys from B09_Estimation.B09_Estimation_Calc_Base import compute_base router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) @@ -41,7 +42,7 @@ async def _b08(project_id: UUID, notes: list[str]) -> dict[str, Any]: async def payload(project_id: UUID, root: Path) -> dict[str, Any]: notes: list[str] = [] b08 = await _b08(project_id, notes) - keys = prices.linked_resource_keys(root) + keys = await asyncio.to_thread(linked_resource_keys, root) if not keys: notes.append( "연결된 구조물 조합 사본 없음 — 노무 · 경비 · 중기 단가 줄은 사본이 연결되면 생김" @@ -68,7 +69,7 @@ async def estimation(project_id: UUID) -> dict[str, Any]: async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> dict[str, Any]: root = await _project_root(project_id) await asyncio.to_thread(store.write_user, root, body.get("user")) - keys = prices.linked_resource_keys(root) + keys = await asyncio.to_thread(linked_resource_keys, root) book = await asyncio.to_thread(store.read_book, root) book = await asyncio.to_thread(prices.extend_book, book, keys) await asyncio.to_thread(store.write_book, root, book) @@ -78,7 +79,7 @@ async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> @router.post("/{project_id}/estimation/prices") async def refresh_prices(project_id: UUID) -> dict[str, Any]: root = await _project_root(project_id) - keys = prices.linked_resource_keys(root) + keys = await asyncio.to_thread(linked_resource_keys, root) book = await asyncio.to_thread(prices.refresh_book, keys) await asyncio.to_thread(store.write_book, root, book) return await payload(project_id, root) diff --git a/resources/tester/test_144_b09_skeleton.py b/resources/tester/test_144_b09_skeleton.py index 913befed5..d24f7ce29 100644 --- a/resources/tester/test_144_b09_skeleton.py +++ b/resources/tester/test_144_b09_skeleton.py @@ -112,3 +112,22 @@ def test_연결_없으면_빈_표와_연결_없음(tmp_path, monkeypatch): assert any("연결된 구조물 조합 사본 없음" in n for n in body["notes"]) assert body["book"] is None # GET 은 아무것도 쓰지 않음 assert not (tmp_path / "B09_Estimation").exists() + + +def test_연결된_사본이_부른_자원만(tmp_path): + """사본 단가 묶음 통째(고르기 후보 기계 100여)가 아니라 로직이 실제로 부른 줄만.""" + from B09_Estimation.B09_Estimation_Linked import used_keys + from M02_MasterTemplete import M02_Item_Prices as item_prices + + bundle = item_prices.to_json(item_prices.collect(["GC000268"])) # 기계 시간당 기계경비 + files = { + "단가.json": bundle, + "일위대가.json": {"줄": {"종류": "일위대가", "담은로직": [{"로직": "GC000268"}]}}, + "입력값.json": {"GC000268": {"기계": {"값": "0101-0019"}, "지역": {"값": "전국평균"}}}, + } + for name, data in files.items(): + (tmp_path / name).write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + assert used_keys(tmp_path) == ["EQ000004", "MO000002", "LB000048"] + (tmp_path / "입력값.json").write_text("{}", encoding="utf-8") + assert used_keys(tmp_path) == [] # 입력 빈 로직은 못 돎 — 부른 줄 없음 + assert used_keys(tmp_path / "없음") == [] From 95dd19fd2e4c245118093efb32f5e4c74f95da4f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 15:05:38 +0900 Subject: [PATCH 6/6] =?UTF-8?q?chore(B09):=20=EC=9B=90=EA=B0=80=20?= =?UTF-8?q?=EB=B0=94=ED=83=95=20=ED=8C=8C=EC=9D=BC=20=EB=A6=B0=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=E2=80=94=20=EA=B8=B4=20=EC=A4=84=20=C2=B7?= =?UTF-8?q?=20=EC=95=88=20=EC=93=B0=EB=8A=94=20import(144)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X --- B09_Estimation/B09_Estimation_Calc_Base.py | 3 ++- B09_Estimation/B09_Estimation_Linked.py | 6 +++--- B09_Estimation/B09_Estimation_Prices.py | 1 - B09_Estimation/B09_Estimation_Router.py | 17 ++++++++++------- B09_Estimation/B09_Estimation_Store.py | 6 ++++-- resources/tester/test_144_b09_base_calc.py | 2 +- resources/tester/test_144_b09_skeleton.py | 3 ++- 7 files changed, 22 insertions(+), 16 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Calc_Base.py b/B09_Estimation/B09_Estimation_Calc_Base.py index 0c0677cac..972268937 100644 --- a/B09_Estimation/B09_Estimation_Calc_Base.py +++ b/B09_Estimation/B09_Estimation_Calc_Base.py @@ -25,7 +25,8 @@ from typing import Any #: 떠도는 끝수(부동소수) — 이만큼은 자리 올림 · 버림에서 무시 EPS = 1e-7 -#: 자재단가대비표 출처 칸 차례 — 엑셀 조달 · 물가정보 · 물가자료 차례(같은 값이면 앞 칸) ↔ M01 재료_자재품목 값 열 · 유가 = 오피넷 +#: 자재단가대비표 출처 칸 차례 — 엑셀 조달 · 물가정보 · 물가자료(같은 값이면 앞 칸) +# ↔ M01 재료_자재품목 값 열 · 유가 = 오피넷 SOURCES = ("관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가") #: 자재 줄 사용자 표시 diff --git a/B09_Estimation/B09_Estimation_Linked.py b/B09_Estimation/B09_Estimation_Linked.py index 8bf747390..e3495f1d1 100644 --- a/B09_Estimation/B09_Estimation_Linked.py +++ b/B09_Estimation/B09_Estimation_Linked.py @@ -1,9 +1,9 @@ """B09 연결된 사본 → 원가 수요 자원 키(PLAN 144 · 143-1 원천) — 서버 단독 · 읽기만. -연결된 사본 = `M02_Item_Snapshot.linked_snapshots`(지정 ∩ 같은 항목 사본 · B08 · B09 공용) · +연결된 사본 = `M02_Item_Snapshot.linked_snapshots`(지정 ∩ 같은 항목 사본 · B08 · B09 공용). 사본마다 `일위대가.json` 담은 로직을 그 사본 단가 묶음(`단가.json`) · 입력값 · 고른자재로 돌리며 -실제로 부른 자원 줄(인력 LB · 기계 EQ · 자재 MT · 유가 MO)만 모음 — 단가 묶음 통째(고르기 후보 전부)는 -안 씀 · 값은 쓰지 않음(단가는 원가 한 벌 `price_book.json` 이 맡음). +실제로 부른 자원 줄(인력 LB · 기계 EQ · 자재 MT · 유가 MO)만 모음 — 단가 묶음 통째 +(고르기 후보 전부)는 안 씀 · 값은 쓰지 않음(단가는 원가 한 벌 `price_book.json` 이 맡음). 구조물 조합 사본 없으면 그 구조물은 없음 · 일위대가 없는 사본은 자원 없음. """ diff --git a/B09_Estimation/B09_Estimation_Prices.py b/B09_Estimation/B09_Estimation_Prices.py index 967a0e423..2ac4ab7be 100644 --- a/B09_Estimation/B09_Estimation_Prices.py +++ b/B09_Estimation/B09_Estimation_Prices.py @@ -15,7 +15,6 @@ from __future__ import annotations from datetime import datetime from decimal import Decimal -from pathlib import Path from typing import Any from M01_MasterData import M01_MasterData_Store as m01 diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index cb94b6692..7ac339c94 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -1,12 +1,15 @@ """B09 원가계산 길(PLAN 144 · 145) — 결과는 저장하지 않고 GET 때 서버가 셈(B08 꼴). - GET /api/projects/{id}/estimation 입력(굳힌 단가 · 수요 · B08 수량) + 사용자 값 → 표 전부 - PUT /api/projects/{id}/estimation {user} [저장] — 사용자 값 · 책에 없는 새 단가 줄만 굳힘 - → 서버가 정본으로 다시 셈 - POST /api/projects/{id}/estimation/prices [단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 + GET /api/projects/{id}/estimation + 입력(굳힌 단가 · 수요 · B08 수량) + 사용자 값 → 표 전부 + PUT /api/projects/{id}/estimation {user} + [저장] — 사용자 값 · 책에 없는 새 단가 줄만 굳힘 → 서버가 정본으로 다시 셈 + POST /api/projects/{id}/estimation/prices + [단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 -저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) · 단가 = `B09_Estimation_Prices` · -셈 = `B09_Estimation_Calc`(화면 짝과 같은 식). +저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) · +단가 = `B09_Estimation_Prices` · +수요 = `B09_Estimation_Linked`(연결된 사본) · 셈 = `B09_Estimation_Calc_*`(화면 짝과 같은 식). """ from __future__ import annotations @@ -22,8 +25,8 @@ from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Material import material_sheets from B09_Estimation import B09_Estimation_Prices as prices from B09_Estimation import B09_Estimation_Store as store -from B09_Estimation.B09_Estimation_Linked import linked_resource_keys from B09_Estimation.B09_Estimation_Calc_Base import compute_base +from B09_Estimation.B09_Estimation_Linked import linked_resource_keys router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index a97b14bce..1710b3f92 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -1,7 +1,9 @@ """B09 원가 저장 자리(PLAN 144-1) — 프로젝트 안 `B09_Estimation/v1/`. - price_book.json 굳힌 단가 한 벌 {"made": 시각, "rows": {키: 입력 줄}, "driver_factor": …} - — [단가 갱신] 때만 M01 로 새로 · [저장] 때 책에 없는 새 줄만 더함(있는 줄 안 바꿈) + price_book.json 굳힌 단가 한 벌 {"made": 시각, "rows": {키: 입력 줄}, + "driver_factor": …, "fx": […]} + — [단가 갱신] 때만 M01 로 새로 · + [저장] 때 책에 없는 새 줄만 더함(있는 줄 안 바꿈) user_values.json 사용자 값 {"materials": {자재 키: {applied?, supply?}}, "estimate": {…}} — 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본) diff --git a/resources/tester/test_144_b09_base_calc.py b/resources/tester/test_144_b09_base_calc.py index 22cccf081..83df24dc8 100644 --- a/resources/tester/test_144_b09_base_calc.py +++ b/resources/tester/test_144_b09_base_calc.py @@ -17,8 +17,8 @@ if str(ROOT) not in sys.path: from B09_Estimation.B09_Estimation_Calc_Base import ( # noqa: E402 compute_base, - rounddown, round_half, + rounddown, roundup, ) diff --git a/resources/tester/test_144_b09_skeleton.py b/resources/tester/test_144_b09_skeleton.py index d24f7ce29..23cfd7ec7 100644 --- a/resources/tester/test_144_b09_skeleton.py +++ b/resources/tester/test_144_b09_skeleton.py @@ -2,7 +2,8 @@ - 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 「연결 점검」 한 탭 · INITIAL 안 옮김) - 저장 = 프로젝트 안 `B09_Estimation/v1/`(사용자 값 · 굳힌 단가) · 결과는 저장 안 함 -- 줄 = 수요 따라(사용자 승인 ①) — 연결된 사본 없으면 노무 · 경비 · 중기 빈 표 · B08 자재만 줄 · 출처 빈칸 +- 줄 = 수요 따라(사용자 승인 ①) — 연결된 사본 없으면 노무 · 경비 · 중기 빈 표 + · B08 자재만 줄 · 출처 빈칸 """ import asyncio