B06_B08_연계 수정 시작

This commit is contained in:
2026-07-25 17:58:13 +09:00
parent add71aa5a3
commit 497a7026c2
61 changed files with 3237 additions and 2489 deletions
@@ -22,6 +22,7 @@ import type {
DitchType,
GroundType,
SectionMode,
SectionSample,
} from "./B06_wf3_ProfileCross_Api_Fetch";
const SVG_NS = "http://www.w3.org/2000/svg";
@@ -155,6 +156,35 @@ function toggle(
return { wrap, button };
}
/**
* 길게 누르는 동안 같은 동작을 반복한다(B05 종단 선 제어 패턴 재사용). 0.5초 유지하면
* 0.1초 간격(0.1m씩) 반복이 시작된다. 버튼이 재빌드로 사라져도 반복이 끊기지 않도록
* 타이머·종료 감지를 버튼이 아니라 window 이벤트로 들고 있는다.
*/
function createHoldRepeater(): { start: (action: () => void) => void; stop: () => void } {
let delayTimer = 0;
let repeatTimer = 0;
function stop(): void {
window.clearTimeout(delayTimer);
window.clearInterval(repeatTimer);
delayTimer = 0;
repeatTimer = 0;
window.removeEventListener("pointerup", stop);
window.removeEventListener("pointercancel", stop);
window.removeEventListener("blur", stop);
}
function start(action: () => void): void {
stop();
delayTimer = window.setTimeout(() => {
repeatTimer = window.setInterval(action, 100);
}, 500);
window.addEventListener("pointerup", stop);
window.addEventListener("pointercancel", stop);
window.addEventListener("blur", stop);
}
return { start, stop };
}
/** 암 경계선 상/하/리셋 컨트롤(B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용). E-7: X축 행 배치용 export. */
export function buildRockBoundaryControl(
section: CrossSection,
@@ -174,27 +204,56 @@ export function buildRockBoundaryControl(
const currentOffset = control.offsetFor(section);
readout.textContent = `${currentOffset >= 0 ? "+" : ""}${currentOffset.toFixed(1)}m`;
const repeater = createHoldRepeater();
const makeButton = (
label: string,
title: string,
className: string,
onClick: () => void,
hold = false,
): HTMLButtonElement => {
const button = document.createElement("button");
button.type = "button";
button.className = `b06-design__rockb-btn ${className}`;
button.textContent = label;
button.title = title;
button.addEventListener("click", onClick);
button.title = hold ? `${title}\n(길게 누르면 0.5초 뒤부터 연속 조정)` : title;
if (hold) {
// 포인터로 누르면 즉시 1회 반응하고 반복을 예약한다. 이어지는 click은 중복이라 삼킨다.
let swallowClick = false;
button.addEventListener("pointerdown", (event) => {
event.stopPropagation();
swallowClick = true;
onClick();
repeater.start(onClick);
});
button.addEventListener("click", (event) => {
event.stopPropagation();
if (swallowClick) {
swallowClick = false;
return;
}
onClick();
});
} else {
button.addEventListener("click", onClick);
}
return button;
};
group.append(
makeButton("▲", `${L("B06_Design_RockBoundary_Up")} (+${control.stepM}m)`, "is-up", () =>
control.adjust(section.chainage_m, control.stepM),
makeButton(
"▲",
`${L("B06_Design_RockBoundary_Up")} (+${control.stepM}m)`,
"is-up",
() => control.adjust(section.chainage_m, control.stepM),
true,
),
makeButton("▼", `${L("B06_Design_RockBoundary_Down")} (-${control.stepM}m)`, "is-down", () =>
control.adjust(section.chainage_m, -control.stepM),
makeButton(
"▼",
`${L("B06_Design_RockBoundary_Down")} (-${control.stepM}m)`,
"is-down",
() => control.adjust(section.chainage_m, -control.stepM),
true,
),
makeButton("↺", L("B06_Design_RockBoundary_Reset"), "is-reset", () =>
control.reset(section.chainage_m),
@@ -394,23 +453,67 @@ export function buildAreaReadout(design: CrossDesign | undefined): HTMLElement {
return readout;
}
/** 횡단 SVG에 표준단면 설계선을 겹쳐 그린다. */
/**
* 횡단 SVG에 표준단면 설계선을 겹쳐 그린다. 지면선과 겹치는 구간(사면이 지반을 추종하는
* 부분)만 점선으로 그려 뒤에 깔린 지표선이 비쳐 보이게 하고, 나머지는 실선으로 둔다.
*/
export function appendCrossDesignOverlay(
svg: SVGSVGElement,
design: CrossDesign,
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
groundSamples: SectionSample[],
): void {
const line = design.design_line;
if (!line || line.length < 2) return;
const points = line.map((point) => `${x(point.offset_m)},${toDisplayY(point.elevation_m)}`);
const polyline = document.createElementNS(SVG_NS, "polyline");
polyline.setAttribute("points", points.join(" "));
polyline.setAttribute("class", "b06-chart__design-cross");
svg.append(polyline);
// 차도·노견 경계 짧은 수직 틱(N-4-2): 노면 단일 기울기라 육안 구분이 안 되는 경계를
// 표시한다. 노견 바깥 끝(road_edges)은 측구·사면 꺾임으로 이미 구분되므로 제외한다.
// 지면선 표고 보간기(정렬된 유효 샘플, 범위 밖 끝값 클램프).
const ground = groundSamples
.filter((s) => s.valid !== false && s.elevation_m !== null && Number.isFinite(s.elevation_m))
.map((s) => ({ offset: s.offset_m ?? 0, elevation: s.elevation_m as number }))
.sort((a, b) => a.offset - b.offset);
const groundAt = (offset: number): number | null => {
if (!ground.length) return null;
if (offset <= ground[0].offset) return ground[0].elevation;
const last = ground[ground.length - 1];
if (offset >= last.offset) return last.elevation;
for (let i = 1; i < ground.length; i += 1) {
if (offset > ground[i].offset) continue;
const a = ground[i - 1];
const b = ground[i];
const span = b.offset - a.offset;
if (span <= 0) return b.elevation;
return a.elevation + (b.elevation - a.elevation) * ((offset - a.offset) / span);
}
return last.elevation;
};
// 각 설계선 점이 지면선과 픽셀 단위로 겹치는지 판정한다.
const OVERLAP_PX = 2;
const overlap = line.map((point) => {
const g = groundAt(point.offset_m);
if (g === null) return false;
return Math.abs(toDisplayY(point.elevation_m) - toDisplayY(g)) <= OVERLAP_PX;
});
// 세그먼트별로 그린다: 양 끝이 모두 겹치면 점선(overlap), 아니면 실선.
for (let i = 1; i < line.length; i += 1) {
const seg = document.createElementNS(SVG_NS, "line");
seg.setAttribute("x1", String(x(line[i - 1].offset_m)));
seg.setAttribute("y1", String(toDisplayY(line[i - 1].elevation_m)));
seg.setAttribute("x2", String(x(line[i].offset_m)));
seg.setAttribute("y2", String(toDisplayY(line[i].elevation_m)));
seg.setAttribute(
"class",
overlap[i - 1] && overlap[i]
? "b06-chart__design-cross b06-chart__design-cross--overlap"
: "b06-chart__design-cross",
);
svg.append(seg);
}
// 차도·노견 경계 짧은 수직 틱(N-4-2): ±3.6px(기존 ±6의 60%). 노면 단일 기울기라 육안
// 구분이 안 되는 경계를 표시한다. 노견 바깥 끝(road_edges)은 측구·사면 꺾임으로 이미 구분됨.
const edges = design.carriageway_edges;
if (edges) {
for (const edge of [edges.left, edges.right]) {
@@ -418,9 +521,9 @@ export function appendCrossDesignOverlay(
const cy = toDisplayY(edge.elevation_m);
const tick = document.createElementNS(SVG_NS, "line");
tick.setAttribute("x1", String(cx));
tick.setAttribute("y1", String(cy - 6));
tick.setAttribute("y1", String(cy - 3.6));
tick.setAttribute("x2", String(cx));
tick.setAttribute("y2", String(cy + 6));
tick.setAttribute("y2", String(cy + 3.6));
tick.setAttribute("class", "b06-chart__carriageway-tick");
svg.append(tick);
}
@@ -373,7 +373,7 @@ export function createCrossSectionCard(
y(elevationMid + (elevation - elevationMid) * exaggeration);
// 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다.
appendPavementOverlay(svg, section.design, x, toDisplayY);
appendCrossDesignOverlay(svg, section.design, x, toDisplayY);
appendCrossDesignOverlay(svg, section.design, x, toDisplayY, sourceSamples);
// 암 경계선은 지면선(지반선) 복사 + 오프셋 — 계획선 기준이 아님에 유의.
if (rockBoundary && section.design.geometry_preset === "rock") {
appendRockBoundaryOverlay(
@@ -33,6 +33,7 @@ import {
createSectionView,
type RockBoundaryControl,
} from "./B06_wf3_ProfileCross_UI_Section_View";
import { designElevationAt } from "./B06_wf3_ProfileCross_UI_Section_Common";
import {
createStandardPanel,
type StandardPanelController,
@@ -178,19 +179,31 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
}
/**
* 로드 시 2단계 경사 필드(`two_stage_slope`)가 없는 옛 암 측점 design을 최신 엔진으로
* 자동 재계산한다(E-1). 엔진 업데이트 전 저장된 암 design은 단일 경사로 남아 있어,
* 지반유형을 다시 고르기 전엔 2단계가 반영되지 않던 문제를 해소한다. 세션 암경계
* 오프셋을 실어 재계산하므로 지반유형 변경과 동일한 결과가 나온다.
* 로드 시 stale design을 최신 엔진·최신 종단 계획고로 자동 재계산한다(E-1 + N-6).
* 대상: (1) 2단계 경사 필드(`two_stage_slope`)가 없는 옛 암 측점, (2) B05에서 종단이
* 변경·확정돼 저장된 계산 기준 계획고(`design.design_elevation_m`)가 현재 계획선
* (`design_profiles`) 보간값과 어긋난 측점. 종단이 안 바뀐 측점은 0건이라 불필요한 API
* 호출이 없다. 측점별 버튼 선택값은 `changeFromDesign()` 경유로 보존한다.
*/
function reconcileStaleRockDesigns(): void {
async function reconcileStaleDesigns(): Promise<void> {
if (!sectionDetail) return;
for (const section of sectionDetail.cross_sections) {
const profiles = sectionDetail.longitudinal.design_profiles;
const stale = sectionDetail.cross_sections.filter((section) => {
const design = section.design;
if (design?.geometry_preset === "rock" && design.two_stage_slope === undefined) {
if (!design) return false;
if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true;
const planZ = designElevationAt(profiles, section.chainage_m);
return planZ !== undefined && Math.abs(planZ - design.design_elevation_m) > 1e-3;
});
if (!stale.length) return;
showLoadingOverlay();
try {
for (const section of stale) {
const change = changeFromDesign(section.chainage_m);
if (change) void handleDesignChange(section.chainage_m, change);
if (change) await handleDesignChange(section.chainage_m, change);
}
} finally {
hideLoadingOverlay();
}
}
@@ -462,7 +475,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
stationInterval = storedOptions.station_interval_m;
appliedHalfWidth = crossHalfWidth();
renderSectionDetail();
reconcileStaleRockDesigns(); // 옛 암 design 2단계 자동 재계산(E-1)
void reconcileStaleDesigns(); // 옛 암 2단계 + 종단 변경 반영 자동 재계산(E-1 + N-6)
updateActionState();
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
@@ -483,7 +483,7 @@
color: var(--color-text-muted);
}
/* 횡단 표준단면 설계선 오버레이 */
/* 횡단 표준단면 설계선 오버레이. 기본은 실선. */
.b06-chart__design-cross {
fill: none;
stroke: var(--color-royal-amethyst);
@@ -491,6 +491,11 @@
stroke-linejoin: round;
}
/* 지면선과 겹치는 구간만 점선(dash:gap = 1:1)으로 그려 뒤 지표선이 빈 칸으로 비쳐 보이게 한다. */
.b06-chart__design-cross--overlap {
stroke-dasharray: 4 4;
}
/* 차도·노견 경계 짧은 수직 틱(N-4-2) — 설계선과 같은 계열, 얇게. */
.b06-chart__carriageway-tick {
stroke: var(--color-royal-amethyst);
+20
View File
@@ -14,6 +14,7 @@
"clsx": "^2.1.1",
"es-toolkit": "^1.16.0",
"file-saver": "^2.0.5",
"forest-road-webapp": "file:../..",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-toastify": "^11.0.5",
@@ -44,6 +45,21 @@
"vitest": "^3.1.1"
}
},
"../..": {
"name": "forest-road-webapp",
"version": "0.1.0",
"dependencies": {
"maplibre-gl": "^5.24.0",
"three": "^0.185.0"
},
"devDependencies": {
"@types/node": "^26.1.0",
"@types/three": "^0.185.0",
"prettier": "^3.0.0",
"typescript": "^6.0.3",
"vite": "^8.1.0"
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -4273,6 +4289,10 @@
"dev": true,
"peer": true
},
"node_modules/forest-road-webapp": {
"resolved": "../..",
"link": true
},
"node_modules/forever-agent": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
@@ -20,6 +20,7 @@
"clsx": "^2.1.1",
"es-toolkit": "^1.16.0",
"file-saver": "^2.0.5",
"forest-road-webapp": "file:../..",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-toastify": "^11.0.5",
+61
View File
@@ -0,0 +1,61 @@
const req = async <T>(url: string, init?: RequestInit): Promise<T> => {
const response = await fetch(url, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
...init,
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.detail ?? `요청 실패 (${response.status})`);
}
return response.json() as Promise<T>;
};
const path = (projectId: string, suffix: string) =>
`/api/b08/${encodeURIComponent(projectId)}${suffix}`;
export const B08Api = {
basis: (p: string, v: string) => req<any>(path(p, `/basis/${encodeURIComponent(v)}`)),
saveBasis: (p: string, v: string, data: any) =>
req<any>(path(p, `/basis/${encodeURIComponent(v)}`), {
method: "PUT",
body: JSON.stringify(data),
}),
catalog: (p: string) => req<any[]>(path(p, "/catalog")),
savePriceBook: (p: string, data: any) =>
req(path(p, "/catalog/price-books"), { method: "POST", body: JSON.stringify(data) }),
saveItem: (p: string, data: any) =>
req(path(p, "/catalog/items"), { method: "POST", body: JSON.stringify(data) }),
addCandidate: (p: string, v: string, data: any) =>
req(path(p, `/catalog/${encodeURIComponent(v)}/candidates`), {
method: "POST",
body: JSON.stringify(data),
}),
applyPrice: (p: string, v: string, data: any) =>
req(path(p, `/catalog/${encodeURIComponent(v)}/apply`), {
method: "POST",
body: JSON.stringify(data),
}),
costing: (p: string) => req<any>(path(p, "/costing")),
saveUnitCost: (p: string, data: any) =>
req<any>(path(p, "/unit-costs"), { method: "POST", body: JSON.stringify(data) }),
saveEquipment: (p: string, data: any) =>
req<any>(path(p, "/equipment-rates"), { method: "POST", body: JSON.stringify(data) }),
saveCostBasis: (p: string, data: any) =>
req<any>(path(p, "/cost-basis"), { method: "POST", body: JSON.stringify(data) }),
quantities: (p: string) => req<any>(path(p, "/quantities")),
saveWbs: (p: string, data: any) =>
req(path(p, "/work-breakdown"), { method: "POST", body: JSON.stringify(data) }),
saveQuantity: (p: string, data: any) =>
req(path(p, "/quantities"), { method: "POST", body: JSON.stringify(data) }),
confirmQuantities: (p: string) => req(path(p, "/quantities/confirm"), { method: "POST" }),
calculate: (p: string, data: any) =>
req<any>(path(p, "/calculate/final"), { method: "POST", body: JSON.stringify(data) }),
importReference: (p: string, data: any) =>
req<any>(path(p, "/reference/import"), { method: "POST", body: JSON.stringify(data) }),
reconcile: (p: string, runId: string, sourceId: string) =>
req<any>(path(p, `/reconcile/${encodeURIComponent(runId)}/${encodeURIComponent(sourceId)}`), {
method: "POST",
}),
runs: (p: string) => req<any[]>(path(p, "/calculation-runs")),
};
@@ -0,0 +1,17 @@
"""B08 도메인 DDL 조립점. 전역 migration runner 연결은 B08 외부 작업이다."""
from B08_wf5_Quantity.B08_wf5_Quantity_Database_Basis import BASIS_DDL
from B08_wf5_Quantity.B08_wf5_Quantity_Database_Calculation import CALCULATION_DDL
from B08_wf5_Quantity.B08_wf5_Quantity_Database_Catalog import CATALOG_DDL
from B08_wf5_Quantity.B08_wf5_Quantity_Database_CostBasis import COST_BASIS_DDL
from B08_wf5_Quantity.B08_wf5_Quantity_Database_Quantity import QUANTITY_DDL
from B08_wf5_Quantity.B08_wf5_Quantity_Database_Reconciliation import RECONCILIATION_DDL
from B08_wf5_Quantity.B08_wf5_Quantity_Database_UnitCost import UNIT_COST_DDL
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:
await cursor.execute(statement)
await connection.commit()
@@ -0,0 +1,29 @@
BASIS_DDL = (
"""CREATE TABLE IF NOT EXISTS b08_basis_versions (
id BIGINT AUTO_INCREMENT PRIMARY KEY, project_id CHAR(36) NOT NULL,
version VARCHAR(50) NOT NULL, base_date DATE NOT NULL, region VARCHAR(100) NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'KRW', status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
confirmed_by BIGINT NULL, confirmed_at DATETIME NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_b08_basis(project_id,version))""",
"""CREATE TABLE IF NOT EXISTS b08_price_sources (
id BIGINT AUTO_INCREMENT PRIMARY KEY, project_id CHAR(36) NOT NULL,
source_code VARCHAR(40) NOT NULL, source_name VARCHAR(100) NOT NULL,
priority_no INT NOT NULL DEFAULT 100, publisher VARCHAR(150) NULL,
reference_date DATE NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
UNIQUE KEY uq_b08_source(project_id,source_code))""",
"""CREATE TABLE IF NOT EXISTS b08_exchange_rates (
id BIGINT AUTO_INCREMENT PRIMARY KEY, project_id CHAR(36) NOT NULL,
basis_version VARCHAR(50) NOT NULL, currency CHAR(3) NOT NULL,
rate_to_krw DECIMAL(20,8) NOT NULL, source_id BIGINT NULL,
effective_from DATE NOT NULL, effective_to DATE NULL,
UNIQUE KEY uq_b08_fx(project_id,basis_version,currency))""",
"""CREATE TABLE IF NOT EXISTS b08_rate_policies (
id BIGINT AUTO_INCREMENT PRIMARY KEY, project_id CHAR(36) NOT NULL,
basis_version VARCHAR(50) NOT NULL, rule_code VARCHAR(80) NOT NULL,
rule_name VARCHAR(150) NOT NULL, base_expression LONGTEXT NOT NULL,
rate_value DECIMAL(20,10) NULL, minimum_amount BIGINT NULL, maximum_amount BIGINT NULL,
rounding_mode VARCHAR(30) NOT NULL, rounding_unit BIGINT NOT NULL DEFAULT 1,
condition_json LONGTEXT NOT NULL, source_reference VARCHAR(500) NULL,
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', sort_order INT NOT NULL DEFAULT 0,
UNIQUE KEY uq_b08_rate_policy(project_id,basis_version,rule_code))""",
)
@@ -0,0 +1,39 @@
CALCULATION_DDL = (
"""CREATE TABLE IF NOT EXISTS b08_calculation_runs (
id CHAR(36) PRIMARY KEY, project_id CHAR(36) NOT NULL, calculation_type VARCHAR(30) NOT NULL,
basis_version VARCHAR(50) NOT NULL, price_version VARCHAR(50) NOT NULL,
quantity_version INT NOT NULL, rule_version VARCHAR(50) NOT NULL,
input_hash CHAR(64) NOT NULL, status VARCHAR(20) NOT NULL,
error_json LONGTEXT NOT NULL, created_by BIGINT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
KEY ix_b08_calc_run(project_id,created_at))""",
"""CREATE TABLE IF NOT EXISTS b08_calculation_inputs (
id BIGINT AUTO_INCREMENT PRIMARY KEY, run_id CHAR(36) NOT NULL,
input_type VARCHAR(30) NOT NULL, reference_id VARCHAR(80) NOT NULL,
snapshot_json LONGTEXT NOT NULL, KEY ix_b08_calc_input(run_id))""",
"""CREATE TABLE IF NOT EXISTS b08_estimate_lines (
id BIGINT AUTO_INCREMENT PRIMARY KEY, run_id CHAR(36) NOT NULL,
quantity_item_id CHAR(36) NOT NULL, wbs_id CHAR(36) NOT NULL,
quantity DECIMAL(20,6) NOT NULL, unit_labor DECIMAL(20,4) NOT NULL,
unit_material DECIMAL(20,4) NOT NULL, unit_expense DECIMAL(20,4) NOT NULL,
labor_amount BIGINT NOT NULL, material_amount BIGINT NOT NULL,
expense_amount BIGINT NOT NULL, total_amount BIGINT NOT NULL,
trace_json LONGTEXT NOT NULL, KEY ix_b08_estimate_line(run_id,wbs_id))""",
"""CREATE TABLE IF NOT EXISTS b08_cost_aggregates (
id BIGINT AUTO_INCREMENT PRIMARY KEY, run_id CHAR(36) NOT NULL,
aggregate_type VARCHAR(30) NOT NULL, group_key VARCHAR(100) NOT NULL,
labor_amount BIGINT NOT NULL, material_amount BIGINT NOT NULL,
expense_amount BIGINT NOT NULL, total_amount BIGINT NOT NULL,
UNIQUE KEY uq_b08_aggregate(run_id,aggregate_type,group_key))""",
"""CREATE TABLE IF NOT EXISTS b08_indirect_cost_results (
id BIGINT AUTO_INCREMENT PRIMARY KEY, run_id CHAR(36) NOT NULL,
rule_code VARCHAR(80) NOT NULL, base_amount BIGINT NOT NULL,
rate_value DECIMAL(20,10) NULL, result_amount BIGINT NOT NULL,
trace_json LONGTEXT NOT NULL, sort_order INT NOT NULL,
UNIQUE KEY uq_b08_indirect(run_id,rule_code))""",
"""CREATE TABLE IF NOT EXISTS b08_final_cost_results (
run_id CHAR(36) PRIMARY KEY, direct_cost BIGINT NOT NULL, net_cost BIGINT NOT NULL,
general_admin BIGINT NOT NULL, profit BIGINT NOT NULL, total_cost BIGINT NOT NULL,
vat BIGINT NOT NULL, contract_cost BIGINT NOT NULL, government_material BIGINT NOT NULL,
procurement_fee BIGINT NOT NULL, total_project_cost BIGINT NOT NULL,
trace_json LONGTEXT NOT NULL)""",
)
@@ -0,0 +1,31 @@
CATALOG_DDL = (
"""CREATE TABLE IF NOT EXISTS b08_catalog_items (
id CHAR(36) PRIMARY KEY, project_id CHAR(36) NOT NULL, item_type VARCHAR(20) NOT NULL,
item_code VARCHAR(50) NOT NULL, item_name VARCHAR(255) NOT NULL,
specification VARCHAR(255) NOT NULL DEFAULT '', unit VARCHAR(30) NOT NULL,
cost_type VARCHAR(20) NOT NULL, procurement_type VARCHAR(20) NOT NULL DEFAULT 'PRIVATE',
active TINYINT(1) NOT NULL DEFAULT 1, UNIQUE KEY uq_b08_catalog(project_id,item_code))""",
"""CREATE TABLE IF NOT EXISTS b08_external_code_mappings (
id BIGINT AUTO_INCREMENT PRIMARY KEY, project_id CHAR(36) NOT NULL,
item_id CHAR(36) NOT NULL, system_code VARCHAR(30) NOT NULL,
external_code VARCHAR(80) NOT NULL, external_version VARCHAR(50) NULL,
UNIQUE KEY uq_b08_mapping(project_id,system_code,external_code))""",
"""CREATE TABLE IF NOT EXISTS b08_price_books (
id BIGINT AUTO_INCREMENT PRIMARY KEY, project_id CHAR(36) NOT NULL,
price_version VARCHAR(50) NOT NULL, basis_version VARCHAR(50) NOT NULL,
name VARCHAR(150) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
effective_date DATE NOT NULL, UNIQUE KEY uq_b08_book(project_id,price_version))""",
"""CREATE TABLE IF NOT EXISTS b08_price_entries (
id BIGINT AUTO_INCREMENT PRIMARY KEY, project_id CHAR(36) NOT NULL,
price_version VARCHAR(50) NOT NULL, item_id CHAR(36) NOT NULL, source_id BIGINT NOT NULL,
source_price DECIMAL(20,4) NOT NULL, currency CHAR(3) NOT NULL DEFAULT 'KRW',
exchange_rate DECIMAL(20,8) NOT NULL DEFAULT 1, converted_price DECIMAL(20,4) NOT NULL,
reference_page VARCHAR(50) NULL, valid_from DATE NOT NULL, valid_to DATE NULL,
KEY ix_b08_price_entry(project_id,price_version,item_id))""",
"""CREATE TABLE IF NOT EXISTS b08_applied_prices (
project_id CHAR(36) NOT NULL, price_version VARCHAR(50) NOT NULL,
item_id CHAR(36) NOT NULL, price_entry_id BIGINT NOT NULL,
applied_price DECIMAL(20,4) NOT NULL, selection_reason VARCHAR(500) NOT NULL,
approved_by BIGINT NULL, approved_at DATETIME NULL,
PRIMARY KEY(project_id,price_version,item_id))""",
)
@@ -0,0 +1,19 @@
COST_BASIS_DDL = (
"""CREATE TABLE IF NOT EXISTS b08_cost_basis (
id CHAR(36) PRIMARY KEY, project_id CHAR(36) NOT NULL, basis_code VARCHAR(50) NOT NULL,
name VARCHAR(255) NOT NULL, specification VARCHAR(255) NOT NULL DEFAULT '', unit VARCHAR(30) NOT NULL,
formula_note LONGTEXT NULL, labor_price DECIMAL(20,4) NOT NULL DEFAULT 0,
material_price DECIMAL(20,4) NOT NULL DEFAULT 0, expense_price DECIMAL(20,4) NOT NULL DEFAULT 0,
total_price DECIMAL(20,4) NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', version_no INT NOT NULL DEFAULT 1,
UNIQUE KEY uq_b08_cost_basis(project_id,basis_code,version_no))""",
"""CREATE TABLE IF NOT EXISTS b08_cost_basis_components (
id BIGINT AUTO_INCREMENT PRIMARY KEY, cost_basis_id CHAR(36) NOT NULL,
component_type VARCHAR(20) NOT NULL, reference_id CHAR(36) NOT NULL,
quantity_expression VARCHAR(500) NOT NULL, cost_type VARCHAR(20) NOT NULL,
sort_order INT NOT NULL DEFAULT 0, KEY ix_b08_cb_component(cost_basis_id))""",
"""CREATE TABLE IF NOT EXISTS b08_formula_variables (
id BIGINT AUTO_INCREMENT PRIMARY KEY, cost_basis_id CHAR(36) NOT NULL,
variable_code VARCHAR(50) NOT NULL, label VARCHAR(150) NOT NULL,
value DECIMAL(20,8) NULL, unit VARCHAR(30) NULL, required TINYINT(1) NOT NULL DEFAULT 1,
UNIQUE KEY uq_b08_variable(cost_basis_id,variable_code))""",
)
@@ -0,0 +1,27 @@
QUANTITY_DDL = (
"""CREATE TABLE IF NOT EXISTS b08_work_breakdown (
id CHAR(36) PRIMARY KEY, project_id CHAR(36) NOT NULL, parent_id CHAR(36) NULL,
wbs_code VARCHAR(50) NOT NULL, name VARCHAR(255) NOT NULL, level_no INT NOT NULL,
sort_order INT NOT NULL DEFAULT 0, UNIQUE KEY uq_b08_wbs(project_id,wbs_code))""",
"""CREATE TABLE IF NOT EXISTS b08_design_quantity_items (
id CHAR(36) PRIMARY KEY, project_id CHAR(36) NOT NULL, wbs_id CHAR(36) NOT NULL,
reference_type VARCHAR(20) NOT NULL, reference_id CHAR(36) NOT NULL,
item_name VARCHAR(255) NOT NULL, specification VARCHAR(255) NOT NULL DEFAULT '', unit VARCHAR(30) NOT NULL,
design_quantity DECIMAL(20,6) NULL, adjusted_quantity DECIMAL(20,6) NULL,
confirmed_quantity DECIMAL(20,6) NULL, adjustment_reason VARCHAR(500) NULL,
procurement_type VARCHAR(20) NOT NULL DEFAULT 'PRIVATE', excluded TINYINT(1) NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', sort_order INT NOT NULL DEFAULT 0,
updated_by BIGINT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY ix_b08_design_qty(project_id,wbs_id))""",
"""CREATE TABLE IF NOT EXISTS b08_quantity_revisions (
id BIGINT AUTO_INCREMENT PRIMARY KEY, quantity_item_id CHAR(36) NOT NULL,
revision_no INT NOT NULL, before_json LONGTEXT NOT NULL, after_json LONGTEXT NOT NULL,
change_reason VARCHAR(500) NOT NULL, changed_by BIGINT NULL,
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_b08_qty_revision(quantity_item_id,revision_no))""",
"""CREATE TABLE IF NOT EXISTS b08_quantity_confirmations (
id BIGINT AUTO_INCREMENT PRIMARY KEY, project_id CHAR(36) NOT NULL,
quantity_version INT NOT NULL, input_hash CHAR(64) NOT NULL,
confirmed_by BIGINT NULL, confirmed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_b08_qty_confirm(project_id,quantity_version))""",
)
@@ -0,0 +1,22 @@
RECONCILIATION_DDL = (
"""CREATE TABLE IF NOT EXISTS b08_source_files (
id CHAR(36) PRIMARY KEY, project_id CHAR(36) NOT NULL, source_type VARCHAR(20) NOT NULL,
original_filename VARCHAR(255) NOT NULL, sha256 CHAR(64) NOT NULL,
source_version VARCHAR(50) NULL, stored_path VARCHAR(500) NOT NULL,
imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_b08_source_file(project_id,sha256))""",
"""CREATE TABLE IF NOT EXISTS b08_reference_values (
id BIGINT AUTO_INCREMENT PRIMARY KEY, source_file_id CHAR(36) NOT NULL,
stage_code VARCHAR(80) NOT NULL, reference_key VARCHAR(100) NOT NULL,
amount BIGINT NOT NULL, metadata_json LONGTEXT NOT NULL,
UNIQUE KEY uq_b08_reference(source_file_id,stage_code,reference_key))""",
"""CREATE TABLE IF NOT EXISTS b08_reconciliation_runs (
id CHAR(36) PRIMARY KEY, project_id CHAR(36) NOT NULL, calculation_run_id CHAR(36) NOT NULL,
source_file_id CHAR(36) NOT NULL, status VARCHAR(20) NOT NULL,
first_difference_stage VARCHAR(80) NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
"""CREATE TABLE IF NOT EXISTS b08_reconciliation_differences (
id BIGINT AUTO_INCREMENT PRIMARY KEY, reconciliation_run_id CHAR(36) NOT NULL,
stage_code VARCHAR(80) NOT NULL, reference_key VARCHAR(100) NOT NULL,
expected_amount BIGINT NOT NULL, actual_amount BIGINT NOT NULL, difference_amount BIGINT NOT NULL,
KEY ix_b08_recon_diff(reconciliation_run_id,stage_code))""",
)
@@ -0,0 +1,30 @@
UNIT_COST_DDL = (
"""CREATE TABLE IF NOT EXISTS b08_unit_costs (
id CHAR(36) PRIMARY KEY, project_id CHAR(36) NOT NULL, unit_cost_code VARCHAR(50) NOT NULL,
name VARCHAR(255) NOT NULL, specification VARCHAR(255) NOT NULL DEFAULT '', unit VARCHAR(30) NOT NULL,
rounding_mode VARCHAR(30) NOT NULL DEFAULT 'ROUND_HALF_UP', rounding_unit BIGINT NOT NULL DEFAULT 1,
labor_price DECIMAL(20,4) NOT NULL DEFAULT 0, material_price DECIMAL(20,4) NOT NULL DEFAULT 0,
expense_price DECIMAL(20,4) NOT NULL DEFAULT 0, total_price DECIMAL(20,4) NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', version_no INT NOT NULL DEFAULT 1,
UNIQUE KEY uq_b08_unit_cost(project_id,unit_cost_code,version_no))""",
"""CREATE TABLE IF NOT EXISTS b08_unit_cost_components (
id BIGINT AUTO_INCREMENT PRIMARY KEY, unit_cost_id CHAR(36) NOT NULL,
component_type VARCHAR(20) NOT NULL, reference_id CHAR(36) NOT NULL,
quantity DECIMAL(20,8) NOT NULL, cost_type VARCHAR(20) NOT NULL,
sort_order INT NOT NULL DEFAULT 0, note VARCHAR(500) NULL,
KEY ix_b08_uc_component(unit_cost_id))""",
"""CREATE TABLE IF NOT EXISTS b08_equipment_rates (
id CHAR(36) PRIMARY KEY, project_id CHAR(36) NOT NULL, equipment_code VARCHAR(50) NOT NULL,
name VARCHAR(255) NOT NULL, specification VARCHAR(255) NOT NULL DEFAULT '', unit VARCHAR(30) NOT NULL DEFAULT 'hr',
equipment_price DECIMAL(20,4) NOT NULL DEFAULT 0, annual_hours DECIMAL(20,4) NOT NULL DEFAULT 0,
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,
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))""",
)
@@ -0,0 +1,19 @@
from decimal import Decimal
from B08_wf5_Quantity.B08_wf5_Quantity_Engine_Formula import evaluate, round_amount
from B08_wf5_Quantity.B08_wf5_Quantity_Schema_CostBasis import CostBasis
from B08_wf5_Quantity.B08_wf5_Quantity_Schema_UnitCost import CostResult
def calculate_cost_basis(model: CostBasis) -> CostResult:
variables={v.code:v.value for v in model.variables if v.value is not None}
missing=[v.label for v in model.variables if v.required and v.value is None]
if missing: raise ValueError("필수 산출변수 미입력: "+", ".join(missing))
buckets={"LABOR":Decimal(0),"MATERIAL":Decimal(0),"EXPENSE":Decimal(0)}; trace=[]
for component in sorted(model.components,key=lambda row:row.sort_order):
quantity=evaluate(component.quantity_expression,variables)
if quantity<0: raise ValueError(f"{component.reference_name}: 구성수량이 음수입니다.")
amount=quantity*component.unit_price; buckets[component.cost_type]+=amount
trace.append({"reference_id":component.reference_id,"expression":component.quantity_expression,
"quantity":str(quantity),"unit_price":str(component.unit_price),"amount":str(amount)})
labor=round_amount(buckets["LABOR"],"FLOOR",1); material=round_amount(buckets["MATERIAL"],"FLOOR",1)
expense=round_amount(buckets["EXPENSE"],"FLOOR",1)
return CostResult(labor=labor,material=material,expense=expense,total=labor+material+expense,trace=trace)
@@ -0,0 +1,34 @@
from decimal import Decimal
from B08_wf5_Quantity.B08_wf5_Quantity_Engine_Formula import round_amount
from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Calculation import CostTotals, EstimateInput, EstimateLine
def calculate_estimate(inputs: list[EstimateInput]) -> tuple[list[EstimateLine],CostTotals]:
if not inputs: raise ValueError("설계수량 항목이 없습니다.")
lines=[]; totals=CostTotals()
for row in inputs:
if row.quantity<0: raise ValueError(f"{row.quantity_item_id}: 수량이 음수입니다.")
labor=round_amount(row.quantity*row.unit_price.labor,"FLOOR",1)
material=round_amount(row.quantity*row.unit_price.material,"FLOOR",1)
expense=round_amount(row.quantity*row.unit_price.expense,"FLOOR",1)
total=labor+material+expense
line=EstimateLine(quantity_item_id=row.quantity_item_id,wbs_id=row.wbs_id,quantity=row.quantity,
unit_labor=row.unit_price.labor,unit_material=row.unit_price.material,unit_expense=row.unit_price.expense,
labor_amount=labor,material_amount=material,expense_amount=expense,total_amount=total,
procurement_type=row.procurement_type,trace={"formula":"quantity × unit price"})
lines.append(line)
if row.procurement_type=="GOVERNMENT": totals.government_material+=total
elif row.procurement_type=="EXCLUDED": totals.excluded_amount+=total
else: totals.labor+=labor; totals.material+=material; totals.expense+=expense
totals.direct_cost=totals.labor+totals.material+totals.expense
return lines,totals
def aggregate_by_wbs(lines: list[EstimateLine]) -> dict[str,CostTotals]:
result={}
for line in lines:
total=result.setdefault(line.wbs_id,CostTotals())
if line.procurement_type=="GOVERNMENT": total.government_material+=line.total_amount
elif line.procurement_type=="EXCLUDED": total.excluded_amount+=line.total_amount
else:
total.labor+=line.labor_amount; total.material+=line.material_amount; total.expense+=line.expense_amount
total.direct_cost=total.labor+total.material+total.expense
return result
@@ -0,0 +1,48 @@
from B08_wf5_Quantity.B08_wf5_Quantity_Engine_Indirect import calculate_indirect
from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Basis import RatePolicy
from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Calculation import CostTotals, FinalCost
_RESERVED = ("GENERAL_ADMIN", "PROFIT", "VAT", "PROCUREMENT_FEE")
def calculate_final_cost(totals: CostTotals, policies: list[RatePolicy]) -> FinalCost:
context = {"LABOR": totals.labor, "MATERIAL": totals.material, "EXPENSE": totals.expense,
"DIRECT_COST": totals.direct_cost, "GOVERNMENT_MATERIAL": totals.government_material}
ordered = sorted(policies, key=lambda row: row.sort_order)
overhead = [row for row in ordered if row.rule_code not in _RESERVED]
results, context = calculate_indirect(overhead, context)
net_cost = totals.direct_cost + sum(row.result_amount for row in results)
context["NET_COST"] = net_cost
general_rows, context = _stage(ordered, "GENERAL_ADMIN", context)
results += general_rows
general = context.get("GENERAL_ADMIN", 0)
context["AFTER_GENERAL"] = net_cost + general
profit_rows, context = _stage(ordered, "PROFIT", context)
results += profit_rows
profit = context.get("PROFIT", 0)
total_cost = net_cost + general + profit
context["TOTAL_COST"] = total_cost
vat_rows, context = _stage(ordered, "VAT", context)
results += vat_rows
vat = context.get("VAT", 0)
contract_cost = total_cost + vat
context["CONTRACT_COST"] = contract_cost
fee_rows, context = _stage(ordered, "PROCUREMENT_FEE", context)
results += fee_rows
fee = context.get("PROCUREMENT_FEE", 0)
total_project_cost = contract_cost + totals.government_material + fee
context["TOTAL_PROJECT_COST"] = total_project_cost
return FinalCost(direct_cost=totals.direct_cost, net_cost=net_cost, general_admin=general,
profit=profit, total_cost=total_cost, vat=vat, contract_cost=contract_cost,
government_material=totals.government_material, procurement_fee=fee,
total_project_cost=total_project_cost, indirect_results=results,
trace={"excluded_amount": totals.excluded_amount, "context": context})
def _stage(policies: list[RatePolicy], code: str, context: dict[str, int]):
selected = [row for row in policies if row.rule_code == code]
if not selected:
return [], context
return calculate_indirect(selected, context)
@@ -0,0 +1,35 @@
from __future__ import annotations
import ast
from decimal import Decimal, ROUND_CEILING, ROUND_FLOOR, ROUND_HALF_UP
_ALLOWED_BINARY = {ast.Add: lambda a,b:a+b, ast.Sub:lambda a,b:a-b,
ast.Mult:lambda a,b:a*b, ast.Div:lambda a,b:a/b}
_ALLOWED_UNARY = {ast.UAdd:lambda a:a, ast.USub:lambda a:-a}
def evaluate(expression: str, variables: dict[str, Decimal | int]) -> Decimal:
values = {key: Decimal(str(value)) for key,value in variables.items()}
tree = ast.parse(expression, mode="eval")
def visit(node):
if isinstance(node, ast.Expression): return visit(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value,(int,float)): return Decimal(str(node.value))
if isinstance(node, ast.Name):
if node.id not in values: raise ValueError(f"수식 변수 미입력: {node.id}")
return values[node.id]
if isinstance(node, ast.BinOp) and type(node.op) in _ALLOWED_BINARY:
right=visit(node.right)
if isinstance(node.op,ast.Div) and right==0: raise ValueError("0으로 나눌 수 없습니다.")
return _ALLOWED_BINARY[type(node.op)](visit(node.left),right)
if isinstance(node,ast.UnaryOp) and type(node.op) in _ALLOWED_UNARY:
return _ALLOWED_UNARY[type(node.op)](visit(node.operand))
if isinstance(node,ast.Call) and isinstance(node.func,ast.Name) and node.func.id in {"min","max"}:
args=[visit(arg) for arg in node.args]
return (min if node.func.id=="min" else max)(args)
raise ValueError(f"허용되지 않은 수식 요소: {type(node).__name__}")
return visit(tree)
def round_amount(value: Decimal, mode: str="ROUND", unit: int=1) -> int:
if unit < 1: raise ValueError("절사 단위는 1 이상이어야 합니다.")
rounding={"ROUND":ROUND_HALF_UP,"FLOOR":ROUND_FLOOR,"CEILING":ROUND_CEILING}.get(mode)
if rounding is None: raise ValueError(f"지원하지 않는 반올림 방식: {mode}")
step=Decimal(unit)
return int((value/step).quantize(Decimal("1"),rounding=rounding)*step)
@@ -0,0 +1,19 @@
from decimal import Decimal
from B08_wf5_Quantity.B08_wf5_Quantity_Engine_Formula import evaluate, round_amount
from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Basis import RatePolicy
from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Calculation import IndirectResult
def calculate_indirect(policies: list[RatePolicy], initial: dict[str,int]) -> tuple[list[IndirectResult],dict[str,int]]:
context=dict(initial); results=[]
for rule in sorted(policies,key=lambda row:row.sort_order):
if rule.status!="APPROVED": raise ValueError(f"미승인 요율 규칙: {rule.rule_name}")
base=evaluate(rule.base_expression,context)
raw=base*(rule.rate_value if rule.rate_value is not None else Decimal(1))
if rule.minimum_amount is not None: raw=max(raw,Decimal(rule.minimum_amount))
if rule.maximum_amount is not None: raw=min(raw,Decimal(rule.maximum_amount))
amount=round_amount(raw,rule.rounding_mode,rule.rounding_unit)
context[rule.rule_code]=amount
results.append(IndirectResult(rule_code=rule.rule_code,rule_name=rule.rule_name,
base_amount=round_amount(base,"ROUND",1),rate_value=rule.rate_value,result_amount=amount,
trace={"expression":rule.base_expression,"raw":str(raw),"rounding":rule.rounding_mode,"unit":rule.rounding_unit}))
return results,context
@@ -0,0 +1,23 @@
from decimal import Decimal
from B08_wf5_Quantity.B08_wf5_Quantity_Engine_Formula import round_amount
from B08_wf5_Quantity.B08_wf5_Quantity_Schema_UnitCost import CostResult, EquipmentRate, UnitCost
def _calculate(components, mode: str, unit: int) -> CostResult:
buckets={"LABOR":Decimal(0),"MATERIAL":Decimal(0),"EXPENSE":Decimal(0)}; trace=[]
for component in sorted(components,key=lambda row:row.sort_order):
amount=component.quantity*component.unit_price
buckets[component.cost_type]+=amount
trace.append({"reference_id":component.reference_id,"quantity":str(component.quantity),
"unit_price":str(component.unit_price),"amount":str(amount),"cost_type":component.cost_type})
labor=round_amount(buckets["LABOR"],mode,unit); material=round_amount(buckets["MATERIAL"],mode,unit)
expense=round_amount(buckets["EXPENSE"],mode,unit)
return CostResult(labor=labor,material=material,expense=expense,total=labor+material+expense,trace=trace)
def calculate_unit_cost(model: UnitCost) -> CostResult:
if not model.components: raise ValueError(f"{model.name}: 구성요소가 없습니다.")
return _calculate(model.components,model.rounding_mode,model.rounding_unit)
def calculate_equipment_rate(model: EquipmentRate) -> CostResult:
if model.annual_hours<=0: raise ValueError(f"{model.name}: 연간 가동시간이 필요합니다.")
if not model.components: raise ValueError(f"{model.name}: 손료·연료·운전원 구성요소가 없습니다.")
return _calculate(model.components,"FLOOR",1)
@@ -0,0 +1,23 @@
import json
from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Basis import BasisWorkspace,ExchangeRate,PriceSource,RatePolicy
class BasisRepository:
def __init__(self,connection): self.db=connection
async def load(self,project_id:str,version:str)->BasisWorkspace|None:
async with self.db.cursor() as c:
await c.execute("SELECT * FROM b08_basis_versions WHERE project_id=%s AND version=%s",(project_id,version)); head=await c.fetchone()
if not head:return None
await c.execute("SELECT * FROM b08_price_sources WHERE project_id=%s ORDER BY priority_no",(project_id,)); sources=await c.fetchall()
await c.execute("SELECT * FROM b08_exchange_rates WHERE project_id=%s AND basis_version=%s",(project_id,version)); rates=await c.fetchall()
await c.execute("SELECT * FROM b08_rate_policies WHERE project_id=%s AND basis_version=%s ORDER BY sort_order",(project_id,version)); policies=await c.fetchall()
return BasisWorkspace(project_id=project_id,version=version,base_date=head["base_date"],region=head["region"],currency=head["currency"],status=head["status"],price_sources=[PriceSource.model_validate(x) for x in sources],exchange_rates=[ExchangeRate.model_validate(x) for x in rates],rate_policies=[RatePolicy(**{**x,"condition_json":json.loads(x["condition_json"])}) for x in policies])
async def save(self,data:BasisWorkspace)->None:
async with self.db.cursor() as c:
await c.execute("""INSERT INTO b08_basis_versions(project_id,version,base_date,region,currency,status) VALUES(%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE base_date=VALUES(base_date),region=VALUES(region),currency=VALUES(currency),status=VALUES(status)""",(data.project_id,data.version,data.base_date,data.region,data.currency,data.status))
await c.execute("DELETE FROM b08_price_sources WHERE project_id=%s",(data.project_id,))
for x in data.price_sources: await c.execute("INSERT INTO b08_price_sources(project_id,source_code,source_name,priority_no,publisher,reference_date) VALUES(%s,%s,%s,%s,%s,%s)",(data.project_id,x.source_code,x.source_name,x.priority_no,x.publisher,x.reference_date))
await c.execute("DELETE FROM b08_exchange_rates WHERE project_id=%s AND basis_version=%s",(data.project_id,data.version))
for x in data.exchange_rates: await c.execute("INSERT INTO b08_exchange_rates(project_id,basis_version,currency,rate_to_krw,source_id,effective_from,effective_to) VALUES(%s,%s,%s,%s,%s,%s,%s)",(data.project_id,data.version,x.currency,x.rate_to_krw,x.source_id,x.effective_from,x.effective_to))
await c.execute("DELETE FROM b08_rate_policies WHERE project_id=%s AND basis_version=%s",(data.project_id,data.version))
for x in data.rate_policies: await c.execute("""INSERT INTO b08_rate_policies(project_id,basis_version,rule_code,rule_name,base_expression,rate_value,minimum_amount,maximum_amount,rounding_mode,rounding_unit,condition_json,source_reference,status,sort_order) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",(data.project_id,data.version,x.rule_code,x.rule_name,x.base_expression,x.rate_value,x.minimum_amount,x.maximum_amount,x.rounding_mode,x.rounding_unit,json.dumps(x.condition_json,ensure_ascii=False),x.source_reference,x.status,x.sort_order))
await self.db.commit()
@@ -0,0 +1,109 @@
import json
from uuid import uuid4
from B08_wf5_Quantity.B08_wf5_Quantity_Engine_Estimate import aggregate_by_wbs
from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Calculation import (
CostTotals,
EstimateLine,
FinalCost,
)
class CalculationRepository:
def __init__(self, connection):
self.db = connection
async def save(
self,
project_id: str,
versions: dict,
input_hash: str,
snapshots: list[dict],
lines: list[EstimateLine],
totals: CostTotals,
final: FinalCost,
user_id: int | None,
) -> str:
run_id = str(uuid4())
async with self.db.cursor() as cursor:
await cursor.execute(
"""INSERT INTO b08_calculation_runs(
id,project_id,calculation_type,basis_version,price_version,
quantity_version,rule_version,input_hash,status,error_json,created_by
) VALUES(%s,%s,'FINAL',%s,%s,%s,%s,%s,'COMPLETE','[]',%s)""",
(run_id, project_id, versions["basis_version"], versions["price_version"],
versions["quantity_version"], versions["rule_version"], input_hash, user_id),
)
for snapshot in snapshots:
await cursor.execute(
"""INSERT INTO b08_calculation_inputs(
run_id,input_type,reference_id,snapshot_json
) VALUES(%s,'QUANTITY_ITEM',%s,%s)""",
(run_id, snapshot["quantity_item_id"],
json.dumps(snapshot, ensure_ascii=False)),
)
for line in lines:
await cursor.execute(
"""INSERT INTO b08_estimate_lines(
run_id,quantity_item_id,wbs_id,quantity,unit_labor,
unit_material,unit_expense,labor_amount,material_amount,
expense_amount,total_amount,trace_json
) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(run_id, line.quantity_item_id, line.wbs_id, line.quantity,
line.unit_labor, line.unit_material, line.unit_expense,
line.labor_amount, line.material_amount, line.expense_amount,
line.total_amount, json.dumps(line.trace, ensure_ascii=False)),
)
await self._save_aggregates(cursor, run_id, lines, totals)
for sort_order, result in enumerate(final.indirect_results):
await cursor.execute(
"""INSERT INTO b08_indirect_cost_results(
run_id,rule_code,base_amount,rate_value,result_amount,
trace_json,sort_order
) VALUES(%s,%s,%s,%s,%s,%s,%s)""",
(run_id, result.rule_code, result.base_amount, result.rate_value,
result.result_amount, json.dumps(result.trace, ensure_ascii=False),
sort_order),
)
await cursor.execute(
"""INSERT INTO b08_final_cost_results(
run_id,direct_cost,net_cost,general_admin,profit,total_cost,
vat,contract_cost,government_material,procurement_fee,
total_project_cost,trace_json
) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(run_id, final.direct_cost, final.net_cost, final.general_admin,
final.profit, final.total_cost, final.vat, final.contract_cost,
final.government_material, final.procurement_fee,
final.total_project_cost, json.dumps(final.trace, ensure_ascii=False)),
)
await self.db.commit()
return run_id
@staticmethod
async def _save_aggregates(cursor, run_id: str, lines: list[EstimateLine], totals: CostTotals) -> None:
grouped = aggregate_by_wbs(lines)
grouped["__TOTAL__"] = totals
for group_key, total in grouped.items():
aggregate_type = "TOTAL" if group_key == "__TOTAL__" else "WBS"
total_amount = (
total.direct_cost + total.government_material + total.excluded_amount
)
await cursor.execute(
"""INSERT INTO b08_cost_aggregates(
run_id,aggregate_type,group_key,labor_amount,material_amount,
expense_amount,total_amount
) VALUES(%s,%s,%s,%s,%s,%s,%s)""",
(run_id, aggregate_type, group_key, total.labor, total.material,
total.expense, total_amount),
)
async def history(self, project_id: str) -> list[dict]:
async with self.db.cursor() as cursor:
await cursor.execute(
"""SELECT r.*, f.total_project_cost
FROM b08_calculation_runs r
LEFT JOIN b08_final_cost_results f ON f.run_id=r.id
WHERE r.project_id=%s ORDER BY r.created_at DESC""",
(project_id,),
)
return list(await cursor.fetchall())
@@ -0,0 +1,164 @@
import hashlib
import json
from decimal import Decimal
from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Basis import RatePolicy
from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Calculation import (
CalculationVersions,
EstimateInput,
UnitPriceBreakdown,
)
class CalculationSourceRepository:
def __init__(self, connection):
self.db = connection
async def load(
self, project_id: str, versions: CalculationVersions
) -> tuple[list[EstimateInput], list[RatePolicy], list[dict]]:
async with self.db.cursor() as cursor:
await self._validate_versions(cursor, project_id, versions)
await cursor.execute(self._input_query(), (versions.price_version, project_id))
rows = list(await cursor.fetchall())
await cursor.execute(
"""SELECT * FROM b08_rate_policies
WHERE project_id=%s AND basis_version=%s AND status='APPROVED'
ORDER BY sort_order""",
(project_id, versions.rule_version),
)
policy_rows = list(await cursor.fetchall())
if not rows:
raise ValueError("확정된 설계수량 항목이 없습니다.")
missing = [row for row in rows if not row["reference_found"]]
if missing:
raise ValueError(f"확정 적용단가를 찾을 수 없는 항목 {len(missing)}건이 있습니다.")
await self._validate_quantity_hash(project_id, versions.quantity_version, rows)
inputs = [self._to_input(row) for row in rows]
policies = [self._to_policy(row) for row in policy_rows]
snapshots = [self._snapshot(row) for row in rows]
return inputs, policies, snapshots
async def _validate_versions(self, cursor, project_id: str, versions: CalculationVersions) -> None:
await cursor.execute(
"""SELECT 1 FROM b08_basis_versions
WHERE project_id=%s AND version=%s AND status='CONFIRMED'""",
(project_id, versions.basis_version),
)
if not await cursor.fetchone():
raise ValueError("확정된 기준정보 버전이 아닙니다.")
if versions.rule_version != versions.basis_version:
raise ValueError("요율 규칙 버전은 기준정보 버전과 같아야 합니다.")
await cursor.execute(
"""SELECT 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),
)
if not await cursor.fetchone():
raise ValueError("확정 가격판과 기준정보 버전이 일치하지 않습니다.")
await cursor.execute(
"""SELECT quantity_version FROM b08_quantity_confirmations
WHERE project_id=%s ORDER BY quantity_version DESC LIMIT 1""",
(project_id,),
)
latest = await cursor.fetchone()
if not latest or latest["quantity_version"] != versions.quantity_version:
raise ValueError("최신 확정 수량 버전을 사용해야 합니다.")
async def _validate_quantity_hash(
self, project_id: str, quantity_version: int, rows: list[dict]
) -> None:
snapshot = [
{"id": row["id"], "quantity": row["confirmed_quantity"]}
for row in sorted(rows, key=lambda item: item["id"])
]
digest = hashlib.sha256(
json.dumps(snapshot, default=str, sort_keys=True).encode()
).hexdigest()
async with self.db.cursor() as cursor:
await cursor.execute(
"""SELECT input_hash FROM b08_quantity_confirmations
WHERE project_id=%s AND quantity_version=%s""",
(project_id, quantity_version),
)
confirmation = await cursor.fetchone()
if not confirmation or confirmation["input_hash"] != digest:
raise ValueError("확정 후 설계수량이 변경되어 다시 확정해야 합니다.")
@staticmethod
def _input_query() -> str:
return """
SELECT q.*,
CASE q.reference_type
WHEN 'CATALOG' THEN IF(ci.cost_type='LABOR',ap.applied_price,0)
ELSE COALESCE(uc.labor_price,eq.labor_price,cb.labor_price,0)
END AS unit_labor,
CASE q.reference_type
WHEN 'CATALOG' THEN IF(ci.cost_type='MATERIAL',ap.applied_price,0)
ELSE COALESCE(uc.material_price,eq.material_price,cb.material_price,0)
END AS unit_material,
CASE q.reference_type
WHEN 'CATALOG' THEN IF(ci.cost_type='EXPENSE',ap.applied_price,0)
ELSE COALESCE(uc.expense_price,eq.expense_price,cb.expense_price,0)
END AS unit_expense,
CASE q.reference_type
WHEN 'CATALOG' THEN ap.item_id IS NOT NULL
WHEN 'UNIT_COST' THEN uc.id IS NOT NULL
WHEN 'EQUIPMENT' THEN eq.id IS NOT NULL
WHEN 'COST_BASIS' THEN cb.id IS NOT NULL
ELSE 0
END AS reference_found
FROM b08_design_quantity_items q
LEFT JOIN b08_catalog_items ci
ON q.reference_type='CATALOG' AND ci.id=q.reference_id
AND ci.project_id=q.project_id
LEFT JOIN b08_applied_prices ap
ON ap.project_id=q.project_id AND ap.item_id=ci.id AND ap.price_version=%s
LEFT JOIN b08_unit_costs uc
ON q.reference_type='UNIT_COST' AND uc.id=q.reference_id
AND uc.project_id=q.project_id AND uc.status='CONFIRMED'
LEFT JOIN b08_equipment_rates eq
ON q.reference_type='EQUIPMENT' AND eq.id=q.reference_id
AND eq.project_id=q.project_id AND eq.status='CONFIRMED'
LEFT JOIN b08_cost_basis cb
ON q.reference_type='COST_BASIS' AND cb.id=q.reference_id
AND cb.project_id=q.project_id AND cb.status='CONFIRMED'
WHERE q.project_id=%s AND q.status='CONFIRMED' AND q.excluded=0
ORDER BY q.id
"""
@staticmethod
def _to_input(row: dict) -> EstimateInput:
return EstimateInput(
quantity_item_id=row["id"],
wbs_id=row["wbs_id"],
quantity=Decimal(str(row["confirmed_quantity"])),
unit_price=UnitPriceBreakdown(
labor=Decimal(str(row["unit_labor"] or 0)),
material=Decimal(str(row["unit_material"] or 0)),
expense=Decimal(str(row["unit_expense"] or 0)),
),
procurement_type=row["procurement_type"],
)
@staticmethod
def _to_policy(row: dict) -> RatePolicy:
values = dict(row)
condition = values.get("condition_json")
values["condition_json"] = json.loads(condition) if isinstance(condition, str) else (condition or {})
return RatePolicy.model_validate(values)
@staticmethod
def _snapshot(row: dict) -> dict:
return {
"quantity_item_id": row["id"],
"wbs_id": row["wbs_id"],
"reference_type": row["reference_type"],
"reference_id": row["reference_id"],
"quantity": str(row["confirmed_quantity"]),
"unit_labor": str(row["unit_labor"] or 0),
"unit_material": str(row["unit_material"] or 0),
"unit_expense": str(row["unit_expense"] or 0),
"procurement_type": row["procurement_type"],
}
@@ -0,0 +1,137 @@
from decimal import Decimal
from uuid import uuid4
from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Catalog import (
AppliedPrice,
CatalogItem,
PriceBook,
PriceCandidate,
)
class CatalogRepository:
def __init__(self, connection):
self.db = connection
async def save_book(self, project_id: str, row: PriceBook) -> None:
async with self.db.cursor() as cursor:
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),
)
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 save_item(self, project_id: str, item: CatalogItem) -> str:
item_id = item.id or str(uuid4())
async with self.db.cursor() as cursor:
await cursor.execute(
"""
INSERT INTO b08_catalog_items(
id,project_id,item_type,item_code,item_name,specification,
unit,cost_type,procurement_type
) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE
item_type=VALUES(item_type), item_name=VALUES(item_name),
specification=VALUES(specification), unit=VALUES(unit),
cost_type=VALUES(cost_type), procurement_type=VALUES(procurement_type)
""",
(item_id, project_id, item.item_type, item.item_code, item.item_name,
item.specification, item.unit, item.cost_type, item.procurement_type),
)
await self.db.commit()
return item_id
async def add_candidate(self, project_id: str, version: str, row: PriceCandidate) -> int:
converted_price = Decimal(row.source_price) * Decimal(row.exchange_rate)
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)
""",
(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),
)
result = cursor.lastrowid
await self.db.commit()
return result
async def apply(self, project_id: str, version: str, row: AppliedPrice) -> 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""",
(row.price_entry_id, project_id, version, row.item_id),
)
if not await cursor.fetchone():
raise ValueError("적용할 후보가격이 품목 또는 가격판과 일치하지 않습니다.")
await cursor.execute(
"""
INSERT INTO b08_applied_prices(
project_id,price_version,item_id,price_entry_id,applied_price,selection_reason
) VALUES(%s,%s,%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE
price_entry_id=VALUES(price_entry_id), applied_price=VALUES(applied_price),
selection_reason=VALUES(selection_reason), approved_at=NULL
""",
(project_id, version, row.item_id, row.price_entry_id,
row.applied_price, row.selection_reason),
)
await self._mark_dependents_stale(cursor, project_id, 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),
)
@@ -0,0 +1,14 @@
from uuid import uuid4
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())
@@ -0,0 +1,19 @@
from decimal import Decimal
class PricingResolver:
def __init__(self,connection):self.db=connection
async def resolve(self,component_type:str,reference_id:str,cost_type:str)->Decimal:
queries={
"CATALOG":("""SELECT a.applied_price labor_price,a.applied_price material_price,a.applied_price expense_price,i.cost_type FROM b08_applied_prices a JOIN b08_price_books b ON b.project_id=a.project_id AND b.price_version=a.price_version AND b.status='CONFIRMED' JOIN b08_catalog_items i ON i.id=a.item_id WHERE a.item_id=%s ORDER BY b.effective_date DESC LIMIT 1"""),
"UNIT_COST":"SELECT labor_price,material_price,expense_price,NULL cost_type FROM b08_unit_costs WHERE id=%s AND status='CONFIRMED'",
"EQUIPMENT":"SELECT labor_price,material_price,expense_price,NULL cost_type FROM b08_equipment_rates WHERE id=%s AND status='CONFIRMED'",
"COST_BASIS":"SELECT labor_price,material_price,expense_price,NULL cost_type FROM b08_cost_basis WHERE id=%s AND status='CONFIRMED'",
}
if component_type not in queries:raise ValueError(f"지원하지 않는 참조유형: {component_type}")
async with self.db.cursor() as c:await c.execute(queries[component_type],(reference_id,));row=await c.fetchone()
if not row:raise ValueError(f"확정 적용단가 없음: {component_type}/{reference_id}")
if component_type=="CATALOG" and row["cost_type"]!=cost_type:raise ValueError(f"품목 비용분류 불일치: {reference_id}")
return Decimal(str(row[{"LABOR":"labor_price","MATERIAL":"material_price","EXPENSE":"expense_price"}[cost_type]]))
async def hydrate(self,components):
for item in components:item.unit_price=await self.resolve(item.component_type,item.reference_id,item.cost_type)
return components
@@ -0,0 +1,187 @@
import hashlib
import json
from uuid import uuid4
from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Quantity import (
DesignQuantity,
WorkBreakdown,
)
class QuantityRepository:
def __init__(self, connection):
self.db = connection
async def workspace(self, project_id: str) -> dict:
async with self.db.cursor() as cursor:
await cursor.execute(
"SELECT * FROM b08_work_breakdown WHERE project_id=%s ORDER BY level_no,sort_order",
(project_id,),
)
work_breakdown = list(await cursor.fetchall())
await cursor.execute(self._quantity_query(), (project_id,))
quantities = list(await cursor.fetchall())
await cursor.execute(
"""SELECT quantity_version, input_hash, confirmed_by, confirmed_at
FROM b08_quantity_confirmations WHERE project_id=%s
ORDER BY quantity_version DESC LIMIT 1""",
(project_id,),
)
confirmation = await cursor.fetchone()
return {
"work_breakdown": work_breakdown,
"quantities": quantities,
"confirmation": confirmation,
}
@staticmethod
def _quantity_query() -> str:
return """
SELECT q.*,
CASE q.reference_type
WHEN 'CATALOG' THEN IF(ci.cost_type='LABOR',ap.applied_price,0)
ELSE COALESCE(uc.labor_price,eq.labor_price,cb.labor_price,0)
END AS unit_labor,
CASE q.reference_type
WHEN 'CATALOG' THEN IF(ci.cost_type='MATERIAL',ap.applied_price,0)
ELSE COALESCE(uc.material_price,eq.material_price,cb.material_price,0)
END AS unit_material,
CASE q.reference_type
WHEN 'CATALOG' THEN IF(ci.cost_type='EXPENSE',ap.applied_price,0)
ELSE COALESCE(uc.expense_price,eq.expense_price,cb.expense_price,0)
END AS unit_expense,
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
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'
)
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'
LEFT JOIN b08_equipment_rates eq
ON q.reference_type='EQUIPMENT' AND eq.id=q.reference_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'
WHERE q.project_id=%s
ORDER BY q.wbs_id,q.sort_order
"""
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:
await cursor.execute(
"""INSERT INTO b08_work_breakdown(
id,project_id,parent_id,wbs_code,name,level_no,sort_order
) VALUES(%s,%s,%s,%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE parent_id=VALUES(parent_id),
name=VALUES(name),level_no=VALUES(level_no),sort_order=VALUES(sort_order)""",
(row_id, project_id, row.parent_id, row.code, row.name,
row.level_no, row.sort_order),
)
await self.db.commit()
return row_id
async def 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 cursor.execute(
"SELECT * FROM b08_design_quantity_items WHERE id=%s FOR UPDATE",
(row_id,),
)
before = await cursor.fetchone()
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),
procurement_type=VALUES(procurement_type),excluded=VALUES(excluded),
status=VALUES(status),sort_order=VALUES(sort_order),updated_by=VALUES(updated_by)""",
(row_id, project_id, row.wbs_id, row.reference_type, row.reference_id,
row.item_name, row.specification, row.unit, row.design_quantity,
row.adjusted_quantity, row.confirmed_quantity, row.adjustment_reason,
row.procurement_type, row.excluded, row.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.db.commit()
return row_id
async def confirm(self, project_id: str, user_id: int | None) -> int:
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,),
)
missing = (await cursor.fetchone())["missing"]
if missing:
raise ValueError(f"수량 미입력 항목 {missing}건이 있습니다.")
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 unavailable:
raise ValueError(f"확정 적용단가가 없는 항목 {len(unavailable)}건이 있습니다.")
await cursor.execute(
"""UPDATE b08_design_quantity_items
SET confirmed_quantity=COALESCE(adjusted_quantity,design_quantity),
status='CONFIRMED',updated_by=%s
WHERE project_id=%s""",
(user_id, project_id),
)
snapshot = [
{"id": row["id"], "quantity": row["adjusted_quantity"] or row["design_quantity"]}
for row in rows
]
payload = json.dumps(snapshot, default=str, sort_keys=True).encode()
digest = hashlib.sha256(payload).hexdigest()
await cursor.execute(
"""SELECT COALESCE(MAX(quantity_version),0)+1 AS version
FROM b08_quantity_confirmations WHERE project_id=%s""",
(project_id,),
)
version = (await cursor.fetchone())["version"]
await cursor.execute(
"""INSERT INTO b08_quantity_confirmations(
project_id,quantity_version,input_hash,confirmed_by
) VALUES(%s,%s,%s,%s)""",
(project_id, version, digest, user_id),
)
await self.db.commit()
return version
@@ -0,0 +1,30 @@
import json
from uuid import uuid4
from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Reconciliation import ReferenceImport,ReconciliationResult
_STAGES=[("DIRECT_COST","direct_cost"),("NET_COST","net_cost"),("GENERAL_ADMIN","general_admin"),("PROFIT","profit"),("TOTAL_COST","total_cost"),("VAT","vat"),("CONTRACT_COST","contract_cost"),("GOVERNMENT_MATERIAL","government_material"),("PROCUREMENT_FEE","procurement_fee"),("TOTAL_PROJECT_COST","total_project_cost")]
class ReconciliationRepository:
def __init__(self,connection):self.db=connection
async def import_reference(self,project_id:str,data:ReferenceImport)->str:
source_id=str(uuid4())
async with self.db.cursor() as c:
await c.execute("INSERT INTO b08_source_files(id,project_id,source_type,original_filename,sha256,source_version,stored_path) VALUES(%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE original_filename=VALUES(original_filename),source_version=VALUES(source_version),stored_path=VALUES(stored_path)",(source_id,project_id,data.source_type,data.original_filename,data.sha256,data.source_version,data.stored_path))
await c.execute("SELECT id FROM b08_source_files WHERE project_id=%s AND sha256=%s",(project_id,data.sha256));source_id=(await c.fetchone())["id"]
await c.execute("DELETE FROM b08_reference_values WHERE source_file_id=%s",(source_id,))
for x in data.values:await c.execute("INSERT INTO b08_reference_values(source_file_id,stage_code,reference_key,amount,metadata_json) VALUES(%s,%s,%s,%s,%s)",(source_id,x.stage_code,x.reference_key,x.amount,json.dumps(x.metadata,ensure_ascii=False)))
await self.db.commit();return source_id
async def reconcile(self,project_id:str,run_id:str,source_id:str)->ReconciliationResult:
async with self.db.cursor() as c:
await c.execute("SELECT f.* FROM b08_final_cost_results f JOIN b08_calculation_runs r ON r.id=f.run_id WHERE f.run_id=%s AND r.project_id=%s",(run_id,project_id));actual=await c.fetchone()
if not actual:raise LookupError("계산 실행 결과가 없습니다.")
await c.execute("SELECT stage_code,reference_key,amount FROM b08_reference_values WHERE source_file_id=%s",(source_id,));refs={(x["stage_code"],x["reference_key"]):x["amount"] for x in await c.fetchall()}
differences=[];first=None
for stage,column in _STAGES:
if (stage,"TOTAL") not in refs:continue
expected=refs[(stage,"TOTAL")];current=actual[column];diff=current-expected
if diff!=0 and first is None:first=stage
differences.append({"stage_code":stage,"reference_key":"TOTAL","expected_amount":expected,"actual_amount":current,"difference_amount":diff})
recon_id=str(uuid4());status="DIFFERENT" if first else "MATCHED"
await c.execute("INSERT INTO b08_reconciliation_runs(id,project_id,calculation_run_id,source_file_id,status,first_difference_stage) VALUES(%s,%s,%s,%s,%s,%s)",(recon_id,project_id,run_id,source_id,status,first))
for x in differences:await c.execute("INSERT INTO b08_reconciliation_differences(reconciliation_run_id,stage_code,reference_key,expected_amount,actual_amount,difference_amount) VALUES(%s,%s,%s,%s,%s,%s)",(recon_id,x["stage_code"],x["reference_key"],x["expected_amount"],x["actual_amount"],x["difference_amount"]))
await self.db.commit();return ReconciliationResult(reconciliation_run_id=recon_id,status=status,first_difference_stage=first,differences=differences)
@@ -0,0 +1,21 @@
from uuid import uuid4
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}
@@ -0,0 +1,18 @@
"""B08 하위 Router 조립점. 전역 FastAPI 앱 등록은 B08 외부 작업이다."""
from fastapi import APIRouter
from B08_wf5_Quantity.B08_wf5_Quantity_Router_Basis import router_for as basis_router
from B08_wf5_Quantity.B08_wf5_Quantity_Router_Calculation import router_for as calculation_router
from B08_wf5_Quantity.B08_wf5_Quantity_Router_Catalog import router_for as catalog_router
from B08_wf5_Quantity.B08_wf5_Quantity_Router_Costing import router_for as costing_router
from B08_wf5_Quantity.B08_wf5_Quantity_Router_Quantity import router_for as quantity_router
from B08_wf5_Quantity.B08_wf5_Quantity_Router_Reconciliation import router_for as reconciliation_router
def create_b08_router(connection_provider,user_provider)->APIRouter:
router=APIRouter(prefix="/api/b08",tags=["B08 Quantity & Cost"])
router.include_router(basis_router(connection_provider))
router.include_router(catalog_router(connection_provider))
router.include_router(costing_router(connection_provider))
router.include_router(quantity_router(connection_provider,user_provider))
router.include_router(calculation_router(connection_provider,user_provider))
router.include_router(reconciliation_router(connection_provider))
return router
@@ -0,0 +1,16 @@
from fastapi import APIRouter,Depends,HTTPException
from B08_wf5_Quantity.B08_wf5_Quantity_Repository_Basis import BasisRepository
from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Basis import BasisWorkspace
def router_for(connection_provider):
r=APIRouter()
@r.get("/{project_id}/basis/{version}",response_model=BasisWorkspace)
async def get(project_id:str,version:str,db=Depends(connection_provider)):
data=await BasisRepository(db).load(project_id,version)
if not data:raise HTTPException(404,"기준정보가 없습니다.")
return data
@r.put("/{project_id}/basis/{version}",response_model=BasisWorkspace)
async def put(project_id:str,version:str,data:BasisWorkspace,db=Depends(connection_provider)):
if data.project_id!=project_id or data.version!=version:raise HTTPException(422,"경로와 기준정보 ID가 다릅니다.")
await BasisRepository(db).save(data);return data
return r
@@ -0,0 +1,59 @@
import hashlib
import json
from fastapi import APIRouter, Depends
from B08_wf5_Quantity.B08_wf5_Quantity_Engine_Estimate import calculate_estimate
from B08_wf5_Quantity.B08_wf5_Quantity_Engine_Final import calculate_final_cost
from B08_wf5_Quantity.B08_wf5_Quantity_Repository_Calculation import CalculationRepository
from B08_wf5_Quantity.B08_wf5_Quantity_Repository_CalculationSource import (
CalculationSourceRepository,
)
from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Calculation import (
FinalCalculationRequest,
FinalCalculationResponse,
)
def router_for(connection_provider, user_provider):
router = APIRouter()
@router.post("/{project_id}/calculate/final", response_model=FinalCalculationResponse)
async def calculate(
project_id: str,
data: FinalCalculationRequest,
db=Depends(connection_provider),
user_id=Depends(user_provider),
):
inputs, policies, snapshots = await CalculationSourceRepository(db).load(
project_id, data.versions
)
lines, totals = calculate_estimate(inputs)
final = calculate_final_cost(totals, policies)
hash_source = {
"versions": data.versions.model_dump(mode="json"),
"inputs": snapshots,
"policies": [row.model_dump(mode="json") for row in policies],
}
input_hash = hashlib.sha256(
json.dumps(hash_source, ensure_ascii=False, sort_keys=True).encode()
).hexdigest()
run_id = await CalculationRepository(db).save(
project_id,
data.versions.model_dump(),
input_hash,
snapshots,
lines,
totals,
final,
user_id,
)
return FinalCalculationResponse(
run_id=run_id, lines=lines, totals=totals, final=final
)
@router.get("/{project_id}/calculation-runs")
async def history(project_id: str, db=Depends(connection_provider)):
return await CalculationRepository(db).history(project_id)
return router
@@ -0,0 +1,24 @@
from fastapi import APIRouter, Depends
from B08_wf5_Quantity.B08_wf5_Quantity_Repository_Catalog import CatalogRepository
from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Catalog import AppliedPrice, CatalogItem, PriceBook, PriceCandidate
def router_for(connection_provider):
router = APIRouter()
@router.post("/{project_id}/catalog/price-books")
async def save_book(project_id: str, data: PriceBook, db=Depends(connection_provider)):
await CatalogRepository(db).save_book(project_id, data)
return {"status": "ok"}
@router.get("/{project_id}/catalog")
async def get_catalog(project_id: str, db=Depends(connection_provider)):
return await CatalogRepository(db).list_items(project_id)
@router.post("/{project_id}/catalog/items")
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")
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")
async def apply_price(project_id: str, version: str, data: AppliedPrice, db=Depends(connection_provider)):
await CatalogRepository(db).apply(project_id, version, data)
return {"status": "ok"}
return router
@@ -0,0 +1,23 @@
from fastapi import APIRouter,Depends
from B08_wf5_Quantity.B08_wf5_Quantity_Engine_CostBasis import calculate_cost_basis
from B08_wf5_Quantity.B08_wf5_Quantity_Engine_UnitCost import calculate_equipment_rate,calculate_unit_cost
from B08_wf5_Quantity.B08_wf5_Quantity_Repository_CostBasis import CostBasisRepository
from B08_wf5_Quantity.B08_wf5_Quantity_Repository_Pricing import PricingResolver
from B08_wf5_Quantity.B08_wf5_Quantity_Repository_UnitCost import UnitCostRepository
from B08_wf5_Quantity.B08_wf5_Quantity_Schema_CostBasis import CostBasis
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).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).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).hydrate(data.components);result=calculate_cost_basis(data);await CostBasisRepository(db).save(project_id,data,result);return result
return r
@@ -0,0 +1,16 @@
from fastapi import APIRouter,Depends
from B08_wf5_Quantity.B08_wf5_Quantity_Repository_Quantity import QuantityRepository
from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Quantity import DesignQuantity,WorkBreakdown
def router_for(connection_provider,user_provider):
r=APIRouter()
@r.get("/{project_id}/quantities")
async def get(project_id:str,db=Depends(connection_provider)):return await QuantityRepository(db).workspace(project_id)
@r.post("/{project_id}/work-breakdown")
async def wbs(project_id:str,data:WorkBreakdown,db=Depends(connection_provider)):return {"id":await QuantityRepository(db).save_wbs(project_id,data)}
@r.post("/{project_id}/quantities")
async def quantity(project_id:str,data:DesignQuantity,db=Depends(connection_provider),user_id=Depends(user_provider)):return {"id":await QuantityRepository(db).save_quantity(project_id,data,user_id)}
@r.post("/{project_id}/quantities/confirm")
async def confirm(project_id:str,db=Depends(connection_provider),user_id=Depends(user_provider)):
await QuantityRepository(db).confirm(project_id,user_id);return {"status":"confirmed"}
return r
@@ -0,0 +1,13 @@
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
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
@@ -0,0 +1,46 @@
from datetime import date
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, Field
Status = Literal["DRAFT", "CONFIRMED", "STALE"]
class PriceSource(BaseModel):
id: int | None = None
source_code: str = Field(max_length=40)
source_name: str = Field(max_length=100)
priority_no: int = Field(default=100, ge=1)
publisher: str | None = None
reference_date: date
class ExchangeRate(BaseModel):
currency: str = Field(min_length=3, max_length=3)
rate_to_krw: Decimal = Field(gt=0)
source_id: int | None = None
effective_from: date
effective_to: date | None = None
class RatePolicy(BaseModel):
rule_code: str
rule_name: str
base_expression: str
rate_value: Decimal | None = Field(default=None, ge=0)
minimum_amount: int | None = None
maximum_amount: int | None = None
rounding_mode: Literal["ROUND", "FLOOR", "CEILING"] = "FLOOR"
rounding_unit: int = Field(default=1, ge=1)
condition_json: dict = Field(default_factory=dict)
source_reference: str | None = None
status: Literal["DRAFT", "APPROVED"] = "DRAFT"
sort_order: int = 0
class BasisWorkspace(BaseModel):
project_id: str
version: str
base_date: date
region: str
currency: str = "KRW"
status: Status = "DRAFT"
price_sources: list[PriceSource] = Field(default_factory=list)
exchange_rates: list[ExchangeRate] = Field(default_factory=list)
rate_policies: list[RatePolicy] = Field(default_factory=list)
@@ -0,0 +1,84 @@
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, Field
class UnitPriceBreakdown(BaseModel):
labor: Decimal = Decimal("0")
material: Decimal = Decimal("0")
expense: Decimal = Decimal("0")
class EstimateInput(BaseModel):
quantity_item_id: str
wbs_id: str
quantity: Decimal = Field(ge=0)
unit_price: UnitPriceBreakdown
procurement_type: Literal["PRIVATE", "GOVERNMENT", "EXCLUDED"] = "PRIVATE"
class EstimateLine(BaseModel):
quantity_item_id: str
wbs_id: str
quantity: Decimal
unit_labor: Decimal
unit_material: Decimal
unit_expense: Decimal
labor_amount: int
material_amount: int
expense_amount: int
total_amount: int
procurement_type: str
trace: dict = Field(default_factory=dict)
class CostTotals(BaseModel):
labor: int = 0
material: int = 0
expense: int = 0
direct_cost: int = 0
government_material: int = 0
excluded_amount: int = 0
class IndirectResult(BaseModel):
rule_code: str
rule_name: str
base_amount: int
rate_value: Decimal | None
result_amount: int
trace: dict
class FinalCost(BaseModel):
direct_cost: int
net_cost: int
general_admin: int
profit: int
total_cost: int
vat: int
contract_cost: int
government_material: int
procurement_fee: int
total_project_cost: int
indirect_results: list[IndirectResult]
trace: dict
class CalculationVersions(BaseModel):
basis_version: str
price_version: str
quantity_version: int = Field(ge=1)
rule_version: str
class FinalCalculationRequest(BaseModel):
versions: CalculationVersions
class FinalCalculationResponse(BaseModel):
run_id: str
lines: list[EstimateLine]
totals: CostTotals
final: FinalCost
@@ -0,0 +1,50 @@
from datetime import date
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, Field
ItemType = Literal["MATERIAL", "LABOR", "EQUIPMENT", "EXPENSE"]
CostType = Literal["MATERIAL", "LABOR", "EXPENSE"]
class CatalogItem(BaseModel):
id: str | None = None
item_type: ItemType
item_code: str
item_name: str
specification: str = ""
unit: str
cost_type: CostType
procurement_type: Literal["PRIVATE", "GOVERNMENT", "EXCLUDED"] = "PRIVATE"
class PriceBook(BaseModel):
price_version: str
basis_version: str
name: str
status: Literal["DRAFT", "CONFIRMED"] = "DRAFT"
effective_date: date
class PriceCandidate(BaseModel):
id: int | None = None
item_id: str
source_id: int
source_price: Decimal = Field(ge=0)
currency: str = "KRW"
exchange_rate: Decimal = Field(default=Decimal("1"), gt=0)
converted_price: Decimal = Field(ge=0)
reference_page: str | None = None
valid_from: date
valid_to: date | None = None
class AppliedPrice(BaseModel):
item_id: str
price_entry_id: int
applied_price: Decimal = Field(ge=0)
selection_reason: str = Field(min_length=1, max_length=500)
class CatalogWorkspace(BaseModel):
price_version: str
status: Literal["DRAFT", "CONFIRMED", "STALE"] = "DRAFT"
items: list[CatalogItem] = Field(default_factory=list)
candidates: list[PriceCandidate] = Field(default_factory=list)
applied_prices: list[AppliedPrice] = Field(default_factory=list)
@@ -0,0 +1,33 @@
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, Field
from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Catalog import CostType
class FormulaVariable(BaseModel):
code: str
label: str
value: Decimal | None = None
unit: str | None = None
required: bool = True
class BasisComponent(BaseModel):
id: int | None = None
component_type: Literal["CATALOG", "UNIT_COST", "EQUIPMENT", "COST_BASIS"]
reference_id: str
reference_name: str = ""
quantity_expression: str
unit_price: Decimal = Field(ge=0)
cost_type: CostType
sort_order: int = 0
class CostBasis(BaseModel):
id: str | None = None
code: str
name: str
specification: str = ""
unit: str
formula_note: str | None = None
status: Literal["DRAFT", "CONFIRMED", "STALE"] = "DRAFT"
version_no: int = 1
variables: list[FormulaVariable] = Field(default_factory=list)
components: list[BasisComponent] = Field(default_factory=list)
@@ -0,0 +1,44 @@
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, Field, model_validator
class WorkBreakdown(BaseModel):
id: str | None = None
parent_id: str | None = None
code: str
name: str
level_no: int = Field(ge=1, le=10)
sort_order: int = 0
class DesignQuantity(BaseModel):
id: str | None = None
wbs_id: str
reference_type: Literal["CATALOG", "UNIT_COST", "EQUIPMENT", "COST_BASIS"]
reference_id: str
item_name: str
specification: str = ""
unit: str
design_quantity: Decimal | None = Field(default=None, ge=0)
adjusted_quantity: Decimal | None = Field(default=None, ge=0)
confirmed_quantity: Decimal | None = Field(default=None, ge=0)
adjustment_reason: str | None = None
procurement_type: Literal["PRIVATE", "GOVERNMENT", "EXCLUDED"] = "PRIVATE"
excluded: bool = False
status: Literal["DRAFT", "ADJUSTED", "CONFIRMED"] = "DRAFT"
sort_order: int = 0
@model_validator(mode="after")
def validate_adjustment(self):
if self.adjusted_quantity is not None and not self.adjustment_reason:
raise ValueError("보정수량에는 변경 사유가 필요합니다.")
return self
@property
def effective_quantity(self) -> Decimal:
for value in (self.confirmed_quantity, self.adjusted_quantity, self.design_quantity):
if value is not None:
return value
raise ValueError(f"{self.item_name}: 수량이 입력되지 않았습니다.")
@@ -0,0 +1,20 @@
from pydantic import BaseModel,Field
from typing import Literal
class ReferenceValue(BaseModel):
stage_code:str
reference_key:str="TOTAL"
amount:int=Field(ge=0)
metadata:dict=Field(default_factory=dict)
class ReferenceImport(BaseModel):
source_type:Literal["STC","XLSX","MANUAL"]
original_filename:str
sha256:str=Field(min_length=64,max_length=64)
source_version:str|None=None
stored_path:str
values:list[ReferenceValue]
class ReconciliationResult(BaseModel):
reconciliation_run_id:str
status:Literal["MATCHED","DIFFERENT"]
first_difference_stage:str|None
differences:list[dict]
@@ -0,0 +1,47 @@
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, Field
from B08_wf5_Quantity.B08_wf5_Quantity_Schema_Catalog import CostType
ReferenceType = Literal["CATALOG", "UNIT_COST", "EQUIPMENT"]
class CostComponent(BaseModel):
id: int | None = None
component_type: ReferenceType
reference_id: str
reference_name: str = ""
quantity: Decimal = Field(ge=0)
unit_price: Decimal = Field(ge=0)
cost_type: CostType
sort_order: int = 0
class UnitCost(BaseModel):
id: str | None = None
code: str
name: str
specification: str = ""
unit: str
rounding_mode: Literal["ROUND", "FLOOR", "CEILING"] = "FLOOR"
rounding_unit: int = Field(default=1, ge=1)
status: Literal["DRAFT", "CONFIRMED", "STALE"] = "DRAFT"
version_no: int = 1
components: list[CostComponent] = Field(default_factory=list)
class EquipmentRate(BaseModel):
id: str | None = None
code: str
name: str
specification: str = ""
unit: str = "hr"
equipment_price: Decimal = Field(default=0, ge=0)
annual_hours: Decimal = Field(default=0, ge=0)
status: Literal["DRAFT", "CONFIRMED", "STALE"] = "DRAFT"
version_no: int = 1
components: list[CostComponent] = Field(default_factory=list)
class CostResult(BaseModel):
labor: int
material: int
expense: int
total: int
trace: list[dict] = Field(default_factory=list)
+101
View File
@@ -0,0 +1,101 @@
import { B08Api } from "./B08_wf5_Quantity_Api";
import { B08State, TabId, WorkspaceData } from "./B08_wf5_Quantity_Types";
const empty = (): WorkspaceData => ({
basis: null,
catalog: [],
costing: { unit_costs: [], equipment_rates: [], cost_basis: [] },
quantities: { work_breakdown: [], quantities: [], confirmation: null },
runs: [],
latest: null,
});
export class B08Store {
state: B08State;
private listeners = new Set<(s: B08State) => void>();
constructor(projectId: string) {
this.state = {
projectId,
basisVersion: "current",
activeTab: "basis",
loading: false,
message: "",
data: empty(),
};
}
subscribe(fn: (s: B08State) => void) {
this.listeners.add(fn);
fn(this.state);
return () => this.listeners.delete(fn);
}
private emit(patch: Partial<B08State>) {
this.state = { ...this.state, ...patch };
this.listeners.forEach((listener) => listener(this.state));
}
setTab(tab: TabId) {
this.emit({ activeTab: tab });
}
message(text: string) {
this.emit({ message: text });
}
async load() {
this.emit({ loading: true, message: "B08 DB ?묒뾽怨듦컙??遺덈윭?ㅻ뒗 以묒엯?덈떎." });
const projectId = this.state.projectId;
const version = this.state.basisVersion;
const settled = await Promise.allSettled([
B08Api.basis(projectId, version),
B08Api.catalog(projectId),
B08Api.costing(projectId),
B08Api.quantities(projectId),
B08Api.runs(projectId),
]);
const data = empty();
if (settled[0].status === "fulfilled") data.basis = settled[0].value;
if (settled[1].status === "fulfilled") data.catalog = settled[1].value;
if (settled[2].status === "fulfilled") data.costing = settled[2].value;
if (settled[3].status === "fulfilled") data.quantities = settled[3].value;
if (settled[4].status === "fulfilled") data.runs = settled[4].value;
const failed = settled.filter((result) => result.status === "rejected").length;
this.emit({
loading: false,
data,
message: failed
? `DB ?곌껐 ?먮뒗 珥덇린 ?곗씠?곌? ?녿뒗 ?곸뿭 ${failed}媛쒓? ?덉뒿?덈떎. 媛믪쓣 ?낅젰???쒖옉?섏꽭??`
: "紐⑤뱺 B08 ?곗씠?곕? 遺덈윭?붿뒿?덈떎.",
});
}
async calculate() {
const data = this.state.data;
const basis = data.basis;
if (!basis) throw new Error("湲곗??뺣낫瑜?癒쇱? ?€?ν븯?몄슂.");
const confirmation = data.quantities.confirmation;
if (!confirmation) throw new Error("?ㅺ퀎?섎웾??癒쇱? ?뺤젙?섏꽭??");
const unconfirmed = data.quantities.quantities.filter(
(item: any) => item.status !== "CONFIRMED",
);
if (unconfirmed.length) throw new Error(`?ㅺ퀎?섎웾 誘명솗??${unconfirmed.length}嫄?);
const priceVersion = data.quantities.quantities.find(
(item: any) => item.price_version,
)?.price_version;
if (!priceVersion) throw new Error("?뺤젙??媛€寃⑺뙋???놁뒿?덈떎.");
const result = await B08Api.calculate(this.state.projectId, {
versions: {
basis_version: basis.version,
price_version: priceVersion,
quantity_version: confirmation.quantity_version,
rule_version: basis.version,
},
inputs,
policies: basis.rate_policies,
});
const nextData = { ...this.state.data, latest: result };
this.emit({ data: nextData, message: "理쒖쥌怨듭궗鍮?怨꾩궛 ?ㅽ뻾???€?ν뻽?듬땲??" });
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
export type TabId =
| "basis"
| "catalog"
| "unitCost"
| "equipment"
| "costBasis"
| "quantity"
| "estimate"
| "aggregation"
| "indirect"
| "final";
export interface QuantityWorkspace {
work_breakdown: any[];
quantities: any[];
confirmation: { quantity_version: number; input_hash: string } | null;
}
export interface WorkspaceData {
basis: any | null;
catalog: any[];
costing: { unit_costs: any[]; equipment_rates: any[]; cost_basis: any[] };
quantities: QuantityWorkspace;
runs: any[];
latest: any | null;
}
export interface B08State {
projectId: string;
basisVersion: string;
activeTab: TabId;
loading: boolean;
message: string;
data: WorkspaceData;
}
export interface TabContext {
state: B08State;
refresh: () => Promise<void>;
message: (text: string) => void;
calculate: () => Promise<void>;
}
export const TABS: Array<{ id: TabId; label: string; step: string }> = [
{ id: "basis", label: "기준정보", step: "01" },
{ id: "catalog", label: "기초단가", step: "02" },
{ id: "unitCost", label: "일위대가", step: "03" },
{ id: "equipment", label: "중기사용료", step: "04" },
{ id: "costBasis", label: "단가산출근거", step: "05" },
{ id: "quantity", label: "설계수량 입력", step: "06" },
{ id: "estimate", label: "설계내역", step: "07" },
{ id: "aggregation", label: "집계·총괄", step: "08" },
{ id: "indirect", label: "간접비·원가", step: "09" },
{ id: "final", label: "최종공사비·대조", step: "10" },
];
@@ -0,0 +1,53 @@
export const el = <K extends keyof HTMLElementTagNameMap>(
tag: K,
className = "",
text = "",
): HTMLElementTagNameMap[K] => {
const node = document.createElement(tag);
node.className = className;
if (text) node.textContent = text;
return node;
};
export const input = (name: string, label: string, type = "text", value = "") => {
const wrap = el("label", "b08-field");
wrap.append(el("span", "", label));
const control = el("input") as HTMLInputElement;
control.name = name;
control.type = type;
control.value = value;
wrap.append(control);
return wrap;
};
export const select = (name: string, label: string, values: Array<[string, string]>) => {
const wrap = el("label", "b08-field");
wrap.append(el("span", "", label));
const control = el("select") as HTMLSelectElement;
control.name = name;
values.forEach(([v, t]) => control.add(new Option(t, v)));
wrap.append(control);
return wrap;
};
export const formData = (form: HTMLFormElement) => Object.fromEntries(new FormData(form).entries());
export const table = (headers: string[], rows: Array<Array<string | number | null>>) => {
const t = el("table", "b08-table");
const head = el("thead");
const hr = el("tr");
headers.forEach((x) => hr.append(el("th", "", x)));
head.append(hr);
const body = el("tbody");
rows.forEach((row) => {
const tr = el("tr");
row.forEach((x) => tr.append(el("td", "", x == null ? "-" : String(x))));
body.append(tr);
});
t.append(head, body);
return t;
};
export const money = (value: number) => `${new Intl.NumberFormat("ko-KR").format(value || 0)}`;
export const section = (title: string, description: string) => {
const root = el("section", "b08-tab-section");
const header = el("header", "b08-tab-heading");
header.append(el("h3", "", title), el("p", "", description));
root.append(header);
return root;
};
+69 -213
View File
@@ -1,222 +1,78 @@
/* =============================================================================
* B08_wf5_Quantity_UI_Page.ts
* 08: 5차 ( UI )
*
* (2025 ...) 145 릿 UI.
* (B07 ) (null) , 릿 .
* B08_wf5_Quantity .
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { renderPendingWorkflow, workflowSteps } from "../A00_Common/b_page_scaffold";
import {
INITIAL_QUANTITY_TEMPLATES,
QUANTITY_CATEGORY_LABELS,
QuantityCategory,
QuantityItemTemplate,
} from "./B08_wf5_Quantity_Template";
import { B08Store } from "./B08_wf5_Quantity_Store";
import { TABS, TabContext, TabId } from "./B08_wf5_Quantity_Types";
import { renderBasis } from "./B08_wf5_Quantity_UI_Tab_Basis";
import { renderCatalog } from "./B08_wf5_Quantity_UI_Tab_Catalog";
import { renderUnitCost } from "./B08_wf5_Quantity_UI_Tab_UnitCost";
import { renderEquipment } from "./B08_wf5_Quantity_UI_Tab_Equipment";
import { renderCostBasis } from "./B08_wf5_Quantity_UI_Tab_CostBasis";
import { renderQuantity } from "./B08_wf5_Quantity_UI_Tab_Quantity";
import { renderEstimate } from "./B08_wf5_Quantity_UI_Tab_Estimate";
import { renderAggregation } from "./B08_wf5_Quantity_UI_Tab_Aggregation";
import { renderIndirect } from "./B08_wf5_Quantity_UI_Tab_Indirect";
import { renderFinal } from "./B08_wf5_Quantity_UI_Tab_Final";
import "./B08_wf5_Quantity_UI_Style.css";
/** locale 헬퍼 */
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 현재 활성화된 공종 탭 */
let currentCategory: QuantityCategory = "earthwork";
/**
* B08
*/
export async function renderB08Quantity(root: HTMLElement): Promise<void> {
// 1. 공통 Workflow Shell 렌더링
const renders: Record<TabId, (ctx: TabContext) => HTMLElement> = {
basis: renderBasis,
catalog: renderCatalog,
unitCost: renderUnitCost,
equipment: renderEquipment,
costBasis: renderCostBasis,
quantity: renderQuantity,
estimate: renderEstimate,
aggregation: renderAggregation,
indirect: renderIndirect,
final: renderFinal,
};
const currentProject = () =>
new URLSearchParams(location.search).get("project_id") ??
sessionStorage.getItem("currentProjectId") ??
"";
export async function renderB08Quantity(root: HTMLElement) {
await renderPendingWorkflow(root, {
title: L("B08_Quantity_Title"),
title: "5차 · 수량산출 및 원가계산",
steps: workflowSteps(),
activeStep: 5,
});
// 2. 본문 컨테이너 슬롯 확보 (Workflow Shell 내부 본문 영역)
const bodyContainer = root.querySelector(".workflow-body") || root;
bodyContainer.innerHTML = ""; // 기존 준비중 메시지 대체
const container = document.createElement("div");
container.className = "b08-quantity-container";
bodyContainer.appendChild(container);
// 3. UI 컴포넌트 조합
renderSummaryGrid(container);
renderTabBar(container);
renderTableArea(container);
renderActionBar(container);
}
/**
* 1. ( )
*/
function renderSummaryGrid(parent: HTMLElement): void {
const grid = document.createElement("div");
grid.className = "b08-summary-grid";
const summaryItems = [
{ title: "전체 엑셀 템플릿", val: "총 145개 항목", desc: "6개 세부 공종 100% 매핑 완료" },
{ title: "단가산출 / 일위대가", val: "65개 표준공종", desc: "토공 40종 + 일위대가 25종" },
{ title: "자재 & 노무비", val: "51개 자재/인력", desc: "자재 37종 + 노무인부 14종" },
{ title: "중기 & 일식/폐기물", val: "29개 중기/일식", desc: "중기시간 24종 + 폐기물 5종" },
];
grid.innerHTML = summaryItems
.map(
(item) => `
<div class="b08-card">
<div class="b08-card-header">${item.title}</div>
<div class="b08-card-value empty-state">${item.val}</div>
<div class="b08-card-sub">${item.desc}</div>
</div>
`,
)
.join("");
parent.appendChild(grid);
}
/**
* 2.
*/
function renderTabBar(parent: HTMLElement): void {
const tabBar = document.createElement("div");
tabBar.className = "b08-tab-bar";
const categories: QuantityCategory[] = [
"earthwork",
"structure",
"material",
"equipment",
"waste",
"labor",
];
categories.forEach((cat) => {
const btn = document.createElement("button");
btn.className = `b08-tab-btn ${cat === currentCategory ? "active" : ""}`;
btn.textContent = QUANTITY_CATEGORY_LABELS[cat].ko;
btn.addEventListener("click", () => {
currentCategory = cat;
// 탭 업데이트
tabBar.querySelectorAll(".b08-tab-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
// 테이블 재렌더링
const tableArea = parent.querySelector("#b08-table-area");
if (tableArea) {
tableArea.innerHTML = "";
renderTableContent(tableArea as HTMLElement);
}
});
tabBar.appendChild(btn);
const body = root.querySelector<HTMLElement>(".workflow-body") ?? root;
body.replaceChildren();
const projectId = currentProject();
const page = document.createElement("main");
page.className = "b08-page";
page.innerHTML = `<header class="b08-header"><div><p>WF5 · QUANTITY & COST</p><h2>수량산출·원가계산 작업공간</h2></div><span class="b08-project"></span></header><p class="b08-message" role="status"></p><nav class="b08-tabs" aria-label="원가계산 단계"></nav><div class="b08-content"></div>`;
body.append(page);
const message = page.querySelector<HTMLElement>(".b08-message")!,
tabs = page.querySelector<HTMLElement>(".b08-tabs")!,
content = page.querySelector<HTMLElement>(".b08-content")!,
project = page.querySelector<HTMLElement>(".b08-project")!;
project.textContent = projectId ? `프로젝트 ${projectId}` : "프로젝트 미선택";
const store = new B08Store(projectId);
TABS.forEach((tab) => {
const button = document.createElement("button");
button.dataset.tab = tab.id;
button.innerHTML = `<b>${tab.step}</b><span>${tab.label}</span>`;
button.onclick = () => store.setTab(tab.id);
tabs.append(button);
});
parent.appendChild(tabBar);
}
/**
* 3.
*/
function renderTableArea(parent: HTMLElement): void {
const tableArea = document.createElement("div");
tableArea.id = "b08-table-area";
tableArea.className = "b08-table-wrapper";
parent.appendChild(tableArea);
renderTableContent(tableArea);
}
/**
* ( )
*/
function renderTableContent(parent: HTMLElement): void {
const items = INITIAL_QUANTITY_TEMPLATES.filter((item) => item.category === currentCategory);
const table = document.createElement("table");
table.className = "b08-table";
table.innerHTML = `
<thead>
<tr>
<th style="width: 60px;"></th>
<th></th>
<th></th>
<th style="width: 60px;"></th>
<th> (raw_qty)</th>
<th style="width: 80px;"></th>
<th> </th>
<th> (Formula)</th>
<th> / </th>
</tr>
</thead>
<tbody>
${items.map((item) => renderTableRow(item)).join("")}
</tbody>
`;
parent.appendChild(table);
}
/**
* HTML
*/
function renderTableRow(item: QuantityItemTemplate): string {
const rawQtyDisplay =
item.raw_qty !== null
? item.raw_qty.toLocaleString()
: `<span class="b08-empty-val">[입력 대기]</span>`;
const calcQtyDisplay =
item.calc_qty !== null
? item.calc_qty.toLocaleString()
: `<span class="b08-empty-val">[수식 자동계산]</span>`;
const codeBadge = item.code_ref
? `<span class="b08-badge b08-badge-code">${item.code_ref}</span>`
: "";
return `
<tr>
<td>${item.item_no}</td>
<td><strong>${item.name}</strong></td>
<td>${item.spec}</td>
<td>${item.unit}</td>
<td>${rawQtyDisplay}</td>
<td>${item.allowance_rate}</td>
<td>${calcQtyDisplay}</td>
<td><span class="b08-formula-cell">${item.formula_desc}</span></td>
<td>
<span class="b08-badge b08-badge-sheet">${item.excel_ref_sheet}</span>
${codeBadge}
</td>
</tr>
`;
}
/**
* 4. /
*/
function renderActionBar(parent: HTMLElement): void {
const actionBar = document.createElement("div");
actionBar.className = "b08-action-bar";
actionBar.innerHTML = `
<div class="b08-info-text">
💡 <strong> 145 릿 </strong>: (B07 ) .
</div>
<button class="b08-btn-confirm">
B09 >
</button>
`;
const confirmBtn = actionBar.querySelector(".b08-btn-confirm");
confirmBtn?.addEventListener("click", () => {
alert("B08 수량 템플릿 항목(145종)이 확정되었습니다. B09 견적 파이프라인으로 전달됩니다.");
store.subscribe((state) => {
message.textContent = state.message;
tabs
.querySelectorAll("button")
.forEach((x) =>
x.classList.toggle("active", (x as HTMLElement).dataset.tab === state.activeTab),
);
content.replaceChildren(
renders[state.activeTab]({
state,
refresh: () => store.load(),
message: (text) => store.message(text),
calculate: () => store.calculate(),
}),
);
});
parent.appendChild(actionBar);
if (!projectId) {
store.message("프로젝트를 선택해야 DB 작업공간을 사용할 수 있습니다.");
return;
}
await store.load();
}
+168 -180
View File
@@ -1,212 +1,200 @@
/* =============================================================================
* B08_wf5_Quantity_UI_Style.css
* B08 수량 산출 UI 스타일 정의
* ========================================================================== */
.b08-quantity-container {
display: flex;
flex-direction: column;
gap: 20px;
padding: 24px;
background-color: var(--bg-primary, #1e1e2d);
color: var(--text-primary, #ffffff);
font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
sans-serif;
.b08-page {
--bg: #0d1422;
--panel: #172235;
--line: #2a3a54;
--text: #e8eef8;
--muted: #91a2ba;
--accent: #6d8cff;
display: grid;
gap: 16px;
min-height: calc(100vh - 120px);
padding: 22px;
color: var(--text);
background: linear-gradient(145deg, #0a101b, var(--bg));
box-sizing: border-box;
}
/* 요약 카드 그리드 */
.b08-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.b08-card {
background: var(--bg-secondary, #2b2b40);
border: 1px solid var(--border-color, #3a3a55);
border-radius: 12px;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition:
transform 0.2s ease,
border-color 0.2s ease;
}
.b08-card:hover {
transform: translateY(-2px);
border-color: var(--accent-color, #4e80ee);
}
.b08-card-header {
font-size: 0.85rem;
color: var(--text-secondary, #9a9ab0);
font-weight: 600;
.b08-header {
display: flex;
align-items: center;
gap: 6px;
justify-content: space-between;
}
.b08-card-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-highlight, #38ef7d);
.b08-header p,
.b08-tab-heading p {
margin: 0;
color: var(--muted);
font-size: 12px;
}
.b08-card-value.empty-state {
color: #ff9f43;
font-size: 1.1rem;
font-style: italic;
.b08-header h2,
.b08-tab-heading h3 {
margin: 4px 0;
}
.b08-card-sub {
font-size: 0.75rem;
color: #727290;
.b08-project {
padding: 6px 11px;
border: 1px solid var(--line);
border-radius: 999px;
color: #b9c8dd;
font-size: 12px;
}
/* 공종 탭 버튼 영역 */
.b08-tab-bar {
.b08-message {
min-height: 20px;
margin: 0;
color: #ffd078;
font-size: 13px;
}
.b08-tabs {
display: flex;
gap: 8px;
border-bottom: 2px solid var(--border-color, #3a3a55);
gap: 6px;
padding-bottom: 8px;
overflow-x: auto;
border-bottom: 1px solid var(--line);
}
.b08-tab-btn {
background: transparent;
.b08-tabs button {
display: grid;
grid-template-columns: auto 1fr;
gap: 7px;
align-items: center;
min-width: max-content;
padding: 9px 12px;
color: var(--muted);
border: 1px solid transparent;
color: var(--text-secondary, #a0a0c0);
padding: 10px 18px;
border-radius: 8px 8px 0 0;
border-radius: 8px;
background: transparent;
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
transition: all 0.2s ease;
white-space: nowrap;
}
.b08-tab-btn:hover {
background: var(--bg-hover, rgba(255, 255, 255, 0.05));
color: #ffffff;
.b08-tabs button b {
display: grid;
place-items: center;
width: 25px;
height: 25px;
border-radius: 50%;
background: #233149;
font-size: 11px;
}
.b08-tab-btn.active {
background: var(--bg-secondary, #2b2b40);
border-color: var(--accent-color, #4e80ee) var(--accent-color, #4e80ee) transparent
var(--accent-color, #4e80ee);
color: var(--accent-color, #4e80ee);
.b08-tabs button.active {
color: white;
border-color: #516fb9;
background: #1c2a43;
}
/* 수량 집계 데이터 테이블 */
.b08-table-wrapper {
background: var(--bg-secondary, #2b2b40);
.b08-tabs button.active b {
background: var(--accent);
}
.b08-content {
min-width: 0;
}
.b08-tab-section {
display: grid;
gap: 14px;
margin-bottom: 16px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 12px;
border: 1px solid var(--border-color, #3a3a55);
overflow-x: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
background: color-mix(in srgb, var(--panel) 96%, transparent);
box-shadow: 0 10px 25px rgb(0 0 0/16%);
}
.b08-tab-section .b08-tab-section {
margin: 0;
background: #111c2e;
box-shadow: none;
}
.b08-tab-heading {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 1px solid var(--line);
padding-bottom: 12px;
}
.b08-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 10px;
align-items: end;
}
.b08-inline-form {
display: flex;
gap: 8px;
align-items: end;
flex-wrap: wrap;
}
.b08-policy-form .b08-field {
min-width: 130px;
}
.b08-field {
display: grid;
gap: 5px;
min-width: 150px;
color: var(--muted);
font-size: 12px;
}
.b08-field input,
.b08-field select {
width: 100%;
padding: 8px 9px;
color: var(--text);
border: 1px solid var(--line);
border-radius: 7px;
background: #0e1727;
box-sizing: border-box;
}
.b08-button {
padding: 9px 13px;
color: var(--text);
border: 1px solid var(--line);
border-radius: 7px;
background: #25344d;
cursor: pointer;
}
.b08-button--primary {
color: white;
border-color: #7b96ff;
background: var(--accent);
}
.b08-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.b08-table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 0.88rem;
font-size: 12px;
}
.b08-table th {
background-color: rgba(0, 0, 0, 0.25);
color: var(--text-secondary, #b5b5d0);
font-weight: 600;
padding: 12px 14px;
border-bottom: 1px solid var(--border-color, #3a3a55);
.b08-table th,
.b08-table td {
padding: 9px 10px;
text-align: left;
border-bottom: 1px solid var(--line);
white-space: nowrap;
}
.b08-table td {
padding: 12px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
color: #e0e0f0;
.b08-table th {
position: sticky;
top: 0;
color: #b9c8dc;
background: #101a2a;
}
.b08-table tbody tr:hover {
background-color: rgba(255, 255, 255, 0.03);
background: rgb(109 140 255/5%);
}
.b08-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
.b08-tab-section > .b08-table {
display: block;
overflow: auto;
}
.b08-badge-sheet {
background-color: rgba(78, 128, 238, 0.15);
color: #4e80ee;
border: 1px solid rgba(78, 128, 238, 0.3);
.b08-empty,
.b08-run-id {
color: var(--muted);
}
.b08-badge-code {
background-color: rgba(155, 89, 182, 0.15);
color: #af7ac5;
border: 1px solid rgba(155, 89, 182, 0.3);
}
.b08-empty-val {
color: #ff9f43;
font-style: italic;
font-weight: 500;
}
.b08-formula-cell {
font-family: "Fira Code", "Courier New", monospace;
font-size: 0.8rem;
color: #00cec9;
background: rgba(0, 206, 201, 0.05);
padding: 4px 8px;
border-radius: 4px;
}
/* 하단 컨트롤 바 */
.b08-action-bar {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-secondary, #2b2b40);
padding: 16px 24px;
border-radius: 12px;
border: 1px solid var(--border-color, #3a3a55);
}
.b08-info-text {
font-size: 0.85rem;
color: #9a9ab0;
}
.b08-btn-confirm {
background: linear-gradient(135deg, #4e80ee 0%, #38ef7d 100%);
color: #000000;
font-weight: 700;
border: none;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.b08-btn-confirm:hover {
opacity: 0.9;
transform: scale(1.02);
@media (max-width: 760px) {
.b08-page {
padding: 12px;
}
.b08-header {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
.b08-form-grid {
grid-template-columns: 1fr;
}
.b08-inline-form {
align-items: stretch;
flex-direction: column;
}
}
@@ -0,0 +1,31 @@
import { money, section, table } from "./B08_wf5_Quantity_UI_Dom";
import { TabContext } from "./B08_wf5_Quantity_Types";
export function renderAggregation(ctx: TabContext) {
const root = section(
"집계·총괄설계내역",
"계산 실행 스냅샷의 재료·노무·경비를 공종별로 집계합니다.",
);
const groups = new Map<string, any>();
for (const x of ctx.state.data.latest?.lines ?? []) {
const g = groups.get(x.wbs_id) ?? { labor: 0, material: 0, expense: 0, total: 0 };
g.labor += x.labor_amount;
g.material += x.material_amount;
g.expense += x.expense_amount;
g.total += x.total_amount;
groups.set(x.wbs_id, g);
}
const wbs = ctx.state.data.quantities.work_breakdown;
root.append(
table(
["공종", "노무비", "재료비", "경비", "합계"],
[...groups].map(([id, x]) => [
wbs.find((w: any) => w.id === id)?.name ?? id,
money(x.labor),
money(x.material),
money(x.expense),
money(x.total),
]),
),
);
return root;
}
@@ -0,0 +1,162 @@
import { B08Api } from "./B08_wf5_Quantity_Api";
import { el, formData, input, section, select, table } from "./B08_wf5_Quantity_UI_Dom";
import { TabContext } from "./B08_wf5_Quantity_Types";
export function renderBasis(ctx: TabContext) {
const b = ctx.state.data.basis ?? {
project_id: ctx.state.projectId,
version: ctx.state.basisVersion,
base_date: "",
region: "",
currency: "KRW",
status: "DRAFT",
price_sources: [],
exchange_rates: [],
rate_policies: [],
};
const root = section("기준정보", "가격 출처·환율·제경비 요율을 버전 단위로 관리합니다.");
const meta = el("form", "b08-form-grid") as HTMLFormElement;
meta.append(
input("base_date", "기준일", "date", b.base_date ?? ""),
input("region", "적용 지역", "text", b.region ?? ""),
input("currency", "기준 통화", "text", b.currency ?? "KRW"),
select("status", "상태", [
["DRAFT", "작성중"],
["CONFIRMED", "확정"],
]),
);
(meta.elements.namedItem("status") as HTMLSelectElement).value = b.status;
const save = el("button", "b08-button b08-button--primary", "기준정보 저장") as HTMLButtonElement;
save.type = "submit";
meta.append(save);
meta.onsubmit = async (e) => {
e.preventDefault();
const x = formData(meta);
await B08Api.saveBasis(ctx.state.projectId, b.version, {
...b,
...x,
project_id: ctx.state.projectId,
version: b.version,
});
ctx.message("기준정보를 저장했습니다.");
await ctx.refresh();
};
root.append(meta);
root.append(renderSources(ctx, b), renderFx(ctx, b), renderPolicies(ctx, b));
return root;
}
function renderSources(ctx: TabContext, b: any) {
const box = section("가격 출처", "조달가격·물가정보·견적 등 후보단가의 출처와 우선순위입니다.");
const f = el("form", "b08-inline-form") as HTMLFormElement;
f.append(
input("source_code", "출처 코드"),
input("source_name", "출처명"),
input("priority_no", "우선순위", "number", "100"),
input("reference_date", "기준일", "date"),
);
const btn = el("button", "b08-button", "출처 추가") as HTMLButtonElement;
btn.type = "submit";
f.append(btn);
f.onsubmit = async (e) => {
e.preventDefault();
const x = formData(f);
b.price_sources.push({ ...x, priority_no: Number(x.priority_no) });
await B08Api.saveBasis(ctx.state.projectId, b.version, b);
await ctx.refresh();
};
box.append(
f,
table(
["코드", "출처", "우선순위", "기준일"],
b.price_sources.map((x: any) => [
x.source_code,
x.source_name,
x.priority_no,
x.reference_date,
]),
),
);
return box;
}
function renderFx(ctx: TabContext, b: any) {
const box = section("환율", "외화 기초단가를 원화로 변환할 때 적용합니다.");
const f = el("form", "b08-inline-form") as HTMLFormElement;
f.append(
input("currency", "통화", "text"),
input("rate_to_krw", "원화환율", "number"),
input("effective_from", "적용일", "date"),
);
const btn = el("button", "b08-button", "환율 추가") as HTMLButtonElement;
btn.type = "submit";
f.append(btn);
f.onsubmit = async (e) => {
e.preventDefault();
const x = formData(f);
b.exchange_rates.push({ ...x, rate_to_krw: String(x.rate_to_krw) });
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]),
),
);
return box;
}
function renderPolicies(ctx: TabContext, b: any) {
const box = section("요율 규칙", "승인된 규칙만 최종공사비 계산에 사용됩니다.");
const f = el("form", "b08-inline-form b08-policy-form") as HTMLFormElement;
f.append(
input("rule_code", "규칙 코드"),
input("rule_name", "규칙명"),
input("base_expression", "기준식"),
input("rate_value", "요율", "number"),
input("minimum_amount", "최소금액", "number"),
input("maximum_amount", "최대금액", "number"),
select("rounding_mode", "처리", [
["FLOOR", "절사"],
["ROUND", "반올림"],
["CEILING", "올림"],
]),
input("rounding_unit", "단위", "number", "1"),
input("source_reference", "근거 문서"),
select("status", "상태", [
["DRAFT", "작성중"],
["APPROVED", "승인"],
]),
);
const btn = el("button", "b08-button", "요율 추가") as HTMLButtonElement;
btn.type = "submit";
f.append(btn);
f.onsubmit = async (e) => {
e.preventDefault();
const x = formData(f);
b.rate_policies.push({
...x,
rate_value: x.rate_value || null,
rounding_unit: Number(x.rounding_unit),
minimum_amount: x.minimum_amount === "" ? null : Number(x.minimum_amount),
maximum_amount: x.maximum_amount === "" ? null : Number(x.maximum_amount),
condition_json: {},
sort_order: b.rate_policies.length,
});
await B08Api.saveBasis(ctx.state.projectId, b.version, b);
await ctx.refresh();
};
box.append(
f,
table(
["코드", "규칙", "기준식", "요율", "처리", "상태"],
b.rate_policies.map((x: any) => [
x.rule_code,
x.rule_name,
x.base_expression,
x.rate_value,
x.rounding_mode,
x.status,
]),
),
);
return box;
}
@@ -0,0 +1,160 @@
import { B08Api } from "./B08_wf5_Quantity_Api";
import { el, formData, input, money, section, select, table } from "./B08_wf5_Quantity_UI_Dom";
import { TabContext } from "./B08_wf5_Quantity_Types";
export function renderCatalog(ctx: TabContext) {
const root = section(
"재료·노무·장비·경비 기초단가",
"출처별 후보단가 중 승인한 적용단가만 후속 계산에 사용합니다.",
);
root.append(priceBookForm(ctx), itemForm(ctx), candidateForm(ctx), appliedPriceForm(ctx));
root.append(
table(
["유형", "코드", "명칭", "규격", "단위", "비용", "적용단가", "선택근거"],
ctx.state.data.catalog.map((x: any) => [
x.item_type,
x.item_code,
x.item_name,
x.specification,
x.unit,
x.cost_type,
x.applied_price == null ? "미선택" : money(Number(x.applied_price)),
x.selection_reason,
]),
),
);
return root;
}
function priceBookForm(ctx: TabContext) {
const box = section("단가표 버전", "기초단가는 반드시 특정 기준정보와 단가표 버전에 속합니다.");
const form = el("form", "b08-inline-form") as HTMLFormElement;
form.append(
input("price_version", "단가 버전"),
input("basis_version", "기준 버전", "text", ctx.state.basisVersion),
input("name", "단가표명"),
input("effective_date", "적용일", "date"),
select("status", "상태", [
["DRAFT", "작성중"],
["CONFIRMED", "확정"],
]),
);
const button = el("button", "b08-button", "단가표 저장") as HTMLButtonElement;
button.type = "submit";
form.append(button);
form.onsubmit = async (event) => {
event.preventDefault();
await B08Api.savePriceBook(ctx.state.projectId, formData(form));
ctx.message("단가표 버전을 저장했습니다.");
};
box.append(form);
return box;
}
function itemForm(ctx: TabContext) {
const box = section("품목 마스터", "재료·노무·장비·경비의 코드·규격·단위를 등록합니다.");
const form = el("form", "b08-form-grid") as HTMLFormElement;
form.append(
select("item_type", "품목 유형", [
["MATERIAL", "재료"],
["LABOR", "노무"],
["EQUIPMENT", "장비"],
["EXPENSE", "경비"],
]),
input("item_code", "품목 코드"),
input("item_name", "명칭"),
input("specification", "규격"),
input("unit", "단위"),
select("cost_type", "비용 분류", [
["MATERIAL", "재료비"],
["LABOR", "노무비"],
["EXPENSE", "경비"],
]),
select("procurement_type", "조달 구분", [
["PRIVATE", "사급"],
["GOVERNMENT", "관급"],
["EXCLUDED", "제외"],
]),
);
const button = el("button", "b08-button b08-button--primary", "품목 저장") as HTMLButtonElement;
button.type = "submit";
form.append(button);
form.onsubmit = async (event) => {
event.preventDefault();
await B08Api.saveItem(ctx.state.projectId, formData(form));
await ctx.refresh();
};
box.append(form);
return box;
}
function candidateForm(ctx: TabContext) {
const box = section(
"출처별 후보단가",
"원단가·통화·환율·원화 환산단가와 근거 페이지를 저장합니다.",
);
const form = el("form", "b08-inline-form") as HTMLFormElement;
form.append(
input("price_version", "단가 버전"),
input("item_id", "품목 ID"),
input("source_id", "출처 ID", "number"),
input("source_price", "원단가", "number"),
input("currency", "통화", "text", "KRW"),
input("exchange_rate", "환율", "number", "1"),
input("converted_price", "원화단가", "number"),
input("reference_page", "근거 페이지"),
input("valid_from", "적용일", "date"),
);
const button = el("button", "b08-button", "후보단가 저장") as HTMLButtonElement;
button.type = "submit";
form.append(button);
form.onsubmit = async (event) => {
event.preventDefault();
const x = formData(form);
const result: any = await B08Api.addCandidate(ctx.state.projectId, String(x.price_version), {
...x,
source_id: Number(x.source_id),
source_price: Number(x.source_price),
exchange_rate: Number(x.exchange_rate),
converted_price: Number(x.converted_price),
valid_to: null,
});
ctx.message(`후보단가 ID ${result.id} 저장 완료`);
};
box.append(form);
return box;
}
function appliedPriceForm(ctx: TabContext) {
const box = section(
"적용단가 승인",
"후보단가 ID와 선택 사유를 지정해야 구성원가에 사용할 수 있습니다.",
);
const form = el("form", "b08-inline-form") as HTMLFormElement;
form.append(
input("price_version", "단가 버전"),
input("item_id", "품목 ID"),
input("price_entry_id", "후보단가 ID", "number"),
input("applied_price", "적용단가", "number"),
input("selection_reason", "선택 사유"),
);
const button = el(
"button",
"b08-button b08-button--primary",
"적용단가 저장",
) as HTMLButtonElement;
button.type = "submit";
form.append(button);
form.onsubmit = async (event) => {
event.preventDefault();
const x = formData(form);
await B08Api.applyPrice(ctx.state.projectId, String(x.price_version), {
...x,
price_entry_id: Number(x.price_entry_id),
applied_price: Number(x.applied_price),
});
await ctx.refresh();
};
box.append(form);
return box;
}
@@ -0,0 +1,102 @@
import { B08Api } from "./B08_wf5_Quantity_Api";
import { el, formData, input, section, select, table } from "./B08_wf5_Quantity_UI_Dom";
import { TabContext } from "./B08_wf5_Quantity_Types";
export function renderCostBasis(ctx: TabContext) {
const root = section(
"단가산출근거",
"기초단가·일위대가·중기사용료와 수량식을 구조적으로 연결합니다.",
);
const variables: any[] = [],
components: any[] = [];
const f = el("form", "b08-form-grid") as HTMLFormElement;
f.append(
input("code", "산근 코드"),
input("name", "명칭"),
input("specification", "규격"),
input("unit", "단위"),
input("formula_note", "산출 설명"),
select("status", "상태", [
["DRAFT", "작성중"],
["CONFIRMED", "확정"],
]),
);
const vf = el("form", "b08-inline-form") as HTMLFormElement;
vf.append(
input("code", "변수 코드"),
input("label", "변수명"),
input("value", "값", "number"),
input("unit", "단위"),
);
const va = el("button", "b08-button", "변수 추가") as HTMLButtonElement;
va.type = "submit";
vf.append(va);
const status = el("p", "", "변수 0건 · 구성 0건");
vf.onsubmit = (e) => {
e.preventDefault();
const x = formData(vf);
variables.push({ ...x, value: x.value === "" ? null : Number(x.value), required: true });
status.textContent = `변수 ${variables.length}건 · 구성 ${components.length}`;
};
const cf = el("form", "b08-inline-form") as HTMLFormElement;
cf.append(
select("component_type", "유형", [
["CATALOG", "기초단가"],
["UNIT_COST", "일위대가"],
["EQUIPMENT", "중기"],
["COST_BASIS", "산근"],
]),
input("reference_id", "참조 ID"),
input("reference_name", "참조명"),
input("quantity_expression", "수량식"),
select("cost_type", "비용", [
["LABOR", "노무"],
["MATERIAL", "재료"],
["EXPENSE", "경비"],
]),
);
const ca = el("button", "b08-button", "구성 추가") as HTMLButtonElement;
ca.type = "submit";
cf.append(ca);
cf.onsubmit = (e) => {
e.preventDefault();
const x = formData(cf);
components.push({ ...x, unit_price: 0, sort_order: components.length });
status.textContent = `변수 ${variables.length}건 · 구성 ${components.length}`;
};
const save = el(
"button",
"b08-button b08-button--primary",
"산출근거 저장·계산",
) as HTMLButtonElement;
save.type = "submit";
f.append(save);
f.onsubmit = async (e) => {
e.preventDefault();
const result = await B08Api.saveCostBasis(ctx.state.projectId, {
...formData(f),
status: x.status,
version_no: 1,
variables,
components,
});
ctx.message(`단가산출근거 계산 완료: ${result.total.toLocaleString()}`);
await ctx.refresh();
};
root.append(
f,
vf,
cf,
status,
table(
["코드", "명칭", "규격", "단위", "상태"],
ctx.state.data.costing.cost_basis.map((x: any) => [
x.basis_code,
x.name,
x.specification,
x.unit,
x.status,
]),
),
);
return root;
}
@@ -0,0 +1,88 @@
import { B08Api } from "./B08_wf5_Quantity_Api";
import { el, formData, input, section, select, table } from "./B08_wf5_Quantity_UI_Dom";
import { TabContext } from "./B08_wf5_Quantity_Types";
export function renderEquipment(ctx: TabContext) {
const root = section(
"중기사용료",
"기계가격·가동시간과 손료·연료·운전원·정비비를 시간당 비용으로 관리합니다.",
);
const components: any[] = [];
const f = el("form", "b08-form-grid") as HTMLFormElement;
f.append(
input("code", "장비 코드"),
input("name", "장비명"),
input("specification", "규격"),
input("unit", "단위", "text", "hr"),
input("equipment_price", "기계가격", "number"),
input("annual_hours", "연간 가동시간", "number"),
select("status", "상태", [
["DRAFT", "작성중"],
["CONFIRMED", "확정"],
]),
);
const cf = el("form", "b08-inline-form") as HTMLFormElement;
cf.append(
input("reference_id", "품목 ID"),
input("reference_name", "구성 코드"),
input("quantity", "시간당 수량", "number"),
select("cost_type", "비용", [
["LABOR", "노무"],
["MATERIAL", "재료"],
["EXPENSE", "경비"],
]),
);
const add = el("button", "b08-button", "구성 추가") as HTMLButtonElement;
add.type = "submit";
cf.append(add);
const count = el("p", "", "구성 0건");
cf.onsubmit = (e) => {
e.preventDefault();
const x = formData(cf);
components.push({
...x,
component_type: "CATALOG",
quantity: Number(x.quantity),
unit_price: 0,
sort_order: components.length,
});
count.textContent = `구성 ${components.length}`;
};
const save = el(
"button",
"b08-button b08-button--primary",
"중기사용료 저장·계산",
) as HTMLButtonElement;
save.type = "submit";
f.append(save);
f.onsubmit = async (e) => {
e.preventDefault();
const x = formData(f);
const result = await B08Api.saveEquipment(ctx.state.projectId, {
...x,
equipment_price: Number(x.equipment_price),
annual_hours: Number(x.annual_hours),
status: x.status,
version_no: 1,
components,
});
ctx.message(`중기사용료 계산 완료: ${result.total.toLocaleString()}`);
await ctx.refresh();
};
root.append(
f,
cf,
count,
table(
["코드", "장비", "규격", "기계가격", "가동시간", "상태"],
ctx.state.data.costing.equipment_rates.map((x: any) => [
x.equipment_code,
x.name,
x.specification,
x.equipment_price,
x.annual_hours,
x.status,
]),
),
);
return root;
}
@@ -0,0 +1,38 @@
import { money, section, table } from "./B08_wf5_Quantity_UI_Dom";
import { TabContext } from "./B08_wf5_Quantity_Types";
export function renderEstimate(ctx: TabContext) {
const root = section("공종별 설계내역", "확정수량 × 적용단가의 노무비·재료비·경비를 표시합니다.");
const lines = ctx.state.data.latest?.lines ?? [];
root.append(
table(
[
"수량항목",
"공종",
"수량",
"노무단가",
"재료단가",
"경비단가",
"노무비",
"재료비",
"경비",
"합계",
],
lines.map((x: any) => [
x.quantity_item_id,
x.wbs_id,
x.quantity,
x.unit_labor,
x.unit_material,
x.unit_expense,
money(x.labor_amount),
money(x.material_amount),
money(x.expense_amount),
money(x.total_amount),
]),
),
);
if (!lines.length)
root.querySelector("tbody")!.innerHTML =
'<tr><td colspan="10">최종 계산 실행 전입니다. 설계수량과 요율을 확정하세요.</td></tr>';
return root;
}
@@ -0,0 +1,124 @@
import { B08Api } from "./B08_wf5_Quantity_Api";
import { el, formData, input, money, section, table } from "./B08_wf5_Quantity_UI_Dom";
import { TabContext } from "./B08_wf5_Quantity_Types";
const STAGES: Array<[string, string]> = [
["DIRECT_COST", "직접공사비"],
["NET_COST", "순공사원가"],
["GENERAL_ADMIN", "일반관리비"],
["PROFIT", "이윤"],
["TOTAL_COST", "총원가"],
["VAT", "부가가치세"],
["CONTRACT_COST", "도급공사비"],
["GOVERNMENT_MATERIAL", "관급자재대"],
["PROCUREMENT_FEE", "조달수수료"],
["TOTAL_PROJECT_COST", "총공사비"],
];
export function renderFinal(ctx: TabContext) {
const root = section(
"최종공사비·기준대조",
"직접공사비부터 총공사비까지 동일 계산 실행 ID로 확정합니다.",
);
const run = ctx.state.data.latest;
const button = el(
"button",
"b08-button b08-button--primary",
"전체 단계 계산 실행",
) as HTMLButtonElement;
button.onclick = async () => {
button.disabled = true;
try {
await ctx.calculate();
} finally {
button.disabled = false;
}
};
root.append(button);
if (!run) {
root.append(el("p", "b08-empty", "아직 계산 실행이 없습니다."));
return root;
}
const f = run.final;
root.append(
el("p", "b08-run-id", `계산 실행 ${run.run_id}`),
table(
["단계", "금액"],
[
["직접공사비", money(f.direct_cost)],
["순공사원가", money(f.net_cost)],
["일반관리비", money(f.general_admin)],
["이윤", money(f.profit)],
["총원가", money(f.total_cost)],
["부가가치세", money(f.vat)],
["도급공사비", money(f.contract_cost)],
["관급자재대", money(f.government_material)],
["조달수수료", money(f.procurement_fee)],
["총공사비", money(f.total_project_cost)],
],
),
reconciliationForm(ctx, run.run_id),
);
return root;
}
function reconciliationForm(ctx: TabContext, runId: string) {
const box = section(
"STmate/XLSX 기준금액 대조",
"원본 파일의 단계별 기준금액을 등록하면 최초 불일치 단계를 찾습니다.",
);
const form = el("form", "b08-form-grid") as HTMLFormElement;
form.append(
input("original_filename", "원본 파일명"),
input("sha256", "SHA-256"),
input("source_version", "STmate 버전"),
);
STAGES.forEach(([code, label]) => form.append(input(code, label, "number")));
const button = el("button", "b08-button", "기준 등록·대조") as HTMLButtonElement;
button.type = "submit";
form.append(button);
const result = el("div", "b08-reconcile-result");
form.onsubmit = async (event) => {
event.preventDefault();
const x = formData(form);
const values = STAGES.filter(([code]) => x[code] !== "").map(([stage_code]) => ({
stage_code,
reference_key: "TOTAL",
amount: Number(x[stage_code]),
metadata: {},
}));
const imported: any = await B08Api.importReference(ctx.state.projectId, {
source_type: "MANUAL",
original_filename: x.original_filename,
sha256: x.sha256,
source_version: x.source_version || null,
stored_path: `b08://reference/${x.original_filename}`,
values,
});
const compared: any = await B08Api.reconcile(
ctx.state.projectId,
runId,
imported.source_file_id,
);
result.replaceChildren(
el(
"strong",
compared.status === "MATCHED" ? "is-match" : "is-different",
compared.status === "MATCHED"
? "모든 등록 기준금액 일치"
: `최초 불일치: ${compared.first_difference_stage}`,
),
table(
["단계", "기준", "계산", "차이"],
compared.differences.map((d: any) => [
d.stage_code,
money(d.expected_amount),
money(d.actual_amount),
money(d.difference_amount),
]),
),
);
};
box.append(form, result);
return box;
}
@@ -0,0 +1,27 @@
import { money, section, table } from "./B08_wf5_Quantity_UI_Dom";
import { TabContext } from "./B08_wf5_Quantity_Types";
export function renderIndirect(ctx: TabContext) {
const root = section(
"제경비·원가계산",
"승인된 요율 규칙을 순서대로 적용하고 기준금액과 절사근거를 보존합니다.",
);
const rows = ctx.state.data.latest?.final?.indirect_results ?? [];
root.append(
table(
["규칙", "명칭", "기준금액", "요율", "결과", "기준식", "금액처리"],
rows.map((x: any) => [
x.rule_code,
x.rule_name,
money(x.base_amount),
x.rate_value,
money(x.result_amount),
x.trace.expression,
`${x.trace.rounding}/${x.trace.unit}`,
]),
),
);
if (!rows.length)
root.querySelector("tbody")!.innerHTML =
'<tr><td colspan="7">승인된 제경비 규칙으로 최종 계산을 실행하세요.</td></tr>';
return root;
}
@@ -0,0 +1,142 @@
import { B08Api } from "./B08_wf5_Quantity_Api";
import { el, formData, input, section, select, table } from "./B08_wf5_Quantity_UI_Dom";
import { TabContext } from "./B08_wf5_Quantity_Types";
export function 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(
input("code", "공종 코드"),
input("name", "공종명"),
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) => {
event.preventDefault();
const x = formData(wbsForm);
await B08Api.saveWbs(ctx.state.projectId, {
...x,
parent_id: null,
level_no: Number(x.level_no),
sort_order: Number(x.sort_order),
});
await ctx.refresh();
};
const references: Array<[string, string]> = [];
ctx.state.data.catalog
.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}`]),
);
const form = el("form", "b08-form-grid") as HTMLFormElement;
form.append(
select(
"wbs_id",
"공종",
wbs.map((x: any) => [x.id, `${x.wbs_code} ${x.name}`]),
),
select("reference", "확정 적용단가", references),
input("item_name", "내역 명칭"),
input("specification", "규격"),
input("unit", "단위"),
input("design_quantity", "설계수량", "number"),
input("adjusted_quantity", "보정수량", "number"),
input("adjustment_reason", "보정 사유"),
select("procurement_type", "구분", [
["PRIVATE", "사급"],
["GOVERNMENT", "관급"],
["EXCLUDED", "제외"],
]),
);
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("확정된 적용단가를 먼저 등록하세요.");
await B08Api.saveQuantity(ctx.state.projectId, {
...x,
reference_type,
reference_id,
design_quantity: x.design_quantity === "" ? null : Number(x.design_quantity),
adjusted_quantity: x.adjusted_quantity === "" ? null : Number(x.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,
});
ctx.message("설계수량을 저장했습니다.");
await ctx.refresh();
};
const confirm = el(
"button",
"b08-button b08-button--primary",
"전체 수량 확정",
) as HTMLButtonElement;
confirm.onclick = async () => {
await B08Api.confirmQuantities(ctx.state.projectId);
ctx.message("설계수량을 확정했습니다.");
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,
]),
),
);
return root;
}
@@ -0,0 +1,96 @@
import { B08Api } from "./B08_wf5_Quantity_Api";
import { el, formData, input, section, select, table } from "./B08_wf5_Quantity_UI_Dom";
import { TabContext } from "./B08_wf5_Quantity_Types";
export function renderUnitCost(ctx: TabContext) {
const root = section("일위대가", "재료·노무·장비·경비의 투입계수로 단위당 비용을 계산합니다.");
const components: any[] = [];
const f = el("form", "b08-form-grid") as HTMLFormElement;
f.append(
input("code", "일위대가 코드"),
input("name", "명칭"),
input("specification", "규격"),
input("unit", "단위"),
select("rounding_mode", "금액 처리", [
["FLOOR", "절사"],
["ROUND", "반올림"],
["CEILING", "올림"],
]),
input("rounding_unit", "처리 단위", "number", "1"),
select("status", "상태", [
["DRAFT", "작성중"],
["CONFIRMED", "확정"],
]),
);
const cf = componentForm(components, root);
const btn = el(
"button",
"b08-button b08-button--primary",
"일위대가 저장·계산",
) as HTMLButtonElement;
btn.type = "submit";
f.append(btn);
f.onsubmit = async (e) => {
e.preventDefault();
if (!components.length) throw new Error("구성요소를 추가하세요.");
const x = formData(f);
const result = await B08Api.saveUnitCost(ctx.state.projectId, {
...x,
rounding_unit: Number(x.rounding_unit),
status: x.status,
version_no: 1,
components,
});
ctx.message(`일위대가 계산 완료: ${result.total.toLocaleString()}`);
await ctx.refresh();
};
root.append(
f,
cf,
table(
["코드", "명칭", "규격", "단위", "상태"],
ctx.state.data.costing.unit_costs.map((x: any) => [
x.unit_cost_code,
x.name,
x.specification,
x.unit,
x.status,
]),
),
);
return root;
}
function componentForm(rows: any[], root: HTMLElement) {
const box = section("구성요소", "적용단가와 투입계수를 입력합니다.");
const f = el("form", "b08-inline-form") as HTMLFormElement;
f.append(
select("component_type", "유형", [
["CATALOG", "기초단가"],
["EQUIPMENT", "중기"],
["UNIT_COST", "일위대가"],
]),
input("reference_id", "참조 ID"),
input("reference_name", "참조명"),
input("quantity", "투입계수", "number"),
select("cost_type", "비용", [
["LABOR", "노무"],
["MATERIAL", "재료"],
["EXPENSE", "경비"],
]),
);
const b = el("button", "b08-button", "구성 추가") as HTMLButtonElement;
b.type = "submit";
f.append(b);
f.onsubmit = (e) => {
e.preventDefault();
const x = formData(f);
rows.push({
...x,
quantity: Number(x.quantity),
unit_price: 0,
sort_order: rows.length,
});
root.querySelector(".b08-component-count")!.textContent = `구성 ${rows.length}`;
};
box.append(f, el("p", "b08-component-count", "구성 0건"));
return box;
}
+2 -1
View File
@@ -6,7 +6,8 @@
"scripts": {
"dev": "node ./config/node_modules/vite/bin/vite.js --configLoader runner",
"build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:b07-cad",
"build:b07-cad": "npm --prefix B07_wf4_DesignDetail/openwebcad run build",
"install:b07-cad": "npm --prefix B07_wf4_DesignDetail/openwebcad install",
"build:b07-cad": "npm run install:b07-cad && npm --prefix B07_wf4_DesignDetail/openwebcad run build",
"preview": "node ./config/node_modules/vite/bin/vite.js preview --configLoader runner",
"typecheck": "node ./config/node_modules/typescript/bin/tsc --noEmit",
"format": "node ./config/node_modules/prettier/bin/prettier.cjs --write \"../**/*.{ts,css,html}\""