From d0145098991853409e191a3862fefce1da45045c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 25 Jul 2026 19:41:05 +0900 Subject: [PATCH] =?UTF-8?q?B08=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=EC=8B=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- B08_wf5_Quantity/B08_wf5_Quantity_Api.ts | 33 +- B08_wf5_Quantity/B08_wf5_Quantity_Database.py | 7 +- .../B08_wf5_Quantity_Database_UnitCost.py | 22 +- .../B08_wf5_Quantity_Engine_Final.py | 7 + .../B08_wf5_Quantity_Engine_UnitCost.py | 54 +- .../B08_wf5_Quantity_Repository_Basis.py | 7 +- ...B08_wf5_Quantity_Repository_Calculation.py | 53 +- ...5_Quantity_Repository_CalculationSource.py | 19 +- .../B08_wf5_Quantity_Repository_Catalog.py | 268 ++++++---- .../B08_wf5_Quantity_Repository_CostBasis.py | 93 +++- .../B08_wf5_Quantity_Repository_Pricing.py | 2 +- .../B08_wf5_Quantity_Repository_Quantity.py | 327 ++++++++++-- .../B08_wf5_Quantity_Repository_Stale.py | 73 +++ .../B08_wf5_Quantity_Repository_UnitCost.py | 169 +++++- .../B08_wf5_Quantity_Router_Basis.py | 2 + .../B08_wf5_Quantity_Router_Calculation.py | 13 +- .../B08_wf5_Quantity_Router_Catalog.py | 5 + .../B08_wf5_Quantity_Router_Costing.py | 68 ++- .../B08_wf5_Quantity_Router_Errors.py | 14 + .../B08_wf5_Quantity_Router_Quantity.py | 109 +++- .../B08_wf5_Quantity_Router_Reconciliation.py | 52 +- .../B08_wf5_Quantity_Schema_Catalog.py | 4 +- .../B08_wf5_Quantity_Schema_Quantity.py | 14 +- .../B08_wf5_Quantity_Schema_UnitCost.py | 4 + B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts | 2 + .../B08_wf5_Quantity_UI_Style.css | 49 ++ .../B08_wf5_Quantity_UI_Tab_Basis.ts | 28 +- .../B08_wf5_Quantity_UI_Tab_Catalog.ts | 4 - .../B08_wf5_Quantity_UI_Tab_Equipment.ts | 27 +- .../B08_wf5_Quantity_UI_Tab_Final.ts | 76 ++- .../B08_wf5_Quantity_UI_Tab_Quantity.ts | 482 +++++++++++++++--- 31 files changed, 1722 insertions(+), 365 deletions(-) create mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Repository_Stale.py create mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Router_Errors.py diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Api.ts b/B08_wf5_Quantity/B08_wf5_Quantity_Api.ts index 3c397dd3..8092c782 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Api.ts +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Api.ts @@ -1,3 +1,9 @@ +let errorHandler: (message: string) => void = () => undefined; + +export const setB08ApiErrorHandler = (handler: (message: string) => void) => { + errorHandler = handler; +}; + const req = async (url: string, init?: RequestInit): Promise => { const response = await fetch(url, { credentials: "include", @@ -6,7 +12,9 @@ const req = async (url: string, init?: RequestInit): Promise => { }); if (!response.ok) { const body = await response.json().catch(() => ({})); - throw new Error(body.detail ?? `요청 실패 (${response.status})`); + const message = body.detail ?? `요청 실패 (${response.status})`; + errorHandler(message); + throw new Error(message); } return response.json() as Promise; }; @@ -48,7 +56,28 @@ export const B08Api = { req(path(p, "/work-breakdown"), { method: "POST", body: JSON.stringify(data) }), saveQuantity: (p: string, data: any) => req(path(p, "/quantities"), { method: "POST", body: JSON.stringify(data) }), + saveQuantitiesBulk: (p: string, items: any[]) => + req(path(p, "/quantities/bulk"), { + method: "POST", + body: JSON.stringify({ items }), + }), confirmQuantities: (p: string) => req(path(p, "/quantities/confirm"), { method: "POST" }), + cloneQuantity: (p: string, id: string) => + req(path(p, `/quantities/${encodeURIComponent(id)}/clone`), { method: "POST" }), + deleteQuantity: (p: string, id: string) => + req(path(p, `/quantities/${encodeURIComponent(id)}`), { method: "DELETE" }), + reorderQuantities: (p: string, items: Array<{ id: string; sort_order: number }>) => + req(path(p, "/quantities/reorder"), { + method: "POST", + body: JSON.stringify({ items }), + }), + deleteWbs: (p: string, id: string) => + req(path(p, `/work-breakdown/${encodeURIComponent(id)}`), { method: "DELETE" }), + reorderWbs: (p: string, items: Array<{ id: string; sort_order: number }>) => + req(path(p, "/work-breakdown/reorder"), { + method: "POST", + body: JSON.stringify({ items }), + }), calculate: (p: string, data: any) => req(path(p, "/calculate/final"), { method: "POST", body: JSON.stringify(data) }), importReference: (p: string, data: any) => @@ -58,5 +87,7 @@ export const B08Api = { method: "POST", }), runs: (p: string) => req(path(p, "/calculation-runs")), + calculationRun: (p: string, runId: string) => + req(path(p, `/calculation-runs/${encodeURIComponent(runId)}`)), latestCalculation: (p: string) => req(path(p, "/calculation-runs/latest")), }; diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Database.py b/B08_wf5_Quantity/B08_wf5_Quantity_Database.py index 38ea0e9b..e3cccae7 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Database.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Database.py @@ -5,13 +5,16 @@ from B08_wf5_Quantity.B08_wf5_Quantity_Database_Catalog import CATALOG_DDL from B08_wf5_Quantity.B08_wf5_Quantity_Database_CostBasis import COST_BASIS_DDL from B08_wf5_Quantity.B08_wf5_Quantity_Database_Quantity import QUANTITY_DDL from B08_wf5_Quantity.B08_wf5_Quantity_Database_Reconciliation import RECONCILIATION_DDL -from B08_wf5_Quantity.B08_wf5_Quantity_Database_UnitCost import UNIT_COST_DDL +from B08_wf5_Quantity.B08_wf5_Quantity_Database_UnitCost import ( + UNIT_COST_DDL, + UNIT_COST_MIGRATION_DDL, +) DDL_STATEMENTS = (BASIS_DDL + CATALOG_DDL + UNIT_COST_DDL + COST_BASIS_DDL + QUANTITY_DDL + CALCULATION_DDL + RECONCILIATION_DDL) async def initialize_b08_tables(connection) -> None: async with connection.cursor() as cursor: - for statement in DDL_STATEMENTS: + for statement in DDL_STATEMENTS + UNIT_COST_MIGRATION_DDL: await cursor.execute(statement) await connection.commit() \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Database_UnitCost.py b/B08_wf5_Quantity/B08_wf5_Quantity_Database_UnitCost.py index 543954c8..d73117b3 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Database_UnitCost.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Database_UnitCost.py @@ -17,14 +17,34 @@ UNIT_COST_DDL = ( id CHAR(36) PRIMARY KEY, project_id CHAR(36) NOT NULL, equipment_code VARCHAR(50) NOT NULL, name VARCHAR(255) NOT NULL, specification VARCHAR(255) NOT NULL DEFAULT '', unit VARCHAR(30) NOT NULL DEFAULT 'hr', equipment_price DECIMAL(20,4) NOT NULL DEFAULT 0, annual_hours DECIMAL(20,4) NOT NULL DEFAULT 0, + useful_life_years DECIMAL(10,4) NOT NULL DEFAULT 0, residual_rate DECIMAL(12,8) NOT NULL DEFAULT 0, + repair_rate DECIMAL(12,8) NOT NULL DEFAULT 0, management_rate DECIMAL(12,8) NOT NULL DEFAULT 0, labor_price DECIMAL(20,4) NOT NULL DEFAULT 0, material_price DECIMAL(20,4) NOT NULL DEFAULT 0, expense_price DECIMAL(20,4) NOT NULL DEFAULT 0, total_price DECIMAL(20,4) NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', version_no INT NOT NULL DEFAULT 1, UNIQUE KEY uq_b08_equipment(project_id,equipment_code,version_no))""", """CREATE TABLE IF NOT EXISTS b08_equipment_rate_components ( id BIGINT AUTO_INCREMENT PRIMARY KEY, equipment_rate_id CHAR(36) NOT NULL, - component_code VARCHAR(50) NOT NULL, item_id CHAR(36) NULL, + component_code VARCHAR(50) NOT NULL, component_type VARCHAR(20) NOT NULL DEFAULT 'CATALOG', + item_id CHAR(36) NULL, expression VARCHAR(500) NULL, quantity DECIMAL(20,8) NULL, cost_type VARCHAR(20) NOT NULL, sort_order INT NOT NULL DEFAULT 0, KEY ix_b08_eq_component(equipment_rate_id))""", +) +UNIT_COST_MIGRATION_DDL = ( + """ALTER TABLE b08_equipment_rate_components + ADD COLUMN IF NOT EXISTS component_type VARCHAR(20) NOT NULL DEFAULT 'CATALOG' + AFTER component_code""", + """ALTER TABLE b08_equipment_rates + ADD COLUMN IF NOT EXISTS useful_life_years DECIMAL(10,4) NOT NULL DEFAULT 0 + AFTER annual_hours""", + """ALTER TABLE b08_equipment_rates + ADD COLUMN IF NOT EXISTS residual_rate DECIMAL(12,8) NOT NULL DEFAULT 0 + AFTER useful_life_years""", + """ALTER TABLE b08_equipment_rates + ADD COLUMN IF NOT EXISTS repair_rate DECIMAL(12,8) NOT NULL DEFAULT 0 + AFTER residual_rate""", + """ALTER TABLE b08_equipment_rates + ADD COLUMN IF NOT EXISTS management_rate DECIMAL(12,8) NOT NULL DEFAULT 0 + AFTER repair_rate""", ) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Final.py b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Final.py index 6c0615f9..d1846b4c 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Final.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Final.py @@ -5,6 +5,13 @@ from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Calculation import CostTotals, Fin _RESERVED = ("GENERAL_ADMIN", "PROFIT", "VAT", "PROCUREMENT_FEE") def calculate_final_cost(totals: CostTotals, policies: list[RatePolicy]) -> FinalCost: + required = {"GENERAL_ADMIN", "PROFIT", "VAT"} + if totals.government_material: + required.add("PROCUREMENT_FEE") + available = {policy.rule_code for policy in policies if policy.status == "APPROVED"} + missing = sorted(required - available) + if missing: + raise ValueError("필수 원가 규칙이 없습니다: " + ", ".join(missing)) context = {"LABOR": totals.labor, "MATERIAL": totals.material, "EXPENSE": totals.expense, "DIRECT_COST": totals.direct_cost, "GOVERNMENT_MATERIAL": totals.government_material} ordered = sorted(policies, key=lambda row: row.sort_order) diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_UnitCost.py b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_UnitCost.py index eece87f8..575cd053 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_UnitCost.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_UnitCost.py @@ -44,25 +44,53 @@ def calculate_unit_cost(model: UnitCost) -> CostResult: def calculate_equipment_rate(model: EquipmentRate) -> CostResult: if model.annual_hours <= 0: raise ValueError(f"{model.name}: 연간 가동시간이 필요합니다.") + if model.equipment_price > 0 and model.useful_life_years <= 0: + raise ValueError(f"{model.name}: 기계가격이 있으면 내용연수가 필요합니다.") if not model.components: raise ValueError(f"{model.name}: 연료·운전·정비 구성요소가 없습니다.") + component_cost = _calculate(model.components, "FLOOR", 1) - hourly_ownership = round_amount(model.equipment_price / model.annual_hours, "FLOOR", 1) - expense = component_cost.expense + hourly_ownership - trace = [ - { - "component_type": "EQUIPMENT_OWNERSHIP", - "equipment_price": str(model.equipment_price), - "annual_hours": str(model.annual_hours), - "amount": str(hourly_ownership), - "cost_type": "EXPENSE", - }, - *component_cost.trace, - ] + ownership = _equipment_ownership(model) + expense = component_cost.expense + ownership["total"] + trace = [*ownership["trace"], *component_cost.trace] return CostResult( labor=component_cost.labor, material=component_cost.material, expense=expense, total=component_cost.labor + component_cost.material + expense, trace=trace, - ) \ No newline at end of file + ) + + +def _equipment_ownership(model: EquipmentRate) -> dict: + if model.equipment_price == 0: + return {"total": 0, "trace": []} + depreciation_raw = ( + model.equipment_price * (Decimal(1) - model.residual_rate) + / model.useful_life_years + / model.annual_hours + ) + repair_raw = model.equipment_price * model.repair_rate / model.annual_hours + management_raw = model.equipment_price * model.management_rate / model.annual_hours + rows = [ + ("DEPRECIATION", "감가상각비", depreciation_raw), + ("REPAIR", "수선비", repair_raw), + ("MANAGEMENT", "관리비", management_raw), + ] + trace = [] + total = 0 + for code, name, raw in rows: + amount = round_amount(raw, "FLOOR", 1) + total += amount + trace.append( + { + "component_type": code, + "name": name, + "equipment_price": str(model.equipment_price), + "annual_hours": str(model.annual_hours), + "raw": str(raw), + "amount": str(amount), + "cost_type": "EXPENSE", + } + ) + return {"total": total, "trace": trace} \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Basis.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Basis.py index f3ddf845..4909a214 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Basis.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Basis.py @@ -1,5 +1,6 @@ import json +from B08_wf5_Quantity.B08_wf5_Quantity_Repository_Stale import StaleRepository from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Basis import ( BasisWorkspace, ExchangeRate, @@ -127,8 +128,4 @@ class BasisRepository: WHERE project_id=%s AND basis_version=%s AND status='CONFIRMED'""", (project_id, version), ) - for table in ("b08_unit_costs", "b08_equipment_rates", "b08_cost_basis"): - await cursor.execute( - f"UPDATE {table} SET status='STALE' WHERE project_id=%s AND status='CONFIRMED'", - (project_id,), - ) \ No newline at end of file + await StaleRepository.mark_all(cursor, project_id) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Calculation.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Calculation.py index 3e9e230f..74861f98 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Calculation.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Calculation.py @@ -101,39 +101,57 @@ class CalculationRepository: async def latest_detail(self, project_id: str) -> dict | None: async with self.db.cursor() as cursor: await cursor.execute( - """SELECT * FROM b08_calculation_runs + """SELECT id FROM b08_calculation_runs WHERE project_id=%s AND status='COMPLETE' - ORDER BY created_at DESC LIMIT 1""", + ORDER BY created_at DESC,id DESC LIMIT 1""", (project_id,), ) run = await cursor.fetchone() + if not run: + return None + return await self.detail(project_id, run["id"]) + + async def detail(self, project_id: str, run_id: str) -> dict: + async with self.db.cursor() as cursor: + await cursor.execute( + """SELECT * FROM b08_calculation_runs + WHERE project_id=%s AND id=%s AND status='COMPLETE'""", + (project_id, run_id), + ) + run = await cursor.fetchone() if not run: - return None + raise LookupError("계산 실행을 찾을 수 없습니다.") await cursor.execute( "SELECT * FROM b08_estimate_lines WHERE run_id=%s ORDER BY id", - (run["id"],), + (run_id,), ) lines = list(await cursor.fetchall()) await cursor.execute( - "SELECT * FROM b08_cost_aggregates WHERE run_id=%s AND aggregate_type='TOTAL'", - (run["id"],), + """SELECT * FROM b08_cost_aggregates + WHERE run_id=%s AND aggregate_type='TOTAL'""", + (run_id,), ) aggregate = await cursor.fetchone() or {} await cursor.execute( - "SELECT * FROM b08_indirect_cost_results WHERE run_id=%s ORDER BY sort_order", - (run["id"],), + """SELECT * FROM b08_indirect_cost_results + WHERE run_id=%s ORDER BY sort_order""", + (run_id,), ) indirect = list(await cursor.fetchall()) await cursor.execute( "SELECT * FROM b08_final_cost_results WHERE run_id=%s", - (run["id"],), + (run_id,), ) final = await cursor.fetchone() + if not final: + raise LookupError("최종공사비 결과를 찾을 수 없습니다.") for line in lines: line["trace"] = json.loads(line.pop("trace_json")) for result in indirect: result["trace"] = json.loads(result.pop("trace_json")) - result["rule_name"] = result["trace"].get("rule_name", result["rule_code"]) + result["rule_name"] = result["trace"].get( + "rule_name", result["rule_code"] + ) final_trace = json.loads(final.pop("trace_json")) final["trace"] = final_trace final["indirect_results"] = indirect @@ -145,7 +163,20 @@ class CalculationRepository: "government_material": final["government_material"], "excluded_amount": final_trace.get("excluded_amount", 0), } - return {"run_id": run["id"], "lines": lines, "totals": totals, "final": final} + versions = { + "basis_version": run["basis_version"], + "price_version": run["price_version"], + "quantity_version": run["quantity_version"], + "rule_version": run["rule_version"], + } + return { + "run_id": run_id, + "created_at": run["created_at"], + "versions": versions, + "lines": lines, + "totals": totals, + "final": final, + } async def history(self, project_id: str) -> list[dict]: async with self.db.cursor() as cursor: await cursor.execute( diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CalculationSource.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CalculationSource.py index 7037f77d..09d0b017 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CalculationSource.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CalculationSource.py @@ -50,13 +50,18 @@ class CalculationSourceRepository: if versions.rule_version != versions.basis_version: raise ValueError("요율 규칙 버전은 기준정보 버전과 같아야 합니다.") await cursor.execute( - """SELECT 1 FROM b08_price_books - WHERE project_id=%s AND price_version=%s AND basis_version=%s - AND status='CONFIRMED'""", - (project_id, versions.price_version, versions.basis_version), + """SELECT price_version,basis_version FROM b08_price_books + WHERE project_id=%s AND status='CONFIRMED' + ORDER BY effective_date DESC,id DESC LIMIT 1""", + (project_id,), ) - if not await cursor.fetchone(): - raise ValueError("확정 가격판과 기준정보 버전이 일치하지 않습니다.") + price_book = await cursor.fetchone() + if ( + not price_book + or price_book["price_version"] != versions.price_version + or price_book["basis_version"] != versions.basis_version + ): + raise ValueError("최신 확정 가격판과 기준정보 버전을 사용해야 합니다.") await cursor.execute( """SELECT quantity_version FROM b08_quantity_confirmations WHERE project_id=%s ORDER BY quantity_version DESC LIMIT 1""", @@ -125,7 +130,7 @@ class CalculationSourceRepository: ON q.reference_type='COST_BASIS' AND cb.id=q.reference_id AND cb.project_id=q.project_id AND cb.status='CONFIRMED' WHERE q.project_id=%s AND q.status='CONFIRMED' - ORDER BY q.id + ORDER BY q.wbs_id,q.sort_order,q.id """ @staticmethod diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Catalog.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Catalog.py index e3b718ea..01d89aca 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Catalog.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Catalog.py @@ -1,6 +1,7 @@ from decimal import Decimal from uuid import uuid4 +from B08_wf5_Quantity.B08_wf5_Quantity_Repository_Stale import StaleRepository from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Catalog import ( AppliedPrice, CatalogItem, @@ -15,71 +16,53 @@ class CatalogRepository: async def save_book(self, project_id: str, row: PriceBook) -> None: async with self.db.cursor() as cursor: - await cursor.execute( - """ - INSERT INTO b08_price_books( - project_id, price_version, basis_version, name, status, effective_date - ) VALUES(%s,%s,%s,%s,%s,%s) - ON DUPLICATE KEY UPDATE - basis_version=VALUES(basis_version), name=VALUES(name), - status=VALUES(status), effective_date=VALUES(effective_date) - """, - (project_id, row.price_version, row.basis_version, row.name, row.status, row.effective_date), - ) if row.status == "CONFIRMED": await cursor.execute( - "UPDATE b08_unit_costs SET status='STALE' WHERE project_id=%s AND status='CONFIRMED'", - (project_id,), - ) - await cursor.execute( - "UPDATE b08_equipment_rates SET status='STALE' WHERE project_id=%s AND status='CONFIRMED'", - (project_id,), - ) - await cursor.execute( - "UPDATE b08_cost_basis SET status='STALE' WHERE project_id=%s AND status='CONFIRMED'", - (project_id,), + """SELECT 1 FROM b08_basis_versions + WHERE project_id=%s AND version=%s AND status='CONFIRMED'""", + (project_id, row.basis_version), ) + if not await cursor.fetchone(): + raise ValueError("확정 가격판은 확정 기준정보 버전과 연결해야 합니다.") + await cursor.execute( + """INSERT INTO b08_price_books( + project_id,price_version,basis_version,name,status,effective_date + ) VALUES(%s,%s,%s,%s,%s,%s) + ON DUPLICATE KEY UPDATE basis_version=VALUES(basis_version), + name=VALUES(name),status=VALUES(status), + effective_date=VALUES(effective_date)""", + (project_id, row.price_version, row.basis_version, row.name, + row.status, row.effective_date), + ) + if row.status == "CONFIRMED": + await StaleRepository.mark_all(cursor, project_id) await self.db.commit() - async def list_items(self, project_id: str) -> list[dict]: - sql = """ - SELECT i.*, a.applied_price, a.selection_reason, b.price_version - FROM b08_catalog_items i - LEFT JOIN b08_price_books b - ON b.project_id=i.project_id AND b.status='CONFIRMED' - AND b.effective_date=( - SELECT MAX(x.effective_date) FROM b08_price_books x - WHERE x.project_id=i.project_id AND x.status='CONFIRMED' - ) - LEFT JOIN b08_applied_prices a - ON a.project_id=i.project_id AND a.item_id=i.id - AND a.price_version=b.price_version - WHERE i.project_id=%s AND i.active=1 - ORDER BY i.item_type, i.item_code - """ - async with self.db.cursor() as cursor: - await cursor.execute(sql, (project_id,)) - return list(await cursor.fetchall()) - async def workspace(self, project_id: str) -> dict: - items = await self.list_items(project_id) async with self.db.cursor() as cursor: + await cursor.execute(self._items_query(), (project_id,)) + items = list(await cursor.fetchall()) await cursor.execute( - "SELECT * FROM b08_price_sources WHERE project_id=%s ORDER BY priority_no,id", + """SELECT * FROM b08_price_sources + WHERE project_id=%s AND status='ACTIVE' ORDER BY priority_no,id""", (project_id,), ) sources = list(await cursor.fetchall()) await cursor.execute( - "SELECT * FROM b08_price_books WHERE project_id=%s ORDER BY effective_date DESC,id DESC", + """SELECT * FROM b08_price_books WHERE project_id=%s + ORDER BY effective_date DESC,id DESC""", (project_id,), ) price_books = list(await cursor.fetchall()) await cursor.execute( """SELECT e.*,i.item_code,i.item_name,s.source_name FROM b08_price_entries e - JOIN b08_catalog_items i ON i.id=e.item_id AND i.project_id=e.project_id - JOIN b08_price_sources s ON s.id=e.source_id AND s.project_id=e.project_id - WHERE e.project_id=%s ORDER BY e.price_version,i.item_code,e.id""", + JOIN b08_catalog_items i + ON i.id=e.item_id AND i.project_id=e.project_id + JOIN b08_price_sources s + ON s.id=e.source_id AND s.project_id=e.project_id + WHERE e.project_id=%s + ORDER BY e.price_version,i.item_code,e.id""", (project_id,), ) candidates = list(await cursor.fetchall()) @@ -89,91 +72,160 @@ class CatalogRepository: "items": items, "candidates": candidates, } + + @staticmethod + def _items_query() -> str: + return """ + SELECT i.*,a.applied_price,a.selection_reason,b.price_version + FROM b08_catalog_items i + LEFT JOIN b08_price_books b + ON b.id=( + SELECT x.id FROM b08_price_books x + WHERE x.project_id=i.project_id AND x.status='CONFIRMED' + ORDER BY x.effective_date DESC,x.id DESC LIMIT 1 + ) + LEFT JOIN b08_applied_prices a + ON a.project_id=i.project_id AND a.item_id=i.id + AND a.price_version=b.price_version + WHERE i.project_id=%s AND i.active=1 + ORDER BY i.item_type,i.item_code + """ + async def save_item(self, project_id: str, item: CatalogItem) -> str: - item_id = item.id or str(uuid4()) async with self.db.cursor() as cursor: + if item.id: + item_id = item.id + else: + await cursor.execute( + "SELECT id FROM b08_catalog_items WHERE project_id=%s AND item_code=%s", + (project_id, item.item_code), + ) + existing_item = await cursor.fetchone() + item_id = existing_item["id"] if existing_item else str(uuid4()) + if item.id: + await cursor.execute( + "SELECT project_id FROM b08_catalog_items WHERE id=%s", + (item.id,), + ) + existing = await cursor.fetchone() + if existing and existing["project_id"] != project_id: + raise ValueError("다른 프로젝트의 품목은 수정할 수 없습니다.") await cursor.execute( - """ - INSERT INTO b08_catalog_items( - id,project_id,item_type,item_code,item_name,specification, - unit,cost_type,procurement_type - ) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s) - ON DUPLICATE KEY UPDATE - item_type=VALUES(item_type), item_name=VALUES(item_name), - specification=VALUES(specification), unit=VALUES(unit), - cost_type=VALUES(cost_type), procurement_type=VALUES(procurement_type) - """, + """INSERT INTO b08_catalog_items( + id,project_id,item_type,item_code,item_name,specification, + unit,cost_type,procurement_type + ) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s) + ON DUPLICATE KEY UPDATE item_type=VALUES(item_type), + item_name=VALUES(item_name),specification=VALUES(specification), + unit=VALUES(unit),cost_type=VALUES(cost_type), + procurement_type=VALUES(procurement_type)""", (item_id, project_id, item.item_type, item.item_code, item.item_name, item.specification, item.unit, item.cost_type, item.procurement_type), ) await self.db.commit() return item_id - async def add_candidate(self, project_id: str, version: str, row: PriceCandidate) -> int: - converted_price = Decimal(row.source_price) * Decimal(row.exchange_rate) + async def add_candidate( + self, project_id: str, version: str, row: PriceCandidate + ) -> int: async with self.db.cursor() as cursor: await cursor.execute( - """ - INSERT INTO b08_price_entries( - project_id,price_version,item_id,source_id,source_price,currency, - exchange_rate,converted_price,reference_page,valid_from,valid_to - ) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) - """, + """SELECT basis_version FROM b08_price_books + WHERE project_id=%s AND price_version=%s""", + (project_id, version), + ) + price_book = await cursor.fetchone() + if not price_book: + raise ValueError("등록된 가격판이 아닙니다.") + await cursor.execute( + """SELECT 1 FROM b08_catalog_items + WHERE project_id=%s AND id=%s AND active=1""", + (project_id, row.item_id), + ) + if not await cursor.fetchone(): + raise ValueError("프로젝트의 활성 품목이 아닙니다.") + await cursor.execute( + """SELECT 1 FROM b08_price_sources + WHERE project_id=%s AND id=%s AND status='ACTIVE'""", + (project_id, row.source_id), + ) + if not await cursor.fetchone(): + raise ValueError("프로젝트의 활성 가격출처가 아닙니다.") + exchange_rate = await self._exchange_rate( + cursor, + project_id, + price_book["basis_version"], + row.currency, + row.valid_from, + ) + converted_price = Decimal(row.source_price) * exchange_rate + await cursor.execute( + """INSERT INTO b08_price_entries( + project_id,price_version,item_id,source_id,source_price, + currency,exchange_rate,converted_price,reference_page, + valid_from,valid_to + ) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", (project_id, version, row.item_id, row.source_id, row.source_price, - row.currency, row.exchange_rate, converted_price, row.reference_page, - row.valid_from, row.valid_to), + row.currency.upper(), exchange_rate, converted_price, + row.reference_page, row.valid_from, row.valid_to), ) result = cursor.lastrowid await self.db.commit() return result - async def apply(self, project_id: str, version: str, row: AppliedPrice, user_id: int | None) -> None: + @staticmethod + async def _exchange_rate( + cursor, + project_id: str, + basis_version: str, + currency: str, + valid_from, + ) -> Decimal: + if currency.upper() == "KRW": + return Decimal("1") + await cursor.execute( + """SELECT rate_to_krw FROM b08_exchange_rates + WHERE project_id=%s AND basis_version=%s AND currency=%s + AND effective_from<=%s + AND (effective_to IS NULL OR effective_to>=%s) + ORDER BY effective_from DESC,id DESC LIMIT 1""", + (project_id, basis_version, currency.upper(), valid_from, valid_from), + ) + rate = await cursor.fetchone() + if not rate: + raise ValueError(f"{currency.upper()} 적용일 환율이 기준정보에 없습니다.") + return Decimal(str(rate["rate_to_krw"])) + async def apply( + self, + project_id: str, + version: str, + row: AppliedPrice, + user_id: int | None, + ) -> None: async with self.db.cursor() as cursor: await cursor.execute( - """SELECT id FROM b08_price_entries - WHERE id=%s AND project_id=%s AND price_version=%s AND item_id=%s""", + """SELECT e.id FROM b08_price_entries e + JOIN b08_price_books b + ON b.project_id=e.project_id AND b.price_version=e.price_version + WHERE e.id=%s AND e.project_id=%s AND e.price_version=%s + AND e.item_id=%s AND b.status='CONFIRMED'""", (row.price_entry_id, project_id, version, row.item_id), ) if not await cursor.fetchone(): - raise ValueError("적용할 후보가격이 품목 또는 가격판과 일치하지 않습니다.") + raise ValueError("확정 가격판의 후보가격만 적용할 수 있습니다.") await cursor.execute( - """ - INSERT INTO b08_applied_prices( - project_id,price_version,item_id,price_entry_id,applied_price,selection_reason, - approved_by,approved_at - ) VALUES(%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP) - ON DUPLICATE KEY UPDATE - price_entry_id=VALUES(price_entry_id), applied_price=VALUES(applied_price), - selection_reason=VALUES(selection_reason), approved_by=VALUES(approved_by), approved_at=CURRENT_TIMESTAMP - """, + """INSERT INTO b08_applied_prices( + project_id,price_version,item_id,price_entry_id, + applied_price,selection_reason,approved_by,approved_at + ) VALUES(%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP) + ON DUPLICATE KEY UPDATE price_entry_id=VALUES(price_entry_id), + applied_price=VALUES(applied_price), + selection_reason=VALUES(selection_reason), + approved_by=VALUES(approved_by),approved_at=CURRENT_TIMESTAMP""", (project_id, version, row.item_id, row.price_entry_id, row.applied_price, row.selection_reason, user_id), ) - await self._mark_dependents_stale(cursor, project_id, row.item_id) + await StaleRepository.mark_dependents( + cursor, project_id, "CATALOG", row.item_id + ) await self.db.commit() - - @staticmethod - async def _mark_dependents_stale(cursor, project_id: str, item_id: str) -> None: - await cursor.execute( - """UPDATE b08_unit_costs u - JOIN b08_unit_cost_components c ON c.unit_cost_id=u.id - SET u.status='STALE' - WHERE u.project_id=%s AND c.component_type='CATALOG' - AND c.reference_id=%s AND u.status='CONFIRMED'""", - (project_id, item_id), - ) - await cursor.execute( - """UPDATE b08_equipment_rates e - JOIN b08_equipment_rate_components c ON c.equipment_rate_id=e.id - SET e.status='STALE' - WHERE e.project_id=%s AND c.item_id=%s AND e.status='CONFIRMED'""", - (project_id, item_id), - ) - await cursor.execute( - """UPDATE b08_cost_basis b - JOIN b08_cost_basis_components c ON c.cost_basis_id=b.id - SET b.status='STALE' - WHERE b.project_id=%s AND c.component_type='CATALOG' - AND c.reference_id=%s AND b.status='CONFIRMED'""", - (project_id, item_id), - ) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CostBasis.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CostBasis.py index a335f377..3f2c0425 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CostBasis.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CostBasis.py @@ -1,14 +1,87 @@ from uuid import uuid4 + +from B08_wf5_Quantity.B08_wf5_Quantity_Repository_Stale import StaleRepository from B08_wf5_Quantity.B08_wf5_Quantity_Schema_CostBasis import CostBasis + class CostBasisRepository: - def __init__(self,connection):self.db=connection - async def save(self,project_id:str,row:CostBasis,result)->str: - row_id=row.id or str(uuid4()) - async with self.db.cursor() as c: - await c.execute("""INSERT INTO b08_cost_basis(id,project_id,basis_code,name,specification,unit,formula_note,labor_price,material_price,expense_price,total_price,status,version_no) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE name=VALUES(name),specification=VALUES(specification),unit=VALUES(unit),formula_note=VALUES(formula_note),labor_price=VALUES(labor_price),material_price=VALUES(material_price),expense_price=VALUES(expense_price),total_price=VALUES(total_price),status=VALUES(status)""",(row_id,project_id,row.code,row.name,row.specification,row.unit,row.formula_note,result.labor,result.material,result.expense,result.total,row.status,row.version_no));await c.execute("DELETE FROM b08_cost_basis_components WHERE cost_basis_id=%s",(row_id,));await c.execute("DELETE FROM b08_formula_variables WHERE cost_basis_id=%s",(row_id,)) - for x in row.components: await c.execute("INSERT INTO b08_cost_basis_components(cost_basis_id,component_type,reference_id,quantity_expression,cost_type,sort_order) VALUES(%s,%s,%s,%s,%s,%s)",(row_id,x.component_type,x.reference_id,x.quantity_expression,x.cost_type,x.sort_order)) - for x in row.variables: await c.execute("INSERT INTO b08_formula_variables(cost_basis_id,variable_code,label,value,unit,required) VALUES(%s,%s,%s,%s,%s,%s)",(row_id,x.code,x.label,x.value,x.unit,x.required)) - await self.db.commit();return row_id - async def list_all(self,project_id:str)->list[dict]: - async with self.db.cursor() as c: await c.execute("SELECT * FROM b08_cost_basis WHERE project_id=%s ORDER BY basis_code",(project_id,));return list(await c.fetchall()) \ No newline at end of file + def __init__(self, connection): + self.db = connection + + async def save(self, project_id: str, row: CostBasis, result) -> str: + async with self.db.cursor() as cursor: + row_id = await self._resolve_id(cursor, project_id, row) + if any(component.reference_id == row_id for component in row.components): + raise ValueError("단가산출근거는 자기 자신을 참조할 수 없습니다.") + await cursor.execute( + """INSERT INTO b08_cost_basis( + id,project_id,basis_code,name,specification,unit,formula_note, + labor_price,material_price,expense_price,total_price,status,version_no + ) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + ON DUPLICATE KEY UPDATE name=VALUES(name), + specification=VALUES(specification),unit=VALUES(unit), + formula_note=VALUES(formula_note),labor_price=VALUES(labor_price), + material_price=VALUES(material_price),expense_price=VALUES(expense_price), + total_price=VALUES(total_price),status=VALUES(status)""", + (row_id, project_id, row.code, row.name, row.specification, row.unit, + row.formula_note, result.labor, result.material, result.expense, + result.total, row.status, row.version_no), + ) + await cursor.execute( + "DELETE FROM b08_cost_basis_components WHERE cost_basis_id=%s", + (row_id,), + ) + await cursor.execute( + "DELETE FROM b08_formula_variables WHERE cost_basis_id=%s", + (row_id,), + ) + for component in row.components: + await cursor.execute( + """INSERT INTO b08_cost_basis_components( + cost_basis_id,component_type,reference_id, + quantity_expression,cost_type,sort_order + ) VALUES(%s,%s,%s,%s,%s,%s)""", + (row_id, component.component_type, component.reference_id, + component.quantity_expression, component.cost_type, + component.sort_order), + ) + for variable in row.variables: + await cursor.execute( + """INSERT INTO b08_formula_variables( + cost_basis_id,variable_code,label,value,unit,required + ) VALUES(%s,%s,%s,%s,%s,%s)""", + (row_id, variable.code, variable.label, variable.value, + variable.unit, variable.required), + ) + await StaleRepository.mark_dependents( + cursor, project_id, "COST_BASIS", row_id + ) + await self.db.commit() + return row_id + + async def list_all(self, project_id: str) -> list[dict]: + async with self.db.cursor() as cursor: + await cursor.execute( + "SELECT * FROM b08_cost_basis WHERE project_id=%s ORDER BY basis_code", + (project_id,), + ) + return list(await cursor.fetchall()) + + @staticmethod + async def _resolve_id(cursor, project_id: str, row: CostBasis) -> str: + if row.id: + await cursor.execute( + "SELECT project_id FROM b08_cost_basis WHERE id=%s", + (row.id,), + ) + existing = await cursor.fetchone() + if existing and existing["project_id"] != project_id: + raise ValueError("다른 프로젝트의 산출근거는 수정할 수 없습니다.") + return row.id + await cursor.execute( + """SELECT id FROM b08_cost_basis + WHERE project_id=%s AND basis_code=%s AND version_no=%s""", + (project_id, row.code, row.version_no), + ) + existing = await cursor.fetchone() + return existing["id"] if existing else str(uuid4()) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Pricing.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Pricing.py index b7bf8a68..c9071ff2 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Pricing.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Pricing.py @@ -15,7 +15,7 @@ class PricingResolver: AND b.price_version=a.price_version AND b.status='CONFIRMED' JOIN b08_catalog_items i ON i.id=a.item_id AND i.project_id=a.project_id WHERE a.project_id=%s AND a.item_id=%s - ORDER BY b.effective_date DESC LIMIT 1""", + ORDER BY b.effective_date DESC,b.id DESC LIMIT 1""", "UNIT_COST": """SELECT labor_price,material_price,expense_price,NULL AS cost_type FROM b08_unit_costs WHERE project_id=%s AND id=%s AND status='CONFIRMED'""", "EQUIPMENT": """SELECT labor_price,material_price,expense_price,NULL AS cost_type diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Quantity.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Quantity.py index 9a651ba6..35471843 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Quantity.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Quantity.py @@ -3,7 +3,9 @@ import json from uuid import uuid4 from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Quantity import ( + BulkQuantityRequest, DesignQuantity, + SortRequest, WorkBreakdown, ) @@ -15,14 +17,15 @@ class QuantityRepository: async def workspace(self, project_id: str) -> dict: async with self.db.cursor() as cursor: await cursor.execute( - "SELECT * FROM b08_work_breakdown WHERE project_id=%s ORDER BY level_no,sort_order", + """SELECT * FROM b08_work_breakdown + WHERE project_id=%s ORDER BY level_no,sort_order,wbs_code""", (project_id,), ) work_breakdown = list(await cursor.fetchall()) await cursor.execute(self._quantity_query(), (project_id,)) quantities = list(await cursor.fetchall()) await cursor.execute( - """SELECT quantity_version, input_hash, confirmed_by, confirmed_at + """SELECT quantity_version,input_hash,confirmed_by,confirmed_at FROM b08_quantity_confirmations WHERE project_id=%s ORDER BY quantity_version DESC LIMIT 1""", (project_id,), @@ -50,127 +53,288 @@ class QuantityRepository: WHEN 'CATALOG' THEN IF(ci.cost_type='EXPENSE',ap.applied_price,0) ELSE COALESCE(uc.expense_price,eq.expense_price,cb.expense_price,0) END AS unit_expense, + CASE q.reference_type + WHEN 'CATALOG' THEN ap.item_id IS NOT NULL + WHEN 'UNIT_COST' THEN uc.id IS NOT NULL + WHEN 'EQUIPMENT' THEN eq.id IS NOT NULL + WHEN 'COST_BASIS' THEN cb.id IS NOT NULL + ELSE 0 + END AS reference_found, pb.price_version FROM b08_design_quantity_items q LEFT JOIN b08_catalog_items ci ON q.reference_type='CATALOG' AND ci.id=q.reference_id + AND ci.project_id=q.project_id AND ci.active=1 LEFT JOIN b08_price_books pb - ON pb.project_id=q.project_id AND pb.status='CONFIRMED' - AND pb.effective_date=( - SELECT MAX(x.effective_date) FROM b08_price_books x - WHERE x.project_id=q.project_id AND x.status='CONFIRMED' - ) + ON pb.id=( + SELECT x.id FROM b08_price_books x + WHERE x.project_id=q.project_id AND x.status='CONFIRMED' + ORDER BY x.effective_date DESC,x.id DESC LIMIT 1 + ) LEFT JOIN b08_applied_prices ap ON ap.project_id=q.project_id AND ap.item_id=ci.id AND ap.price_version=pb.price_version LEFT JOIN b08_unit_costs uc ON q.reference_type='UNIT_COST' AND uc.id=q.reference_id - AND uc.status='CONFIRMED' + AND uc.project_id=q.project_id AND uc.status='CONFIRMED' LEFT JOIN b08_equipment_rates eq ON q.reference_type='EQUIPMENT' AND eq.id=q.reference_id - AND eq.status='CONFIRMED' + AND eq.project_id=q.project_id AND eq.status='CONFIRMED' LEFT JOIN b08_cost_basis cb ON q.reference_type='COST_BASIS' AND cb.id=q.reference_id - AND cb.status='CONFIRMED' + AND cb.project_id=q.project_id AND cb.status='CONFIRMED' WHERE q.project_id=%s - ORDER BY q.wbs_id,q.sort_order + ORDER BY q.wbs_id,q.sort_order,q.id """ async def save_wbs(self, project_id: str, row: WorkBreakdown) -> str: - row_id = row.id or str(uuid4()) async with self.db.cursor() as cursor: + if row.id: + await self._assert_owner(cursor, "b08_work_breakdown", project_id, row.id) + row_id = row.id + else: + await cursor.execute( + """SELECT id FROM b08_work_breakdown + WHERE project_id=%s AND wbs_code=%s""", + (project_id, row.code), + ) + existing = await cursor.fetchone() + row_id = existing["id"] if existing else str(uuid4()) + if row.parent_id: + await self._assert_owner( + cursor, "b08_work_breakdown", project_id, row.parent_id + ) + if row.parent_id == row_id: + raise ValueError("공종은 자기 자신을 상위 공종으로 지정할 수 없습니다.") await cursor.execute( """INSERT INTO b08_work_breakdown( id,project_id,parent_id,wbs_code,name,level_no,sort_order ) VALUES(%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE parent_id=VALUES(parent_id), - name=VALUES(name),level_no=VALUES(level_no),sort_order=VALUES(sort_order)""", + name=VALUES(name),level_no=VALUES(level_no), + sort_order=VALUES(sort_order)""", (row_id, project_id, row.parent_id, row.code, row.name, row.level_no, row.sort_order), ) await self.db.commit() return row_id + async def delete_wbs(self, project_id: str, row_id: str) -> None: + async with self.db.cursor() as cursor: + await self._assert_owner(cursor, "b08_work_breakdown", project_id, row_id) + await cursor.execute( + """SELECT + (SELECT COUNT(*) FROM b08_work_breakdown WHERE parent_id=%s) AS children, + (SELECT COUNT(*) FROM b08_design_quantity_items WHERE wbs_id=%s) AS quantities""", + (row_id, row_id), + ) + usage = await cursor.fetchone() + if usage["children"] or usage["quantities"]: + raise ValueError("하위 공종이나 수량 항목이 있는 공종은 삭제할 수 없습니다.") + await cursor.execute( + "DELETE FROM b08_work_breakdown WHERE project_id=%s AND id=%s", + (project_id, row_id), + ) + await self.db.commit() + async def save_quantity( self, project_id: str, row: DesignQuantity, user_id: int | None ) -> str: row_id = row.id or str(uuid4()) async with self.db.cursor() as cursor: + await self._assert_owner(cursor, "b08_work_breakdown", project_id, row.wbs_id) + await self._validate_reference( + cursor, project_id, row.reference_type, row.reference_id + ) await cursor.execute( "SELECT * FROM b08_design_quantity_items WHERE id=%s FOR UPDATE", (row_id,), ) before = await cursor.fetchone() + if before and before["project_id"] != project_id: + raise ValueError("다른 프로젝트의 수량 항목은 수정할 수 없습니다.") + status = "ADJUSTED" if row.adjusted_quantity is not None else "DRAFT" await cursor.execute( """INSERT INTO b08_design_quantity_items( id,project_id,wbs_id,reference_type,reference_id,item_name, specification,unit,design_quantity,adjusted_quantity, confirmed_quantity,adjustment_reason,procurement_type, excluded,status,sort_order,updated_by - ) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) - ON DUPLICATE KEY UPDATE - wbs_id=VALUES(wbs_id),reference_type=VALUES(reference_type), - reference_id=VALUES(reference_id),item_name=VALUES(item_name), - specification=VALUES(specification),unit=VALUES(unit), - design_quantity=VALUES(design_quantity), - adjusted_quantity=VALUES(adjusted_quantity), - confirmed_quantity=NULL,adjustment_reason=VALUES(adjustment_reason), + ) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,NULL,%s,%s,%s,%s,%s,%s) + ON DUPLICATE KEY UPDATE wbs_id=VALUES(wbs_id), + reference_type=VALUES(reference_type),reference_id=VALUES(reference_id), + item_name=VALUES(item_name),specification=VALUES(specification), + unit=VALUES(unit),design_quantity=VALUES(design_quantity), + adjusted_quantity=VALUES(adjusted_quantity),confirmed_quantity=NULL, + adjustment_reason=VALUES(adjustment_reason), procurement_type=VALUES(procurement_type),excluded=VALUES(excluded), - status=VALUES(status),sort_order=VALUES(sort_order),updated_by=VALUES(updated_by)""", + status=VALUES(status),sort_order=VALUES(sort_order), + updated_by=VALUES(updated_by)""", (row_id, project_id, row.wbs_id, row.reference_type, row.reference_id, row.item_name, row.specification, row.unit, row.design_quantity, - row.adjusted_quantity, row.confirmed_quantity, row.adjustment_reason, - row.procurement_type, row.excluded, row.status, row.sort_order, user_id), + row.adjusted_quantity, row.adjustment_reason, row.procurement_type, + row.excluded, status, row.sort_order, user_id), ) if before: - await cursor.execute( - """SELECT COALESCE(MAX(revision_no),0)+1 AS n - FROM b08_quantity_revisions WHERE quantity_item_id=%s""", - (row_id,), - ) - revision_no = (await cursor.fetchone())["n"] - await cursor.execute( - """INSERT INTO b08_quantity_revisions( - quantity_item_id,revision_no,before_json,after_json, - change_reason,changed_by - ) VALUES(%s,%s,%s,%s,%s,%s)""", - (row_id, revision_no, - json.dumps(before, default=str, ensure_ascii=False), - row.model_dump_json(), row.adjustment_reason or "수량 변경", user_id), + await self._write_revision( + cursor, row_id, before, row.model_dump(mode="json"), + row.adjustment_reason or "수량 변경", user_id, ) await self.db.commit() return row_id - async def confirm(self, project_id: str, user_id: int | None) -> int: + async def save_quantity_bulk( + self, + project_id: str, + request: BulkQuantityRequest, + user_id: int | None, + ) -> list[str]: + row_ids: list[str] = [] + try: + async with self.db.cursor() as cursor: + for row in request.items: + await self._assert_owner( + cursor, "b08_work_breakdown", project_id, row.wbs_id + ) + await self._validate_reference( + cursor, project_id, row.reference_type, row.reference_id + ) + row_id = str(uuid4()) + status = ( + "ADJUSTED" if row.adjusted_quantity is not None else "DRAFT" + ) + await cursor.execute( + """INSERT INTO b08_design_quantity_items( + id,project_id,wbs_id,reference_type,reference_id, + item_name,specification,unit,design_quantity, + adjusted_quantity,confirmed_quantity,adjustment_reason, + procurement_type,excluded,status,sort_order,updated_by + ) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,NULL,%s,%s,%s,%s,%s,%s)""", + (row_id, project_id, row.wbs_id, row.reference_type, + row.reference_id, row.item_name, row.specification, row.unit, + row.design_quantity, row.adjusted_quantity, + row.adjustment_reason, row.procurement_type, row.excluded, + status, row.sort_order, user_id), + ) + row_ids.append(row_id) + await self.db.commit() + except Exception: + await self.db.rollback() + raise + return row_ids + async def clone_quantity( + self, project_id: str, row_id: str, user_id: int | None + ) -> str: async with self.db.cursor() as cursor: await cursor.execute( - """SELECT COUNT(*) AS missing FROM b08_design_quantity_items - WHERE project_id=%s AND design_quantity IS NULL""", - (project_id,), + """SELECT * FROM b08_design_quantity_items + WHERE project_id=%s AND id=%s""", + (project_id, row_id), ) - missing = (await cursor.fetchone())["missing"] - if missing: - raise ValueError(f"수량 미입력 항목 {missing}건이 있습니다.") + source = await cursor.fetchone() + if not source: + raise ValueError("복제할 수량 항목이 없습니다.") + new_id = str(uuid4()) + await cursor.execute( + """SELECT COALESCE(MAX(sort_order),-1)+1 AS next_order + FROM b08_design_quantity_items + WHERE project_id=%s AND wbs_id=%s""", + (project_id, source["wbs_id"]), + ) + sort_order = (await cursor.fetchone())["next_order"] + await cursor.execute( + """INSERT INTO b08_design_quantity_items( + id,project_id,wbs_id,reference_type,reference_id,item_name, + specification,unit,design_quantity,adjusted_quantity, + confirmed_quantity,adjustment_reason,procurement_type, + excluded,status,sort_order,updated_by + ) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,NULL,NULL,NULL,%s,%s,'DRAFT',%s,%s)""", + (new_id, project_id, source["wbs_id"], source["reference_type"], + source["reference_id"], f'{source["item_name"]} 복사본', + source["specification"], source["unit"], source["design_quantity"], + source["procurement_type"], source["excluded"], sort_order, user_id), + ) + await self.db.commit() + return new_id + + async def delete_quantity( + self, project_id: str, row_id: str, user_id: int | None + ) -> None: + async with self.db.cursor() as cursor: + await cursor.execute( + """SELECT * FROM b08_design_quantity_items + WHERE project_id=%s AND id=%s FOR UPDATE""", + (project_id, row_id), + ) + before = await cursor.fetchone() + if not before: + raise ValueError("삭제할 수량 항목이 없습니다.") + await self._write_revision( + cursor, row_id, before, {"deleted": True}, "수량 항목 삭제", user_id + ) + await cursor.execute( + "DELETE FROM b08_design_quantity_items WHERE project_id=%s AND id=%s", + (project_id, row_id), + ) + await self.db.commit() + + async def reorder_quantities(self, project_id: str, request: SortRequest) -> None: + async with self.db.cursor() as cursor: + for item in request.items: + await self._assert_owner( + cursor, "b08_design_quantity_items", project_id, item.id + ) + await cursor.execute( + """UPDATE b08_design_quantity_items + SET sort_order=%s,confirmed_quantity=NULL,status='DRAFT' + WHERE project_id=%s AND id=%s""", + (item.sort_order, project_id, item.id), + ) + await self.db.commit() + + async def reorder_wbs(self, project_id: str, request: SortRequest) -> None: + async with self.db.cursor() as cursor: + for item in request.items: + await self._assert_owner( + cursor, "b08_work_breakdown", project_id, item.id + ) + await cursor.execute( + """UPDATE b08_work_breakdown SET sort_order=%s + WHERE project_id=%s AND id=%s""", + (item.sort_order, project_id, item.id), + ) + await self.db.commit() + + async def confirm(self, project_id: str, user_id: int | None) -> int: + async with self.db.cursor() as cursor: await cursor.execute(self._quantity_query(), (project_id,)) rows = list(await cursor.fetchall()) - unavailable = [row for row in rows if all( - row.get(key) in (None, 0) for key in ("unit_labor", "unit_material", "unit_expense") - )] + if not rows: + raise ValueError("확정할 설계수량 항목이 없습니다.") + missing = [row for row in rows if row["design_quantity"] is None] + if missing: + raise ValueError(f"수량 미입력 항목 {len(missing)}건이 있습니다.") + unavailable = [row for row in rows if not row["reference_found"]] if unavailable: raise ValueError(f"확정 적용단가가 없는 항목 {len(unavailable)}건이 있습니다.") await cursor.execute( """UPDATE b08_design_quantity_items SET confirmed_quantity=COALESCE(adjusted_quantity,design_quantity), - status='CONFIRMED',updated_by=%s - WHERE project_id=%s""", + status='CONFIRMED',updated_by=%s WHERE project_id=%s""", (user_id, project_id), ) snapshot = [ - {"id": row["id"], "quantity": row["adjusted_quantity"] if row["adjusted_quantity"] is not None else row["design_quantity"]} + { + "id": row["id"], + "quantity": row["adjusted_quantity"] + if row["adjusted_quantity"] is not None + else row["design_quantity"], + } for row in sorted(rows, key=lambda item: item["id"]) ] - payload = json.dumps(snapshot, default=str, sort_keys=True).encode() - digest = hashlib.sha256(payload).hexdigest() + digest = hashlib.sha256( + json.dumps(snapshot, default=str, sort_keys=True).encode() + ).hexdigest() await cursor.execute( """SELECT COALESCE(MAX(quantity_version),0)+1 AS version FROM b08_quantity_confirmations WHERE project_id=%s""", @@ -184,4 +348,59 @@ class QuantityRepository: (project_id, version, digest, user_id), ) await self.db.commit() - return version \ No newline at end of file + return version + + @staticmethod + async def _validate_reference( + cursor, project_id: str, reference_type: str, reference_id: str + ) -> None: + tables = { + "CATALOG": "b08_catalog_items", + "UNIT_COST": "b08_unit_costs", + "EQUIPMENT": "b08_equipment_rates", + "COST_BASIS": "b08_cost_basis", + } + table = tables[reference_type] + await cursor.execute( + f"SELECT 1 FROM {table} WHERE project_id=%s AND id=%s", + (project_id, reference_id), + ) + if not await cursor.fetchone(): + raise ValueError("프로젝트에 속한 단가 참조가 아닙니다.") + + @staticmethod + async def _assert_owner(cursor, table: str, project_id: str, row_id: str) -> None: + await cursor.execute( + f"SELECT project_id FROM {table} WHERE id=%s", + (row_id,), + ) + existing = await cursor.fetchone() + if not existing: + raise ValueError("대상을 찾을 수 없습니다.") + if existing["project_id"] != project_id: + raise ValueError("다른 프로젝트의 항목은 수정할 수 없습니다.") + + @staticmethod + async def _write_revision( + cursor, + row_id: str, + before: dict, + after: dict, + reason: str, + user_id: int | None, + ) -> None: + await cursor.execute( + """SELECT COALESCE(MAX(revision_no),0)+1 AS n + FROM b08_quantity_revisions WHERE quantity_item_id=%s""", + (row_id,), + ) + revision_no = (await cursor.fetchone())["n"] + await cursor.execute( + """INSERT INTO b08_quantity_revisions( + quantity_item_id,revision_no,before_json,after_json, + change_reason,changed_by + ) VALUES(%s,%s,%s,%s,%s,%s)""", + (row_id, revision_no, + json.dumps(before, default=str, ensure_ascii=False), + json.dumps(after, default=str, ensure_ascii=False), reason, user_id), + ) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Stale.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Stale.py new file mode 100644 index 00000000..4e795d1d --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Stale.py @@ -0,0 +1,73 @@ +class StaleRepository: + _TABLES = { + "UNIT_COST": "b08_unit_costs", + "EQUIPMENT": "b08_equipment_rates", + "COST_BASIS": "b08_cost_basis", + } + + @classmethod + async def mark_all(cls, cursor, project_id: str) -> None: + for table in cls._TABLES.values(): + await cursor.execute( + f"UPDATE {table} SET status='STALE' " + "WHERE project_id=%s AND status='CONFIRMED'", + (project_id,), + ) + + @classmethod + async def mark_dependents( + cls, cursor, project_id: str, source_type: str, source_id: str + ) -> None: + queue = [(source_type, source_id)] + visited: set[tuple[str, str]] = set() + while queue: + reference_type, reference_id = queue.pop(0) + key = (reference_type, reference_id) + if key in visited: + continue + visited.add(key) + dependents = await cls._find_dependents( + cursor, project_id, reference_type, reference_id + ) + for dependent_type, dependent_id in dependents: + if (dependent_type, dependent_id) in visited: + continue + table = cls._TABLES[dependent_type] + await cursor.execute( + f"UPDATE {table} SET status='STALE' " + "WHERE project_id=%s AND id=%s AND status='CONFIRMED'", + (project_id, dependent_id), + ) + if cursor.rowcount: + queue.append((dependent_type, dependent_id)) + + @staticmethod + async def _find_dependents( + cursor, project_id: str, reference_type: str, reference_id: str + ) -> list[tuple[str, str]]: + await cursor.execute( + """SELECT 'UNIT_COST' AS dependent_type,u.id AS dependent_id + FROM b08_unit_costs u + JOIN b08_unit_cost_components c ON c.unit_cost_id=u.id + WHERE u.project_id=%s AND u.status='CONFIRMED' + AND c.component_type=%s AND c.reference_id=%s + UNION + SELECT 'EQUIPMENT',e.id + FROM b08_equipment_rates e + JOIN b08_equipment_rate_components c ON c.equipment_rate_id=e.id + WHERE e.project_id=%s AND e.status='CONFIRMED' + AND c.component_type=%s AND c.item_id=%s + UNION + SELECT 'COST_BASIS',b.id + FROM b08_cost_basis b + JOIN b08_cost_basis_components c ON c.cost_basis_id=b.id + WHERE b.project_id=%s AND b.status='CONFIRMED' + AND c.component_type=%s AND c.reference_id=%s""", + (project_id, reference_type, reference_id, + project_id, reference_type, reference_id, + project_id, reference_type, reference_id), + ) + return [ + (row["dependent_type"], row["dependent_id"]) + for row in await cursor.fetchall() + ] \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_UnitCost.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_UnitCost.py index ca15145e..b9728704 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_UnitCost.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_UnitCost.py @@ -1,21 +1,154 @@ from uuid import uuid4 -from B08_wf5_Quantity.B08_wf5_Quantity_Schema_UnitCost import EquipmentRate,UnitCost + +from B08_wf5_Quantity.B08_wf5_Quantity_Repository_Stale import StaleRepository +from B08_wf5_Quantity.B08_wf5_Quantity_Schema_UnitCost import EquipmentRate, UnitCost + class UnitCostRepository: - def __init__(self,connection):self.db=connection - async def save_unit_cost(self,project_id:str,row:UnitCost,result)->str: - row_id=row.id or str(uuid4()) - async with self.db.cursor() as c: - await c.execute("""INSERT INTO b08_unit_costs(id,project_id,unit_cost_code,name,specification,unit,rounding_mode,rounding_unit,labor_price,material_price,expense_price,total_price,status,version_no) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE name=VALUES(name),specification=VALUES(specification),unit=VALUES(unit),rounding_mode=VALUES(rounding_mode),rounding_unit=VALUES(rounding_unit),labor_price=VALUES(labor_price),material_price=VALUES(material_price),expense_price=VALUES(expense_price),total_price=VALUES(total_price),status=VALUES(status)""",(row_id,project_id,row.code,row.name,row.specification,row.unit,row.rounding_mode,row.rounding_unit,result.labor,result.material,result.expense,result.total,row.status,row.version_no));await c.execute("DELETE FROM b08_unit_cost_components WHERE unit_cost_id=%s",(row_id,)) - for x in row.components: await c.execute("INSERT INTO b08_unit_cost_components(unit_cost_id,component_type,reference_id,quantity,cost_type,sort_order,note) VALUES(%s,%s,%s,%s,%s,%s,%s)",(row_id,x.component_type,x.reference_id,x.quantity,x.cost_type,x.sort_order,x.reference_name)) - await self.db.commit();return row_id - async def save_equipment(self,project_id:str,row:EquipmentRate,result)->str: - row_id=row.id or str(uuid4()) - async with self.db.cursor() as c: - await c.execute("""INSERT INTO b08_equipment_rates(id,project_id,equipment_code,name,specification,unit,equipment_price,annual_hours,labor_price,material_price,expense_price,total_price,status,version_no) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE name=VALUES(name),specification=VALUES(specification),equipment_price=VALUES(equipment_price),annual_hours=VALUES(annual_hours),labor_price=VALUES(labor_price),material_price=VALUES(material_price),expense_price=VALUES(expense_price),total_price=VALUES(total_price),status=VALUES(status)""",(row_id,project_id,row.code,row.name,row.specification,row.unit,row.equipment_price,row.annual_hours,result.labor,result.material,result.expense,result.total,row.status,row.version_no));await c.execute("DELETE FROM b08_equipment_rate_components WHERE equipment_rate_id=%s",(row_id,)) - for x in row.components: await c.execute("INSERT INTO b08_equipment_rate_components(equipment_rate_id,component_code,item_id,quantity,cost_type,sort_order) VALUES(%s,%s,%s,%s,%s,%s)",(row_id,x.reference_name or x.reference_id,x.reference_id,x.quantity,x.cost_type,x.sort_order)) - await self.db.commit();return row_id - async def list_all(self,project_id:str)->dict: - async with self.db.cursor() as c: - await c.execute("SELECT * FROM b08_unit_costs WHERE project_id=%s ORDER BY unit_cost_code",(project_id,));unit=list(await c.fetchall());await c.execute("SELECT * FROM b08_equipment_rates WHERE project_id=%s ORDER BY equipment_code",(project_id,));equipment=list(await c.fetchall()) - return {"unit_costs":unit,"equipment_rates":equipment} \ No newline at end of file + def __init__(self, connection): + self.db = connection + + async def save_unit_cost(self, project_id: str, row: UnitCost, result) -> str: + async with self.db.cursor() as cursor: + row_id = await self._unit_cost_id(cursor, project_id, row) + self._prevent_self_reference(row_id, row.components) + await cursor.execute( + """INSERT INTO b08_unit_costs( + id,project_id,unit_cost_code,name,specification,unit, + rounding_mode,rounding_unit,labor_price,material_price, + expense_price,total_price,status,version_no + ) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + ON DUPLICATE KEY UPDATE name=VALUES(name), + specification=VALUES(specification),unit=VALUES(unit), + rounding_mode=VALUES(rounding_mode), + rounding_unit=VALUES(rounding_unit),labor_price=VALUES(labor_price), + material_price=VALUES(material_price),expense_price=VALUES(expense_price), + total_price=VALUES(total_price),status=VALUES(status)""", + (row_id, project_id, row.code, row.name, row.specification, row.unit, + row.rounding_mode, row.rounding_unit, result.labor, result.material, + result.expense, result.total, row.status, row.version_no), + ) + await cursor.execute( + "DELETE FROM b08_unit_cost_components WHERE unit_cost_id=%s", + (row_id,), + ) + for component in row.components: + await cursor.execute( + """INSERT INTO b08_unit_cost_components( + unit_cost_id,component_type,reference_id,quantity, + cost_type,sort_order,note + ) VALUES(%s,%s,%s,%s,%s,%s,%s)""", + (row_id, component.component_type, component.reference_id, + component.quantity, component.cost_type, component.sort_order, + component.reference_name), + ) + await StaleRepository.mark_dependents( + cursor, project_id, "UNIT_COST", row_id + ) + await self.db.commit() + return row_id + + async def save_equipment(self, project_id: str, row: EquipmentRate, result) -> str: + async with self.db.cursor() as cursor: + row_id = await self._equipment_id(cursor, project_id, row) + self._prevent_self_reference(row_id, row.components) + await cursor.execute( + """INSERT INTO b08_equipment_rates( + id,project_id,equipment_code,name,specification,unit, + equipment_price,annual_hours,useful_life_years,residual_rate, + repair_rate,management_rate,labor_price,material_price, + expense_price,total_price,status,version_no + ) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + ON DUPLICATE KEY UPDATE name=VALUES(name), + specification=VALUES(specification),unit=VALUES(unit), + equipment_price=VALUES(equipment_price),annual_hours=VALUES(annual_hours), + useful_life_years=VALUES(useful_life_years), + residual_rate=VALUES(residual_rate),repair_rate=VALUES(repair_rate), + management_rate=VALUES(management_rate),labor_price=VALUES(labor_price), + material_price=VALUES(material_price),expense_price=VALUES(expense_price), + total_price=VALUES(total_price),status=VALUES(status)""", + (row_id, project_id, row.code, row.name, row.specification, row.unit, + row.equipment_price, row.annual_hours, row.useful_life_years, + row.residual_rate, row.repair_rate, row.management_rate, + result.labor, result.material, + result.expense, result.total, row.status, row.version_no), + ) + await cursor.execute( + "DELETE FROM b08_equipment_rate_components WHERE equipment_rate_id=%s", + (row_id,), + ) + for component in row.components: + await cursor.execute( + """INSERT INTO b08_equipment_rate_components( + equipment_rate_id,component_code,component_type,item_id, + quantity,cost_type,sort_order + ) VALUES(%s,%s,%s,%s,%s,%s,%s)""", + (row_id, component.reference_name or component.reference_id, + component.component_type, component.reference_id, + component.quantity, component.cost_type, component.sort_order), + ) + await StaleRepository.mark_dependents( + cursor, project_id, "EQUIPMENT", row_id + ) + await self.db.commit() + return row_id + + async def list_all(self, project_id: str) -> dict: + async with self.db.cursor() as cursor: + await cursor.execute( + "SELECT * FROM b08_unit_costs WHERE project_id=%s ORDER BY unit_cost_code", + (project_id,), + ) + unit_costs = list(await cursor.fetchall()) + await cursor.execute( + """SELECT * FROM b08_equipment_rates + WHERE project_id=%s ORDER BY equipment_code""", + (project_id,), + ) + equipment_rates = list(await cursor.fetchall()) + return {"unit_costs": unit_costs, "equipment_rates": equipment_rates} + + @staticmethod + async def _unit_cost_id(cursor, project_id: str, row: UnitCost) -> str: + if row.id: + await UnitCostRepository._assert_owner( + cursor, "b08_unit_costs", project_id, row.id + ) + return row.id + await cursor.execute( + """SELECT id FROM b08_unit_costs + WHERE project_id=%s AND unit_cost_code=%s AND version_no=%s""", + (project_id, row.code, row.version_no), + ) + existing = await cursor.fetchone() + return existing["id"] if existing else str(uuid4()) + + @staticmethod + async def _equipment_id(cursor, project_id: str, row: EquipmentRate) -> str: + if row.id: + await UnitCostRepository._assert_owner( + cursor, "b08_equipment_rates", project_id, row.id + ) + return row.id + await cursor.execute( + """SELECT id FROM b08_equipment_rates + WHERE project_id=%s AND equipment_code=%s AND version_no=%s""", + (project_id, row.code, row.version_no), + ) + existing = await cursor.fetchone() + return existing["id"] if existing else str(uuid4()) + + @staticmethod + async def _assert_owner(cursor, table: str, project_id: str, row_id: str) -> None: + await cursor.execute( + f"SELECT project_id FROM {table} WHERE id=%s", + (row_id,), + ) + existing = await cursor.fetchone() + if existing and existing["project_id"] != project_id: + raise ValueError("다른 프로젝트의 원가 항목은 수정할 수 없습니다.") + + @staticmethod + def _prevent_self_reference(row_id: str, components) -> None: + if any(component.reference_id == row_id for component in components): + raise ValueError("원가 항목은 자기 자신을 구성요소로 참조할 수 없습니다.") \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Basis.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Basis.py index 9e2727f1..734e8c6d 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Basis.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Basis.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException +from B08_wf5_Quantity.B08_wf5_Quantity_Router_Errors import domain_errors from B08_wf5_Quantity.B08_wf5_Quantity_Repository_Basis import BasisRepository from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Basis import BasisWorkspace @@ -15,6 +16,7 @@ def router_for(connection_provider, user_provider): return data @router.put("/{project_id}/basis/{version}", response_model=BasisWorkspace) + @domain_errors async def put( project_id: str, version: str, diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Calculation.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Calculation.py index 24f444e9..7793c78e 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Calculation.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Calculation.py @@ -1,7 +1,7 @@ import hashlib import json -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from B08_wf5_Quantity.B08_wf5_Quantity_Engine_Estimate import calculate_estimate from B08_wf5_Quantity.B08_wf5_Quantity_Engine_Final import calculate_final_cost @@ -9,6 +9,7 @@ from B08_wf5_Quantity.B08_wf5_Quantity_Repository_Calculation import Calculation from B08_wf5_Quantity.B08_wf5_Quantity_Repository_CalculationSource import ( CalculationSourceRepository, ) +from B08_wf5_Quantity.B08_wf5_Quantity_Router_Errors import domain_errors from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Calculation import ( FinalCalculationRequest, FinalCalculationResponse, @@ -19,6 +20,7 @@ def router_for(connection_provider, user_provider): router = APIRouter() @router.post("/{project_id}/calculate/final", response_model=FinalCalculationResponse) + @domain_errors async def calculate( project_id: str, data: FinalCalculationRequest, @@ -55,6 +57,15 @@ def router_for(connection_provider, user_provider): @router.get("/{project_id}/calculation-runs/latest") async def latest(project_id: str, db=Depends(connection_provider)): return await CalculationRepository(db).latest_detail(project_id) + + @router.get("/{project_id}/calculation-runs/{run_id}") + async def detail( + project_id: str, run_id: str, db=Depends(connection_provider) + ): + try: + return await CalculationRepository(db).detail(project_id, run_id) + except LookupError as error: + raise HTTPException(404, str(error)) from error @router.get("/{project_id}/calculation-runs") async def history(project_id: str, db=Depends(connection_provider)): return await CalculationRepository(db).history(project_id) diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Catalog.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Catalog.py index 269bce7c..acc9daa3 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Catalog.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Catalog.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends +from B08_wf5_Quantity.B08_wf5_Quantity_Router_Errors import domain_errors from B08_wf5_Quantity.B08_wf5_Quantity_Repository_Catalog import CatalogRepository from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Catalog import ( AppliedPrice, @@ -13,6 +14,7 @@ def router_for(connection_provider, user_provider): router = APIRouter() @router.post("/{project_id}/catalog/price-books") + @domain_errors async def save_book(project_id: str, data: PriceBook, db=Depends(connection_provider)): await CatalogRepository(db).save_book(project_id, data) return {"status": "ok"} @@ -22,16 +24,19 @@ def router_for(connection_provider, user_provider): return await CatalogRepository(db).workspace(project_id) @router.post("/{project_id}/catalog/items") + @domain_errors async def save_item(project_id: str, data: CatalogItem, db=Depends(connection_provider)): return {"id": await CatalogRepository(db).save_item(project_id, data)} @router.post("/{project_id}/catalog/{version}/candidates") + @domain_errors async def add_candidate( project_id: str, version: str, data: PriceCandidate, db=Depends(connection_provider) ): return {"id": await CatalogRepository(db).add_candidate(project_id, version, data)} @router.post("/{project_id}/catalog/{version}/apply") + @domain_errors async def apply_price( project_id: str, version: str, diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Costing.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Costing.py index 5e8dd6a5..17f37a5a 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Costing.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Costing.py @@ -1,23 +1,59 @@ -from fastapi import APIRouter,Depends +from fastapi import APIRouter, Depends + from B08_wf5_Quantity.B08_wf5_Quantity_Engine_CostBasis import calculate_cost_basis -from B08_wf5_Quantity.B08_wf5_Quantity_Engine_UnitCost import calculate_equipment_rate,calculate_unit_cost +from B08_wf5_Quantity.B08_wf5_Quantity_Engine_UnitCost import ( + calculate_equipment_rate, + calculate_unit_cost, +) from B08_wf5_Quantity.B08_wf5_Quantity_Repository_CostBasis import CostBasisRepository from B08_wf5_Quantity.B08_wf5_Quantity_Repository_Pricing import PricingResolver from B08_wf5_Quantity.B08_wf5_Quantity_Repository_UnitCost import UnitCostRepository +from B08_wf5_Quantity.B08_wf5_Quantity_Router_Errors import domain_errors from B08_wf5_Quantity.B08_wf5_Quantity_Schema_CostBasis import CostBasis -from B08_wf5_Quantity.B08_wf5_Quantity_Schema_UnitCost import CostResult,EquipmentRate,UnitCost +from B08_wf5_Quantity.B08_wf5_Quantity_Schema_UnitCost import ( + CostResult, + EquipmentRate, + UnitCost, +) + def router_for(connection_provider): - r=APIRouter() - @r.get("/{project_id}/costing") - async def get(project_id:str,db=Depends(connection_provider)):return {**await UnitCostRepository(db).list_all(project_id),"cost_basis":await CostBasisRepository(db).list_all(project_id)} - @r.post("/{project_id}/unit-costs",response_model=CostResult) - async def unit(project_id:str,data:UnitCost,db=Depends(connection_provider)): - data.components=await PricingResolver(db,project_id).hydrate(data.components);result=calculate_unit_cost(data);await UnitCostRepository(db).save_unit_cost(project_id,data,result);return result - @r.post("/{project_id}/equipment-rates",response_model=CostResult) - async def equipment(project_id:str,data:EquipmentRate,db=Depends(connection_provider)): - data.components=await PricingResolver(db,project_id).hydrate(data.components);result=calculate_equipment_rate(data);await UnitCostRepository(db).save_equipment(project_id,data,result);return result - @r.post("/{project_id}/cost-basis",response_model=CostResult) - async def basis(project_id:str,data:CostBasis,db=Depends(connection_provider)): - data.components=await PricingResolver(db,project_id).hydrate(data.components);result=calculate_cost_basis(data);await CostBasisRepository(db).save(project_id,data,result);return result - return r \ No newline at end of file + router = APIRouter() + + @router.get("/{project_id}/costing") + async def get(project_id: str, db=Depends(connection_provider)): + unit_costs = await UnitCostRepository(db).list_all(project_id) + cost_basis = await CostBasisRepository(db).list_all(project_id) + return {**unit_costs, "cost_basis": cost_basis} + + @router.post("/{project_id}/unit-costs", response_model=CostResult) + @domain_errors + async def save_unit_cost( + project_id: str, data: UnitCost, db=Depends(connection_provider) + ): + data.components = await PricingResolver(db, project_id).hydrate(data.components) + result = calculate_unit_cost(data) + await UnitCostRepository(db).save_unit_cost(project_id, data, result) + return result + + @router.post("/{project_id}/equipment-rates", response_model=CostResult) + @domain_errors + async def save_equipment( + project_id: str, data: EquipmentRate, db=Depends(connection_provider) + ): + data.components = await PricingResolver(db, project_id).hydrate(data.components) + result = calculate_equipment_rate(data) + await UnitCostRepository(db).save_equipment(project_id, data, result) + return result + + @router.post("/{project_id}/cost-basis", response_model=CostResult) + @domain_errors + async def save_cost_basis( + project_id: str, data: CostBasis, db=Depends(connection_provider) + ): + data.components = await PricingResolver(db, project_id).hydrate(data.components) + result = calculate_cost_basis(data) + await CostBasisRepository(db).save(project_id, data, result) + return result + + return router \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Errors.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Errors.py new file mode 100644 index 00000000..c1da5fd0 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Errors.py @@ -0,0 +1,14 @@ +from functools import wraps + +from fastapi import HTTPException + + +def domain_errors(endpoint): + @wraps(endpoint) + async def wrapped(*args, **kwargs): + try: + return await endpoint(*args, **kwargs) + except ValueError as error: + raise HTTPException(422, str(error)) from error + + return wrapped \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Quantity.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Quantity.py index 44558343..5410a77f 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Quantity.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Quantity.py @@ -1,7 +1,12 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from B08_wf5_Quantity.B08_wf5_Quantity_Repository_Quantity import QuantityRepository -from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Quantity import DesignQuantity, WorkBreakdown +from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Quantity import ( + BulkQuantityRequest, + DesignQuantity, + SortRequest, + WorkBreakdown, +) def router_for(connection_provider, user_provider): @@ -12,8 +17,33 @@ def router_for(connection_provider, user_provider): return await QuantityRepository(db).workspace(project_id) @router.post("/{project_id}/work-breakdown") - async def save_wbs(project_id: str, data: WorkBreakdown, db=Depends(connection_provider)): - return {"id": await QuantityRepository(db).save_wbs(project_id, data)} + async def save_wbs( + project_id: str, data: WorkBreakdown, db=Depends(connection_provider) + ): + try: + return {"id": await QuantityRepository(db).save_wbs(project_id, data)} + except ValueError as error: + raise HTTPException(422, str(error)) from error + + @router.delete("/{project_id}/work-breakdown/{wbs_id}") + async def delete_wbs( + project_id: str, wbs_id: str, db=Depends(connection_provider) + ): + try: + await QuantityRepository(db).delete_wbs(project_id, wbs_id) + return {"status": "deleted"} + except ValueError as error: + raise HTTPException(422, str(error)) from error + + @router.post("/{project_id}/work-breakdown/reorder") + async def reorder_wbs( + project_id: str, data: SortRequest, db=Depends(connection_provider) + ): + try: + await QuantityRepository(db).reorder_wbs(project_id, data) + return {"status": "reordered"} + except ValueError as error: + raise HTTPException(422, str(error)) from error @router.post("/{project_id}/quantities") async def save_quantity( @@ -22,15 +52,80 @@ def router_for(connection_provider, user_provider): db=Depends(connection_provider), user_id=Depends(user_provider), ): - return {"id": await QuantityRepository(db).save_quantity(project_id, data, user_id)} + try: + row_id = await QuantityRepository(db).save_quantity( + project_id, data, user_id + ) + return {"id": row_id} + except ValueError as error: + raise HTTPException(422, str(error)) from error + @router.post("/{project_id}/quantities/bulk") + async def save_quantity_bulk( + project_id: str, + data: BulkQuantityRequest, + db=Depends(connection_provider), + user_id=Depends(user_provider), + ): + try: + row_ids = await QuantityRepository(db).save_quantity_bulk( + project_id, data, user_id + ) + return {"ids": row_ids, "count": len(row_ids)} + except ValueError as error: + raise HTTPException(422, str(error)) from error @router.post("/{project_id}/quantities/confirm") async def confirm( project_id: str, db=Depends(connection_provider), user_id=Depends(user_provider), ): - version = await QuantityRepository(db).confirm(project_id, user_id) - return {"status": "confirmed", "quantity_version": version} + try: + version = await QuantityRepository(db).confirm(project_id, user_id) + return {"status": "confirmed", "quantity_version": version} + except ValueError as error: + raise HTTPException(422, str(error)) from error + + @router.post("/{project_id}/quantities/reorder") + async def reorder_quantities( + project_id: str, + data: SortRequest, + db=Depends(connection_provider), + ): + try: + await QuantityRepository(db).reorder_quantities(project_id, data) + return {"status": "reordered"} + except ValueError as error: + raise HTTPException(422, str(error)) from error + + @router.post("/{project_id}/quantities/{quantity_id}/clone") + async def clone_quantity( + project_id: str, + quantity_id: str, + db=Depends(connection_provider), + user_id=Depends(user_provider), + ): + try: + row_id = await QuantityRepository(db).clone_quantity( + project_id, quantity_id, user_id + ) + return {"id": row_id} + except ValueError as error: + raise HTTPException(422, str(error)) from error + + @router.delete("/{project_id}/quantities/{quantity_id}") + async def delete_quantity( + project_id: str, + quantity_id: str, + db=Depends(connection_provider), + user_id=Depends(user_provider), + ): + try: + await QuantityRepository(db).delete_quantity( + project_id, quantity_id, user_id + ) + return {"status": "deleted"} + except ValueError as error: + raise HTTPException(422, str(error)) from error return router \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Reconciliation.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Reconciliation.py index d1a851af..b21ae3bc 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Reconciliation.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Reconciliation.py @@ -1,13 +1,43 @@ -from fastapi import APIRouter,Depends,HTTPException -from B08_wf5_Quantity.B08_wf5_Quantity_Repository_Reconciliation import ReconciliationRepository -from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Reconciliation import ReferenceImport,ReconciliationResult +from fastapi import APIRouter, Depends, HTTPException + +from B08_wf5_Quantity.B08_wf5_Quantity_Repository_Reconciliation import ( + ReconciliationRepository, +) +from B08_wf5_Quantity.B08_wf5_Quantity_Router_Errors import domain_errors +from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Reconciliation import ( + ReconciliationResult, + ReferenceImport, +) + def router_for(connection_provider): - r=APIRouter() - @r.post("/{project_id}/reference/import") - async def import_reference(project_id:str,data:ReferenceImport,db=Depends(connection_provider)):return {"source_file_id":await ReconciliationRepository(db).import_reference(project_id,data)} - @r.post("/{project_id}/reconcile/{run_id}/{source_id}",response_model=ReconciliationResult) - async def reconcile(project_id:str,run_id:str,source_id:str,db=Depends(connection_provider)): - try:return await ReconciliationRepository(db).reconcile(project_id,run_id,source_id) - except LookupError as error:raise HTTPException(404,str(error)) from error - return r \ No newline at end of file + router = APIRouter() + + @router.post("/{project_id}/reference/import") + @domain_errors + async def import_reference( + project_id: str, + data: ReferenceImport, + db=Depends(connection_provider), + ): + source_id = await ReconciliationRepository(db).import_reference(project_id, data) + return {"source_file_id": source_id} + + @router.post( + "/{project_id}/reconcile/{run_id}/{source_id}", + response_model=ReconciliationResult, + ) + async def reconcile( + project_id: str, + run_id: str, + source_id: str, + db=Depends(connection_provider), + ): + try: + return await ReconciliationRepository(db).reconcile( + project_id, run_id, source_id + ) + except LookupError as error: + raise HTTPException(404, str(error)) from error + + return router \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Catalog.py b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Catalog.py index 1a7c5f10..9d1e5967 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Catalog.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Catalog.py @@ -30,8 +30,8 @@ class PriceCandidate(BaseModel): source_id: int source_price: Decimal = Field(ge=0) currency: str = "KRW" - exchange_rate: Decimal = Field(default=Decimal("1"), gt=0) - converted_price: Decimal = Field(ge=0) + exchange_rate: Decimal | None = Field(default=None, gt=0) + converted_price: Decimal | None = Field(default=None, ge=0) reference_page: str | None = None valid_from: date valid_to: date | None = None diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Quantity.py b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Quantity.py index 314f2ba2..9f7abac3 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Quantity.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Quantity.py @@ -41,4 +41,16 @@ class DesignQuantity(BaseModel): for value in (self.confirmed_quantity, self.adjusted_quantity, self.design_quantity): if value is not None: return value - raise ValueError(f"{self.item_name}: 수량이 입력되지 않았습니다.") \ No newline at end of file + raise ValueError(f"{self.item_name}: 수량이 입력되지 않았습니다.") + + +class SortRow(BaseModel): + id: str + sort_order: int = Field(ge=0) + + +class SortRequest(BaseModel): + items: list[SortRow] = Field(min_length=1) + +class BulkQuantityRequest(BaseModel): + items: list[DesignQuantity] = Field(min_length=1) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Schema_UnitCost.py b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_UnitCost.py index c7a92f2a..a238f5bb 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Schema_UnitCost.py +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_UnitCost.py @@ -35,6 +35,10 @@ class EquipmentRate(BaseModel): unit: str = "hr" equipment_price: Decimal = Field(default=0, ge=0) annual_hours: Decimal = Field(default=0, ge=0) + useful_life_years: Decimal = Field(default=0, ge=0) + residual_rate: Decimal = Field(default=0, ge=0, le=1) + repair_rate: Decimal = Field(default=0, ge=0) + management_rate: Decimal = Field(default=0, ge=0) status: Literal["DRAFT", "CONFIRMED", "STALE"] = "DRAFT" version_no: int = 1 components: list[CostComponent] = Field(default_factory=list) diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts index 71a9c9a8..43525857 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts @@ -1,4 +1,5 @@ import { renderPendingWorkflow, workflowSteps } from "../A00_Common/b_page_scaffold"; +import { setB08ApiErrorHandler } from "./B08_wf5_Quantity_Api"; import { B08Store } from "./B08_wf5_Quantity_Store"; import { TABS, TabContext, TabId } from "./B08_wf5_Quantity_Types"; import { renderBasis } from "./B08_wf5_Quantity_UI_Tab_Basis"; @@ -47,6 +48,7 @@ export async function renderB08Quantity(root: HTMLElement) { project = page.querySelector(".b08-project")!; project.textContent = projectId ? `프로젝트 ${projectId}` : "프로젝트 미선택"; const store = new B08Store(projectId); + setB08ApiErrorHandler((text) => store.message(text)); TABS.forEach((tab) => { const button = document.createElement("button"); button.dataset.tab = tab.id; diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Style.css b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Style.css index 39a4b33a..d0bab75f 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Style.css +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Style.css @@ -198,3 +198,52 @@ flex-direction: column; } } + +.b08-row-list { + display: grid; + gap: 8px; + margin-top: 12px; +} + +.b08-row-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--b08-border, #d8dee8); + border-radius: 8px; + background: #fff; +} + +.b08-row-actions { + display: flex; + flex-wrap: wrap; + gap: 4px; + white-space: nowrap; +} + +.b08-button--small { + min-height: 28px; + padding: 4px 8px; + font-size: 12px; +} + +.b08-bulk-form { + display: grid; + gap: 10px; +} + +.b08-bulk-input { + width: 100%; + min-height: 150px; + padding: 10px; + border: 1px solid var(--b08-border, #d8dee8); + border-radius: 8px; + font: + 13px/1.5 ui-monospace, + SFMono-Regular, + Consolas, + monospace; + resize: vertical; +} diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Basis.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Basis.ts index 56f8d76d..5ebab6a8 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Basis.ts +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Basis.ts @@ -51,6 +51,7 @@ function renderSources(ctx: TabContext, b: any) { input("source_code", "출처 코드"), input("source_name", "출처명"), input("priority_no", "우선순위", "number", "100"), + input("publisher", "발행기관"), input("reference_date", "기준일", "date"), ); const btn = el("button", "b08-button", "출처 추가") as HTMLButtonElement; @@ -66,10 +67,11 @@ function renderSources(ctx: TabContext, b: any) { box.append( f, table( - ["코드", "출처", "우선순위", "기준일"], + ["코드", "출처", "발행기관", "우선순위", "기준일"], b.price_sources.map((x: any) => [ x.source_code, x.source_name, + x.publisher, x.priority_no, x.reference_date, ]), @@ -83,7 +85,12 @@ function renderFx(ctx: TabContext, b: any) { f.append( input("currency", "통화", "text"), input("rate_to_krw", "원화환율", "number"), - input("effective_from", "적용일", "date"), + select("source_id", "환율 출처", [ + ["", "직접 입력"], + ...b.price_sources.map((x: any) => [String(x.id), x.source_name]), + ]), + input("effective_from", "적용 시작일", "date"), + input("effective_to", "적용 종료일", "date"), ); const btn = el("button", "b08-button", "환율 추가") as HTMLButtonElement; btn.type = "submit"; @@ -91,15 +98,26 @@ function renderFx(ctx: TabContext, b: any) { f.onsubmit = async (e) => { e.preventDefault(); const x = formData(f); - b.exchange_rates.push({ ...x, rate_to_krw: String(x.rate_to_krw) }); + b.exchange_rates.push({ + ...x, + rate_to_krw: String(x.rate_to_krw), + source_id: x.source_id === "" ? null : Number(x.source_id), + effective_to: x.effective_to || null, + }); await B08Api.saveBasis(ctx.state.projectId, b.version, b); await ctx.refresh(); }; box.append( f, table( - ["통화", "원화환율", "적용일"], - b.exchange_rates.map((x: any) => [x.currency, x.rate_to_krw, x.effective_from]), + ["통화", "원화환율", "출처", "시작일", "종료일"], + b.exchange_rates.map((x: any) => [ + x.currency, + x.rate_to_krw, + b.price_sources.find((source: any) => source.id === x.source_id)?.source_name, + x.effective_from, + x.effective_to, + ]), ), ); return box; diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Catalog.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Catalog.ts index 00bae86b..52f9af92 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Catalog.ts +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Catalog.ts @@ -118,7 +118,6 @@ function candidateForm(ctx: TabContext) { ), input("source_price", "원단가", "number"), input("currency", "통화", "text", "KRW"), - input("exchange_rate", "환율", "number", "1"), input("reference_page", "근거 페이지"), input("valid_from", "적용일", "date"), ); @@ -129,13 +128,10 @@ function candidateForm(ctx: TabContext) { event.preventDefault(); const value = formData(form); const sourcePrice = Number(value.source_price); - const exchangeRate = Number(value.exchange_rate); await B08Api.addCandidate(ctx.state.projectId, String(value.price_version), { ...value, source_id: Number(value.source_id), source_price: sourcePrice, - exchange_rate: exchangeRate, - converted_price: sourcePrice * exchangeRate, valid_to: null, }); ctx.message("후보가격을 저장했습니다."); diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Equipment.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Equipment.ts index e70cf263..4875c47c 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Equipment.ts +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Equipment.ts @@ -17,6 +17,10 @@ export function renderEquipment(ctx: TabContext) { input("unit", "단위", "text", "hr"), input("equipment_price", "기계가격", "number"), input("annual_hours", "연간 가동시간", "number"), + input("useful_life_years", "내용연수(년)", "number"), + input("residual_rate", "잔존율(소수)", "number", "0"), + input("repair_rate", "연간 수선율(소수)", "number", "0"), + input("management_rate", "연간 관리율(소수)", "number", "0"), select("status", "상태", [ ["DRAFT", "작성중"], ["CONFIRMED", "확정"], @@ -63,6 +67,10 @@ export function renderEquipment(ctx: TabContext) { ...value, equipment_price: Number(value.equipment_price), annual_hours: Number(value.annual_hours), + useful_life_years: Number(value.useful_life_years), + residual_rate: Number(value.residual_rate), + repair_rate: Number(value.repair_rate), + management_rate: Number(value.management_rate), version_no: 1, components, }); @@ -74,11 +82,28 @@ export function renderEquipment(ctx: TabContext) { componentForm, count, table( - ["코드", "장비", "단위", "노무비", "재료비", "경비", "합계", "상태"], + [ + "코드", + "장비", + "단위", + "내용연수", + "잔존율", + "수선율", + "관리율", + "노무비", + "재료비", + "경비", + "합계", + "상태", + ], ctx.state.data.costing.equipment_rates.map((item: any) => [ item.equipment_code, item.name, item.unit, + item.useful_life_years, + item.residual_rate, + item.repair_rate, + item.management_rate, money(Number(item.labor_price)), money(Number(item.material_price)), money(Number(item.expense_price)), diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Final.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Final.ts index c41ce0d6..4fd5eef5 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Final.ts +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Final.ts @@ -1,5 +1,5 @@ import { B08Api } from "./B08_wf5_Quantity_Api"; -import { el, formData, input, money, section, table } from "./B08_wf5_Quantity_UI_Dom"; +import { el, formData, input, money, section, select, table } from "./B08_wf5_Quantity_UI_Dom"; import { TabContext } from "./B08_wf5_Quantity_Types"; const STAGES: Array<[string, string]> = [ @@ -57,11 +57,85 @@ export function renderFinal(ctx: TabContext) { ["총공사비", money(f.total_project_cost)], ], ), + comparisonForm(ctx), reconciliationForm(ctx, run.run_id), ); return root; } +function comparisonForm(ctx: TabContext) { + const box = section( + "계산 실행 비교", + "두 저장 실행의 기준정보·가격판·수량 버전과 최종 단계별 차이를 비교합니다.", + ); + const runs = ctx.state.data.runs; + if (runs.length < 2) { + box.append(el("p", "b08-empty", "비교하려면 완료된 계산 실행이 2개 이상 필요합니다.")); + return box; + } + const options = runs.map((run: any) => [ + run.id, + `${run.created_at} · ${run.id.slice(0, 8)} · ${money(Number(run.total_project_cost))}`, + ]) as Array<[string, string]>; + const form = el("form", "b08-inline-form") as HTMLFormElement; + form.append(select("baseline", "기준 실행", options), select("candidate", "비교 실행", options)); + (form.elements.namedItem("baseline") as HTMLSelectElement).value = runs[1].id; + (form.elements.namedItem("candidate") as HTMLSelectElement).value = runs[0].id; + const compare = el("button", "b08-button", "실행 비교") as HTMLButtonElement; + compare.type = "submit"; + form.append(compare); + const result = el("div", "b08-compare-result"); + form.onsubmit = async (event) => { + event.preventDefault(); + const value = formData(form); + if (value.baseline === value.candidate) { + ctx.message("서로 다른 계산 실행을 선택하세요."); + return; + } + const [baseline, candidate] = await Promise.all([ + B08Api.calculationRun(ctx.state.projectId, String(value.baseline)), + B08Api.calculationRun(ctx.state.projectId, String(value.candidate)), + ]); + result.replaceChildren( + table( + ["버전", "기준 실행", "비교 실행"], + [ + ["기준정보", baseline.versions.basis_version, candidate.versions.basis_version], + ["가격판", baseline.versions.price_version, candidate.versions.price_version], + ["수량", baseline.versions.quantity_version, candidate.versions.quantity_version], + ["요율", baseline.versions.rule_version, candidate.versions.rule_version], + ], + ), + table( + ["단계", "기준 실행", "비교 실행", "차이"], + STAGES.map(([code, label]) => { + const key = stageField(code); + const before = Number(baseline.final[key] ?? 0); + const after = Number(candidate.final[key] ?? 0); + return [label, money(before), money(after), money(after - before)]; + }), + ), + ); + }; + box.append(form, result); + return box; +} + +function stageField(code: string) { + const fields: Record = { + DIRECT_COST: "direct_cost", + NET_COST: "net_cost", + GENERAL_ADMIN: "general_admin", + PROFIT: "profit", + TOTAL_COST: "total_cost", + VAT: "vat", + CONTRACT_COST: "contract_cost", + GOVERNMENT_MATERIAL: "government_material", + PROCUREMENT_FEE: "procurement_fee", + TOTAL_PROJECT_COST: "total_project_cost", + }; + return fields[code]; +} function reconciliationForm(ctx: TabContext, runId: string) { const box = section( "STmate/XLSX 기준금액 대조", diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Quantity.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Quantity.ts index 23d56974..5b981439 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Quantity.ts +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Quantity.ts @@ -1,142 +1,454 @@ import { B08Api } from "./B08_wf5_Quantity_Api"; -import { el, formData, input, section, select, table } from "./B08_wf5_Quantity_UI_Dom"; +import { el, formData, input, section, select } from "./B08_wf5_Quantity_UI_Dom"; import { TabContext } from "./B08_wf5_Quantity_Types"; +interface QuantityReference { + type: "CATALOG" | "UNIT_COST" | "EQUIPMENT" | "COST_BASIS"; + id: string; + label: string; + name: string; + specification: string; + unit: string; + procurement_type: string; + code: string; +} + export function renderQuantity(ctx: TabContext) { const root = section( "설계수량 직접 입력", - "공종과 확정 단가 항목을 선택한 뒤 사용자가 설계수량을 직접 입력합니다.", + "공종과 확정 단가를 연결하고 사용자가 설계수량을 입력·보정·확정합니다.", ); - const wbs = ctx.state.data.quantities.work_breakdown; - const wbsForm = el("form", "b08-inline-form") as HTMLFormElement; - wbsForm.append( + const editor = renderQuantityEditor(ctx); + root.append(renderWbsEditor(ctx), editor, renderBulkInput(ctx), renderQuantityTable(ctx, editor)); + return root; +} + +function renderWbsEditor(ctx: TabContext) { + const box = section("공종 계층", "공종을 추가하거나 기존 공종의 명칭·단계·순서를 수정합니다."); + const form = el("form", "b08-inline-form") as HTMLFormElement; + let editingId: string | null = null; + form.append( input("code", "공종 코드"), input("name", "공종명"), - input("level_no", "계층", "number", "1"), + input("level_no", "단계", "number", "1"), input("sort_order", "순서", "number", "0"), ); - const addWbs = el("button", "b08-button", "공종 추가") as HTMLButtonElement; - addWbs.type = "submit"; - wbsForm.append(addWbs); - wbsForm.onsubmit = async (event) => { + const save = el("button", "b08-button", "공종 저장") as HTMLButtonElement; + save.type = "submit"; + form.append(save); + form.onsubmit = async (event) => { event.preventDefault(); - const x = formData(wbsForm); + const value = formData(form); await B08Api.saveWbs(ctx.state.projectId, { - ...x, + id: editingId, parent_id: null, - level_no: Number(x.level_no), - sort_order: Number(x.sort_order), + code: value.code, + name: value.name, + level_no: Number(value.level_no), + sort_order: Number(value.sort_order), }); + ctx.message(editingId ? "공종을 수정했습니다." : "공종을 추가했습니다."); + editingId = null; await ctx.refresh(); }; + const list = el("div", "b08-row-list"); + ctx.state.data.quantities.work_breakdown.forEach((row: any, index: number, rows: any[]) => { + const item = el("div", "b08-row-card"); + item.append(el("span", "", `${row.wbs_code} · ${row.name} · ${row.level_no}단계`)); + const actions = el("div", "b08-row-actions"); + actions.append( + actionButton("수정", () => { + editingId = row.id; + setValue(form, "code", row.wbs_code); + setValue(form, "name", row.name); + setValue(form, "level_no", row.level_no); + setValue(form, "sort_order", row.sort_order); + save.textContent = "공종 수정"; + }), + actionButton("위", () => moveWbs(ctx, rows, index, -1), index === 0), + actionButton("아래", () => moveWbs(ctx, rows, index, 1), index === rows.length - 1), + actionButton("삭제", async () => { + if (!window.confirm(`${row.name} 공종을 삭제할까요?`)) return; + await B08Api.deleteWbs(ctx.state.projectId, row.id); + await ctx.refresh(); + }), + ); + item.append(actions); + list.append(item); + }); + box.append(form, list); + return box; +} - const references: Array<[string, string]> = []; - ctx.state.data.catalog.items - .filter((x: any) => x.applied_price != null) - .forEach((x: any) => - references.push([`CATALOG|${x.id}`, `[기초] ${x.item_code} ${x.item_name}`]), - ); - ctx.state.data.costing.unit_costs - .filter((x: any) => x.status === "CONFIRMED") - .forEach((x: any) => - references.push([`UNIT_COST|${x.id}`, `[일위] ${x.unit_cost_code} ${x.name}`]), - ); - ctx.state.data.costing.equipment_rates - .filter((x: any) => x.status === "CONFIRMED") - .forEach((x: any) => - references.push([`EQUIPMENT|${x.id}`, `[중기] ${x.equipment_code} ${x.name}`]), - ); - ctx.state.data.costing.cost_basis - .filter((x: any) => x.status === "CONFIRMED") - .forEach((x: any) => - references.push([`COST_BASIS|${x.id}`, `[산근] ${x.basis_code} ${x.name}`]), - ); - +function renderQuantityEditor(ctx: TabContext) { + const box = section( + "수량 항목", + "확정 DB 단가를 선택하면 기본 명칭·규격·단위를 자동으로 채웁니다.", + ); + const references = buildReferences(ctx); const form = el("form", "b08-form-grid") as HTMLFormElement; + let editingId: string | null = null; form.append( select( "wbs_id", "공종", - wbs.map((x: any) => [x.id, `${x.wbs_code} ${x.name}`]), + ctx.state.data.quantities.work_breakdown.map((row: any) => [ + row.id, + `${row.wbs_code} ${row.name}`, + ]), + ), + select( + "reference", + "확정 적용단가", + references.map((row) => [`${row.type}|${row.id}`, row.label]), ), - select("reference", "확정 적용단가", references), input("item_name", "내역 명칭"), input("specification", "규격"), input("unit", "단위"), input("design_quantity", "설계수량", "number"), input("adjusted_quantity", "보정수량", "number"), input("adjustment_reason", "보정 사유"), - select("procurement_type", "구분", [ + select("procurement_type", "조달 구분", [ ["PRIVATE", "사급"], ["GOVERNMENT", "관급"], ["EXCLUDED", "제외"], ]), ); + for (const name of ["design_quantity", "adjusted_quantity"]) { + (form.elements.namedItem(name) as HTMLInputElement).step = "any"; + } + const referenceControl = form.elements.namedItem("reference") as HTMLSelectElement; + referenceControl.onchange = () => applyReference(form, references, referenceControl.value); + referenceControl.dispatchEvent(new Event("change")); const save = el("button", "b08-button b08-button--primary", "설계수량 저장") as HTMLButtonElement; save.type = "submit"; form.append(save); form.onsubmit = async (event) => { event.preventDefault(); - const x = formData(form); - const [reference_type, reference_id] = String(x.reference).split("|"); - if (!reference_id) throw new Error("확정된 적용단가를 먼저 등록하세요."); + const value = formData(form); + const [reference_type, reference_id] = String(value.reference).split("|"); + if (!reference_id) throw new Error("확정 적용단가를 먼저 등록하세요."); await B08Api.saveQuantity(ctx.state.projectId, { - ...x, + id: editingId, + wbs_id: value.wbs_id, reference_type, reference_id, - design_quantity: x.design_quantity === "" ? null : Number(x.design_quantity), - adjusted_quantity: x.adjusted_quantity === "" ? null : Number(x.adjusted_quantity), + item_name: value.item_name, + specification: value.specification, + unit: value.unit, + design_quantity: nullableNumber(value.design_quantity), + adjusted_quantity: nullableNumber(value.adjusted_quantity), confirmed_quantity: null, - adjustment_reason: x.adjustment_reason || null, - excluded: x.procurement_type === "EXCLUDED", - status: x.adjusted_quantity === "" ? "DRAFT" : "ADJUSTED", - sort_order: ctx.state.data.quantities.quantities.length, + adjustment_reason: value.adjustment_reason || null, + procurement_type: value.procurement_type, + excluded: value.procurement_type === "EXCLUDED", + status: value.adjusted_quantity === "" ? "DRAFT" : "ADJUSTED", + sort_order: editingId + ? (ctx.state.data.quantities.quantities.find((row: any) => row.id === editingId) + ?.sort_order ?? 0) + : ctx.state.data.quantities.quantities.length, }); - ctx.message("설계수량을 저장했습니다."); + ctx.message(editingId ? "설계수량을 수정했습니다." : "설계수량을 저장했습니다."); + editingId = null; await ctx.refresh(); }; + (box as any).editQuantity = (row: any) => { + editingId = row.id; + setValue(form, "wbs_id", row.wbs_id); + setValue(form, "reference", `${row.reference_type}|${row.reference_id}`); + setValue(form, "item_name", row.item_name); + setValue(form, "specification", row.specification); + setValue(form, "unit", row.unit); + setValue(form, "design_quantity", row.design_quantity); + setValue(form, "adjusted_quantity", row.adjusted_quantity); + setValue(form, "adjustment_reason", row.adjustment_reason); + setValue(form, "procurement_type", row.procurement_type); + save.textContent = "설계수량 수정"; + form.scrollIntoView({ behavior: "smooth", block: "start" }); + }; const confirm = el( "button", "b08-button b08-button--primary", "전체 수량 확정", ) as HTMLButtonElement; + confirm.type = "button"; confirm.onclick = async () => { - await B08Api.confirmQuantities(ctx.state.projectId); - ctx.message("설계수량을 확정했습니다."); + const result: any = await B08Api.confirmQuantities(ctx.state.projectId); + ctx.message(`설계수량 버전 ${result.quantity_version}을 확정했습니다.`); await ctx.refresh(); }; - root.append( - wbsForm, - form, - confirm, - table( - [ - "공종", - "명칭", - "규격", - "단위", - "설계", - "보정", - "확정", - "노무단가", - "재료단가", - "경비단가", - "상태", - ], - ctx.state.data.quantities.quantities.map((x: any) => [ - wbs.find((w: any) => w.id === x.wbs_id)?.name, - x.item_name, - x.specification, - x.unit, - x.design_quantity, - x.adjusted_quantity, - x.confirmed_quantity, - x.unit_labor, - x.unit_material, - x.unit_expense, - x.status, - ]), - ), + box.append(form, confirm); + return box; +} + +function renderBulkInput(ctx: TabContext) { + const box = section( + "표 붙여넣기", + "열 순서: 공종코드, 참조유형, 참조코드, 설계수량, 보정수량, 보정사유. 탭 또는 쉼표로 구분합니다.", ); - return root; + const form = el("form", "b08-bulk-form") as HTMLFormElement; + const textarea = el("textarea", "b08-bulk-input") as HTMLTextAreaElement; + textarea.name = "rows"; + textarea.rows = 8; + textarea.placeholder = "WBS-01\tUNIT_COST\tUC-001\t120.5\t\t\nWBS-02,CATALOG,MAT-001,30,,,"; + const save = el("button", "b08-button", "붙여넣은 수량 저장") as HTMLButtonElement; + save.type = "submit"; + form.append(textarea, save); + form.onsubmit = async (event) => { + event.preventDefault(); + const parsed = parseBulkRows(ctx, textarea.value); + if (parsed.errors.length) { + ctx.message(`대량 입력 오류: ${parsed.errors.join(" / ")}`); + return; + } + const result: any = await B08Api.saveQuantitiesBulk(ctx.state.projectId, parsed.items); + ctx.message(`설계수량 ${result.count}건을 저장했습니다.`); + await ctx.refresh(); + }; + box.append(form); + return box; +} + +function parseBulkRows(ctx: TabContext, text: string) { + const references = buildReferences(ctx); + const workBreakdown = ctx.state.data.quantities.work_breakdown; + const items: any[] = []; + const errors: string[] = []; + const lines = text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + lines.forEach((line, index) => { + const columns = (line.includes("\t") ? line.split("\t") : line.split(",")).map((value) => + value.trim(), + ); + const [wbsCode, referenceTypeRaw, referenceCode, designRaw, adjustedRaw = "", reason = ""] = + columns; + const referenceType = referenceTypeRaw?.toUpperCase(); + const wbs = workBreakdown.find((row: any) => row.wbs_code === wbsCode); + const reference = references.find( + (row) => row.type === referenceType && row.code === referenceCode, + ); + const designQuantity = Number(designRaw); + const adjustedQuantity = adjustedRaw === "" ? null : Number(adjustedRaw); + const lineErrors: string[] = []; + if (!wbs) lineErrors.push(`공종 ${wbsCode || "미입력"}`); + if (!reference) { + lineErrors.push(`참조 ${referenceTypeRaw || "미입력"}/${referenceCode || "미입력"}`); + } + if (designRaw === "" || !Number.isFinite(designQuantity) || designQuantity < 0) { + lineErrors.push("설계수량"); + } + if (adjustedQuantity != null && (!Number.isFinite(adjustedQuantity) || adjustedQuantity < 0)) { + lineErrors.push("보정수량"); + } + if (adjustedQuantity != null && !reason) lineErrors.push("보정사유"); + if (lineErrors.length) { + errors.push(`${index + 1}행(${lineErrors.join(", ")})`); + return; + } + items.push({ + wbs_id: wbs.id, + reference_type: reference!.type, + reference_id: reference!.id, + item_name: reference!.name, + specification: reference!.specification, + unit: reference!.unit, + design_quantity: designQuantity, + adjusted_quantity: adjustedQuantity, + confirmed_quantity: null, + adjustment_reason: adjustedQuantity == null ? null : reason, + procurement_type: reference!.procurement_type, + excluded: reference!.procurement_type === "EXCLUDED", + status: adjustedQuantity == null ? "DRAFT" : "ADJUSTED", + sort_order: ctx.state.data.quantities.quantities.length + items.length, + }); + }); + if (!lines.length) errors.push("붙여넣은 행이 없습니다."); + return { items, errors }; +} +function renderQuantityTable(ctx: TabContext, editor: HTMLElement) { + const box = section("수량 목록", "수정·복제·삭제 또는 순서 변경 후 다시 확정해야 합니다."); + const table = el("table", "b08-table"); + const head = el("thead"); + const header = el("tr"); + [ + "공종", + "명칭", + "규격", + "단위", + "설계", + "보정", + "확정", + "노무단가", + "재료단가", + "경비단가", + "상태", + "작업", + ].forEach((label) => header.append(el("th", "", label))); + head.append(header); + const body = el("tbody"); + const rows = ctx.state.data.quantities.quantities; + rows.forEach((row: any, index: number) => { + const tr = el("tr"); + const wbs = ctx.state.data.quantities.work_breakdown.find( + (item: any) => item.id === row.wbs_id, + ); + [ + wbs?.name ?? row.wbs_id, + row.item_name, + row.specification, + row.unit, + row.design_quantity, + row.adjusted_quantity, + row.confirmed_quantity, + row.unit_labor, + row.unit_material, + row.unit_expense, + row.status, + ].forEach((value) => tr.append(el("td", "", value == null ? "-" : String(value)))); + const actions = el("td", "b08-row-actions"); + actions.append( + actionButton("수정", () => { + (editor as any).editQuantity(row); + }), + actionButton("복제", async () => { + await B08Api.cloneQuantity(ctx.state.projectId, row.id); + await ctx.refresh(); + }), + actionButton("위", () => moveQuantity(ctx, rows, index, -1), index === 0), + actionButton("아래", () => moveQuantity(ctx, rows, index, 1), index === rows.length - 1), + actionButton("삭제", async () => { + if (!window.confirm(`${row.item_name} 수량 항목을 삭제할까요?`)) return; + await B08Api.deleteQuantity(ctx.state.projectId, row.id); + await ctx.refresh(); + }), + ); + tr.append(actions); + body.append(tr); + }); + if (!rows.length) { + const empty = el("tr"); + const cell = el("td", "", "등록된 설계수량이 없습니다."); + cell.colSpan = 12; + empty.append(cell); + body.append(empty); + } + table.append(head, body); + box.append(table); + return box; +} + +function buildReferences(ctx: TabContext): QuantityReference[] { + const rows: QuantityReference[] = []; + ctx.state.data.catalog.items + .filter((item: any) => item.applied_price != null) + .forEach((item: any) => + rows.push({ + type: "CATALOG", + id: item.id, + label: `[기초단가] ${item.item_code} ${item.item_name}`, + name: item.item_name, + specification: item.specification, + unit: item.unit, + procurement_type: item.procurement_type, + code: item.item_code, + }), + ); + addCostingReferences( + rows, + "UNIT_COST", + "일위대가", + ctx.state.data.costing.unit_costs, + "unit_cost_code", + ); + addCostingReferences( + rows, + "EQUIPMENT", + "중기사용료", + ctx.state.data.costing.equipment_rates, + "equipment_code", + ); + addCostingReferences( + rows, + "COST_BASIS", + "산출근거", + ctx.state.data.costing.cost_basis, + "basis_code", + ); + return rows; +} + +function addCostingReferences( + target: QuantityReference[], + type: QuantityReference["type"], + label: string, + source: any[], + codeKey: string, +) { + source + .filter((item) => item.status === "CONFIRMED") + .forEach((item) => + target.push({ + type, + id: item.id, + label: `[${label}] ${item[codeKey]} ${item.name}`, + name: item.name, + specification: item.specification, + unit: item.unit, + procurement_type: "PRIVATE", + code: item[codeKey], + }), + ); +} + +function applyReference(form: HTMLFormElement, references: QuantityReference[], value: string) { + const [type, id] = value.split("|"); + const reference = references.find((row) => row.type === type && row.id === id); + if (!reference) return; + setValue(form, "item_name", reference.name); + setValue(form, "specification", reference.specification); + setValue(form, "unit", reference.unit); + setValue(form, "procurement_type", reference.procurement_type); +} + +async function moveQuantity(ctx: TabContext, rows: any[], index: number, offset: number) { + const target = index + offset; + if (target < 0 || target >= rows.length) return; + const ordered = [...rows]; + [ordered[index], ordered[target]] = [ordered[target], ordered[index]]; + await B08Api.reorderQuantities( + ctx.state.projectId, + ordered.map((row, sort_order) => ({ id: row.id, sort_order })), + ); + await ctx.refresh(); +} + +async function moveWbs(ctx: TabContext, rows: any[], index: number, offset: number) { + const target = index + offset; + if (target < 0 || target >= rows.length) return; + const ordered = [...rows]; + [ordered[index], ordered[target]] = [ordered[target], ordered[index]]; + await B08Api.reorderWbs( + ctx.state.projectId, + ordered.map((row, sort_order) => ({ id: row.id, sort_order })), + ); + await ctx.refresh(); +} + +function actionButton(label: string, action: () => void | Promise, disabled = false) { + const button = el("button", "b08-button b08-button--small", label) as HTMLButtonElement; + button.type = "button"; + button.disabled = disabled; + button.onclick = () => void action(); + return button; +} + +function setValue(form: HTMLFormElement, name: string, value: unknown) { + const control = form.elements.namedItem(name) as HTMLInputElement | HTMLSelectElement | null; + if (control) control.value = value == null ? "" : String(value); +} + +function nullableNumber(value: unknown) { + return value === "" || value == null ? null : Number(value); }