From 4a5bf002f78cc046a94db19e22906ae3385106d2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 26 Jul 2026 10:29:50 +0900 Subject: [PATCH] auto: 2026-07-26 10:29 (ESD_LAPTOP) --- ...06_wf3_ProfileCross_UI_Standard_Diagram.ts | 30 +- B08_wf5_Quantity/0_old_260726_codex.zip | Bin 0 -> 71509 bytes B08_wf5_Quantity/B08_wf5_Quantity_Api.ts | 93 ---- B08_wf5_Quantity/B08_wf5_Quantity_Database.py | 20 - .../B08_wf5_Quantity_Database_Basis.py | 29 -- .../B08_wf5_Quantity_Database_Calculation.py | 39 -- .../B08_wf5_Quantity_Database_Catalog.py | 31 -- .../B08_wf5_Quantity_Database_CostBasis.py | 19 - .../B08_wf5_Quantity_Database_Quantity.py | 27 -- ...08_wf5_Quantity_Database_Reconciliation.py | 22 - .../B08_wf5_Quantity_Database_UnitCost.py | 50 -- .../B08_wf5_Quantity_Engine_CostBasis.py | 19 - .../B08_wf5_Quantity_Engine_Estimate.py | 34 -- .../B08_wf5_Quantity_Engine_Final.py | 55 --- .../B08_wf5_Quantity_Engine_Formula.py | 35 -- .../B08_wf5_Quantity_Engine_Indirect.py | 19 - .../B08_wf5_Quantity_Engine_UnitCost.py | 96 ---- .../B08_wf5_Quantity_Repository_Basis.py | 131 ----- ...B08_wf5_Quantity_Repository_Calculation.py | 189 -------- ...5_Quantity_Repository_CalculationSource.py | 169 ------- .../B08_wf5_Quantity_Repository_Catalog.py | 231 --------- .../B08_wf5_Quantity_Repository_CostBasis.py | 87 ---- .../B08_wf5_Quantity_Repository_Pricing.py | 47 -- .../B08_wf5_Quantity_Repository_Quantity.py | 406 ---------------- ..._wf5_Quantity_Repository_Reconciliation.py | 30 -- .../B08_wf5_Quantity_Repository_Stale.py | 73 --- .../B08_wf5_Quantity_Repository_UnitCost.py | 154 ------ B08_wf5_Quantity/B08_wf5_Quantity_Router.py | 18 - .../B08_wf5_Quantity_Router_Basis.py | 32 -- .../B08_wf5_Quantity_Router_Calculation.py | 73 --- .../B08_wf5_Quantity_Router_Catalog.py | 50 -- .../B08_wf5_Quantity_Router_Costing.py | 59 --- .../B08_wf5_Quantity_Router_Errors.py | 14 - .../B08_wf5_Quantity_Router_Quantity.py | 131 ----- .../B08_wf5_Quantity_Router_Reconciliation.py | 43 -- .../B08_wf5_Quantity_Schema_Basis.py | 46 -- .../B08_wf5_Quantity_Schema_Calculation.py | 84 ---- .../B08_wf5_Quantity_Schema_Catalog.py | 50 -- .../B08_wf5_Quantity_Schema_CostBasis.py | 33 -- .../B08_wf5_Quantity_Schema_Quantity.py | 56 --- .../B08_wf5_Quantity_Schema_Reconciliation.py | 20 - .../B08_wf5_Quantity_Schema_UnitCost.py | 51 -- B08_wf5_Quantity/B08_wf5_Quantity_Store.ts | 101 ---- B08_wf5_Quantity/B08_wf5_Quantity_Types.ts | 55 --- B08_wf5_Quantity/B08_wf5_Quantity_UI_Dom.ts | 53 -- B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts | 80 --- .../B08_wf5_Quantity_UI_References.ts | 55 --- .../B08_wf5_Quantity_UI_Style.css | 249 ---------- .../B08_wf5_Quantity_UI_Tab_Aggregation.ts | 31 -- .../B08_wf5_Quantity_UI_Tab_Basis.ts | 180 ------- .../B08_wf5_Quantity_UI_Tab_Catalog.ts | 192 -------- .../B08_wf5_Quantity_UI_Tab_CostBasis.ts | 130 ----- .../B08_wf5_Quantity_UI_Tab_Equipment.ts | 116 ----- .../B08_wf5_Quantity_UI_Tab_Estimate.ts | 38 -- .../B08_wf5_Quantity_UI_Tab_Final.ts | 198 -------- .../B08_wf5_Quantity_UI_Tab_Indirect.ts | 27 -- .../B08_wf5_Quantity_UI_Tab_Quantity.ts | 454 ------------------ .../B08_wf5_Quantity_UI_Tab_UnitCost.ts | 103 ---- 58 files changed, 17 insertions(+), 4940 deletions(-) create mode 100644 B08_wf5_Quantity/0_old_260726_codex.zip delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Api.ts delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Database.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Database_Basis.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Database_Calculation.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Database_Catalog.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Database_CostBasis.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Database_Quantity.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Database_Reconciliation.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Database_UnitCost.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Engine_CostBasis.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Engine_Estimate.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Engine_Final.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Engine_Formula.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Engine_Indirect.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Engine_UnitCost.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Repository_Basis.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Repository_Calculation.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Repository_CalculationSource.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Repository_Catalog.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Repository_CostBasis.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Repository_Pricing.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Repository_Quantity.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Repository_Reconciliation.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Repository_Stale.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Repository_UnitCost.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Router.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Router_Basis.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Router_Calculation.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Router_Catalog.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Router_Costing.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Router_Errors.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Router_Quantity.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Router_Reconciliation.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Schema_Basis.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Schema_Calculation.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Schema_Catalog.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Schema_CostBasis.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Schema_Quantity.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Schema_Reconciliation.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Schema_UnitCost.py delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Store.ts delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_Types.ts delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_UI_Dom.ts delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_UI_References.ts delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_UI_Style.css delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Aggregation.ts delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Basis.ts delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Catalog.ts delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_CostBasis.ts delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Equipment.ts delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Estimate.ts delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Final.ts delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Indirect.ts delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Quantity.ts delete mode 100644 B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_UnitCost.ts diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Diagram.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Diagram.ts index ec69143c..765028b9 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Diagram.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Diagram.ts @@ -52,11 +52,7 @@ export function buildStandardDiagram(): HTMLElement { // 지반선(원지반) — 좌측 높고 우측 낮은 사면. 절토/성토의 배경. svg.append( - node( - "polyline", - { points: "10,60 95,122 205,138 290,210", fill: "none" }, - "b06-diag__ground", - ), + node("polyline", { points: "10,60 95,122 205,138 290,210", fill: "none" }, "b06-diag__ground"), ); // 노면(노견 포함): 위치 안내 모식도라 횡단경사는 반영하지 않고 수평으로 그린다(D-7). @@ -78,16 +74,23 @@ export function buildStandardDiagram(): HTMLElement { node("line", { x1: shoulderR, y1: roadY - 5, x2: shoulderR, y2: roadY + 5 }, "b06-diag__tick"), ); - // 중심선(계획고): 노면 중앙 수직 파선. + // 중심선(계획고): 노면 중앙 수직 파선. 라벨 행(y=104)은 비워 노폭 라벨과 겹치지 않게 끊는다. const centerX = (roadLeft + roadRight) / 2; - svg.append(node("line", { x1: centerX, y1: 38, x2: centerX, y2: 162 }, "b06-diag__center")); + svg.append( + node("line", { x1: centerX, y1: 38, x2: centerX, y2: 96 }, "b06-diag__center"), + node("line", { x1: centerX, y1: 112, x2: centerX, y2: 162 }, "b06-diag__center"), + ); // 측구(절토측=좌): 노면 좌끝에서 아래로 파는 사다리꼴. + // 깊이는 기존 16 대비 약 60%(10)로 낮추고, 좌·우 벽 기울기를 4.5로 동일하게 맞춰 + // 상단(80~95)과 하단(84.5~90.5)의 중심이 모두 87.5가 되도록 좌우 대칭을 유지한다. + const ditchDepth = 10; + const ditchWallInset = 4.5; svg.append( node( "polygon", { - points: `${roadLeft},${roadY} ${roadLeft - 6},${roadY + 16} ${roadLeft - 12},${roadY + 16} ${roadLeft - 15},${roadY}`, + points: `${roadLeft},${roadY} ${roadLeft - ditchWallInset},${roadY + ditchDepth} ${roadLeft - 15 + ditchWallInset},${roadY + ditchDepth} ${roadLeft - 15},${roadY}`, }, "b06-diag__ditch", ), @@ -98,18 +101,19 @@ export function buildStandardDiagram(): HTMLElement { // 성토 사면(우): 노면 우끝에서 원지반까지 하향. svg.append(node("line", { x1: roadRight, y1: roadY, x2: 278, y2: 210 }, "b06-diag__fill")); - // 노견 좌/우 라벨은 같은 평행선상 좌·우에 둔다(횡단경사 라벨·화살표는 D-7에서 제거). + // 노견 좌 · 노폭 · 노견 우를 한 행(y=104)에 나란히 둔다. 노견 라벨은 바깥쪽 끝이 아니라 + // 중앙 노폭 라벨 옆까지 안쪽으로 당겨 세 라벨이 중심(150) 기준 대칭이 되게 한다. const labelLineY = 104; svg.append( - text(shoulderL - 6, labelLineY, L("B06_Std_Diagram_ShoulderL"), "b06-diag__label-sm", "end"), - text(shoulderR + 6, labelLineY, L("B06_Std_Diagram_ShoulderR"), "b06-diag__label-sm", "start"), + text(centerX - 22, labelLineY, L("B06_Std_Diagram_ShoulderL"), "b06-diag__label-sm", "end"), + text(centerX, labelLineY, L("B06_Std_Diagram_Road"), "b06-diag__label"), + text(centerX + 22, labelLineY, L("B06_Std_Diagram_ShoulderR"), "b06-diag__label-sm", "start"), ); // 나머지 라벨. svg.append( text(centerX, 30, L("B06_Std_Diagram_Center"), "b06-diag__label"), - text(centerX, 148, L("B06_Std_Diagram_Road"), "b06-diag__label"), - text(roadLeft - 32, roadY + 32, L("B06_Std_Diagram_Ditch"), "b06-diag__label-sm", "middle"), + text(roadLeft - 23, roadY + 24, L("B06_Std_Diagram_Ditch"), "b06-diag__label-sm", "middle"), text(46, 54, L("B06_Std_Diagram_Cut"), "b06-diag__label-sm", "middle"), text(252, 196, L("B06_Std_Diagram_Fill"), "b06-diag__label-sm", "middle"), ); diff --git a/B08_wf5_Quantity/0_old_260726_codex.zip b/B08_wf5_Quantity/0_old_260726_codex.zip new file mode 100644 index 0000000000000000000000000000000000000000..d1a2fa72320e12b53f14447ce3b569d3e245bdd0 GIT binary patch literal 71509 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 literal 0 HcmV?d00001 diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Api.ts b/B08_wf5_Quantity/B08_wf5_Quantity_Api.ts deleted file mode 100644 index 8092c782..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Api.ts +++ /dev/null @@ -1,93 +0,0 @@ -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", - headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, - ...init, - }); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - const message = body.detail ?? `요청 실패 (${response.status})`; - errorHandler(message); - throw new Error(message); - } - return response.json() as Promise; -}; - -const path = (projectId: string, suffix: string) => - `/api/b08/${encodeURIComponent(projectId)}${suffix}`; - -export const B08Api = { - basis: (p: string, v: string) => req(path(p, `/basis/${encodeURIComponent(v)}`)), - saveBasis: (p: string, v: string, data: any) => - req(path(p, `/basis/${encodeURIComponent(v)}`), { - method: "PUT", - body: JSON.stringify(data), - }), - catalog: (p: string) => req(path(p, "/catalog")), - savePriceBook: (p: string, data: any) => - req(path(p, "/catalog/price-books"), { method: "POST", body: JSON.stringify(data) }), - saveItem: (p: string, data: any) => - req(path(p, "/catalog/items"), { method: "POST", body: JSON.stringify(data) }), - addCandidate: (p: string, v: string, data: any) => - req(path(p, `/catalog/${encodeURIComponent(v)}/candidates`), { - method: "POST", - body: JSON.stringify(data), - }), - applyPrice: (p: string, v: string, data: any) => - req(path(p, `/catalog/${encodeURIComponent(v)}/apply`), { - method: "POST", - body: JSON.stringify(data), - }), - costing: (p: string) => req(path(p, "/costing")), - saveUnitCost: (p: string, data: any) => - req(path(p, "/unit-costs"), { method: "POST", body: JSON.stringify(data) }), - saveEquipment: (p: string, data: any) => - req(path(p, "/equipment-rates"), { method: "POST", body: JSON.stringify(data) }), - saveCostBasis: (p: string, data: any) => - req(path(p, "/cost-basis"), { method: "POST", body: JSON.stringify(data) }), - quantities: (p: string) => req(path(p, "/quantities")), - saveWbs: (p: string, data: any) => - 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) => - req(path(p, "/reference/import"), { method: "POST", body: JSON.stringify(data) }), - reconcile: (p: string, runId: string, sourceId: string) => - req(path(p, `/reconcile/${encodeURIComponent(runId)}/${encodeURIComponent(sourceId)}`), { - 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 deleted file mode 100644 index e3cccae7..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Database.py +++ /dev/null @@ -1,20 +0,0 @@ -"""B08 도메인 DDL 조립점. 전역 migration runner 연결은 B08 외부 작업이다.""" -from B08_wf5_Quantity.B08_wf5_Quantity_Database_Basis import BASIS_DDL -from B08_wf5_Quantity.B08_wf5_Quantity_Database_Calculation import CALCULATION_DDL -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, - 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 + 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_Basis.py b/B08_wf5_Quantity/B08_wf5_Quantity_Database_Basis.py deleted file mode 100644 index d7919b95..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Database_Basis.py +++ /dev/null @@ -1,29 +0,0 @@ -BASIS_DDL = ( - """CREATE TABLE IF NOT EXISTS b08_basis_versions ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, project_id CHAR(36) NOT NULL, - version VARCHAR(50) NOT NULL, base_date DATE NOT NULL, region VARCHAR(100) NOT NULL, - currency CHAR(3) NOT NULL DEFAULT 'KRW', status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', - confirmed_by BIGINT NULL, confirmed_at DATETIME NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE KEY uq_b08_basis(project_id,version))""", - """CREATE TABLE IF NOT EXISTS b08_price_sources ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, project_id CHAR(36) NOT NULL, - source_code VARCHAR(40) NOT NULL, source_name VARCHAR(100) NOT NULL, - priority_no INT NOT NULL DEFAULT 100, publisher VARCHAR(150) NULL, - reference_date DATE NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', - UNIQUE KEY uq_b08_source(project_id,source_code))""", - """CREATE TABLE IF NOT EXISTS b08_exchange_rates ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, project_id CHAR(36) NOT NULL, - basis_version VARCHAR(50) NOT NULL, currency CHAR(3) NOT NULL, - rate_to_krw DECIMAL(20,8) NOT NULL, source_id BIGINT NULL, - effective_from DATE NOT NULL, effective_to DATE NULL, - UNIQUE KEY uq_b08_fx(project_id,basis_version,currency))""", - """CREATE TABLE IF NOT EXISTS b08_rate_policies ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, project_id CHAR(36) NOT NULL, - basis_version VARCHAR(50) NOT NULL, rule_code VARCHAR(80) NOT NULL, - rule_name VARCHAR(150) NOT NULL, base_expression LONGTEXT NOT NULL, - rate_value DECIMAL(20,10) NULL, minimum_amount BIGINT NULL, maximum_amount BIGINT NULL, - rounding_mode VARCHAR(30) NOT NULL, rounding_unit BIGINT NOT NULL DEFAULT 1, - condition_json LONGTEXT NOT NULL, source_reference VARCHAR(500) NULL, - status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', sort_order INT NOT NULL DEFAULT 0, - UNIQUE KEY uq_b08_rate_policy(project_id,basis_version,rule_code))""", -) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Database_Calculation.py b/B08_wf5_Quantity/B08_wf5_Quantity_Database_Calculation.py deleted file mode 100644 index 18a692e5..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Database_Calculation.py +++ /dev/null @@ -1,39 +0,0 @@ -CALCULATION_DDL = ( - """CREATE TABLE IF NOT EXISTS b08_calculation_runs ( - id CHAR(36) PRIMARY KEY, project_id CHAR(36) NOT NULL, calculation_type VARCHAR(30) NOT NULL, - basis_version VARCHAR(50) NOT NULL, price_version VARCHAR(50) NOT NULL, - quantity_version INT NOT NULL, rule_version VARCHAR(50) NOT NULL, - input_hash CHAR(64) NOT NULL, status VARCHAR(20) NOT NULL, - error_json LONGTEXT NOT NULL, created_by BIGINT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - KEY ix_b08_calc_run(project_id,created_at))""", - """CREATE TABLE IF NOT EXISTS b08_calculation_inputs ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, run_id CHAR(36) NOT NULL, - input_type VARCHAR(30) NOT NULL, reference_id VARCHAR(80) NOT NULL, - snapshot_json LONGTEXT NOT NULL, KEY ix_b08_calc_input(run_id))""", - """CREATE TABLE IF NOT EXISTS b08_estimate_lines ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, run_id CHAR(36) NOT NULL, - quantity_item_id CHAR(36) NOT NULL, wbs_id CHAR(36) NOT NULL, - quantity DECIMAL(20,6) NOT NULL, unit_labor DECIMAL(20,4) NOT NULL, - unit_material DECIMAL(20,4) NOT NULL, unit_expense DECIMAL(20,4) NOT NULL, - labor_amount BIGINT NOT NULL, material_amount BIGINT NOT NULL, - expense_amount BIGINT NOT NULL, total_amount BIGINT NOT NULL, - trace_json LONGTEXT NOT NULL, KEY ix_b08_estimate_line(run_id,wbs_id))""", - """CREATE TABLE IF NOT EXISTS b08_cost_aggregates ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, run_id CHAR(36) NOT NULL, - aggregate_type VARCHAR(30) NOT NULL, group_key VARCHAR(100) NOT NULL, - labor_amount BIGINT NOT NULL, material_amount BIGINT NOT NULL, - expense_amount BIGINT NOT NULL, total_amount BIGINT NOT NULL, - UNIQUE KEY uq_b08_aggregate(run_id,aggregate_type,group_key))""", - """CREATE TABLE IF NOT EXISTS b08_indirect_cost_results ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, run_id CHAR(36) NOT NULL, - rule_code VARCHAR(80) NOT NULL, base_amount BIGINT NOT NULL, - rate_value DECIMAL(20,10) NULL, result_amount BIGINT NOT NULL, - trace_json LONGTEXT NOT NULL, sort_order INT NOT NULL, - UNIQUE KEY uq_b08_indirect(run_id,rule_code))""", - """CREATE TABLE IF NOT EXISTS b08_final_cost_results ( - run_id CHAR(36) PRIMARY KEY, direct_cost BIGINT NOT NULL, net_cost BIGINT NOT NULL, - general_admin BIGINT NOT NULL, profit BIGINT NOT NULL, total_cost BIGINT NOT NULL, - vat BIGINT NOT NULL, contract_cost BIGINT NOT NULL, government_material BIGINT NOT NULL, - procurement_fee BIGINT NOT NULL, total_project_cost BIGINT NOT NULL, - trace_json LONGTEXT NOT NULL)""", -) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Database_Catalog.py b/B08_wf5_Quantity/B08_wf5_Quantity_Database_Catalog.py deleted file mode 100644 index e189ab84..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Database_Catalog.py +++ /dev/null @@ -1,31 +0,0 @@ -CATALOG_DDL = ( - """CREATE TABLE IF NOT EXISTS b08_catalog_items ( - id CHAR(36) PRIMARY KEY, project_id CHAR(36) NOT NULL, item_type VARCHAR(20) NOT NULL, - item_code VARCHAR(50) NOT NULL, item_name VARCHAR(255) NOT NULL, - specification VARCHAR(255) NOT NULL DEFAULT '', unit VARCHAR(30) NOT NULL, - cost_type VARCHAR(20) NOT NULL, procurement_type VARCHAR(20) NOT NULL DEFAULT 'PRIVATE', - active TINYINT(1) NOT NULL DEFAULT 1, UNIQUE KEY uq_b08_catalog(project_id,item_code))""", - """CREATE TABLE IF NOT EXISTS b08_external_code_mappings ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, project_id CHAR(36) NOT NULL, - item_id CHAR(36) NOT NULL, system_code VARCHAR(30) NOT NULL, - external_code VARCHAR(80) NOT NULL, external_version VARCHAR(50) NULL, - UNIQUE KEY uq_b08_mapping(project_id,system_code,external_code))""", - """CREATE TABLE IF NOT EXISTS b08_price_books ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, project_id CHAR(36) NOT NULL, - price_version VARCHAR(50) NOT NULL, basis_version VARCHAR(50) NOT NULL, - name VARCHAR(150) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', - effective_date DATE NOT NULL, UNIQUE KEY uq_b08_book(project_id,price_version))""", - """CREATE TABLE IF NOT EXISTS b08_price_entries ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, project_id CHAR(36) NOT NULL, - price_version VARCHAR(50) NOT NULL, item_id CHAR(36) NOT NULL, source_id BIGINT NOT NULL, - source_price DECIMAL(20,4) NOT NULL, currency CHAR(3) NOT NULL DEFAULT 'KRW', - exchange_rate DECIMAL(20,8) NOT NULL DEFAULT 1, converted_price DECIMAL(20,4) NOT NULL, - reference_page VARCHAR(50) NULL, valid_from DATE NOT NULL, valid_to DATE NULL, - KEY ix_b08_price_entry(project_id,price_version,item_id))""", - """CREATE TABLE IF NOT EXISTS b08_applied_prices ( - project_id CHAR(36) NOT NULL, price_version VARCHAR(50) NOT NULL, - item_id CHAR(36) NOT NULL, price_entry_id BIGINT NOT NULL, - applied_price DECIMAL(20,4) NOT NULL, selection_reason VARCHAR(500) NOT NULL, - approved_by BIGINT NULL, approved_at DATETIME NULL, - PRIMARY KEY(project_id,price_version,item_id))""", -) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Database_CostBasis.py b/B08_wf5_Quantity/B08_wf5_Quantity_Database_CostBasis.py deleted file mode 100644 index e9295723..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Database_CostBasis.py +++ /dev/null @@ -1,19 +0,0 @@ -COST_BASIS_DDL = ( - """CREATE TABLE IF NOT EXISTS b08_cost_basis ( - id CHAR(36) PRIMARY KEY, project_id CHAR(36) NOT NULL, basis_code VARCHAR(50) NOT NULL, - name VARCHAR(255) NOT NULL, specification VARCHAR(255) NOT NULL DEFAULT '', unit VARCHAR(30) NOT NULL, - formula_note LONGTEXT NULL, 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_cost_basis(project_id,basis_code,version_no))""", - """CREATE TABLE IF NOT EXISTS b08_cost_basis_components ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, cost_basis_id CHAR(36) NOT NULL, - component_type VARCHAR(20) NOT NULL, reference_id CHAR(36) NOT NULL, - quantity_expression VARCHAR(500) NOT NULL, cost_type VARCHAR(20) NOT NULL, - sort_order INT NOT NULL DEFAULT 0, KEY ix_b08_cb_component(cost_basis_id))""", - """CREATE TABLE IF NOT EXISTS b08_formula_variables ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, cost_basis_id CHAR(36) NOT NULL, - variable_code VARCHAR(50) NOT NULL, label VARCHAR(150) NOT NULL, - value DECIMAL(20,8) NULL, unit VARCHAR(30) NULL, required TINYINT(1) NOT NULL DEFAULT 1, - UNIQUE KEY uq_b08_variable(cost_basis_id,variable_code))""", -) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Database_Quantity.py b/B08_wf5_Quantity/B08_wf5_Quantity_Database_Quantity.py deleted file mode 100644 index e1264af1..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Database_Quantity.py +++ /dev/null @@ -1,27 +0,0 @@ -QUANTITY_DDL = ( - """CREATE TABLE IF NOT EXISTS b08_work_breakdown ( - id CHAR(36) PRIMARY KEY, project_id CHAR(36) NOT NULL, parent_id CHAR(36) NULL, - wbs_code VARCHAR(50) NOT NULL, name VARCHAR(255) NOT NULL, level_no INT NOT NULL, - sort_order INT NOT NULL DEFAULT 0, UNIQUE KEY uq_b08_wbs(project_id,wbs_code))""", - """CREATE TABLE IF NOT EXISTS b08_design_quantity_items ( - id CHAR(36) PRIMARY KEY, project_id CHAR(36) NOT NULL, wbs_id CHAR(36) NOT NULL, - reference_type VARCHAR(20) NOT NULL, reference_id CHAR(36) NOT NULL, - item_name VARCHAR(255) NOT NULL, specification VARCHAR(255) NOT NULL DEFAULT '', unit VARCHAR(30) NOT NULL, - design_quantity DECIMAL(20,6) NULL, adjusted_quantity DECIMAL(20,6) NULL, - confirmed_quantity DECIMAL(20,6) NULL, adjustment_reason VARCHAR(500) NULL, - procurement_type VARCHAR(20) NOT NULL DEFAULT 'PRIVATE', excluded TINYINT(1) NOT NULL DEFAULT 0, - status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', sort_order INT NOT NULL DEFAULT 0, - updated_by BIGINT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - KEY ix_b08_design_qty(project_id,wbs_id))""", - """CREATE TABLE IF NOT EXISTS b08_quantity_revisions ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, quantity_item_id CHAR(36) NOT NULL, - revision_no INT NOT NULL, before_json LONGTEXT NOT NULL, after_json LONGTEXT NOT NULL, - change_reason VARCHAR(500) NOT NULL, changed_by BIGINT NULL, - changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE KEY uq_b08_qty_revision(quantity_item_id,revision_no))""", - """CREATE TABLE IF NOT EXISTS b08_quantity_confirmations ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, project_id CHAR(36) NOT NULL, - quantity_version INT NOT NULL, input_hash CHAR(64) NOT NULL, - confirmed_by BIGINT NULL, confirmed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE KEY uq_b08_qty_confirm(project_id,quantity_version))""", -) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Database_Reconciliation.py b/B08_wf5_Quantity/B08_wf5_Quantity_Database_Reconciliation.py deleted file mode 100644 index f4c2a729..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Database_Reconciliation.py +++ /dev/null @@ -1,22 +0,0 @@ -RECONCILIATION_DDL = ( - """CREATE TABLE IF NOT EXISTS b08_source_files ( - id CHAR(36) PRIMARY KEY, project_id CHAR(36) NOT NULL, source_type VARCHAR(20) NOT NULL, - original_filename VARCHAR(255) NOT NULL, sha256 CHAR(64) NOT NULL, - source_version VARCHAR(50) NULL, stored_path VARCHAR(500) NOT NULL, - imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE KEY uq_b08_source_file(project_id,sha256))""", - """CREATE TABLE IF NOT EXISTS b08_reference_values ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, source_file_id CHAR(36) NOT NULL, - stage_code VARCHAR(80) NOT NULL, reference_key VARCHAR(100) NOT NULL, - amount BIGINT NOT NULL, metadata_json LONGTEXT NOT NULL, - UNIQUE KEY uq_b08_reference(source_file_id,stage_code,reference_key))""", - """CREATE TABLE IF NOT EXISTS b08_reconciliation_runs ( - id CHAR(36) PRIMARY KEY, project_id CHAR(36) NOT NULL, calculation_run_id CHAR(36) NOT NULL, - source_file_id CHAR(36) NOT NULL, status VARCHAR(20) NOT NULL, - first_difference_stage VARCHAR(80) NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""", - """CREATE TABLE IF NOT EXISTS b08_reconciliation_differences ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, reconciliation_run_id CHAR(36) NOT NULL, - stage_code VARCHAR(80) NOT NULL, reference_key VARCHAR(100) NOT NULL, - expected_amount BIGINT NOT NULL, actual_amount BIGINT NOT NULL, difference_amount BIGINT NOT NULL, - KEY ix_b08_recon_diff(reconciliation_run_id,stage_code))""", -) \ 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 deleted file mode 100644 index d73117b3..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Database_UnitCost.py +++ /dev/null @@ -1,50 +0,0 @@ -UNIT_COST_DDL = ( - """CREATE TABLE IF NOT EXISTS b08_unit_costs ( - id CHAR(36) PRIMARY KEY, project_id CHAR(36) NOT NULL, unit_cost_code VARCHAR(50) NOT NULL, - name VARCHAR(255) NOT NULL, specification VARCHAR(255) NOT NULL DEFAULT '', unit VARCHAR(30) NOT NULL, - rounding_mode VARCHAR(30) NOT NULL DEFAULT 'ROUND_HALF_UP', rounding_unit BIGINT NOT NULL DEFAULT 1, - 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_unit_cost(project_id,unit_cost_code,version_no))""", - """CREATE TABLE IF NOT EXISTS b08_unit_cost_components ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, unit_cost_id CHAR(36) NOT NULL, - component_type VARCHAR(20) NOT NULL, reference_id CHAR(36) NOT NULL, - quantity DECIMAL(20,8) NOT NULL, cost_type VARCHAR(20) NOT NULL, - sort_order INT NOT NULL DEFAULT 0, note VARCHAR(500) NULL, - KEY ix_b08_uc_component(unit_cost_id))""", - """CREATE TABLE IF NOT EXISTS b08_equipment_rates ( - 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, 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_CostBasis.py b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_CostBasis.py deleted file mode 100644 index 3843c5aa..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_CostBasis.py +++ /dev/null @@ -1,19 +0,0 @@ -from decimal import Decimal -from B08_wf5_Quantity.B08_wf5_Quantity_Engine_Formula import evaluate, round_amount -from B08_wf5_Quantity.B08_wf5_Quantity_Schema_CostBasis import CostBasis -from B08_wf5_Quantity.B08_wf5_Quantity_Schema_UnitCost import CostResult - -def calculate_cost_basis(model: CostBasis) -> CostResult: - variables={v.code:v.value for v in model.variables if v.value is not None} - missing=[v.label for v in model.variables if v.required and v.value is None] - if missing: raise ValueError("필수 산출변수 미입력: "+", ".join(missing)) - buckets={"LABOR":Decimal(0),"MATERIAL":Decimal(0),"EXPENSE":Decimal(0)}; trace=[] - for component in sorted(model.components,key=lambda row:row.sort_order): - quantity=evaluate(component.quantity_expression,variables) - if quantity<0: raise ValueError(f"{component.reference_name}: 구성수량이 음수입니다.") - amount=quantity*component.unit_price; buckets[component.cost_type]+=amount - trace.append({"reference_id":component.reference_id,"expression":component.quantity_expression, - "quantity":str(quantity),"unit_price":str(component.unit_price),"amount":str(amount)}) - labor=round_amount(buckets["LABOR"],"FLOOR",1); material=round_amount(buckets["MATERIAL"],"FLOOR",1) - expense=round_amount(buckets["EXPENSE"],"FLOOR",1) - return CostResult(labor=labor,material=material,expense=expense,total=labor+material+expense,trace=trace) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Estimate.py b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Estimate.py deleted file mode 100644 index 851bb9eb..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Estimate.py +++ /dev/null @@ -1,34 +0,0 @@ -from decimal import Decimal -from B08_wf5_Quantity.B08_wf5_Quantity_Engine_Formula import round_amount -from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Calculation import CostTotals, EstimateInput, EstimateLine - -def calculate_estimate(inputs: list[EstimateInput]) -> tuple[list[EstimateLine],CostTotals]: - if not inputs: raise ValueError("설계수량 항목이 없습니다.") - lines=[]; totals=CostTotals() - for row in inputs: - if row.quantity<0: raise ValueError(f"{row.quantity_item_id}: 수량이 음수입니다.") - labor=round_amount(row.quantity*row.unit_price.labor,"FLOOR",1) - material=round_amount(row.quantity*row.unit_price.material,"FLOOR",1) - expense=round_amount(row.quantity*row.unit_price.expense,"FLOOR",1) - total=labor+material+expense - line=EstimateLine(quantity_item_id=row.quantity_item_id,wbs_id=row.wbs_id,quantity=row.quantity, - unit_labor=row.unit_price.labor,unit_material=row.unit_price.material,unit_expense=row.unit_price.expense, - labor_amount=labor,material_amount=material,expense_amount=expense,total_amount=total, - procurement_type=row.procurement_type,trace={"formula":"quantity × unit price"}) - lines.append(line) - if row.procurement_type=="GOVERNMENT": totals.government_material+=total - elif row.procurement_type=="EXCLUDED": totals.excluded_amount+=total - else: totals.labor+=labor; totals.material+=material; totals.expense+=expense - totals.direct_cost=totals.labor+totals.material+totals.expense - return lines,totals - -def aggregate_by_wbs(lines: list[EstimateLine]) -> dict[str,CostTotals]: - result={} - for line in lines: - total=result.setdefault(line.wbs_id,CostTotals()) - if line.procurement_type=="GOVERNMENT": total.government_material+=line.total_amount - elif line.procurement_type=="EXCLUDED": total.excluded_amount+=line.total_amount - else: - total.labor+=line.labor_amount; total.material+=line.material_amount; total.expense+=line.expense_amount - total.direct_cost=total.labor+total.material+total.expense - return result \ 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 deleted file mode 100644 index d1846b4c..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Final.py +++ /dev/null @@ -1,55 +0,0 @@ -from B08_wf5_Quantity.B08_wf5_Quantity_Engine_Indirect import calculate_indirect -from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Basis import RatePolicy -from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Calculation import CostTotals, FinalCost - -_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) - overhead = [row for row in ordered if row.rule_code not in _RESERVED] - results, context = calculate_indirect(overhead, context) - net_cost = totals.direct_cost + sum(row.result_amount for row in results) - context["NET_COST"] = net_cost - - general_rows, context = _stage(ordered, "GENERAL_ADMIN", context) - results += general_rows - general = context.get("GENERAL_ADMIN", 0) - context["AFTER_GENERAL"] = net_cost + general - - profit_rows, context = _stage(ordered, "PROFIT", context) - results += profit_rows - profit = context.get("PROFIT", 0) - total_cost = net_cost + general + profit - context["TOTAL_COST"] = total_cost - - vat_rows, context = _stage(ordered, "VAT", context) - results += vat_rows - vat = context.get("VAT", 0) - contract_cost = total_cost + vat - context["CONTRACT_COST"] = contract_cost - - fee_rows, context = _stage(ordered, "PROCUREMENT_FEE", context) - results += fee_rows - fee = context.get("PROCUREMENT_FEE", 0) - total_project_cost = contract_cost + totals.government_material + fee - context["TOTAL_PROJECT_COST"] = total_project_cost - return FinalCost(direct_cost=totals.direct_cost, net_cost=net_cost, general_admin=general, - profit=profit, total_cost=total_cost, vat=vat, contract_cost=contract_cost, - government_material=totals.government_material, procurement_fee=fee, - total_project_cost=total_project_cost, indirect_results=results, - trace={"excluded_amount": totals.excluded_amount, "context": context}) - -def _stage(policies: list[RatePolicy], code: str, context: dict[str, int]): - selected = [row for row in policies if row.rule_code == code] - if not selected: - return [], context - return calculate_indirect(selected, context) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Formula.py b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Formula.py deleted file mode 100644 index 41a2f3ca..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Formula.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations -import ast -from decimal import Decimal, ROUND_CEILING, ROUND_FLOOR, ROUND_HALF_UP - -_ALLOWED_BINARY = {ast.Add: lambda a,b:a+b, ast.Sub:lambda a,b:a-b, - ast.Mult:lambda a,b:a*b, ast.Div:lambda a,b:a/b} -_ALLOWED_UNARY = {ast.UAdd:lambda a:a, ast.USub:lambda a:-a} - -def evaluate(expression: str, variables: dict[str, Decimal | int]) -> Decimal: - values = {key: Decimal(str(value)) for key,value in variables.items()} - tree = ast.parse(expression, mode="eval") - def visit(node): - if isinstance(node, ast.Expression): return visit(node.body) - if isinstance(node, ast.Constant) and isinstance(node.value,(int,float)): return Decimal(str(node.value)) - if isinstance(node, ast.Name): - if node.id not in values: raise ValueError(f"수식 변수 미입력: {node.id}") - return values[node.id] - if isinstance(node, ast.BinOp) and type(node.op) in _ALLOWED_BINARY: - right=visit(node.right) - if isinstance(node.op,ast.Div) and right==0: raise ValueError("0으로 나눌 수 없습니다.") - return _ALLOWED_BINARY[type(node.op)](visit(node.left),right) - if isinstance(node,ast.UnaryOp) and type(node.op) in _ALLOWED_UNARY: - return _ALLOWED_UNARY[type(node.op)](visit(node.operand)) - if isinstance(node,ast.Call) and isinstance(node.func,ast.Name) and node.func.id in {"min","max"}: - args=[visit(arg) for arg in node.args] - return (min if node.func.id=="min" else max)(args) - raise ValueError(f"허용되지 않은 수식 요소: {type(node).__name__}") - return visit(tree) - -def round_amount(value: Decimal, mode: str="ROUND", unit: int=1) -> int: - if unit < 1: raise ValueError("절사 단위는 1 이상이어야 합니다.") - rounding={"ROUND":ROUND_HALF_UP,"FLOOR":ROUND_FLOOR,"CEILING":ROUND_CEILING}.get(mode) - if rounding is None: raise ValueError(f"지원하지 않는 반올림 방식: {mode}") - step=Decimal(unit) - return int((value/step).quantize(Decimal("1"),rounding=rounding)*step) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Indirect.py b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Indirect.py deleted file mode 100644 index 4feaf63f..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Indirect.py +++ /dev/null @@ -1,19 +0,0 @@ -from decimal import Decimal -from B08_wf5_Quantity.B08_wf5_Quantity_Engine_Formula import evaluate, round_amount -from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Basis import RatePolicy -from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Calculation import IndirectResult - -def calculate_indirect(policies: list[RatePolicy], initial: dict[str,int]) -> tuple[list[IndirectResult],dict[str,int]]: - context=dict(initial); results=[] - for rule in sorted(policies,key=lambda row:row.sort_order): - if rule.status!="APPROVED": raise ValueError(f"미승인 요율 규칙: {rule.rule_name}") - base=evaluate(rule.base_expression,context) - raw=base*(rule.rate_value if rule.rate_value is not None else Decimal(1)) - if rule.minimum_amount is not None: raw=max(raw,Decimal(rule.minimum_amount)) - if rule.maximum_amount is not None: raw=min(raw,Decimal(rule.maximum_amount)) - amount=round_amount(raw,rule.rounding_mode,rule.rounding_unit) - context[rule.rule_code]=amount - results.append(IndirectResult(rule_code=rule.rule_code,rule_name=rule.rule_name, - base_amount=round_amount(base,"ROUND",1),rate_value=rule.rate_value,result_amount=amount, - trace={"rule_name":rule.rule_name,"expression":rule.base_expression,"raw":str(raw),"rounding":rule.rounding_mode,"unit":rule.rounding_unit})) - return results,context \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_UnitCost.py b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_UnitCost.py deleted file mode 100644 index 575cd053..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_UnitCost.py +++ /dev/null @@ -1,96 +0,0 @@ -from decimal import Decimal - -from B08_wf5_Quantity.B08_wf5_Quantity_Engine_Formula import round_amount -from B08_wf5_Quantity.B08_wf5_Quantity_Schema_UnitCost import ( - CostResult, - EquipmentRate, - UnitCost, -) - - -def _calculate(components, mode: str, unit: int) -> CostResult: - buckets = {"LABOR": Decimal(0), "MATERIAL": Decimal(0), "EXPENSE": Decimal(0)} - trace = [] - for component in sorted(components, key=lambda row: row.sort_order): - amount = component.quantity * component.unit_price - buckets[component.cost_type] += amount - trace.append( - { - "reference_id": component.reference_id, - "quantity": str(component.quantity), - "unit_price": str(component.unit_price), - "amount": str(amount), - "cost_type": component.cost_type, - } - ) - labor = round_amount(buckets["LABOR"], mode, unit) - material = round_amount(buckets["MATERIAL"], mode, unit) - expense = round_amount(buckets["EXPENSE"], mode, unit) - return CostResult( - labor=labor, - material=material, - expense=expense, - total=labor + material + expense, - trace=trace, - ) - - -def calculate_unit_cost(model: UnitCost) -> CostResult: - if not model.components: - raise ValueError(f"{model.name}: 구성요소가 없습니다.") - return _calculate(model.components, model.rounding_mode, model.rounding_unit) - - -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) - 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, - ) - - -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 deleted file mode 100644 index 4909a214..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Basis.py +++ /dev/null @@ -1,131 +0,0 @@ -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, - PriceSource, - RatePolicy, -) - - -class BasisRepository: - def __init__(self, connection): - self.db = connection - - async def load(self, project_id: str, version: str) -> BasisWorkspace | None: - async with self.db.cursor() as cursor: - await cursor.execute( - "SELECT * FROM b08_basis_versions WHERE project_id=%s AND version=%s", - (project_id, version), - ) - head = await cursor.fetchone() - if not head: - return None - await cursor.execute( - """SELECT * FROM b08_price_sources - WHERE project_id=%s AND status='ACTIVE' ORDER BY priority_no""", - (project_id,), - ) - sources = list(await cursor.fetchall()) - await cursor.execute( - "SELECT * FROM b08_exchange_rates WHERE project_id=%s AND basis_version=%s", - (project_id, version), - ) - rates = list(await cursor.fetchall()) - await cursor.execute( - """SELECT * FROM b08_rate_policies - WHERE project_id=%s AND basis_version=%s ORDER BY sort_order""", - (project_id, version), - ) - policies = list(await cursor.fetchall()) - return BasisWorkspace( - project_id=project_id, - version=version, - base_date=head["base_date"], - region=head["region"], - currency=head["currency"], - status=head["status"], - price_sources=[PriceSource.model_validate(row) for row in sources], - exchange_rates=[ExchangeRate.model_validate(row) for row in rates], - rate_policies=[self._policy(row) for row in policies], - ) - - async def save(self, data: BasisWorkspace, user_id: int | None) -> None: - async with self.db.cursor() as cursor: - await cursor.execute( - """INSERT INTO b08_basis_versions( - project_id,version,base_date,region,currency,status,confirmed_by,confirmed_at - ) VALUES(%s,%s,%s,%s,%s,%s,%s, - IF(%s='CONFIRMED',CURRENT_TIMESTAMP,NULL)) - ON DUPLICATE KEY UPDATE - base_date=VALUES(base_date),region=VALUES(region),currency=VALUES(currency), - status=VALUES(status),confirmed_by=VALUES(confirmed_by), - confirmed_at=VALUES(confirmed_at)""", - (data.project_id, data.version, data.base_date, data.region, data.currency, - data.status, user_id if data.status == "CONFIRMED" else None, data.status), - ) - await self._invalidate_dependents(cursor, data.project_id, data.version) - await cursor.execute( - "UPDATE b08_price_sources SET status='INACTIVE' WHERE project_id=%s", - (data.project_id,), - ) - for source in data.price_sources: - await cursor.execute( - """INSERT INTO b08_price_sources( - id,project_id,source_code,source_name,priority_no,publisher, - reference_date,status - ) VALUES(%s,%s,%s,%s,%s,%s,%s,'ACTIVE') - ON DUPLICATE KEY UPDATE source_name=VALUES(source_name), - priority_no=VALUES(priority_no),publisher=VALUES(publisher), - reference_date=VALUES(reference_date),status='ACTIVE'""", - (source.id, data.project_id, source.source_code, source.source_name, - source.priority_no, source.publisher, source.reference_date), - ) - await cursor.execute( - "DELETE FROM b08_exchange_rates WHERE project_id=%s AND basis_version=%s", - (data.project_id, data.version), - ) - for rate in data.exchange_rates: - await cursor.execute( - """INSERT INTO b08_exchange_rates( - project_id,basis_version,currency,rate_to_krw,source_id, - effective_from,effective_to - ) VALUES(%s,%s,%s,%s,%s,%s,%s)""", - (data.project_id, data.version, rate.currency, rate.rate_to_krw, - rate.source_id, rate.effective_from, rate.effective_to), - ) - await cursor.execute( - "DELETE FROM b08_rate_policies WHERE project_id=%s AND basis_version=%s", - (data.project_id, data.version), - ) - for policy in data.rate_policies: - await cursor.execute( - """INSERT INTO b08_rate_policies( - project_id,basis_version,rule_code,rule_name,base_expression, - rate_value,minimum_amount,maximum_amount,rounding_mode, - rounding_unit,condition_json,source_reference,status,sort_order - ) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", - (data.project_id, data.version, policy.rule_code, policy.rule_name, - policy.base_expression, policy.rate_value, policy.minimum_amount, - policy.maximum_amount, policy.rounding_mode, policy.rounding_unit, - json.dumps(policy.condition_json, ensure_ascii=False), - policy.source_reference, policy.status, policy.sort_order), - ) - await self.db.commit() - - @staticmethod - def _policy(row: dict) -> RatePolicy: - values = dict(row) - condition = values.get("condition_json") - values["condition_json"] = json.loads(condition) if isinstance(condition, str) else (condition or {}) - return RatePolicy.model_validate(values) - - @staticmethod - async def _invalidate_dependents(cursor, project_id: str, version: str) -> None: - await cursor.execute( - """UPDATE b08_price_books SET status='STALE' - WHERE project_id=%s AND basis_version=%s AND status='CONFIRMED'""", - (project_id, version), - ) - 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 deleted file mode 100644 index 74861f98..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Calculation.py +++ /dev/null @@ -1,189 +0,0 @@ -import json -from uuid import uuid4 - -from B08_wf5_Quantity.B08_wf5_Quantity_Engine_Estimate import aggregate_by_wbs -from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Calculation import ( - CostTotals, - EstimateLine, - FinalCost, -) - - -class CalculationRepository: - def __init__(self, connection): - self.db = connection - - async def save( - self, - project_id: str, - versions: dict, - input_hash: str, - snapshots: list[dict], - lines: list[EstimateLine], - totals: CostTotals, - final: FinalCost, - user_id: int | None, - ) -> str: - run_id = str(uuid4()) - async with self.db.cursor() as cursor: - await cursor.execute( - """INSERT INTO b08_calculation_runs( - id,project_id,calculation_type,basis_version,price_version, - quantity_version,rule_version,input_hash,status,error_json,created_by - ) VALUES(%s,%s,'FINAL',%s,%s,%s,%s,%s,'COMPLETE','[]',%s)""", - (run_id, project_id, versions["basis_version"], versions["price_version"], - versions["quantity_version"], versions["rule_version"], input_hash, user_id), - ) - for snapshot in snapshots: - await cursor.execute( - """INSERT INTO b08_calculation_inputs( - run_id,input_type,reference_id,snapshot_json - ) VALUES(%s,'QUANTITY_ITEM',%s,%s)""", - (run_id, snapshot["quantity_item_id"], - json.dumps(snapshot, ensure_ascii=False)), - ) - for line in lines: - await cursor.execute( - """INSERT INTO b08_estimate_lines( - run_id,quantity_item_id,wbs_id,quantity,unit_labor, - unit_material,unit_expense,labor_amount,material_amount, - expense_amount,total_amount,trace_json - ) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", - (run_id, line.quantity_item_id, line.wbs_id, line.quantity, - line.unit_labor, line.unit_material, line.unit_expense, - line.labor_amount, line.material_amount, line.expense_amount, - line.total_amount, json.dumps(line.trace, ensure_ascii=False)), - ) - await self._save_aggregates(cursor, run_id, lines, totals) - for sort_order, result in enumerate(final.indirect_results): - await cursor.execute( - """INSERT INTO b08_indirect_cost_results( - run_id,rule_code,base_amount,rate_value,result_amount, - trace_json,sort_order - ) VALUES(%s,%s,%s,%s,%s,%s,%s)""", - (run_id, result.rule_code, result.base_amount, result.rate_value, - result.result_amount, json.dumps(result.trace, ensure_ascii=False), - sort_order), - ) - await cursor.execute( - """INSERT INTO b08_final_cost_results( - run_id,direct_cost,net_cost,general_admin,profit,total_cost, - vat,contract_cost,government_material,procurement_fee, - total_project_cost,trace_json - ) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", - (run_id, final.direct_cost, final.net_cost, final.general_admin, - final.profit, final.total_cost, final.vat, final.contract_cost, - final.government_material, final.procurement_fee, - final.total_project_cost, json.dumps(final.trace, ensure_ascii=False)), - ) - await self.db.commit() - return run_id - - @staticmethod - async def _save_aggregates(cursor, run_id: str, lines: list[EstimateLine], totals: CostTotals) -> None: - grouped = aggregate_by_wbs(lines) - grouped["__TOTAL__"] = totals - for group_key, total in grouped.items(): - aggregate_type = "TOTAL" if group_key == "__TOTAL__" else "WBS" - total_amount = ( - total.direct_cost + total.government_material + total.excluded_amount - ) - await cursor.execute( - """INSERT INTO b08_cost_aggregates( - run_id,aggregate_type,group_key,labor_amount,material_amount, - expense_amount,total_amount - ) VALUES(%s,%s,%s,%s,%s,%s,%s)""", - (run_id, aggregate_type, group_key, total.labor, total.material, - total.expense, total_amount), - ) - - - async def latest_detail(self, project_id: str) -> dict | None: - async with self.db.cursor() as cursor: - await cursor.execute( - """SELECT id FROM b08_calculation_runs - WHERE project_id=%s AND status='COMPLETE' - 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: - raise LookupError("계산 실행을 찾을 수 없습니다.") - await cursor.execute( - "SELECT * FROM b08_estimate_lines WHERE run_id=%s ORDER BY 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,), - ) - aggregate = await cursor.fetchone() or {} - await cursor.execute( - """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,), - ) - 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"] - ) - final_trace = json.loads(final.pop("trace_json")) - final["trace"] = final_trace - final["indirect_results"] = indirect - totals = { - "labor": aggregate.get("labor_amount", 0), - "material": aggregate.get("material_amount", 0), - "expense": aggregate.get("expense_amount", 0), - "direct_cost": final["direct_cost"], - "government_material": final["government_material"], - "excluded_amount": final_trace.get("excluded_amount", 0), - } - 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( - """SELECT r.*, f.total_project_cost - FROM b08_calculation_runs r - LEFT JOIN b08_final_cost_results f ON f.run_id=r.id - WHERE r.project_id=%s ORDER BY r.created_at DESC""", - (project_id,), - ) - return list(await cursor.fetchall()) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CalculationSource.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CalculationSource.py deleted file mode 100644 index 09d0b017..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CalculationSource.py +++ /dev/null @@ -1,169 +0,0 @@ -import hashlib -import json -from decimal import Decimal - -from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Basis import RatePolicy -from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Calculation import ( - CalculationVersions, - EstimateInput, - UnitPriceBreakdown, -) - - -class CalculationSourceRepository: - def __init__(self, connection): - self.db = connection - - async def load( - self, project_id: str, versions: CalculationVersions - ) -> tuple[list[EstimateInput], list[RatePolicy], list[dict]]: - async with self.db.cursor() as cursor: - await self._validate_versions(cursor, project_id, versions) - await cursor.execute(self._input_query(), (versions.price_version, project_id)) - rows = list(await cursor.fetchall()) - await cursor.execute( - """SELECT * FROM b08_rate_policies - WHERE project_id=%s AND basis_version=%s AND status='APPROVED' - ORDER BY sort_order""", - (project_id, versions.rule_version), - ) - policy_rows = list(await cursor.fetchall()) - if not rows: - raise ValueError("확정된 설계수량 항목이 없습니다.") - missing = [row for row in rows if not row["reference_found"]] - if missing: - raise ValueError(f"확정 적용단가를 찾을 수 없는 항목 {len(missing)}건이 있습니다.") - await self._validate_quantity_hash(project_id, versions.quantity_version, rows) - inputs = [self._to_input(row) for row in rows] - policies = [self._to_policy(row) for row in policy_rows] - snapshots = [self._snapshot(row) for row in rows] - return inputs, policies, snapshots - - async def _validate_versions(self, cursor, project_id: str, versions: CalculationVersions) -> None: - await cursor.execute( - """SELECT 1 FROM b08_basis_versions - WHERE project_id=%s AND version=%s AND status='CONFIRMED'""", - (project_id, versions.basis_version), - ) - if not await cursor.fetchone(): - raise ValueError("확정된 기준정보 버전이 아닙니다.") - if versions.rule_version != versions.basis_version: - raise ValueError("요율 규칙 버전은 기준정보 버전과 같아야 합니다.") - await cursor.execute( - """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,), - ) - 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""", - (project_id,), - ) - latest = await cursor.fetchone() - if not latest or latest["quantity_version"] != versions.quantity_version: - raise ValueError("최신 확정 수량 버전을 사용해야 합니다.") - - async def _validate_quantity_hash( - self, project_id: str, quantity_version: int, rows: list[dict] - ) -> None: - snapshot = [ - {"id": row["id"], "quantity": row["confirmed_quantity"]} - for row in sorted(rows, key=lambda item: item["id"]) - ] - digest = hashlib.sha256( - json.dumps(snapshot, default=str, sort_keys=True).encode() - ).hexdigest() - async with self.db.cursor() as cursor: - await cursor.execute( - """SELECT input_hash FROM b08_quantity_confirmations - WHERE project_id=%s AND quantity_version=%s""", - (project_id, quantity_version), - ) - confirmation = await cursor.fetchone() - if not confirmation or confirmation["input_hash"] != digest: - raise ValueError("확정 후 설계수량이 변경되어 다시 확정해야 합니다.") - - @staticmethod - def _input_query() -> str: - return """ - SELECT q.*, - CASE q.reference_type - WHEN 'CATALOG' THEN IF(ci.cost_type='LABOR',ap.applied_price,0) - ELSE COALESCE(uc.labor_price,eq.labor_price,cb.labor_price,0) - END AS unit_labor, - CASE q.reference_type - WHEN 'CATALOG' THEN IF(ci.cost_type='MATERIAL',ap.applied_price,0) - ELSE COALESCE(uc.material_price,eq.material_price,cb.material_price,0) - END AS unit_material, - CASE q.reference_type - 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 - 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 - LEFT JOIN b08_applied_prices ap - ON ap.project_id=q.project_id AND ap.item_id=ci.id AND ap.price_version=%s - LEFT JOIN b08_unit_costs uc - ON q.reference_type='UNIT_COST' AND uc.id=q.reference_id - 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.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.project_id=q.project_id AND cb.status='CONFIRMED' - WHERE q.project_id=%s AND q.status='CONFIRMED' - ORDER BY q.wbs_id,q.sort_order,q.id - """ - - @staticmethod - def _to_input(row: dict) -> EstimateInput: - return EstimateInput( - quantity_item_id=row["id"], - wbs_id=row["wbs_id"], - quantity=Decimal(str(row["confirmed_quantity"])), - unit_price=UnitPriceBreakdown( - labor=Decimal(str(row["unit_labor"] or 0)), - material=Decimal(str(row["unit_material"] or 0)), - expense=Decimal(str(row["unit_expense"] or 0)), - ), - procurement_type="EXCLUDED" if row["excluded"] else row["procurement_type"], - ) - - @staticmethod - def _to_policy(row: dict) -> RatePolicy: - values = dict(row) - condition = values.get("condition_json") - values["condition_json"] = json.loads(condition) if isinstance(condition, str) else (condition or {}) - return RatePolicy.model_validate(values) - - @staticmethod - def _snapshot(row: dict) -> dict: - return { - "quantity_item_id": row["id"], - "wbs_id": row["wbs_id"], - "reference_type": row["reference_type"], - "reference_id": row["reference_id"], - "quantity": str(row["confirmed_quantity"]), - "unit_labor": str(row["unit_labor"] or 0), - "unit_material": str(row["unit_material"] or 0), - "unit_expense": str(row["unit_expense"] or 0), - "procurement_type": "EXCLUDED" if row["excluded"] else row["procurement_type"], - } \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Catalog.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Catalog.py deleted file mode 100644 index 01d89aca..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Catalog.py +++ /dev/null @@ -1,231 +0,0 @@ -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, - PriceBook, - PriceCandidate, -) - - -class CatalogRepository: - def __init__(self, connection): - self.db = connection - - async def save_book(self, project_id: str, row: PriceBook) -> None: - async with self.db.cursor() as cursor: - if row.status == "CONFIRMED": - await cursor.execute( - """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 workspace(self, project_id: str) -> dict: - 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 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""", - (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""", - (project_id,), - ) - candidates = list(await cursor.fetchall()) - return { - "sources": sources, - "price_books": price_books, - "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: - 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)""", - (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: - async with self.db.cursor() as cursor: - await cursor.execute( - """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.upper(), exchange_rate, converted_price, - row.reference_page, row.valid_from, row.valid_to), - ) - result = cursor.lastrowid - await self.db.commit() - return result - - @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 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("확정 가격판의 후보가격만 적용할 수 있습니다.") - 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""", - (project_id, version, row.item_id, row.price_entry_id, - row.applied_price, row.selection_reason, user_id), - ) - await StaleRepository.mark_dependents( - cursor, project_id, "CATALOG", row.item_id - ) - await self.db.commit() diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CostBasis.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CostBasis.py deleted file mode 100644 index 3f2c0425..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CostBasis.py +++ /dev/null @@ -1,87 +0,0 @@ -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: - 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 deleted file mode 100644 index c9071ff2..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Pricing.py +++ /dev/null @@ -1,47 +0,0 @@ -from decimal import Decimal - - -class PricingResolver: - def __init__(self, connection, project_id: str): - self.db = connection - self.project_id = project_id - - async def resolve(self, component_type: str, reference_id: str, cost_type: str) -> Decimal: - queries = { - "CATALOG": """SELECT a.applied_price AS labor_price, - a.applied_price AS material_price,a.applied_price AS expense_price,i.cost_type - FROM b08_applied_prices a - JOIN b08_price_books b ON b.project_id=a.project_id - 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,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 - FROM b08_equipment_rates WHERE project_id=%s AND id=%s AND status='CONFIRMED'""", - "COST_BASIS": """SELECT labor_price,material_price,expense_price,NULL AS cost_type - FROM b08_cost_basis WHERE project_id=%s AND id=%s AND status='CONFIRMED'""", - } - if component_type not in queries: - raise ValueError(f"지원하지 않는 참조유형: {component_type}") - async with self.db.cursor() as cursor: - await cursor.execute(queries[component_type], (self.project_id, reference_id)) - row = await cursor.fetchone() - if not row: - raise ValueError(f"확정 적용단가 없음: {component_type}/{reference_id}") - if component_type == "CATALOG" and row["cost_type"] != cost_type: - raise ValueError(f"품목 비용분류 불일치: {reference_id}") - column = { - "LABOR": "labor_price", - "MATERIAL": "material_price", - "EXPENSE": "expense_price", - }[cost_type] - return Decimal(str(row[column])) - - async def hydrate(self, components): - for item in components: - item.unit_price = await self.resolve( - item.component_type, item.reference_id, item.cost_type - ) - return components \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Quantity.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Quantity.py deleted file mode 100644 index 35471843..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Quantity.py +++ /dev/null @@ -1,406 +0,0 @@ -import hashlib -import json -from uuid import uuid4 - -from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Quantity import ( - BulkQuantityRequest, - DesignQuantity, - SortRequest, - WorkBreakdown, -) - - -class QuantityRepository: - def __init__(self, connection): - self.db = connection - - 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,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 - FROM b08_quantity_confirmations WHERE project_id=%s - ORDER BY quantity_version DESC LIMIT 1""", - (project_id,), - ) - confirmation = await cursor.fetchone() - return { - "work_breakdown": work_breakdown, - "quantities": quantities, - "confirmation": confirmation, - } - - @staticmethod - def _quantity_query() -> str: - return """ - SELECT q.*, - CASE q.reference_type - WHEN 'CATALOG' THEN IF(ci.cost_type='LABOR',ap.applied_price,0) - ELSE COALESCE(uc.labor_price,eq.labor_price,cb.labor_price,0) - END AS unit_labor, - CASE q.reference_type - WHEN 'CATALOG' THEN IF(ci.cost_type='MATERIAL',ap.applied_price,0) - ELSE COALESCE(uc.material_price,eq.material_price,cb.material_price,0) - END AS unit_material, - CASE q.reference_type - 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.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.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.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.project_id=q.project_id AND cb.status='CONFIRMED' - WHERE q.project_id=%s - ORDER BY q.wbs_id,q.sort_order,q.id - """ - - async def save_wbs(self, project_id: str, row: WorkBreakdown) -> str: - 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)""", - (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,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)""", - (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), - ) - if before: - 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 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 * FROM b08_design_quantity_items - WHERE project_id=%s AND id=%s""", - (project_id, row_id), - ) - 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()) - 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""", - (user_id, project_id), - ) - snapshot = [ - { - "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"]) - ] - 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""", - (project_id,), - ) - version = (await cursor.fetchone())["version"] - await cursor.execute( - """INSERT INTO b08_quantity_confirmations( - project_id,quantity_version,input_hash,confirmed_by - ) VALUES(%s,%s,%s,%s)""", - (project_id, version, digest, user_id), - ) - await self.db.commit() - 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_Reconciliation.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Reconciliation.py deleted file mode 100644 index 6dde0a63..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Reconciliation.py +++ /dev/null @@ -1,30 +0,0 @@ -import json -from uuid import uuid4 -from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Reconciliation import ReferenceImport,ReconciliationResult - -_STAGES=[("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")] -class ReconciliationRepository: - def __init__(self,connection):self.db=connection - async def import_reference(self,project_id:str,data:ReferenceImport)->str: - source_id=str(uuid4()) - async with self.db.cursor() as c: - await c.execute("INSERT INTO b08_source_files(id,project_id,source_type,original_filename,sha256,source_version,stored_path) VALUES(%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE original_filename=VALUES(original_filename),source_version=VALUES(source_version),stored_path=VALUES(stored_path)",(source_id,project_id,data.source_type,data.original_filename,data.sha256,data.source_version,data.stored_path)) - await c.execute("SELECT id FROM b08_source_files WHERE project_id=%s AND sha256=%s",(project_id,data.sha256));source_id=(await c.fetchone())["id"] - await c.execute("DELETE FROM b08_reference_values WHERE source_file_id=%s",(source_id,)) - for x in data.values:await c.execute("INSERT INTO b08_reference_values(source_file_id,stage_code,reference_key,amount,metadata_json) VALUES(%s,%s,%s,%s,%s)",(source_id,x.stage_code,x.reference_key,x.amount,json.dumps(x.metadata,ensure_ascii=False))) - await self.db.commit();return source_id - async def reconcile(self,project_id:str,run_id:str,source_id:str)->ReconciliationResult: - async with self.db.cursor() as c: - await c.execute("SELECT f.* FROM b08_final_cost_results f JOIN b08_calculation_runs r ON r.id=f.run_id WHERE f.run_id=%s AND r.project_id=%s",(run_id,project_id));actual=await c.fetchone() - if not actual:raise LookupError("계산 실행 결과가 없습니다.") - await c.execute("SELECT stage_code,reference_key,amount FROM b08_reference_values WHERE source_file_id=%s",(source_id,));refs={(x["stage_code"],x["reference_key"]):x["amount"] for x in await c.fetchall()} - differences=[];first=None - for stage,column in _STAGES: - if (stage,"TOTAL") not in refs:continue - expected=refs[(stage,"TOTAL")];current=actual[column];diff=current-expected - if diff!=0 and first is None:first=stage - differences.append({"stage_code":stage,"reference_key":"TOTAL","expected_amount":expected,"actual_amount":current,"difference_amount":diff}) - recon_id=str(uuid4());status="DIFFERENT" if first else "MATCHED" - await c.execute("INSERT INTO b08_reconciliation_runs(id,project_id,calculation_run_id,source_file_id,status,first_difference_stage) VALUES(%s,%s,%s,%s,%s,%s)",(recon_id,project_id,run_id,source_id,status,first)) - for x in differences:await c.execute("INSERT INTO b08_reconciliation_differences(reconciliation_run_id,stage_code,reference_key,expected_amount,actual_amount,difference_amount) VALUES(%s,%s,%s,%s,%s,%s)",(recon_id,x["stage_code"],x["reference_key"],x["expected_amount"],x["actual_amount"],x["difference_amount"])) - await self.db.commit();return ReconciliationResult(reconciliation_run_id=recon_id,status=status,first_difference_stage=first,differences=differences) \ 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 deleted file mode 100644 index 4e795d1d..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Stale.py +++ /dev/null @@ -1,73 +0,0 @@ -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 deleted file mode 100644 index b9728704..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_UnitCost.py +++ /dev/null @@ -1,154 +0,0 @@ -from uuid import uuid4 - -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: - 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.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router.py deleted file mode 100644 index 5ae0796b..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Router.py +++ /dev/null @@ -1,18 +0,0 @@ -"""B08 하위 Router 조립점. 전역 FastAPI 앱 등록은 B08 외부 작업이다.""" -from fastapi import APIRouter -from B08_wf5_Quantity.B08_wf5_Quantity_Router_Basis import router_for as basis_router -from B08_wf5_Quantity.B08_wf5_Quantity_Router_Calculation import router_for as calculation_router -from B08_wf5_Quantity.B08_wf5_Quantity_Router_Catalog import router_for as catalog_router -from B08_wf5_Quantity.B08_wf5_Quantity_Router_Costing import router_for as costing_router -from B08_wf5_Quantity.B08_wf5_Quantity_Router_Quantity import router_for as quantity_router -from B08_wf5_Quantity.B08_wf5_Quantity_Router_Reconciliation import router_for as reconciliation_router - -def create_b08_router(connection_provider,user_provider)->APIRouter: - router=APIRouter(prefix="/api/b08",tags=["B08 Quantity & Cost"]) - router.include_router(basis_router(connection_provider,user_provider)) - router.include_router(catalog_router(connection_provider,user_provider)) - router.include_router(costing_router(connection_provider)) - router.include_router(quantity_router(connection_provider,user_provider)) - router.include_router(calculation_router(connection_provider,user_provider)) - router.include_router(reconciliation_router(connection_provider)) - return router \ 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 deleted file mode 100644 index 734e8c6d..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Basis.py +++ /dev/null @@ -1,32 +0,0 @@ -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 - - -def router_for(connection_provider, user_provider): - router = APIRouter() - - @router.get("/{project_id}/basis/{version}", response_model=BasisWorkspace) - async def get(project_id: str, version: str, db=Depends(connection_provider)): - data = await BasisRepository(db).load(project_id, version) - if not data: - raise HTTPException(404, "기준정보가 없습니다.") - return data - - @router.put("/{project_id}/basis/{version}", response_model=BasisWorkspace) - @domain_errors - async def put( - project_id: str, - version: str, - data: BasisWorkspace, - db=Depends(connection_provider), - user_id=Depends(user_provider), - ): - if data.project_id != project_id or data.version != version: - raise HTTPException(422, "경로와 기준정보 ID가 다릅니다.") - await BasisRepository(db).save(data, user_id) - return data - - return router \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Calculation.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Calculation.py deleted file mode 100644 index 7793c78e..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Calculation.py +++ /dev/null @@ -1,73 +0,0 @@ -import hashlib -import json - -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 -from B08_wf5_Quantity.B08_wf5_Quantity_Repository_Calculation import CalculationRepository -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, -) - - -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, - db=Depends(connection_provider), - user_id=Depends(user_provider), - ): - inputs, policies, snapshots = await CalculationSourceRepository(db).load( - project_id, data.versions - ) - lines, totals = calculate_estimate(inputs) - final = calculate_final_cost(totals, policies) - hash_source = { - "versions": data.versions.model_dump(mode="json"), - "inputs": snapshots, - "policies": [row.model_dump(mode="json") for row in policies], - } - input_hash = hashlib.sha256( - json.dumps(hash_source, ensure_ascii=False, sort_keys=True).encode() - ).hexdigest() - run_id = await CalculationRepository(db).save( - project_id, - data.versions.model_dump(), - input_hash, - snapshots, - lines, - totals, - final, - user_id, - ) - return FinalCalculationResponse( - run_id=run_id, lines=lines, totals=totals, final=final - ) - - @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) - - return router \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Catalog.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Catalog.py deleted file mode 100644 index acc9daa3..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Catalog.py +++ /dev/null @@ -1,50 +0,0 @@ -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, - CatalogItem, - PriceBook, - PriceCandidate, -) - - -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"} - - @router.get("/{project_id}/catalog") - async def get_catalog(project_id: str, db=Depends(connection_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, - data: AppliedPrice, - db=Depends(connection_provider), - user_id=Depends(user_provider), - ): - await CatalogRepository(db).apply(project_id, version, data, user_id) - return {"status": "ok"} - - return router \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Costing.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Costing.py deleted file mode 100644 index 17f37a5a..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Costing.py +++ /dev/null @@ -1,59 +0,0 @@ -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_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, -) - - -def router_for(connection_provider): - 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 deleted file mode 100644 index c1da5fd0..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Errors.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 5410a77f..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Quantity.py +++ /dev/null @@ -1,131 +0,0 @@ -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 ( - BulkQuantityRequest, - DesignQuantity, - SortRequest, - WorkBreakdown, -) - - -def router_for(connection_provider, user_provider): - router = APIRouter() - - @router.get("/{project_id}/quantities") - async def get(project_id: str, db=Depends(connection_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) - ): - 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( - project_id: str, - data: DesignQuantity, - db=Depends(connection_provider), - user_id=Depends(user_provider), - ): - 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), - ): - 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 deleted file mode 100644 index b21ae3bc..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Reconciliation.py +++ /dev/null @@ -1,43 +0,0 @@ -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): - 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_Basis.py b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Basis.py deleted file mode 100644 index 5e99a5cc..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Basis.py +++ /dev/null @@ -1,46 +0,0 @@ -from datetime import date -from decimal import Decimal -from typing import Literal -from pydantic import BaseModel, Field - -Status = Literal["DRAFT", "CONFIRMED", "STALE"] - -class PriceSource(BaseModel): - id: int | None = None - source_code: str = Field(max_length=40) - source_name: str = Field(max_length=100) - priority_no: int = Field(default=100, ge=1) - publisher: str | None = None - reference_date: date - -class ExchangeRate(BaseModel): - currency: str = Field(min_length=3, max_length=3) - rate_to_krw: Decimal = Field(gt=0) - source_id: int | None = None - effective_from: date - effective_to: date | None = None - -class RatePolicy(BaseModel): - rule_code: str - rule_name: str - base_expression: str - rate_value: Decimal | None = Field(default=None, ge=0) - minimum_amount: int | None = None - maximum_amount: int | None = None - rounding_mode: Literal["ROUND", "FLOOR", "CEILING"] = "FLOOR" - rounding_unit: int = Field(default=1, ge=1) - condition_json: dict = Field(default_factory=dict) - source_reference: str | None = None - status: Literal["DRAFT", "APPROVED"] = "DRAFT" - sort_order: int = 0 - -class BasisWorkspace(BaseModel): - project_id: str - version: str - base_date: date - region: str - currency: str = "KRW" - status: Status = "DRAFT" - price_sources: list[PriceSource] = Field(default_factory=list) - exchange_rates: list[ExchangeRate] = Field(default_factory=list) - rate_policies: list[RatePolicy] = Field(default_factory=list) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Calculation.py b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Calculation.py deleted file mode 100644 index e4203753..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Calculation.py +++ /dev/null @@ -1,84 +0,0 @@ -from decimal import Decimal -from typing import Literal - -from pydantic import BaseModel, Field - - -class UnitPriceBreakdown(BaseModel): - labor: Decimal = Decimal("0") - material: Decimal = Decimal("0") - expense: Decimal = Decimal("0") - - -class EstimateInput(BaseModel): - quantity_item_id: str - wbs_id: str - quantity: Decimal = Field(ge=0) - unit_price: UnitPriceBreakdown - procurement_type: Literal["PRIVATE", "GOVERNMENT", "EXCLUDED"] = "PRIVATE" - - -class EstimateLine(BaseModel): - quantity_item_id: str - wbs_id: str - quantity: Decimal - unit_labor: Decimal - unit_material: Decimal - unit_expense: Decimal - labor_amount: int - material_amount: int - expense_amount: int - total_amount: int - procurement_type: str - trace: dict = Field(default_factory=dict) - - -class CostTotals(BaseModel): - labor: int = 0 - material: int = 0 - expense: int = 0 - direct_cost: int = 0 - government_material: int = 0 - excluded_amount: int = 0 - - -class IndirectResult(BaseModel): - rule_code: str - rule_name: str - base_amount: int - rate_value: Decimal | None - result_amount: int - trace: dict - - -class FinalCost(BaseModel): - direct_cost: int - net_cost: int - general_admin: int - profit: int - total_cost: int - vat: int - contract_cost: int - government_material: int - procurement_fee: int - total_project_cost: int - indirect_results: list[IndirectResult] - trace: dict - - -class CalculationVersions(BaseModel): - basis_version: str - price_version: str - quantity_version: int = Field(ge=1) - rule_version: str - - -class FinalCalculationRequest(BaseModel): - versions: CalculationVersions - - -class FinalCalculationResponse(BaseModel): - run_id: str - lines: list[EstimateLine] - totals: CostTotals - final: FinalCost \ 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 deleted file mode 100644 index 9d1e5967..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Catalog.py +++ /dev/null @@ -1,50 +0,0 @@ -from datetime import date -from decimal import Decimal -from typing import Literal -from pydantic import BaseModel, Field - -ItemType = Literal["MATERIAL", "LABOR", "EQUIPMENT", "EXPENSE"] -CostType = Literal["MATERIAL", "LABOR", "EXPENSE"] - -class CatalogItem(BaseModel): - id: str | None = None - item_type: ItemType - item_code: str - item_name: str - specification: str = "" - unit: str - cost_type: CostType - procurement_type: Literal["PRIVATE", "GOVERNMENT", "EXCLUDED"] = "PRIVATE" - - -class PriceBook(BaseModel): - price_version: str - basis_version: str - name: str - status: Literal["DRAFT", "CONFIRMED"] = "DRAFT" - effective_date: date - -class PriceCandidate(BaseModel): - id: int | None = None - item_id: str - source_id: int - source_price: Decimal = Field(ge=0) - currency: str = "KRW" - 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 - -class AppliedPrice(BaseModel): - item_id: str - price_entry_id: int - applied_price: Decimal = Field(ge=0) - selection_reason: str = Field(min_length=1, max_length=500) - -class CatalogWorkspace(BaseModel): - price_version: str - status: Literal["DRAFT", "CONFIRMED", "STALE"] = "DRAFT" - items: list[CatalogItem] = Field(default_factory=list) - candidates: list[PriceCandidate] = Field(default_factory=list) - applied_prices: list[AppliedPrice] = Field(default_factory=list) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Schema_CostBasis.py b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_CostBasis.py deleted file mode 100644 index cbad9df7..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Schema_CostBasis.py +++ /dev/null @@ -1,33 +0,0 @@ -from decimal import Decimal -from typing import Literal -from pydantic import BaseModel, Field -from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Catalog import CostType - -class FormulaVariable(BaseModel): - code: str - label: str - value: Decimal | None = None - unit: str | None = None - required: bool = True - -class BasisComponent(BaseModel): - id: int | None = None - component_type: Literal["CATALOG", "UNIT_COST", "EQUIPMENT", "COST_BASIS"] - reference_id: str - reference_name: str = "" - quantity_expression: str - unit_price: Decimal = Field(ge=0) - cost_type: CostType - sort_order: int = 0 - -class CostBasis(BaseModel): - id: str | None = None - code: str - name: str - specification: str = "" - unit: str - formula_note: str | None = None - status: Literal["DRAFT", "CONFIRMED", "STALE"] = "DRAFT" - version_no: int = 1 - variables: list[FormulaVariable] = Field(default_factory=list) - components: list[BasisComponent] = Field(default_factory=list) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Quantity.py b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Quantity.py deleted file mode 100644 index 9f7abac3..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Quantity.py +++ /dev/null @@ -1,56 +0,0 @@ -from decimal import Decimal -from typing import Literal - -from pydantic import BaseModel, Field, model_validator - - -class WorkBreakdown(BaseModel): - id: str | None = None - parent_id: str | None = None - code: str - name: str - level_no: int = Field(ge=1, le=10) - sort_order: int = 0 - - -class DesignQuantity(BaseModel): - id: str | None = None - wbs_id: str - reference_type: Literal["CATALOG", "UNIT_COST", "EQUIPMENT", "COST_BASIS"] - reference_id: str - item_name: str - specification: str = "" - unit: str - design_quantity: Decimal | None = Field(default=None, ge=0) - adjusted_quantity: Decimal | None = Field(default=None, ge=0) - confirmed_quantity: Decimal | None = Field(default=None, ge=0) - adjustment_reason: str | None = None - procurement_type: Literal["PRIVATE", "GOVERNMENT", "EXCLUDED"] = "PRIVATE" - excluded: bool = False - status: Literal["DRAFT", "ADJUSTED", "CONFIRMED"] = "DRAFT" - sort_order: int = 0 - - @model_validator(mode="after") - def validate_adjustment(self): - if self.adjusted_quantity is not None and not self.adjustment_reason: - raise ValueError("보정수량에는 변경 사유가 필요합니다.") - return self - - @property - def effective_quantity(self) -> Decimal: - 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}: 수량이 입력되지 않았습니다.") - - -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_Reconciliation.py b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Reconciliation.py deleted file mode 100644 index b2d64b71..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Reconciliation.py +++ /dev/null @@ -1,20 +0,0 @@ -from pydantic import BaseModel,Field -from typing import Literal - -class ReferenceValue(BaseModel): - stage_code:str - reference_key:str="TOTAL" - amount:int=Field(ge=0) - metadata:dict=Field(default_factory=dict) -class ReferenceImport(BaseModel): - source_type:Literal["STC","XLSX","MANUAL"] - original_filename:str - sha256:str=Field(min_length=64,max_length=64) - source_version:str|None=None - stored_path:str - values:list[ReferenceValue] -class ReconciliationResult(BaseModel): - reconciliation_run_id:str - status:Literal["MATCHED","DIFFERENT"] - first_difference_stage:str|None - differences:list[dict] \ 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 deleted file mode 100644 index a238f5bb..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Schema_UnitCost.py +++ /dev/null @@ -1,51 +0,0 @@ -from decimal import Decimal -from typing import Literal -from pydantic import BaseModel, Field -from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Catalog import CostType - -ReferenceType = Literal["CATALOG", "UNIT_COST", "EQUIPMENT"] - -class CostComponent(BaseModel): - id: int | None = None - component_type: ReferenceType - reference_id: str - reference_name: str = "" - quantity: Decimal = Field(ge=0) - unit_price: Decimal = Field(ge=0) - cost_type: CostType - sort_order: int = 0 - -class UnitCost(BaseModel): - id: str | None = None - code: str - name: str - specification: str = "" - unit: str - rounding_mode: Literal["ROUND", "FLOOR", "CEILING"] = "FLOOR" - rounding_unit: int = Field(default=1, ge=1) - status: Literal["DRAFT", "CONFIRMED", "STALE"] = "DRAFT" - version_no: int = 1 - components: list[CostComponent] = Field(default_factory=list) - -class EquipmentRate(BaseModel): - id: str | None = None - code: str - name: str - specification: str = "" - 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) - -class CostResult(BaseModel): - labor: int - material: int - expense: int - total: int - trace: list[dict] = Field(default_factory=list) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Store.ts b/B08_wf5_Quantity/B08_wf5_Quantity_Store.ts deleted file mode 100644 index 2a016f7e..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Store.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { B08Api } from "./B08_wf5_Quantity_Api"; -import { B08State, TabId, WorkspaceData } from "./B08_wf5_Quantity_Types"; - -const empty = (): WorkspaceData => ({ - basis: null, - catalog: { sources: [], price_books: [], items: [], candidates: [] }, - costing: { unit_costs: [], equipment_rates: [], cost_basis: [] }, - quantities: { work_breakdown: [], quantities: [], confirmation: null }, - runs: [], - latest: null, -}); - -export class B08Store { - state: B08State; - private listeners = new Set<(state: B08State) => void>(); - - constructor(projectId: string) { - this.state = { - projectId, - basisVersion: "current", - activeTab: "basis", - loading: false, - message: "", - data: empty(), - }; - } - - subscribe(listener: (state: B08State) => void) { - this.listeners.add(listener); - listener(this.state); - return () => this.listeners.delete(listener); - } - - private emit(patch: Partial) { - this.state = { ...this.state, ...patch }; - this.listeners.forEach((listener) => listener(this.state)); - } - - setTab(tab: TabId) { - this.emit({ activeTab: tab }); - } - - message(text: string) { - this.emit({ message: text }); - } - - async load() { - this.emit({ loading: true, message: "B08 DB 작업공간을 불러오는 중입니다." }); - const projectId = this.state.projectId; - const version = this.state.basisVersion; - const settled = await Promise.allSettled([ - B08Api.basis(projectId, version), - B08Api.catalog(projectId), - B08Api.costing(projectId), - B08Api.quantities(projectId), - B08Api.runs(projectId), - B08Api.latestCalculation(projectId), - ]); - const data = empty(); - if (settled[0].status === "fulfilled") data.basis = settled[0].value; - if (settled[1].status === "fulfilled") data.catalog = settled[1].value; - if (settled[2].status === "fulfilled") data.costing = settled[2].value; - if (settled[3].status === "fulfilled") data.quantities = settled[3].value; - if (settled[4].status === "fulfilled") data.runs = settled[4].value; - if (settled[5].status === "fulfilled") data.latest = settled[5].value; - const failed = settled.filter((result) => result.status === "rejected").length; - this.emit({ - loading: false, - data, - message: failed - ? `DB 연결 또는 초기 데이터가 없는 영역 ${failed}개가 있습니다. 값을 입력해 시작하세요.` - : "모든 B08 데이터를 불러왔습니다.", - }); - } - - async calculate() { - const data = this.state.data; - const basis = data.basis; - if (!basis) throw new Error("기준정보를 먼저 저장하세요."); - const confirmation = data.quantities.confirmation; - if (!confirmation) throw new Error("설계수량을 먼저 확정하세요."); - const unconfirmed = data.quantities.quantities.filter( - (item: any) => item.status !== "CONFIRMED", - ); - if (unconfirmed.length) throw new Error(`설계수량 미확정 ${unconfirmed.length}건`); - const priceVersion = data.quantities.quantities.find( - (item: any) => item.price_version, - )?.price_version; - if (!priceVersion) throw new Error("확정된 가격판이 없습니다."); - const result = await B08Api.calculate(this.state.projectId, { - versions: { - basis_version: basis.version, - price_version: priceVersion, - quantity_version: confirmation.quantity_version, - rule_version: basis.version, - }, - }); - const nextData = { ...this.state.data, latest: result }; - this.emit({ data: nextData, message: "최종공사비 계산 실행을 저장했습니다." }); - } -} diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Types.ts b/B08_wf5_Quantity/B08_wf5_Quantity_Types.ts deleted file mode 100644 index bf13415e..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Types.ts +++ /dev/null @@ -1,55 +0,0 @@ -export type TabId = - | "basis" - | "catalog" - | "unitCost" - | "equipment" - | "costBasis" - | "quantity" - | "estimate" - | "aggregation" - | "indirect" - | "final"; - -export interface QuantityWorkspace { - work_breakdown: any[]; - quantities: any[]; - confirmation: { quantity_version: number; input_hash: string } | null; -} - -export interface WorkspaceData { - basis: any | null; - catalog: { sources: any[]; price_books: any[]; items: any[]; candidates: any[] }; - costing: { unit_costs: any[]; equipment_rates: any[]; cost_basis: any[] }; - quantities: QuantityWorkspace; - runs: any[]; - latest: any | null; -} - -export interface B08State { - projectId: string; - basisVersion: string; - activeTab: TabId; - loading: boolean; - message: string; - data: WorkspaceData; -} - -export interface TabContext { - state: B08State; - refresh: () => Promise; - message: (text: string) => void; - calculate: () => Promise; -} - -export const TABS: Array<{ id: TabId; label: string; step: string }> = [ - { id: "basis", label: "기준정보", step: "01" }, - { id: "catalog", label: "기초단가", step: "02" }, - { id: "unitCost", label: "일위대가", step: "03" }, - { id: "equipment", label: "중기사용료", step: "04" }, - { id: "costBasis", label: "단가산출근거", step: "05" }, - { id: "quantity", label: "설계수량 입력", step: "06" }, - { id: "estimate", label: "설계내역", step: "07" }, - { id: "aggregation", label: "집계·총괄", step: "08" }, - { id: "indirect", label: "간접비·원가", step: "09" }, - { id: "final", label: "최종공사비·대조", step: "10" }, -]; diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Dom.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Dom.ts deleted file mode 100644 index 099580dc..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Dom.ts +++ /dev/null @@ -1,53 +0,0 @@ -export const el = ( - tag: K, - className = "", - text = "", -): HTMLElementTagNameMap[K] => { - const node = document.createElement(tag); - node.className = className; - if (text) node.textContent = text; - return node; -}; -export const input = (name: string, label: string, type = "text", value = "") => { - const wrap = el("label", "b08-field"); - wrap.append(el("span", "", label)); - const control = el("input") as HTMLInputElement; - control.name = name; - control.type = type; - control.value = value; - wrap.append(control); - return wrap; -}; -export const select = (name: string, label: string, values: Array<[string, string]>) => { - const wrap = el("label", "b08-field"); - wrap.append(el("span", "", label)); - const control = el("select") as HTMLSelectElement; - control.name = name; - values.forEach(([v, t]) => control.add(new Option(t, v))); - wrap.append(control); - return wrap; -}; -export const formData = (form: HTMLFormElement) => Object.fromEntries(new FormData(form).entries()); -export const table = (headers: string[], rows: Array>) => { - const t = el("table", "b08-table"); - const head = el("thead"); - const hr = el("tr"); - headers.forEach((x) => hr.append(el("th", "", x))); - head.append(hr); - const body = el("tbody"); - rows.forEach((row) => { - const tr = el("tr"); - row.forEach((x) => tr.append(el("td", "", x == null ? "-" : String(x)))); - body.append(tr); - }); - t.append(head, body); - return t; -}; -export const money = (value: number) => `${new Intl.NumberFormat("ko-KR").format(value || 0)}원`; -export const section = (title: string, description: string) => { - const root = el("section", "b08-tab-section"); - const header = el("header", "b08-tab-heading"); - header.append(el("h3", "", title), el("p", "", description)); - root.append(header); - return root; -}; diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts deleted file mode 100644 index 43525857..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts +++ /dev/null @@ -1,80 +0,0 @@ -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"; -import { renderCatalog } from "./B08_wf5_Quantity_UI_Tab_Catalog"; -import { renderUnitCost } from "./B08_wf5_Quantity_UI_Tab_UnitCost"; -import { renderEquipment } from "./B08_wf5_Quantity_UI_Tab_Equipment"; -import { renderCostBasis } from "./B08_wf5_Quantity_UI_Tab_CostBasis"; -import { renderQuantity } from "./B08_wf5_Quantity_UI_Tab_Quantity"; -import { renderEstimate } from "./B08_wf5_Quantity_UI_Tab_Estimate"; -import { renderAggregation } from "./B08_wf5_Quantity_UI_Tab_Aggregation"; -import { renderIndirect } from "./B08_wf5_Quantity_UI_Tab_Indirect"; -import { renderFinal } from "./B08_wf5_Quantity_UI_Tab_Final"; -import "./B08_wf5_Quantity_UI_Style.css"; -const renders: Record HTMLElement> = { - basis: renderBasis, - catalog: renderCatalog, - unitCost: renderUnitCost, - equipment: renderEquipment, - costBasis: renderCostBasis, - quantity: renderQuantity, - estimate: renderEstimate, - aggregation: renderAggregation, - indirect: renderIndirect, - final: renderFinal, -}; -const currentProject = () => - new URLSearchParams(location.search).get("project_id") ?? - sessionStorage.getItem("currentProjectId") ?? - ""; -export async function renderB08Quantity(root: HTMLElement) { - await renderPendingWorkflow(root, { - title: "5차 · 수량산출 및 원가계산", - steps: workflowSteps(), - activeStep: 5, - }); - const body = root.querySelector(".workflow-body") ?? root; - body.replaceChildren(); - const projectId = currentProject(); - const page = document.createElement("main"); - page.className = "b08-page"; - page.innerHTML = `

WF5 · QUANTITY & COST

수량산출·원가계산 작업공간

`; - body.append(page); - const message = page.querySelector(".b08-message")!, - tabs = page.querySelector(".b08-tabs")!, - content = page.querySelector(".b08-content")!, - 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; - button.innerHTML = `${tab.step}${tab.label}`; - button.onclick = () => store.setTab(tab.id); - tabs.append(button); - }); - store.subscribe((state) => { - message.textContent = state.message; - tabs - .querySelectorAll("button") - .forEach((x) => - x.classList.toggle("active", (x as HTMLElement).dataset.tab === state.activeTab), - ); - content.replaceChildren( - renders[state.activeTab]({ - state, - refresh: () => store.load(), - message: (text) => store.message(text), - calculate: () => store.calculate(), - }), - ); - }); - if (!projectId) { - store.message("프로젝트를 선택해야 DB 작업공간을 사용할 수 있습니다."); - return; - } - await store.load(); -} diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_References.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_References.ts deleted file mode 100644 index 39aebd13..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_References.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { TabContext } from "./B08_wf5_Quantity_Types"; - -export const referenceOptions = ( - ctx: TabContext, - allowed: Array<"CATALOG" | "UNIT_COST" | "EQUIPMENT" | "COST_BASIS">, -): Array<[string, string]> => { - const rows: Array<[string, string]> = []; - if (allowed.includes("CATALOG")) { - ctx.state.data.catalog.items - .filter((item: any) => item.applied_price != null) - .forEach((item: any) => - rows.push([ - `CATALOG|${item.id}|${item.item_name}`, - `[기초단가] ${item.item_code} ${item.item_name} / ${item.cost_type}`, - ]), - ); - } - if (allowed.includes("UNIT_COST")) { - ctx.state.data.costing.unit_costs - .filter((item: any) => item.status === "CONFIRMED") - .forEach((item: any) => - rows.push([ - `UNIT_COST|${item.id}|${item.name}`, - `[일위대가] ${item.unit_cost_code} ${item.name}`, - ]), - ); - } - if (allowed.includes("EQUIPMENT")) { - ctx.state.data.costing.equipment_rates - .filter((item: any) => item.status === "CONFIRMED") - .forEach((item: any) => - rows.push([ - `EQUIPMENT|${item.id}|${item.name}`, - `[중기사용료] ${item.equipment_code} ${item.name}`, - ]), - ); - } - if (allowed.includes("COST_BASIS")) { - ctx.state.data.costing.cost_basis - .filter((item: any) => item.status === "CONFIRMED") - .forEach((item: any) => - rows.push([ - `COST_BASIS|${item.id}|${item.name}`, - `[산출근거] ${item.basis_code} ${item.name}`, - ]), - ); - } - return rows; -}; - -export const parseReference = (value: unknown) => { - const [component_type, reference_id, reference_name] = String(value ?? "").split("|"); - if (!reference_id) throw new Error("확정된 DB 단가 참조를 선택하세요."); - return { component_type, reference_id, reference_name }; -}; diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Style.css b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Style.css deleted file mode 100644 index d0bab75f..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Style.css +++ /dev/null @@ -1,249 +0,0 @@ -.b08-page { - --bg: #0d1422; - --panel: #172235; - --line: #2a3a54; - --text: #e8eef8; - --muted: #91a2ba; - --accent: #6d8cff; - display: grid; - gap: 16px; - min-height: calc(100vh - 120px); - padding: 22px; - color: var(--text); - background: linear-gradient(145deg, #0a101b, var(--bg)); - box-sizing: border-box; -} -.b08-header { - display: flex; - align-items: center; - justify-content: space-between; -} -.b08-header p, -.b08-tab-heading p { - margin: 0; - color: var(--muted); - font-size: 12px; -} -.b08-header h2, -.b08-tab-heading h3 { - margin: 4px 0; -} -.b08-project { - padding: 6px 11px; - border: 1px solid var(--line); - border-radius: 999px; - color: #b9c8dd; - font-size: 12px; -} -.b08-message { - min-height: 20px; - margin: 0; - color: #ffd078; - font-size: 13px; -} -.b08-tabs { - display: flex; - gap: 6px; - padding-bottom: 8px; - overflow-x: auto; - border-bottom: 1px solid var(--line); -} -.b08-tabs button { - display: grid; - grid-template-columns: auto 1fr; - gap: 7px; - align-items: center; - min-width: max-content; - padding: 9px 12px; - color: var(--muted); - border: 1px solid transparent; - border-radius: 8px; - background: transparent; - cursor: pointer; -} -.b08-tabs button b { - display: grid; - place-items: center; - width: 25px; - height: 25px; - border-radius: 50%; - background: #233149; - font-size: 11px; -} -.b08-tabs button.active { - color: white; - border-color: #516fb9; - background: #1c2a43; -} -.b08-tabs button.active b { - background: var(--accent); -} -.b08-content { - min-width: 0; -} -.b08-tab-section { - display: grid; - gap: 14px; - margin-bottom: 16px; - padding: 16px; - border: 1px solid var(--line); - border-radius: 12px; - background: color-mix(in srgb, var(--panel) 96%, transparent); - box-shadow: 0 10px 25px rgb(0 0 0/16%); -} -.b08-tab-section .b08-tab-section { - margin: 0; - background: #111c2e; - box-shadow: none; -} -.b08-tab-heading { - display: flex; - justify-content: space-between; - align-items: flex-start; - border-bottom: 1px solid var(--line); - padding-bottom: 12px; -} -.b08-form-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); - gap: 10px; - align-items: end; -} -.b08-inline-form { - display: flex; - gap: 8px; - align-items: end; - flex-wrap: wrap; -} -.b08-policy-form .b08-field { - min-width: 130px; -} -.b08-field { - display: grid; - gap: 5px; - min-width: 150px; - color: var(--muted); - font-size: 12px; -} -.b08-field input, -.b08-field select { - width: 100%; - padding: 8px 9px; - color: var(--text); - border: 1px solid var(--line); - border-radius: 7px; - background: #0e1727; - box-sizing: border-box; -} -.b08-button { - padding: 9px 13px; - color: var(--text); - border: 1px solid var(--line); - border-radius: 7px; - background: #25344d; - cursor: pointer; -} -.b08-button--primary { - color: white; - border-color: #7b96ff; - background: var(--accent); -} -.b08-button:disabled { - opacity: 0.45; - cursor: not-allowed; -} -.b08-table { - width: 100%; - border-collapse: collapse; - font-size: 12px; -} -.b08-table th, -.b08-table td { - padding: 9px 10px; - text-align: left; - border-bottom: 1px solid var(--line); - white-space: nowrap; -} -.b08-table th { - position: sticky; - top: 0; - color: #b9c8dc; - background: #101a2a; -} -.b08-table tbody tr:hover { - background: rgb(109 140 255/5%); -} -.b08-tab-section > .b08-table { - display: block; - overflow: auto; -} -.b08-empty, -.b08-run-id { - color: var(--muted); -} -@media (max-width: 760px) { - .b08-page { - padding: 12px; - } - .b08-header { - align-items: flex-start; - flex-direction: column; - gap: 8px; - } - .b08-form-grid { - grid-template-columns: 1fr; - } - .b08-inline-form { - align-items: stretch; - 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_Aggregation.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Aggregation.ts deleted file mode 100644 index c1f6d51f..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Aggregation.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { money, section, table } from "./B08_wf5_Quantity_UI_Dom"; -import { TabContext } from "./B08_wf5_Quantity_Types"; -export function renderAggregation(ctx: TabContext) { - const root = section( - "집계·총괄설계내역", - "계산 실행 스냅샷의 재료·노무·경비를 공종별로 집계합니다.", - ); - const groups = new Map(); - for (const x of ctx.state.data.latest?.lines ?? []) { - const g = groups.get(x.wbs_id) ?? { labor: 0, material: 0, expense: 0, total: 0 }; - g.labor += x.labor_amount; - g.material += x.material_amount; - g.expense += x.expense_amount; - g.total += x.total_amount; - groups.set(x.wbs_id, g); - } - const wbs = ctx.state.data.quantities.work_breakdown; - root.append( - table( - ["공종", "노무비", "재료비", "경비", "합계"], - [...groups].map(([id, x]) => [ - wbs.find((w: any) => w.id === id)?.name ?? id, - money(x.labor), - money(x.material), - money(x.expense), - money(x.total), - ]), - ), - ); - return root; -} diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Basis.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Basis.ts deleted file mode 100644 index 5ebab6a8..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Basis.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { B08Api } from "./B08_wf5_Quantity_Api"; -import { el, formData, input, section, select, table } from "./B08_wf5_Quantity_UI_Dom"; -import { TabContext } from "./B08_wf5_Quantity_Types"; -export function renderBasis(ctx: TabContext) { - const b = ctx.state.data.basis ?? { - project_id: ctx.state.projectId, - version: ctx.state.basisVersion, - base_date: "", - region: "", - currency: "KRW", - status: "DRAFT", - price_sources: [], - exchange_rates: [], - rate_policies: [], - }; - const root = section("기준정보", "가격 출처·환율·제경비 요율을 버전 단위로 관리합니다."); - const meta = el("form", "b08-form-grid") as HTMLFormElement; - meta.append( - input("base_date", "기준일", "date", b.base_date ?? ""), - input("region", "적용 지역", "text", b.region ?? ""), - input("currency", "기준 통화", "text", b.currency ?? "KRW"), - select("status", "상태", [ - ["DRAFT", "작성중"], - ["CONFIRMED", "확정"], - ]), - ); - (meta.elements.namedItem("status") as HTMLSelectElement).value = b.status; - const save = el("button", "b08-button b08-button--primary", "기준정보 저장") as HTMLButtonElement; - save.type = "submit"; - meta.append(save); - meta.onsubmit = async (e) => { - e.preventDefault(); - const x = formData(meta); - await B08Api.saveBasis(ctx.state.projectId, b.version, { - ...b, - ...x, - project_id: ctx.state.projectId, - version: b.version, - }); - ctx.message("기준정보를 저장했습니다."); - await ctx.refresh(); - }; - root.append(meta); - root.append(renderSources(ctx, b), renderFx(ctx, b), renderPolicies(ctx, b)); - return root; -} -function renderSources(ctx: TabContext, b: any) { - const box = section("가격 출처", "조달가격·물가정보·견적 등 후보단가의 출처와 우선순위입니다."); - const f = el("form", "b08-inline-form") as HTMLFormElement; - f.append( - 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; - btn.type = "submit"; - f.append(btn); - f.onsubmit = async (e) => { - e.preventDefault(); - const x = formData(f); - b.price_sources.push({ ...x, priority_no: Number(x.priority_no) }); - await B08Api.saveBasis(ctx.state.projectId, b.version, b); - await ctx.refresh(); - }; - box.append( - f, - table( - ["코드", "출처", "발행기관", "우선순위", "기준일"], - b.price_sources.map((x: any) => [ - x.source_code, - x.source_name, - x.publisher, - x.priority_no, - x.reference_date, - ]), - ), - ); - return box; -} -function renderFx(ctx: TabContext, b: any) { - const box = section("환율", "외화 기초단가를 원화로 변환할 때 적용합니다."); - const f = el("form", "b08-inline-form") as HTMLFormElement; - f.append( - input("currency", "통화", "text"), - input("rate_to_krw", "원화환율", "number"), - 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"; - f.append(btn); - f.onsubmit = async (e) => { - e.preventDefault(); - const x = formData(f); - 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, - b.price_sources.find((source: any) => source.id === x.source_id)?.source_name, - x.effective_from, - x.effective_to, - ]), - ), - ); - return box; -} -function renderPolicies(ctx: TabContext, b: any) { - const box = section("요율 규칙", "승인된 규칙만 최종공사비 계산에 사용됩니다."); - const f = el("form", "b08-inline-form b08-policy-form") as HTMLFormElement; - f.append( - input("rule_code", "규칙 코드"), - input("rule_name", "규칙명"), - input("base_expression", "기준식"), - input("rate_value", "요율", "number"), - input("minimum_amount", "최소금액", "number"), - input("maximum_amount", "최대금액", "number"), - select("rounding_mode", "처리", [ - ["FLOOR", "절사"], - ["ROUND", "반올림"], - ["CEILING", "올림"], - ]), - input("rounding_unit", "단위", "number", "1"), - input("source_reference", "근거 문서"), - select("status", "상태", [ - ["DRAFT", "작성중"], - ["APPROVED", "승인"], - ]), - ); - const btn = el("button", "b08-button", "요율 추가") as HTMLButtonElement; - btn.type = "submit"; - f.append(btn); - f.onsubmit = async (e) => { - e.preventDefault(); - const x = formData(f); - b.rate_policies.push({ - ...x, - rate_value: x.rate_value || null, - rounding_unit: Number(x.rounding_unit), - minimum_amount: x.minimum_amount === "" ? null : Number(x.minimum_amount), - maximum_amount: x.maximum_amount === "" ? null : Number(x.maximum_amount), - condition_json: {}, - sort_order: b.rate_policies.length, - }); - await B08Api.saveBasis(ctx.state.projectId, b.version, b); - await ctx.refresh(); - }; - box.append( - f, - table( - ["코드", "규칙", "기준식", "요율", "처리", "상태"], - b.rate_policies.map((x: any) => [ - x.rule_code, - x.rule_name, - x.base_expression, - x.rate_value, - x.rounding_mode, - x.status, - ]), - ), - ); - 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 deleted file mode 100644 index 52f9af92..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Catalog.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { B08Api } from "./B08_wf5_Quantity_Api"; -import { el, formData, input, money, section, select, table } from "./B08_wf5_Quantity_UI_Dom"; -import { TabContext } from "./B08_wf5_Quantity_Types"; - -export function renderCatalog(ctx: TabContext) { - const catalog = ctx.state.data.catalog; - const root = section( - "재료·노무·장비·경비 기초단가", - "가격판, 품목, 출처별 후보가격과 승인 적용단가를 DB에서 관리합니다.", - ); - root.append( - priceBookForm(ctx), - itemForm(ctx), - candidateForm(ctx), - appliedPriceForm(ctx), - table( - ["유형", "코드", "명칭", "규격", "단위", "비용분류", "적용단가", "가격판"], - catalog.items.map((item: any) => [ - item.item_type, - item.item_code, - item.item_name, - item.specification, - item.unit, - item.cost_type, - item.applied_price == null ? "미승인" : money(Number(item.applied_price)), - item.price_version, - ]), - ), - ); - return root; -} - -function priceBookForm(ctx: TabContext) { - const form = el("form", "b08-inline-form") as HTMLFormElement; - form.append( - input("price_version", "가격판 버전"), - input("basis_version", "기준정보 버전", "text", ctx.state.basisVersion), - input("name", "가격판명"), - input("effective_date", "적용일", "date"), - select("status", "상태", [ - ["DRAFT", "작성중"], - ["CONFIRMED", "확정"], - ]), - ); - const button = el("button", "b08-button", "가격판 저장") as HTMLButtonElement; - button.type = "submit"; - form.append(button); - form.onsubmit = async (event) => { - event.preventDefault(); - await B08Api.savePriceBook(ctx.state.projectId, formData(form)); - ctx.message("가격판을 저장했습니다."); - await ctx.refresh(); - }; - return form; -} - -function itemForm(ctx: TabContext) { - const form = el("form", "b08-form-grid") as HTMLFormElement; - form.append( - select("item_type", "품목 유형", [ - ["MATERIAL", "재료"], - ["LABOR", "노무"], - ["EQUIPMENT", "장비"], - ["EXPENSE", "경비"], - ]), - input("item_code", "품목 코드"), - input("item_name", "명칭"), - input("specification", "규격"), - input("unit", "단위"), - select("cost_type", "비용 분류", [ - ["MATERIAL", "재료비"], - ["LABOR", "노무비"], - ["EXPENSE", "경비"], - ]), - select("procurement_type", "조달 구분", [ - ["PRIVATE", "사급"], - ["GOVERNMENT", "관급"], - ["EXCLUDED", "제외"], - ]), - ); - const button = el("button", "b08-button b08-button--primary", "품목 저장") as HTMLButtonElement; - button.type = "submit"; - form.append(button); - form.onsubmit = async (event) => { - event.preventDefault(); - await B08Api.saveItem(ctx.state.projectId, formData(form)); - ctx.message("기초단가 품목을 저장했습니다."); - await ctx.refresh(); - }; - return form; -} - -function candidateForm(ctx: TabContext) { - const catalog = ctx.state.data.catalog; - const box = section( - "출처별 후보가격", - "품목과 가격출처를 선택하면 원화 환산가는 서버가 계산합니다.", - ); - const form = el("form", "b08-inline-form") as HTMLFormElement; - form.append( - select( - "price_version", - "가격판", - catalog.price_books.map((book: any) => [ - book.price_version, - `${book.name} (${book.price_version})`, - ]), - ), - select( - "item_id", - "품목", - catalog.items.map((item: any) => [item.id, `${item.item_code} ${item.item_name}`]), - ), - select( - "source_id", - "가격출처", - catalog.sources.map((source: any) => [String(source.id), source.source_name]), - ), - input("source_price", "원단가", "number"), - input("currency", "통화", "text", "KRW"), - input("reference_page", "근거 페이지"), - input("valid_from", "적용일", "date"), - ); - const button = el("button", "b08-button", "후보가격 저장") as HTMLButtonElement; - button.type = "submit"; - form.append(button); - form.onsubmit = async (event) => { - event.preventDefault(); - const value = formData(form); - const sourcePrice = Number(value.source_price); - await B08Api.addCandidate(ctx.state.projectId, String(value.price_version), { - ...value, - source_id: Number(value.source_id), - source_price: sourcePrice, - valid_to: null, - }); - ctx.message("후보가격을 저장했습니다."); - await ctx.refresh(); - }; - box.append(form); - return box; -} - -function appliedPriceForm(ctx: TabContext) { - const catalog = ctx.state.data.catalog; - const box = section( - "적용단가 승인", - "후보가격을 선택하고 실제 적용단가와 선택 근거를 승인합니다.", - ); - const form = el("form", "b08-inline-form") as HTMLFormElement; - form.append( - select( - "candidate", - "후보가격", - catalog.candidates.map((candidate: any) => [ - `${candidate.price_version}|${candidate.item_id}|${candidate.id}|${candidate.converted_price}`, - `${candidate.price_version} · ${candidate.item_code} · ${candidate.source_name} · ${money(Number(candidate.converted_price))}`, - ]), - ), - input("applied_price", "적용단가", "number"), - input("selection_reason", "선택 근거"), - ); - const button = el( - "button", - "b08-button b08-button--primary", - "적용단가 승인", - ) as HTMLButtonElement; - button.type = "submit"; - form.append(button); - const candidate = form.elements.namedItem("candidate") as HTMLSelectElement; - candidate.onchange = () => { - const parts = candidate.value.split("|"); - (form.elements.namedItem("applied_price") as HTMLInputElement).value = parts[3] ?? ""; - }; - candidate.dispatchEvent(new Event("change")); - form.onsubmit = async (event) => { - event.preventDefault(); - const value = formData(form); - const [version, itemId, entryId] = String(value.candidate).split("|"); - if (!entryId) throw new Error("승인할 후보가격을 먼저 등록하세요."); - await B08Api.applyPrice(ctx.state.projectId, version, { - item_id: itemId, - price_entry_id: Number(entryId), - applied_price: Number(value.applied_price), - selection_reason: value.selection_reason, - }); - ctx.message("적용단가를 승인했습니다. 종속 원가는 재계산 대상으로 전환됩니다."); - await ctx.refresh(); - }; - box.append(form); - return box; -} diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_CostBasis.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_CostBasis.ts deleted file mode 100644 index 2985545e..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_CostBasis.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { B08Api } from "./B08_wf5_Quantity_Api"; -import { el, formData, input, money, section, select, table } from "./B08_wf5_Quantity_UI_Dom"; -import { parseReference, referenceOptions } from "./B08_wf5_Quantity_UI_References"; -import { TabContext } from "./B08_wf5_Quantity_Types"; - -export function renderCostBasis(ctx: TabContext) { - const root = section( - "단가산출근거", - "확정 기초단가·일위대가·중기사용료와 수량식을 구조적으로 연결합니다.", - ); - const variables: any[] = []; - const components: any[] = []; - const form = el("form", "b08-form-grid") as HTMLFormElement; - form.append( - input("code", "산출근거 코드"), - input("name", "명칭"), - input("specification", "규격"), - input("unit", "단위"), - input("formula_note", "산출 설명"), - select("status", "상태", [ - ["DRAFT", "작성중"], - ["CONFIRMED", "확정"], - ]), - ); - const state = el("p", "", "변수 0건 · 구성 0건"); - const variableForm = createVariableForm(variables, components, state); - const componentForm = createComponentForm(ctx, variables, components, state); - const save = el( - "button", - "b08-button b08-button--primary", - "산출근거 계산·저장", - ) as HTMLButtonElement; - save.type = "submit"; - form.append(save); - form.onsubmit = async (event) => { - event.preventDefault(); - if (!components.length) throw new Error("산출근거 구성요소를 추가하세요."); - const value = formData(form); - const result = await B08Api.saveCostBasis(ctx.state.projectId, { - ...value, - version_no: 1, - variables, - components, - }); - ctx.message(`단가산출근거 저장 완료: ${money(result.total)}`); - await ctx.refresh(); - }; - root.append( - form, - variableForm, - componentForm, - state, - table( - ["코드", "명칭", "단위", "노무비", "재료비", "경비", "합계", "상태"], - ctx.state.data.costing.cost_basis.map((item: any) => [ - item.basis_code, - item.name, - item.unit, - money(Number(item.labor_price)), - money(Number(item.material_price)), - money(Number(item.expense_price)), - money(Number(item.total_price)), - item.status, - ]), - ), - ); - return root; -} - -function createVariableForm(variables: any[], components: any[], state: HTMLElement) { - const form = el("form", "b08-inline-form") as HTMLFormElement; - form.append( - input("code", "변수 코드"), - input("label", "변수명"), - input("value", "값", "number"), - input("unit", "단위"), - ); - const add = el("button", "b08-button", "변수 추가") as HTMLButtonElement; - add.type = "submit"; - form.append(add); - form.onsubmit = (event) => { - event.preventDefault(); - const value = formData(form); - variables.push({ - ...value, - value: value.value === "" ? null : Number(value.value), - required: true, - }); - state.textContent = `변수 ${variables.length}건 · 구성 ${components.length}건`; - }; - return form; -} - -function createComponentForm( - ctx: TabContext, - variables: any[], - components: any[], - state: HTMLElement, -) { - const form = el("form", "b08-inline-form") as HTMLFormElement; - form.append( - select( - "reference", - "확정 단가", - referenceOptions(ctx, ["CATALOG", "UNIT_COST", "EQUIPMENT", "COST_BASIS"]), - ), - input("quantity_expression", "수량식"), - select("cost_type", "비용분류", [ - ["LABOR", "노무비"], - ["MATERIAL", "재료비"], - ["EXPENSE", "경비"], - ]), - ); - const add = el("button", "b08-button", "구성 추가") as HTMLButtonElement; - add.type = "submit"; - form.append(add); - form.onsubmit = (event) => { - event.preventDefault(); - const value = formData(form); - components.push({ - ...parseReference(value.reference), - quantity_expression: value.quantity_expression, - unit_price: 0, - cost_type: value.cost_type, - sort_order: components.length, - }); - state.textContent = `변수 ${variables.length}건 · 구성 ${components.length}건`; - }; - return form; -} diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Equipment.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Equipment.ts deleted file mode 100644 index 4875c47c..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Equipment.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { B08Api } from "./B08_wf5_Quantity_Api"; -import { el, formData, input, money, section, select, table } from "./B08_wf5_Quantity_UI_Dom"; -import { parseReference, referenceOptions } from "./B08_wf5_Quantity_UI_References"; -import { TabContext } from "./B08_wf5_Quantity_Types"; - -export function renderEquipment(ctx: TabContext) { - const root = section( - "중기사용료", - "장비가격·연간가동시간과 확정 기초단가 구성요소로 시간당 노무비·재료비·경비를 계산합니다.", - ); - const components: any[] = []; - const form = el("form", "b08-form-grid") as HTMLFormElement; - form.append( - input("code", "장비 코드"), - input("name", "장비명"), - input("specification", "규격"), - 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", "확정"], - ]), - ); - const componentForm = el("form", "b08-inline-form") as HTMLFormElement; - componentForm.append( - select("reference", "기초단가", referenceOptions(ctx, ["CATALOG"])), - input("quantity", "시간당 투입량", "number"), - select("cost_type", "비용분류", [ - ["LABOR", "노무비"], - ["MATERIAL", "재료비"], - ["EXPENSE", "경비"], - ]), - ); - const count = el("p", "", "구성 0건"); - const add = el("button", "b08-button", "구성 추가") as HTMLButtonElement; - add.type = "submit"; - componentForm.append(add); - componentForm.onsubmit = (event) => { - event.preventDefault(); - const value = formData(componentForm); - components.push({ - ...parseReference(value.reference), - quantity: Number(value.quantity), - unit_price: 0, - cost_type: value.cost_type, - sort_order: components.length, - }); - count.textContent = `구성 ${components.length}건`; - }; - const save = el( - "button", - "b08-button b08-button--primary", - "중기사용료 계산·저장", - ) as HTMLButtonElement; - save.type = "submit"; - form.append(save); - form.onsubmit = async (event) => { - event.preventDefault(); - if (!components.length) throw new Error("장비 구성요소를 추가하세요."); - const value = formData(form); - const result = await B08Api.saveEquipment(ctx.state.projectId, { - ...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, - }); - ctx.message(`중기사용료 저장 완료: ${money(result.total)}`); - await ctx.refresh(); - }; - root.append( - form, - 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)), - money(Number(item.total_price)), - item.status, - ]), - ), - ); - return root; -} diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Estimate.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Estimate.ts deleted file mode 100644 index a66edde9..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Estimate.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { money, section, table } from "./B08_wf5_Quantity_UI_Dom"; -import { TabContext } from "./B08_wf5_Quantity_Types"; -export function renderEstimate(ctx: TabContext) { - const root = section("공종별 설계내역", "확정수량 × 적용단가의 노무비·재료비·경비를 표시합니다."); - const lines = ctx.state.data.latest?.lines ?? []; - root.append( - table( - [ - "수량항목", - "공종", - "수량", - "노무단가", - "재료단가", - "경비단가", - "노무비", - "재료비", - "경비", - "합계", - ], - lines.map((x: any) => [ - x.quantity_item_id, - x.wbs_id, - x.quantity, - x.unit_labor, - x.unit_material, - x.unit_expense, - money(x.labor_amount), - money(x.material_amount), - money(x.expense_amount), - money(x.total_amount), - ]), - ), - ); - if (!lines.length) - root.querySelector("tbody")!.innerHTML = - '최종 계산 실행 전입니다. 설계수량과 요율을 확정하세요.'; - return root; -} diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Final.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Final.ts deleted file mode 100644 index 4fd5eef5..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Final.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { B08Api } from "./B08_wf5_Quantity_Api"; -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]> = [ - ["DIRECT_COST", "직접공사비"], - ["NET_COST", "순공사원가"], - ["GENERAL_ADMIN", "일반관리비"], - ["PROFIT", "이윤"], - ["TOTAL_COST", "총원가"], - ["VAT", "부가가치세"], - ["CONTRACT_COST", "도급공사비"], - ["GOVERNMENT_MATERIAL", "관급자재대"], - ["PROCUREMENT_FEE", "조달수수료"], - ["TOTAL_PROJECT_COST", "총공사비"], -]; - -export function renderFinal(ctx: TabContext) { - const root = section( - "최종공사비·기준대조", - "직접공사비부터 총공사비까지 동일 계산 실행 ID로 확정합니다.", - ); - const run = ctx.state.data.latest; - const button = el( - "button", - "b08-button b08-button--primary", - "전체 단계 계산 실행", - ) as HTMLButtonElement; - button.onclick = async () => { - button.disabled = true; - try { - await ctx.calculate(); - } finally { - button.disabled = false; - } - }; - root.append(button); - if (!run) { - root.append(el("p", "b08-empty", "아직 계산 실행이 없습니다.")); - return root; - } - const f = run.final; - root.append( - el("p", "b08-run-id", `계산 실행 ${run.run_id}`), - table( - ["단계", "금액"], - [ - ["직접공사비", money(f.direct_cost)], - ["순공사원가", money(f.net_cost)], - ["일반관리비", money(f.general_admin)], - ["이윤", money(f.profit)], - ["총원가", money(f.total_cost)], - ["부가가치세", money(f.vat)], - ["도급공사비", money(f.contract_cost)], - ["관급자재대", money(f.government_material)], - ["조달수수료", money(f.procurement_fee)], - ["총공사비", 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 기준금액 대조", - "원본 파일의 단계별 기준금액을 등록하면 최초 불일치 단계를 찾습니다.", - ); - const form = el("form", "b08-form-grid") as HTMLFormElement; - form.append( - input("original_filename", "원본 파일명"), - input("sha256", "SHA-256"), - input("source_version", "STmate 버전"), - ); - STAGES.forEach(([code, label]) => form.append(input(code, label, "number"))); - const button = el("button", "b08-button", "기준 등록·대조") as HTMLButtonElement; - button.type = "submit"; - form.append(button); - const result = el("div", "b08-reconcile-result"); - form.onsubmit = async (event) => { - event.preventDefault(); - const x = formData(form); - const values = STAGES.filter(([code]) => x[code] !== "").map(([stage_code]) => ({ - stage_code, - reference_key: "TOTAL", - amount: Number(x[stage_code]), - metadata: {}, - })); - const imported: any = await B08Api.importReference(ctx.state.projectId, { - source_type: "MANUAL", - original_filename: x.original_filename, - sha256: x.sha256, - source_version: x.source_version || null, - stored_path: `b08://reference/${x.original_filename}`, - values, - }); - const compared: any = await B08Api.reconcile( - ctx.state.projectId, - runId, - imported.source_file_id, - ); - result.replaceChildren( - el( - "strong", - compared.status === "MATCHED" ? "is-match" : "is-different", - compared.status === "MATCHED" - ? "모든 등록 기준금액 일치" - : `최초 불일치: ${compared.first_difference_stage}`, - ), - table( - ["단계", "기준", "계산", "차이"], - compared.differences.map((d: any) => [ - d.stage_code, - money(d.expected_amount), - money(d.actual_amount), - money(d.difference_amount), - ]), - ), - ); - }; - box.append(form, result); - return box; -} diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Indirect.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Indirect.ts deleted file mode 100644 index caca5f60..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Indirect.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { money, section, table } from "./B08_wf5_Quantity_UI_Dom"; -import { TabContext } from "./B08_wf5_Quantity_Types"; -export function renderIndirect(ctx: TabContext) { - const root = section( - "제경비·원가계산", - "승인된 요율 규칙을 순서대로 적용하고 기준금액과 절사근거를 보존합니다.", - ); - const rows = ctx.state.data.latest?.final?.indirect_results ?? []; - root.append( - table( - ["규칙", "명칭", "기준금액", "요율", "결과", "기준식", "금액처리"], - rows.map((x: any) => [ - x.rule_code, - x.rule_name, - money(x.base_amount), - x.rate_value, - money(x.result_amount), - x.trace.expression, - `${x.trace.rounding}/${x.trace.unit}`, - ]), - ), - ); - if (!rows.length) - root.querySelector("tbody")!.innerHTML = - '승인된 제경비 규칙으로 최종 계산을 실행하세요.'; - return root; -} diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Quantity.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Quantity.ts deleted file mode 100644 index 5b981439..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Quantity.ts +++ /dev/null @@ -1,454 +0,0 @@ -import { B08Api } from "./B08_wf5_Quantity_Api"; -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 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("sort_order", "순서", "number", "0"), - ); - const save = el("button", "b08-button", "공종 저장") as HTMLButtonElement; - save.type = "submit"; - form.append(save); - form.onsubmit = async (event) => { - event.preventDefault(); - const value = formData(form); - await B08Api.saveWbs(ctx.state.projectId, { - id: editingId, - parent_id: null, - 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; -} - -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", - "공종", - 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]), - ), - input("item_name", "내역 명칭"), - input("specification", "규격"), - input("unit", "단위"), - input("design_quantity", "설계수량", "number"), - input("adjusted_quantity", "보정수량", "number"), - input("adjustment_reason", "보정 사유"), - 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 value = formData(form); - const [reference_type, reference_id] = String(value.reference).split("|"); - if (!reference_id) throw new Error("확정 적용단가를 먼저 등록하세요."); - await B08Api.saveQuantity(ctx.state.projectId, { - id: editingId, - wbs_id: value.wbs_id, - reference_type, - reference_id, - 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: 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(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 () => { - const result: any = await B08Api.confirmQuantities(ctx.state.projectId); - ctx.message(`설계수량 버전 ${result.quantity_version}을 확정했습니다.`); - await ctx.refresh(); - }; - box.append(form, confirm); - return box; -} - -function renderBulkInput(ctx: TabContext) { - const box = section( - "표 붙여넣기", - "열 순서: 공종코드, 참조유형, 참조코드, 설계수량, 보정수량, 보정사유. 탭 또는 쉼표로 구분합니다.", - ); - 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); -} diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_UnitCost.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_UnitCost.ts deleted file mode 100644 index e00c076c..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_UnitCost.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { B08Api } from "./B08_wf5_Quantity_Api"; -import { el, formData, input, money, section, select, table } from "./B08_wf5_Quantity_UI_Dom"; -import { parseReference, referenceOptions } from "./B08_wf5_Quantity_UI_References"; -import { TabContext } from "./B08_wf5_Quantity_Types"; - -export function renderUnitCost(ctx: TabContext) { - const root = section( - "일위대가", - "확정된 기초단가·중기사용료·하위 일위대가와 투입계수로 단위당 원가를 계산하고 DB에 저장합니다.", - ); - const components: any[] = []; - const form = el("form", "b08-form-grid") as HTMLFormElement; - form.append( - input("code", "일위대가 코드"), - input("name", "명칭"), - input("specification", "규격"), - input("unit", "단위"), - select("rounding_mode", "금액 처리", [ - ["FLOOR", "절사"], - ["ROUND", "반올림"], - ["CEILING", "올림"], - ]), - input("rounding_unit", "처리 단위", "number", "1"), - select("status", "상태", [ - ["DRAFT", "작성중"], - ["CONFIRMED", "확정"], - ]), - ); - const componentForm = createComponentForm(ctx, components); - const save = el( - "button", - "b08-button b08-button--primary", - "일위대가 계산·저장", - ) as HTMLButtonElement; - save.type = "submit"; - form.append(save); - form.onsubmit = async (event) => { - event.preventDefault(); - if (!components.length) throw new Error("구성요소를 추가하세요."); - const value = formData(form); - const result = await B08Api.saveUnitCost(ctx.state.projectId, { - ...value, - rounding_unit: Number(value.rounding_unit), - version_no: 1, - components, - }); - ctx.message(`일위대가 저장 완료: ${money(result.total)}`); - await ctx.refresh(); - }; - root.append( - form, - componentForm, - table( - ["코드", "명칭", "단위", "노무비", "재료비", "경비", "합계", "상태"], - ctx.state.data.costing.unit_costs.map((item: any) => [ - item.unit_cost_code, - item.name, - item.unit, - money(Number(item.labor_price)), - money(Number(item.material_price)), - money(Number(item.expense_price)), - money(Number(item.total_price)), - item.status, - ]), - ), - ); - return root; -} - -function createComponentForm(ctx: TabContext, rows: any[]) { - const box = section( - "구성요소", - "DB에서 확정된 단가를 선택하고 투입계수와 비용분류를 지정합니다.", - ); - const form = el("form", "b08-inline-form") as HTMLFormElement; - form.append( - select("reference", "확정 단가", referenceOptions(ctx, ["CATALOG", "EQUIPMENT", "UNIT_COST"])), - input("quantity", "투입계수", "number"), - select("cost_type", "비용분류", [ - ["LABOR", "노무비"], - ["MATERIAL", "재료비"], - ["EXPENSE", "경비"], - ]), - ); - const count = el("p", "b08-component-count", "구성 0건"); - const add = el("button", "b08-button", "구성 추가") as HTMLButtonElement; - add.type = "submit"; - form.append(add); - form.onsubmit = (event) => { - event.preventDefault(); - const value = formData(form); - rows.push({ - ...parseReference(value.reference), - quantity: Number(value.quantity), - unit_price: 0, - cost_type: value.cost_type, - sort_order: rows.length, - }); - count.textContent = `구성 ${rows.length}건`; - }; - box.append(form, count); - return box; -}