auto: 2026-07-26 10:29 (ESD_LAPTOP)
This commit is contained in:
@@ -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"),
|
||||
);
|
||||
|
||||
Binary file not shown.
@@ -1,93 +0,0 @@
|
||||
let errorHandler: (message: string) => void = () => undefined;
|
||||
|
||||
export const setB08ApiErrorHandler = (handler: (message: string) => void) => {
|
||||
errorHandler = handler;
|
||||
};
|
||||
|
||||
const req = async <T>(url: string, init?: RequestInit): Promise<T> => {
|
||||
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<T>;
|
||||
};
|
||||
|
||||
const path = (projectId: string, suffix: string) =>
|
||||
`/api/b08/${encodeURIComponent(projectId)}${suffix}`;
|
||||
|
||||
export const B08Api = {
|
||||
basis: (p: string, v: string) => req<any>(path(p, `/basis/${encodeURIComponent(v)}`)),
|
||||
saveBasis: (p: string, v: string, data: any) =>
|
||||
req<any>(path(p, `/basis/${encodeURIComponent(v)}`), {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
catalog: (p: string) => req<any>(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<any>(path(p, "/costing")),
|
||||
saveUnitCost: (p: string, data: any) =>
|
||||
req<any>(path(p, "/unit-costs"), { method: "POST", body: JSON.stringify(data) }),
|
||||
saveEquipment: (p: string, data: any) =>
|
||||
req<any>(path(p, "/equipment-rates"), { method: "POST", body: JSON.stringify(data) }),
|
||||
saveCostBasis: (p: string, data: any) =>
|
||||
req<any>(path(p, "/cost-basis"), { method: "POST", body: JSON.stringify(data) }),
|
||||
quantities: (p: string) => req<any>(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<any>(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<any>(path(p, "/calculate/final"), { method: "POST", body: JSON.stringify(data) }),
|
||||
importReference: (p: string, data: any) =>
|
||||
req<any>(path(p, "/reference/import"), { method: "POST", body: JSON.stringify(data) }),
|
||||
reconcile: (p: string, runId: string, sourceId: string) =>
|
||||
req<any>(path(p, `/reconcile/${encodeURIComponent(runId)}/${encodeURIComponent(sourceId)}`), {
|
||||
method: "POST",
|
||||
}),
|
||||
runs: (p: string) => req<any[]>(path(p, "/calculation-runs")),
|
||||
calculationRun: (p: string, runId: string) =>
|
||||
req<any>(path(p, `/calculation-runs/${encodeURIComponent(runId)}`)),
|
||||
latestCalculation: (p: string) => req<any | null>(path(p, "/calculation-runs/latest")),
|
||||
};
|
||||
@@ -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()
|
||||
@@ -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))""",
|
||||
)
|
||||
@@ -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)""",
|
||||
)
|
||||
@@ -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))""",
|
||||
)
|
||||
@@ -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))""",
|
||||
)
|
||||
@@ -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))""",
|
||||
)
|
||||
@@ -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))""",
|
||||
)
|
||||
@@ -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""",
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
]
|
||||
@@ -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("원가 항목은 자기 자신을 구성요소로 참조할 수 없습니다.")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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<B08State>) {
|
||||
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: "최종공사비 계산 실행을 저장했습니다." });
|
||||
}
|
||||
}
|
||||
@@ -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<void>;
|
||||
message: (text: string) => void;
|
||||
calculate: () => Promise<void>;
|
||||
}
|
||||
|
||||
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" },
|
||||
];
|
||||
@@ -1,53 +0,0 @@
|
||||
export const el = <K extends keyof HTMLElementTagNameMap>(
|
||||
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<Array<string | number | null>>) => {
|
||||
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;
|
||||
};
|
||||
@@ -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<TabId, (ctx: TabContext) => 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<HTMLElement>(".workflow-body") ?? root;
|
||||
body.replaceChildren();
|
||||
const projectId = currentProject();
|
||||
const page = document.createElement("main");
|
||||
page.className = "b08-page";
|
||||
page.innerHTML = `<header class="b08-header"><div><p>WF5 · QUANTITY & COST</p><h2>수량산출·원가계산 작업공간</h2></div><span class="b08-project"></span></header><p class="b08-message" role="status"></p><nav class="b08-tabs" aria-label="원가계산 단계"></nav><div class="b08-content"></div>`;
|
||||
body.append(page);
|
||||
const message = page.querySelector<HTMLElement>(".b08-message")!,
|
||||
tabs = page.querySelector<HTMLElement>(".b08-tabs")!,
|
||||
content = page.querySelector<HTMLElement>(".b08-content")!,
|
||||
project = page.querySelector<HTMLElement>(".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 = `<b>${tab.step}</b><span>${tab.label}</span>`;
|
||||
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();
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, any>();
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 =
|
||||
'<tr><td colspan="10">최종 계산 실행 전입니다. 설계수량과 요율을 확정하세요.</td></tr>';
|
||||
return root;
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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;
|
||||
}
|
||||
@@ -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 =
|
||||
'<tr><td colspan="7">승인된 제경비 규칙으로 최종 계산을 실행하세요.</td></tr>';
|
||||
return root;
|
||||
}
|
||||
@@ -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<void>, 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user