B06_B08_연계 수정 시작
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}\""
|
||||
|
||||
Reference in New Issue
Block a user