B08페이지 작업 시작

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