diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts index b92c8494..aea39003 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts @@ -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); } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts index 994a3781..83466500 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts @@ -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( diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index d19bc185..48771cac 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -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 { } /** - * 로드 시 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 { 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 { 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}` : ""; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css index ed1740b0..2eb55512 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css @@ -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); diff --git a/B07_wf4_DesignDetail/openwebcad/package-lock.json b/B07_wf4_DesignDetail/openwebcad/package-lock.json index d501c957..ba62d757 100644 --- a/B07_wf4_DesignDetail/openwebcad/package-lock.json +++ b/B07_wf4_DesignDetail/openwebcad/package-lock.json @@ -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", diff --git a/B07_wf4_DesignDetail/openwebcad/package.json b/B07_wf4_DesignDetail/openwebcad/package.json index 729c19f2..f1392bbe 100644 --- a/B07_wf4_DesignDetail/openwebcad/package.json +++ b/B07_wf4_DesignDetail/openwebcad/package.json @@ -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", diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Api.ts b/B08_wf5_Quantity/B08_wf5_Quantity_Api.ts new file mode 100644 index 00000000..678cc7a1 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Api.ts @@ -0,0 +1,61 @@ +const req = async (url: string, init?: RequestInit): Promise => { + const response = await fetch(url, { + credentials: "include", + headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, + ...init, + }); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.detail ?? `요청 실패 (${response.status})`); + } + return response.json() as Promise; +}; + +const path = (projectId: string, suffix: string) => + `/api/b08/${encodeURIComponent(projectId)}${suffix}`; + +export const B08Api = { + basis: (p: string, v: string) => req(path(p, `/basis/${encodeURIComponent(v)}`)), + saveBasis: (p: string, v: string, data: any) => + req(path(p, `/basis/${encodeURIComponent(v)}`), { + method: "PUT", + body: JSON.stringify(data), + }), + catalog: (p: string) => req(path(p, "/catalog")), + savePriceBook: (p: string, data: any) => + req(path(p, "/catalog/price-books"), { method: "POST", body: JSON.stringify(data) }), + saveItem: (p: string, data: any) => + req(path(p, "/catalog/items"), { method: "POST", body: JSON.stringify(data) }), + addCandidate: (p: string, v: string, data: any) => + req(path(p, `/catalog/${encodeURIComponent(v)}/candidates`), { + method: "POST", + body: JSON.stringify(data), + }), + applyPrice: (p: string, v: string, data: any) => + req(path(p, `/catalog/${encodeURIComponent(v)}/apply`), { + method: "POST", + body: JSON.stringify(data), + }), + costing: (p: string) => req(path(p, "/costing")), + saveUnitCost: (p: string, data: any) => + req(path(p, "/unit-costs"), { method: "POST", body: JSON.stringify(data) }), + saveEquipment: (p: string, data: any) => + req(path(p, "/equipment-rates"), { method: "POST", body: JSON.stringify(data) }), + saveCostBasis: (p: string, data: any) => + req(path(p, "/cost-basis"), { method: "POST", body: JSON.stringify(data) }), + quantities: (p: string) => req(path(p, "/quantities")), + saveWbs: (p: string, data: any) => + req(path(p, "/work-breakdown"), { method: "POST", body: JSON.stringify(data) }), + saveQuantity: (p: string, data: any) => + req(path(p, "/quantities"), { method: "POST", body: JSON.stringify(data) }), + confirmQuantities: (p: string) => req(path(p, "/quantities/confirm"), { method: "POST" }), + calculate: (p: string, data: any) => + req(path(p, "/calculate/final"), { method: "POST", body: JSON.stringify(data) }), + importReference: (p: string, data: any) => + req(path(p, "/reference/import"), { method: "POST", body: JSON.stringify(data) }), + reconcile: (p: string, runId: string, sourceId: string) => + req(path(p, `/reconcile/${encodeURIComponent(runId)}/${encodeURIComponent(sourceId)}`), { + method: "POST", + }), + runs: (p: string) => req(path(p, "/calculation-runs")), +}; diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Database.py b/B08_wf5_Quantity/B08_wf5_Quantity_Database.py new file mode 100644 index 00000000..38ea0e9b --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Database.py @@ -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() \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Database_Basis.py b/B08_wf5_Quantity/B08_wf5_Quantity_Database_Basis.py new file mode 100644 index 00000000..d7919b95 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Database_Basis.py @@ -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))""", +) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Database_Calculation.py b/B08_wf5_Quantity/B08_wf5_Quantity_Database_Calculation.py new file mode 100644 index 00000000..18a692e5 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Database_Calculation.py @@ -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)""", +) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Database_Catalog.py b/B08_wf5_Quantity/B08_wf5_Quantity_Database_Catalog.py new file mode 100644 index 00000000..e189ab84 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Database_Catalog.py @@ -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))""", +) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Database_CostBasis.py b/B08_wf5_Quantity/B08_wf5_Quantity_Database_CostBasis.py new file mode 100644 index 00000000..e9295723 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Database_CostBasis.py @@ -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))""", +) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Database_Quantity.py b/B08_wf5_Quantity/B08_wf5_Quantity_Database_Quantity.py new file mode 100644 index 00000000..e1264af1 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Database_Quantity.py @@ -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))""", +) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Database_Reconciliation.py b/B08_wf5_Quantity/B08_wf5_Quantity_Database_Reconciliation.py new file mode 100644 index 00000000..f4c2a729 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Database_Reconciliation.py @@ -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))""", +) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Database_UnitCost.py b/B08_wf5_Quantity/B08_wf5_Quantity_Database_UnitCost.py new file mode 100644 index 00000000..543954c8 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Database_UnitCost.py @@ -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))""", +) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_CostBasis.py b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_CostBasis.py new file mode 100644 index 00000000..3843c5aa --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_CostBasis.py @@ -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) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Estimate.py b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Estimate.py new file mode 100644 index 00000000..851bb9eb --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Estimate.py @@ -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 \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Final.py b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Final.py new file mode 100644 index 00000000..6c0615f9 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Final.py @@ -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) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Formula.py b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Formula.py new file mode 100644 index 00000000..41a2f3ca --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Formula.py @@ -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) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Indirect.py b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Indirect.py new file mode 100644 index 00000000..b4f1065a --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_Indirect.py @@ -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 \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Engine_UnitCost.py b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_UnitCost.py new file mode 100644 index 00000000..9122e935 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Engine_UnitCost.py @@ -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) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Basis.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Basis.py new file mode 100644 index 00000000..7c035c76 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Basis.py @@ -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() \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Calculation.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Calculation.py new file mode 100644 index 00000000..9e5aa6e9 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Calculation.py @@ -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()) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CalculationSource.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CalculationSource.py new file mode 100644 index 00000000..95e24ff1 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CalculationSource.py @@ -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"], + } \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Catalog.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Catalog.py new file mode 100644 index 00000000..863d750d --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Catalog.py @@ -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), + ) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CostBasis.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CostBasis.py new file mode 100644 index 00000000..a335f377 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_CostBasis.py @@ -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()) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Pricing.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Pricing.py new file mode 100644 index 00000000..7170d939 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Pricing.py @@ -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 \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Quantity.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Quantity.py new file mode 100644 index 00000000..33275540 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Quantity.py @@ -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 \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Reconciliation.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Reconciliation.py new file mode 100644 index 00000000..6dde0a63 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_Reconciliation.py @@ -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) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Repository_UnitCost.py b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_UnitCost.py new file mode 100644 index 00000000..ca15145e --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Repository_UnitCost.py @@ -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} \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Router.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router.py new file mode 100644 index 00000000..e6e727c3 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Router.py @@ -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 \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Basis.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Basis.py new file mode 100644 index 00000000..ccbd45a0 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Basis.py @@ -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 \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Calculation.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Calculation.py new file mode 100644 index 00000000..9eb958a8 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Calculation.py @@ -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 \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Catalog.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Catalog.py new file mode 100644 index 00000000..a8b92f86 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Catalog.py @@ -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 \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Costing.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Costing.py new file mode 100644 index 00000000..2ec8c0e2 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Costing.py @@ -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 \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Quantity.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Quantity.py new file mode 100644 index 00000000..64fb1fec --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Quantity.py @@ -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 \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Router_Reconciliation.py b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Reconciliation.py new file mode 100644 index 00000000..d1a851af --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Router_Reconciliation.py @@ -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 \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Basis.py b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Basis.py new file mode 100644 index 00000000..5e99a5cc --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Basis.py @@ -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) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Calculation.py b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Calculation.py new file mode 100644 index 00000000..e4203753 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Calculation.py @@ -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 \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Catalog.py b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Catalog.py new file mode 100644 index 00000000..1a7c5f10 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Catalog.py @@ -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) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Schema_CostBasis.py b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_CostBasis.py new file mode 100644 index 00000000..cbad9df7 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_CostBasis.py @@ -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) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Quantity.py b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Quantity.py new file mode 100644 index 00000000..314f2ba2 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Quantity.py @@ -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}: 수량이 입력되지 않았습니다.") \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Reconciliation.py b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Reconciliation.py new file mode 100644 index 00000000..b2d64b71 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_Reconciliation.py @@ -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] \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Schema_UnitCost.py b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_UnitCost.py new file mode 100644 index 00000000..c7a92f2a --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Schema_UnitCost.py @@ -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) \ No newline at end of file diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Store.ts b/B08_wf5_Quantity/B08_wf5_Quantity_Store.ts new file mode 100644 index 00000000..4ede7810 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Store.ts @@ -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) { + 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: "理쒖쥌怨듭궗鍮?怨꾩궛 ?ㅽ뻾???€?ν뻽?듬땲??" }); + } +} diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Template.ts b/B08_wf5_Quantity/B08_wf5_Quantity_Template.ts deleted file mode 100644 index e64580e5..00000000 --- a/B08_wf5_Quantity/B08_wf5_Quantity_Template.ts +++ /dev/null @@ -1,2068 +0,0 @@ -/* ============================================================================= - * B08_wf5_Quantity_Template.ts - * 5차 워크플로우 (수량 산출) 엑셀 내역서 연동 템플릿 항목 정의 - * - * 엑셀 내역서(2025년 산불진화임도...)의 전체 145개 수량집계표 템플릿 완벽 매핑. - * 이전 단계(B07 등) 및 수식에 의해 자동 조달될 입력값(raw_qty, calc_qty 등)은 비워둔(null) 상태입니다. - * ========================================================================== */ - -export type QuantityCategory = - "earthwork" | "structure" | "revegetation" | "equipment" | "waste" | "material" | "labor"; - -export interface QuantityItemTemplate { - id: string; - item_no: string; // 호표 (예: "1", "2") - category: QuantityCategory; // 공종 분류 - name: string; // 명칭 - spec: string; // 규격 - unit: string; // 단위 - raw_qty: number | null; // B07 등 이전 설계 산출 수량 (비어있음) - allowance_rate: number; // 할증률 / 수량 보정 비율 (기본 1.0) - calc_qty: number | null; // 최종 산출 수량 (raw_qty * allowance_rate 또는 수식 계산) - formula_desc: string; // 수식 및 산출 근거 템플릿 표현식 - excel_ref_sheet: string; // 연동될 엑셀 시트명 - code_ref?: string; // 일위대가/노무/재료/경비 코드 -} - -export const QUANTITY_CATEGORY_LABELS: Record = { - earthwork: { ko: "1. 단가산출 근거 (40종)", en: "1. Unit Price Calcs" }, - structure: { ko: "2. 일위대가 구조물/배수 (25종)", en: "2. Structure & Drainage" }, - revegetation: { ko: "3. 녹화 및 환경 (37종)", en: "3. Revegetation & Misc" }, - equipment: { ko: "4. 중기 사용시간 (24종)", en: "4. Heavy Machinery Hours" }, - waste: { ko: "5. 일식 및 폐기물 (5종)", en: "5. Waste & Lump Sum" }, - material: { ko: "3. 자재 및 녹화 (37종)", en: "3. Materials" }, - labor: { ko: "6. 노무비 인력 (14종)", en: "6. Labor Personnel" }, -}; - -export const INITIAL_QUANTITY_TEMPLATES: QuantityItemTemplate[] = [ - { - id: "DG-001", - item_no: "제 1호표", - category: "earthwork", - name: "콘크리트믹서사용", - spec: "0.45 m3", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('콘크리트믹서사용')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D00083", - }, - { - id: "DG-002", - item_no: "제 2호표", - category: "earthwork", - name: "고임돌채집", - spec: "굴삭기0.7m3", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('고임돌채집')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D00091", - }, - { - id: "DG-003", - item_no: "제 3호표", - category: "earthwork", - name: "막자갈(파쇄석)채집", - spec: "", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('막자갈(파쇄석)채집')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D00408", - }, - { - id: "DG-004", - item_no: "제 4호표", - category: "earthwork", - name: "채움 콘크리트", - spec: "기계비빔", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('채움 콘크리트')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D00492", - }, - { - id: "DG-005", - item_no: "제 5호표", - category: "earthwork", - name: "단끊기", - spec: "절취없음", - unit: "m", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('단끊기')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D00794", - }, - { - id: "DG-006", - item_no: "제 6호표", - category: "earthwork", - name: "막자갈채우기", - spec: "150mm내외", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('막자갈채우기')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D00867", - }, - { - id: "DG-007", - item_no: "제 7호표", - category: "earthwork", - name: "채움CON'C", - spec: "기계비빔", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('채움CON'C')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D00918", - }, - { - id: "DG-008", - item_no: "제 8호표", - category: "earthwork", - name: "고임돌채집", - spec: "굴삭기0.2m3", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('고임돌채집')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01030", - }, - { - id: "DG-009", - item_no: "제 9호표", - category: "earthwork", - name: "떼 하차비", - spec: "", - unit: "m2", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('떼 하차비')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01129", - }, - { - id: "DG-010", - item_no: "제 10호표", - category: "earthwork", - name: "평떼붙이기", - spec: "", - unit: "m2", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('평떼붙이기')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01329", - }, - { - id: "DG-011", - item_no: "제 11호표", - category: "earthwork", - name: "레미콘타설", - spec: "무근(장비)", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('레미콘타설')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01336", - }, - { - id: "DG-012", - item_no: "제 12호표", - category: "earthwork", - name: "유로폼 설치 및 해체", - spec: "간단", - unit: "m2", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('유로폼 설치 및 해체')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01351", - }, - { - id: "DG-013", - item_no: "제 13호표", - category: "earthwork", - name: "유로폼 설치 및 해체", - spec: "보통", - unit: "m2", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('유로폼 설치 및 해체')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01352", - }, - { - id: "DG-014", - item_no: "제 14호표", - category: "earthwork", - name: "콘크리포장(T=20cm)포설", - spec: "인력포설", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('콘크리포장(T=20cm)포설')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01354", - }, - { - id: "DG-015", - item_no: "제 15호표", - category: "earthwork", - name: "파쇄석 채집", - spec: "L3=35cm내외", - unit: "m2", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('파쇄석 채집')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01448", - }, - { - id: "DG-016", - item_no: "제 16호표", - category: "earthwork", - name: "구조물터파기(토사)", - spec: "굴착기0.6m3", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('구조물터파기(토사)')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01456", - }, - { - id: "DG-017", - item_no: "제 17호표", - category: "earthwork", - name: "구조물되메우기", - spec: "굴착기0.6m3", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('구조물되메우기')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01474", - }, - { - id: "DG-018", - item_no: "제 18호표", - category: "earthwork", - name: "구조물잔토처리", - spec: "굴착기0.6m3", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('구조물잔토처리')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01475", - }, - { - id: "DG-019", - item_no: "제 19호표", - category: "earthwork", - name: "제근", - spec: "밀림(90m3/ha이상)", - unit: "m2", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('제근')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01476", - }, - { - id: "DG-020", - item_no: "제 20호표", - category: "earthwork", - name: "토사절취", - spec: "굴삭기 0.7m3", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('토사절취')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01477", - }, - { - id: "DG-021", - item_no: "제 21호표", - category: "earthwork", - name: "암(연암)절취", - spec: "굴삭기07+브레카", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('암(연암)절취')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01478", - }, - { - id: "DG-022", - item_no: "제 22호표", - category: "earthwork", - name: "옆도랑파기(토사)", - spec: "굴삭기 0.4m3", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('옆도랑파기(토사)')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01479", - }, - { - id: "DG-023", - item_no: "제 23호표", - category: "earthwork", - name: "옆도랑파기(연암)", - spec: "대형브레카(굴삭기0.7㎥)", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('옆도랑파기(연암)')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01480", - }, - { - id: "DG-024", - item_no: "제 24호표", - category: "earthwork", - name: "도자운반-토사", - spec: "L=46m", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('도자운반-토사')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01481", - }, - { - id: "DG-025", - item_no: "제 25호표", - category: "earthwork", - name: "도자운반-연암", - spec: "L=44m", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('도자운반-연암')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01482", - }, - { - id: "DG-026", - item_no: "제 26호표", - category: "earthwork", - name: "덤프운반-토사", - spec: "L=85m", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('덤프운반-토사')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01483", - }, - { - id: "DG-027", - item_no: "제 27호표", - category: "earthwork", - name: "덤프운반-연암", - spec: "L=82m", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('덤프운반-연암')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01484", - }, - { - id: "DG-028", - item_no: "제 28호표", - category: "earthwork", - name: "성토사면다짐", - spec: "진동콤팩터", - unit: "m2", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('성토사면다짐')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01485", - }, - { - id: "DG-029", - item_no: "제 29호표", - category: "earthwork", - name: "노면 고르기", - spec: "", - unit: "m2", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('노면 고르기')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01486", - }, - { - id: "DG-030", - item_no: "제 30호표", - category: "earthwork", - name: "사토운반", - spec: "L=409m", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('사토운반')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01487", - }, - { - id: "DG-031", - item_no: "제 31호표", - category: "earthwork", - name: "수축줄눈(컷팅)", - spec: "콘크리트포장", - unit: "m", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('수축줄눈(컷팅)')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01488", - }, - { - id: "DG-032", - item_no: "제 32호표", - category: "earthwork", - name: "낙석방지책(표준부)", - spec: "H=2.5m,B=2.0m", - unit: "경간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('낙석방지책(표준부)')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01489", - }, - { - id: "DG-033", - item_no: "제 33호표", - category: "earthwork", - name: "낙석방지책(단부)", - spec: "H=2.5m,B=2.0m", - unit: "경간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('낙석방지책(단부)')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01490", - }, - { - id: "DG-034", - item_no: "제 34호표", - category: "earthwork", - name: "구조물헐기", - spec: "무근,30cm미만", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('구조물헐기')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01491", - }, - { - id: "DG-035", - item_no: "제 35호표", - category: "earthwork", - name: "씨뿌리기", - spec: "", - unit: "m", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('씨뿌리기')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01492", - }, - { - id: "DG-036", - item_no: "제 36호표", - category: "earthwork", - name: "중기운반", - spec: "L=34.5km", - unit: "식", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('중기운반')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01493", - }, - { - id: "DG-037", - item_no: "제 37호표", - category: "earthwork", - name: "모래(대)운반", - spec: "L=103.3km", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('모래(대)운반')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01494", - }, - { - id: "DG-038", - item_no: "제 38호표", - category: "earthwork", - name: "자갈류(대)운반", - spec: "L=77.4km", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('자갈류(대)운반')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01495", - }, - { - id: "DG-039", - item_no: "제 39호표", - category: "earthwork", - name: "시멘트(대)운반", - spec: "L=77.4km", - unit: "대", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('시멘트(대)운반')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01496", - }, - { - id: "DG-040", - item_no: "제 40호표", - category: "earthwork", - name: "파쇄(굴림)석 대운반", - spec: "L=77.4km", - unit: "톤", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Design.calc('파쇄(굴림)석 대운반')", - excel_ref_sheet: "단가산출근거목록표", - code_ref: "D01497", - }, - { - id: "IW-001", - item_no: "제 1호표", - category: "structure", - name: "유로폼", - spec: "보통", - unit: "m2", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('유로폼')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B00011", - }, - { - id: "IW-002", - item_no: "제 2호표", - category: "structure", - name: "철근(현장)가공", - spec: "Type-Ⅰ-----아님", - unit: "ton", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('철근(현장)가공')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B00032", - }, - { - id: "IW-003", - item_no: "제 3호표", - category: "structure", - name: "모르타르배합", - spec: "1:3", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('모르타르배합')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B00048", - }, - { - id: "IW-004", - item_no: "제 4호표", - category: "structure", - name: "문양 거푸집", - spec: "", - unit: "m2", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('문양 거푸집')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B00233", - }, - { - id: "IW-005", - item_no: "제 5호표", - category: "structure", - name: "연결(이음)철근", - spec: "", - unit: "kg", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('연결(이음)철근')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B00287", - }, - { - id: "IW-006", - item_no: "제 6호표", - category: "structure", - name: "돌붙임", - spec: "메붙임,L3=35cm이하", - unit: "m2", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('돌붙임')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B00374", - }, - { - id: "IW-007", - item_no: "제 7호표", - category: "structure", - name: "돌붙임", - spec: "찰붙임,L3=55cm이하", - unit: "m2", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('돌붙임')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B00378", - }, - { - id: "IW-008", - item_no: "제 8호표", - category: "structure", - name: "파형강관부설", - spec: "D=800mm", - unit: "m", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('파형강관부설')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B00437", - }, - { - id: "IW-009", - item_no: "제 9호표", - category: "structure", - name: "물구멍설치", - spec: "D=50mm", - unit: "m", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('물구멍설치')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B00442", - }, - { - id: "IW-010", - item_no: "제 10호표", - category: "structure", - name: "유로폼", - spec: "간단", - unit: "m2", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('유로폼')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B01299", - }, - { - id: "IW-011", - item_no: "제 11호표", - category: "structure", - name: "철근(현장)조립", - spec: "Type-Ⅰ-----아님", - unit: "ton", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('철근(현장)조립')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B01582", - }, - { - id: "IW-012", - item_no: "제 12호표", - category: "structure", - name: "돌쌓기", - spec: "찰(켜)쌓기,L3=55cm이하", - unit: "m2", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('돌쌓기')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B01608", - }, - { - id: "IW-013", - item_no: "제 13호표", - category: "structure", - name: "돌쌓기(깬돌)", - spec: "메(골)쌓기,L3=55cm이하", - unit: "m2", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('돌쌓기(깬돌)')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B01680", - }, - { - id: "IW-014", - item_no: "제 14호표", - category: "structure", - name: "파형강관부설", - spec: "D=800mm", - unit: "m", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('파형강관부설')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B02119", - }, - { - id: "IW-015", - item_no: "제 15호표", - category: "structure", - name: "관보호공", - spec: "돌쌓기(ㅁ형)", - unit: "개소", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('관보호공')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B02120", - }, - { - id: "IW-016", - item_no: "제 16호표", - category: "structure", - name: "기초CON'C", - spec: "T=50cm, B=70cm", - unit: "m", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('기초CON'C')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B02121", - }, - { - id: "IW-017", - item_no: "제 17호표", - category: "structure", - name: "돌기슭막이(고1.0)", - spec: "(찰쌓기,L3=45cm)", - unit: "m", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('돌기슭막이(고1.0)')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B02122", - }, - { - id: "IW-018", - item_no: "제 18호표", - category: "structure", - name: "돌조공(고0.5)", - spec: "(메쌓기,L3=45cm)", - unit: "m", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('돌조공(고0.5)')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B02123", - }, - { - id: "IW-019", - item_no: "제 19호표", - category: "structure", - name: "제형돌수로", - spec: "B=0.75m", - unit: "m", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('제형돌수로')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B02124", - }, - { - id: "IW-020", - item_no: "제 20호표", - category: "structure", - name: "돌붙임", - spec: "깬돌,메붙임 L3=35cm", - unit: "m2", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('돌붙임')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B02125", - }, - { - id: "IW-021", - item_no: "제 21호표", - category: "structure", - name: "콘크리트포장", - spec: "T=20㎝", - unit: "m2", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('콘크리트포장')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B02126", - }, - { - id: "IW-022", - item_no: "제 22호표", - category: "structure", - name: "포장거푸집", - spec: "T=20cm(1면기준)", - unit: "m", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('포장거푸집')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B02127", - }, - { - id: "IW-023", - item_no: "제 23호표", - category: "structure", - name: "L형옹벽측구", - spec: "H=1.05m", - unit: "m", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('L형옹벽측구')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B02128", - }, - { - id: "IW-024", - item_no: "제 24호표", - category: "structure", - name: "기초CON'C", - spec: "(0.4×0.4 H:0.6)", - unit: "개소", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('기초CON'C')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B02129", - }, - { - id: "IW-025", - item_no: "제 25호표", - category: "structure", - name: "선떼(7급)붙이기", - spec: "(단끊기無;천단부)", - unit: "m", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= B07_Structure.calc('선떼(7급)붙이기')", - excel_ref_sheet: "일위대가목록표", - code_ref: "B02130", - }, - { - id: "EQ-001", - item_no: "1", - category: "equipment", - name: "트럭탑재형 크레인", - spec: "5톤", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('트럭탑재형 크레인')", - excel_ref_sheet: "중기목록표", - code_ref: "X00002", - }, - { - id: "EQ-002", - item_no: "2", - category: "equipment", - name: "굴삭기+브레카(0.7m3)", - spec: "", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('굴삭기+브레카(0.7m3)')", - excel_ref_sheet: "중기목록표", - code_ref: "X00004", - }, - { - id: "EQ-003", - item_no: "3", - category: "equipment", - name: "불도저(무한궤도)", - spec: "19톤", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('불도저(무한궤도)')", - excel_ref_sheet: "중기목록표", - code_ref: "X00008", - }, - { - id: "EQ-004", - item_no: "4", - category: "equipment", - name: "굴삭기(무한궤도)", - spec: "0.4㎥", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('굴삭기(무한궤도)')", - excel_ref_sheet: "중기목록표", - code_ref: "X00021", - }, - { - id: "EQ-005", - item_no: "5", - category: "equipment", - name: "굴삭기(무한궤도)", - spec: "0.7㎥", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('굴삭기(무한궤도)')", - excel_ref_sheet: "중기목록표", - code_ref: "X00022", - }, - { - id: "EQ-006", - item_no: "6", - category: "equipment", - name: "대형 브레이커", - spec: "0.7㎥", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('대형 브레이커')", - excel_ref_sheet: "중기목록표", - code_ref: "X00029", - }, - { - id: "EQ-007", - item_no: "7", - category: "equipment", - name: "유압식 진동콤팩터(굴삭기 부착용)", - spec: "0.7㎥", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('유압식 진동콤팩터(굴삭기 부착용)')", - excel_ref_sheet: "중기목록표", - code_ref: "X00032", - }, - { - id: "EQ-008", - item_no: "8", - category: "equipment", - name: "덤프트럭", - spec: "2.5톤", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('덤프트럭')", - excel_ref_sheet: "중기목록표", - code_ref: "X00060", - }, - { - id: "EQ-009", - item_no: "9", - category: "equipment", - name: "덤프트럭", - spec: "4.5톤", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('덤프트럭')", - excel_ref_sheet: "중기목록표", - code_ref: "X00061", - }, - { - id: "EQ-010", - item_no: "10", - category: "equipment", - name: "덤프트럭", - spec: "10.5톤", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('덤프트럭')", - excel_ref_sheet: "중기목록표", - code_ref: "X00063", - }, - { - id: "EQ-011", - item_no: "11", - category: "equipment", - name: "덤프트럭", - spec: "15톤", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('덤프트럭')", - excel_ref_sheet: "중기목록표", - code_ref: "X00064", - }, - { - id: "EQ-012", - item_no: "12", - category: "equipment", - name: "트럭트랙터및트레일러", - spec: "20톤", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('트럭트랙터및트레일러')", - excel_ref_sheet: "중기목록표", - code_ref: "X00127", - }, - { - id: "EQ-013", - item_no: "13", - category: "equipment", - name: "굴삭기(무한궤도)", - spec: "0.2㎥:할증120%", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('굴삭기(무한궤도)')", - excel_ref_sheet: "중기목록표", - code_ref: "X00268", - }, - { - id: "EQ-014", - item_no: "14", - category: "equipment", - name: "굴삭기(무한궤도)", - spec: "0.7㎥:할증120%", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('굴삭기(무한궤도)')", - excel_ref_sheet: "중기목록표", - code_ref: "X00270", - }, - { - id: "EQ-015", - item_no: "15", - category: "equipment", - name: "덤프트럭", - spec: "2.5톤:할증125%", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('덤프트럭')", - excel_ref_sheet: "중기목록표", - code_ref: "X00272", - }, - { - id: "EQ-016", - item_no: "16", - category: "equipment", - name: "덤프트럭", - spec: "15톤:할증125%", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('덤프트럭')", - excel_ref_sheet: "중기목록표", - code_ref: "X00275", - }, - { - id: "EQ-017", - item_no: "17", - category: "equipment", - name: "굴삭기(타이어)", - spec: "0.6㎥", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('굴삭기(타이어)')", - excel_ref_sheet: "중기목록표", - code_ref: "X00283", - }, - { - id: "EQ-018", - item_no: "18", - category: "equipment", - name: "덤프트럭", - spec: "24톤", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('덤프트럭')", - excel_ref_sheet: "중기목록표", - code_ref: "X00292", - }, - { - id: "EQ-019", - item_no: "19", - category: "equipment", - name: "콘크리트 믹서", - spec: "0.45㎥", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('콘크리트 믹서')", - excel_ref_sheet: "중기목록표", - code_ref: "X00350", - }, - { - id: "EQ-020", - item_no: "20", - category: "equipment", - name: "커터(콘크리트 및 아스팔트용)", - spec: "320-440mm", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('커터(콘크리트 및 아스팔트용)')", - excel_ref_sheet: "중기목록표", - code_ref: "X00352", - }, - { - id: "EQ-021", - item_no: "21", - category: "equipment", - name: "트럭탑재형 크레인", - spec: "10톤", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('트럭탑재형 크레인')", - excel_ref_sheet: "중기목록표", - code_ref: "X00568", - }, - { - id: "EQ-022", - item_no: "22", - category: "equipment", - name: "덤프트럭", - spec: "24톤(할증125%)", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('덤프트럭')", - excel_ref_sheet: "중기목록표", - code_ref: "X00740", - }, - { - id: "EQ-023", - item_no: "23", - category: "equipment", - name: "굴삭기+부착용집게(06)", - spec: "", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('굴삭기+부착용집게(06)')", - excel_ref_sheet: "중기목록표", - code_ref: "X00750", - }, - { - id: "EQ-024", - item_no: "24", - category: "equipment", - name: "동력분무기", - spec: "4.85㎾", - unit: "시간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Equipment.time_calc('동력분무기')", - excel_ref_sheet: "중기목록표", - code_ref: "X00751", - }, - { - id: "MT-001", - item_no: "1", - category: "material", - name: "비닐", - spec: "0.07mm", - unit: "M2", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('비닐')", - excel_ref_sheet: "재료비목록표", - code_ref: "M00011", - }, - { - id: "MT-002", - item_no: "2", - category: "material", - name: "면목", - spec: "A28", - unit: "M", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('면목')", - excel_ref_sheet: "재료비목록표", - code_ref: "M00026", - }, - { - id: "MT-003", - item_no: "3", - category: "material", - name: "문양거푸집", - spec: "910*910*25", - unit: "매", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('문양거푸집')", - excel_ref_sheet: "재료비목록표", - code_ref: "M00028", - }, - { - id: "MT-004", - item_no: "4", - category: "material", - name: "블레이드", - spec: "d=320~400mm t=3.2", - unit: "개", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('블레이드')", - excel_ref_sheet: "재료비목록표", - code_ref: "M00029", - }, - { - id: "MT-005", - item_no: "5", - category: "material", - name: "결속선", - spec: "#20 , 0.9M/M", - unit: "KG", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('결속선')", - excel_ref_sheet: "재료비목록표", - code_ref: "M00034", - }, - { - id: "MT-006", - item_no: "6", - category: "material", - name: "요소", - spec: "", - unit: "KG", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('요소')", - excel_ref_sheet: "재료비목록표", - code_ref: "M00056", - }, - { - id: "MT-007", - item_no: "7", - category: "material", - name: "인산질비료", - spec: "용성인비인산", - unit: "KG", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('인산질비료')", - excel_ref_sheet: "재료비목록표", - code_ref: "M00057", - }, - { - id: "MT-008", - item_no: "8", - category: "material", - name: "잡품", - spec: "주연료의 %", - unit: "%", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('잡품')", - excel_ref_sheet: "재료비목록표", - code_ref: "M00109", - }, - { - id: "MT-009", - item_no: "9", - category: "material", - name: "시멘트", - spec: "별도계상", - unit: "KG", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('시멘트')", - excel_ref_sheet: "재료비목록표", - code_ref: "M00117", - }, - { - id: "MT-010", - item_no: "10", - category: "material", - name: "레미콘", - spec: "별도계상", - unit: "M3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('레미콘')", - excel_ref_sheet: "재료비목록표", - code_ref: "M00125", - }, - { - id: "MT-011", - item_no: "11", - category: "material", - name: "패널", - spec: "600*1200mm", - unit: "매", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('패널')", - excel_ref_sheet: "재료비목록표", - code_ref: "M00131", - }, - { - id: "MT-012", - item_no: "12", - category: "material", - name: "내부코너패널", - spec: "(200+200)*1200mm", - unit: "매", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('내부코너패널')", - excel_ref_sheet: "재료비목록표", - code_ref: "M00132", - }, - { - id: "MT-013", - item_no: "13", - category: "material", - name: "천연부엽토", - spec: "", - unit: "KG", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('천연부엽토')", - excel_ref_sheet: "재료비목록표", - code_ref: "M00157", - }, - { - id: "MT-014", - item_no: "14", - category: "material", - name: "파형강관(800M/M)", - spec: "별도계상", - unit: "M", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('파형강관(800M/M)')", - excel_ref_sheet: "재료비목록표", - code_ref: "M00214", - }, - { - id: "MT-015", - item_no: "15", - category: "material", - name: "이형철근 D13", - spec: "별도계상", - unit: "TON", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('이형철근 D13')", - excel_ref_sheet: "재료비목록표", - code_ref: "M00227", - }, - { - id: "MT-016", - item_no: "16", - category: "material", - name: "와이어매쉬", - spec: "#6,100*100", - unit: "M2", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('와이어매쉬')", - excel_ref_sheet: "재료비목록표", - code_ref: "M00231", - }, - { - id: "MT-017", - item_no: "17", - category: "material", - name: "휘발유", - spec: "무연", - unit: "L", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('휘발유')", - excel_ref_sheet: "재료비목록표", - code_ref: "M00302", - }, - { - id: "MT-018", - item_no: "18", - category: "material", - name: "경유", - spec: "저유황", - unit: "L", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('경유')", - excel_ref_sheet: "재료비목록표", - code_ref: "M00303", - }, - { - id: "MT-019", - item_no: "19", - category: "material", - name: "레미콘(울진)", - spec: "25-18-80(세액포함)", - unit: "M3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('레미콘(울진)')", - excel_ref_sheet: "재료비목록표", - code_ref: "M00344", - }, - { - id: "MT-020", - item_no: "20", - category: "material", - name: "레미콘(울진)", - spec: "25-21-80(세액포함)", - unit: "M3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('레미콘(울진)')", - excel_ref_sheet: "재료비목록표", - code_ref: "M00424", - }, - { - id: "MT-021", - item_no: "21", - category: "material", - name: "깬돌", - spec: "구입-자재별산", - unit: "m2", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('깬돌')", - excel_ref_sheet: "재료비목록표", - code_ref: "M00453", - }, - { - id: "MT-022", - item_no: "22", - category: "material", - name: "모래", - spec: "별도계상", - unit: "㎥", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('모래')", - excel_ref_sheet: "재료비목록표", - code_ref: "M00488", - }, - { - id: "MT-023", - item_no: "23", - category: "material", - name: "조달수수료", - spec: "", - unit: "%", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('조달수수료')", - excel_ref_sheet: "재료비목록표", - code_ref: "M00918", - }, - { - id: "MT-024", - item_no: "24", - category: "material", - name: "물", - spec: "", - unit: "ℓ", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('물')", - excel_ref_sheet: "재료비목록표", - code_ref: "M01054", - }, - { - id: "MT-025", - item_no: "25", - category: "material", - name: "떼", - spec: "", - unit: "m2", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('떼')", - excel_ref_sheet: "재료비목록표", - code_ref: "M01150", - }, - { - id: "MT-026", - item_no: "26", - category: "material", - name: "종자", - spec: "초본류", - unit: "KG", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('종자')", - excel_ref_sheet: "재료비목록표", - code_ref: "M01225", - }, - { - id: "MT-027", - item_no: "27", - category: "material", - name: "종자", - spec: "목본류", - unit: "KG", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('종자')", - excel_ref_sheet: "재료비목록표", - code_ref: "M01226", - }, - { - id: "MT-028", - item_no: "28", - category: "material", - name: "낙석방지책", - spec: "H2500×W2000(표준)", - unit: "경간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('낙석방지책')", - excel_ref_sheet: "재료비목록표", - code_ref: "M01324", - }, - { - id: "MT-029", - item_no: "29", - category: "material", - name: "낙석방지책", - spec: "H2500×W2000(단부)", - unit: "경간", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('낙석방지책')", - excel_ref_sheet: "재료비목록표", - code_ref: "M01325", - }, - { - id: "MT-030", - item_no: "30", - category: "material", - name: "P.V.C PIPE", - spec: "VG2(50m/m)", - unit: "M", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('P.V.C PIPE')", - excel_ref_sheet: "재료비목록표", - code_ref: "M01436", - }, - { - id: "MT-031", - item_no: "31", - category: "material", - name: "파형강관", - spec: "Φ800m/m, 2.7t", - unit: "M", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('파형강관')", - excel_ref_sheet: "재료비목록표", - code_ref: "M01513", - }, - { - id: "MT-032", - item_no: "32", - category: "material", - name: "모래", - spec: "상차가(안동)", - unit: "M3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('모래')", - excel_ref_sheet: "재료비목록표", - code_ref: "M01514", - }, - { - id: "MT-033", - item_no: "33", - category: "material", - name: "자갈", - spec: "상차가(영주)", - unit: "M3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('자갈')", - excel_ref_sheet: "재료비목록표", - code_ref: "M01515", - }, - { - id: "MT-034", - item_no: "34", - category: "material", - name: "막자갈", - spec: "상차가(영주)", - unit: "M3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('막자갈')", - excel_ref_sheet: "재료비목록표", - code_ref: "M01516", - }, - { - id: "MT-035", - item_no: "35", - category: "material", - name: "시멘트", - spec: "40Kg/포", - unit: "대", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('시멘트')", - excel_ref_sheet: "재료비목록표", - code_ref: "M01517", - }, - { - id: "MT-036", - item_no: "36", - category: "material", - name: "이형철근,SD400", - spec: "D13~32m/m(세액별도)", - unit: "TON", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('이형철근,SD400')", - excel_ref_sheet: "재료비목록표", - code_ref: "M01518", - }, - { - id: "MT-037", - item_no: "37", - category: "material", - name: "파쇄석(L3=45~55cm)", - spec: "상차가(영주)", - unit: "톤", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Material.sum('파쇄석(L3=45~55cm)')", - excel_ref_sheet: "재료비목록표", - code_ref: "M01519", - }, - { - id: "LB-001", - item_no: "1", - category: "labor", - name: "형틀목공", - spec: "", - unit: "인", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Labor.total_mandays('형틀목공')", - excel_ref_sheet: "노무비목록표", - code_ref: "L00002", - }, - { - id: "LB-002", - item_no: "2", - category: "labor", - name: "철근공", - spec: "", - unit: "인", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Labor.total_mandays('철근공')", - excel_ref_sheet: "노무비목록표", - code_ref: "L00004", - }, - { - id: "LB-003", - item_no: "3", - category: "labor", - name: "석공", - spec: "", - unit: "인", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Labor.total_mandays('석공')", - excel_ref_sheet: "노무비목록표", - code_ref: "L00005", - }, - { - id: "LB-004", - item_no: "4", - category: "labor", - name: "콘크리트공", - spec: "", - unit: "인", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Labor.total_mandays('콘크리트공')", - excel_ref_sheet: "노무비목록표", - code_ref: "L00007", - }, - { - id: "LB-005", - item_no: "5", - category: "labor", - name: "조경공", - spec: "", - unit: "인", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Labor.total_mandays('조경공')", - excel_ref_sheet: "노무비목록표", - code_ref: "L00012", - }, - { - id: "LB-006", - item_no: "6", - category: "labor", - name: "작업반장", - spec: "", - unit: "인", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Labor.total_mandays('작업반장')", - excel_ref_sheet: "노무비목록표", - code_ref: "L00014", - }, - { - id: "LB-007", - item_no: "7", - category: "labor", - name: "특별인부", - spec: "", - unit: "인", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Labor.total_mandays('특별인부')", - excel_ref_sheet: "노무비목록표", - code_ref: "L00015", - }, - { - id: "LB-008", - item_no: "8", - category: "labor", - name: "보통인부", - spec: "", - unit: "인", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Labor.total_mandays('보통인부')", - excel_ref_sheet: "노무비목록표", - code_ref: "L00016", - }, - { - id: "LB-009", - item_no: "9", - category: "labor", - name: "용접공", - spec: "", - unit: "인", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Labor.total_mandays('용접공')", - excel_ref_sheet: "노무비목록표", - code_ref: "L00019", - }, - { - id: "LB-010", - item_no: "10", - category: "labor", - name: "포장공", - spec: "", - unit: "인", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Labor.total_mandays('포장공')", - excel_ref_sheet: "노무비목록표", - code_ref: "L00025", - }, - { - id: "LB-011", - item_no: "11", - category: "labor", - name: "건설기계운전사", - spec: "", - unit: "인", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Labor.total_mandays('건설기계운전사')", - excel_ref_sheet: "노무비목록표", - code_ref: "L00038", - }, - { - id: "LB-012", - item_no: "12", - category: "labor", - name: "화물차운전사", - spec: "", - unit: "인", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Labor.total_mandays('화물차운전사')", - excel_ref_sheet: "노무비목록표", - code_ref: "L00039", - }, - { - id: "LB-013", - item_no: "13", - category: "labor", - name: "일반기계운전사", - spec: "", - unit: "인", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Labor.total_mandays('일반기계운전사')", - excel_ref_sheet: "노무비목록표", - code_ref: "L00040", - }, - { - id: "LB-014", - item_no: "14", - category: "labor", - name: "배관공(수도)", - spec: "", - unit: "인", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Labor.total_mandays('배관공(수도)')", - excel_ref_sheet: "노무비목록표", - code_ref: "L00061", - }, - { - id: "WS-001", - item_no: "1", - category: "waste", - name: "폐기물처리", - spec: "폐콘크리트", - unit: "TON", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Waste.estimate('폐기물처리')", - excel_ref_sheet: "일식견적목록표", - code_ref: "W00045", - }, - { - id: "WS-002", - item_no: "2", - category: "waste", - name: "폐기물운반", - spec: "30km이하", - unit: "TON", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Waste.estimate('폐기물운반')", - excel_ref_sheet: "일식견적목록표", - code_ref: "W00046", - }, - { - id: "WS-003", - item_no: "3", - category: "waste", - name: "깬돌", - spec: "구입별산", - unit: "m2", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Waste.estimate('깬돌')", - excel_ref_sheet: "일식견적목록표", - code_ref: "W01835", - }, - { - id: "WS-004", - item_no: "4", - category: "waste", - name: "깬돌", - spec: "구입별산", - unit: "Ton", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Waste.estimate('깬돌')", - excel_ref_sheet: "일식견적목록표", - code_ref: "W02335", - }, - { - id: "WS-005", - item_no: "5", - category: "waste", - name: "무대처리", - spec: "", - unit: "m3", - raw_qty: null, - allowance_rate: 1.0, - calc_qty: null, - formula_desc: "= Waste.estimate('무대처리')", - excel_ref_sheet: "일식견적목록표", - code_ref: "W02336", - }, -]; diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Types.ts b/B08_wf5_Quantity/B08_wf5_Quantity_Types.ts new file mode 100644 index 00000000..35963eee --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Types.ts @@ -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; + message: (text: string) => void; + calculate: () => Promise; +} + +export const TABS: Array<{ id: TabId; label: string; step: string }> = [ + { id: "basis", label: "기준정보", step: "01" }, + { id: "catalog", label: "기초단가", step: "02" }, + { id: "unitCost", label: "일위대가", step: "03" }, + { id: "equipment", label: "중기사용료", step: "04" }, + { id: "costBasis", label: "단가산출근거", step: "05" }, + { id: "quantity", label: "설계수량 입력", step: "06" }, + { id: "estimate", label: "설계내역", step: "07" }, + { id: "aggregation", label: "집계·총괄", step: "08" }, + { id: "indirect", label: "간접비·원가", step: "09" }, + { id: "final", label: "최종공사비·대조", step: "10" }, +]; diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Dom.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Dom.ts new file mode 100644 index 00000000..099580dc --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Dom.ts @@ -0,0 +1,53 @@ +export const el = ( + tag: K, + className = "", + text = "", +): HTMLElementTagNameMap[K] => { + const node = document.createElement(tag); + node.className = className; + if (text) node.textContent = text; + return node; +}; +export const input = (name: string, label: string, type = "text", value = "") => { + const wrap = el("label", "b08-field"); + wrap.append(el("span", "", label)); + const control = el("input") as HTMLInputElement; + control.name = name; + control.type = type; + control.value = value; + wrap.append(control); + return wrap; +}; +export const select = (name: string, label: string, values: Array<[string, string]>) => { + const wrap = el("label", "b08-field"); + wrap.append(el("span", "", label)); + const control = el("select") as HTMLSelectElement; + control.name = name; + values.forEach(([v, t]) => control.add(new Option(t, v))); + wrap.append(control); + return wrap; +}; +export const formData = (form: HTMLFormElement) => Object.fromEntries(new FormData(form).entries()); +export const table = (headers: string[], rows: Array>) => { + const t = el("table", "b08-table"); + const head = el("thead"); + const hr = el("tr"); + headers.forEach((x) => hr.append(el("th", "", x))); + head.append(hr); + const body = el("tbody"); + rows.forEach((row) => { + const tr = el("tr"); + row.forEach((x) => tr.append(el("td", "", x == null ? "-" : String(x)))); + body.append(tr); + }); + t.append(head, body); + return t; +}; +export const money = (value: number) => `${new Intl.NumberFormat("ko-KR").format(value || 0)}원`; +export const section = (title: string, description: string) => { + const root = el("section", "b08-tab-section"); + const header = el("header", "b08-tab-heading"); + header.append(el("h3", "", title), el("p", "", description)); + root.append(header); + return root; +}; diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts index d7b74aa6..71a9c9a8 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts @@ -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 { - // 1. 공통 Workflow Shell 렌더링 +const renders: Record HTMLElement> = { + basis: renderBasis, + catalog: renderCatalog, + unitCost: renderUnitCost, + equipment: renderEquipment, + costBasis: renderCostBasis, + quantity: renderQuantity, + estimate: renderEstimate, + aggregation: renderAggregation, + indirect: renderIndirect, + final: renderFinal, +}; +const currentProject = () => + new URLSearchParams(location.search).get("project_id") ?? + sessionStorage.getItem("currentProjectId") ?? + ""; +export async function renderB08Quantity(root: HTMLElement) { await renderPendingWorkflow(root, { - title: 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) => ` -
-
${item.title}
-
${item.val}
-
${item.desc}
-
- `, - ) - .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(".workflow-body") ?? root; + body.replaceChildren(); + const projectId = currentProject(); + const page = document.createElement("main"); + page.className = "b08-page"; + page.innerHTML = `

WF5 · QUANTITY & COST

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

`; + body.append(page); + const message = page.querySelector(".b08-message")!, + tabs = page.querySelector(".b08-tabs")!, + content = page.querySelector(".b08-content")!, + project = page.querySelector(".b08-project")!; + project.textContent = projectId ? `프로젝트 ${projectId}` : "프로젝트 미선택"; + const store = new B08Store(projectId); + TABS.forEach((tab) => { + const button = document.createElement("button"); + button.dataset.tab = tab.id; + button.innerHTML = `${tab.step}${tab.label}`; + button.onclick = () => store.setTab(tab.id); + tabs.append(button); }); - - 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 = ` - - - 호표 - 명칭 - 규격 - 단위 - 설계 산출 수량 (raw_qty) - 할증률 - 최종 산출 수량 - 엑셀 수식 및 산출 근거 (Formula) - 연동 시트 / 코드 - - - - ${items.map((item) => renderTableRow(item)).join("")} - - `; - - parent.appendChild(table); -} - -/** - * 테이블 행 HTML 생성 - */ -function renderTableRow(item: QuantityItemTemplate): string { - const rawQtyDisplay = - item.raw_qty !== null - ? item.raw_qty.toLocaleString() - : `[입력 대기]`; - - const calcQtyDisplay = - item.calc_qty !== null - ? item.calc_qty.toLocaleString() - : `[수식 자동계산]`; - - const codeBadge = item.code_ref - ? `${item.code_ref}` - : ""; - - return ` - - ${item.item_no} - ${item.name} - ${item.spec} - ${item.unit} - ${rawQtyDisplay} - ${item.allowance_rate} - ${calcQtyDisplay} - ${item.formula_desc} - - ${item.excel_ref_sheet} - ${codeBadge} - - - `; -} - -/** - * 4. 하단 컨트롤/액션 바 렌더링 - */ -function renderActionBar(parent: HTMLElement): void { - const actionBar = document.createElement("div"); - actionBar.className = "b08-action-bar"; - - actionBar.innerHTML = ` -
- 💡 엑셀 내역서 145개 전체 템플릿 매핑 완료: 입력 수량값은 차후 이전 워크플로우(B07 등) 및 수식 엔진에 의해 자동 기입됩니다. -
- - `; - - 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(); } diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Style.css b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Style.css index 68935516..39a4b33a 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Style.css +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Style.css @@ -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; + } } diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Aggregation.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Aggregation.ts new file mode 100644 index 00000000..c1f6d51f --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Aggregation.ts @@ -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(); + for (const x of ctx.state.data.latest?.lines ?? []) { + const g = groups.get(x.wbs_id) ?? { labor: 0, material: 0, expense: 0, total: 0 }; + g.labor += x.labor_amount; + g.material += x.material_amount; + g.expense += x.expense_amount; + g.total += x.total_amount; + groups.set(x.wbs_id, g); + } + const wbs = ctx.state.data.quantities.work_breakdown; + root.append( + table( + ["공종", "노무비", "재료비", "경비", "합계"], + [...groups].map(([id, x]) => [ + wbs.find((w: any) => w.id === id)?.name ?? id, + money(x.labor), + money(x.material), + money(x.expense), + money(x.total), + ]), + ), + ); + return root; +} diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Basis.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Basis.ts new file mode 100644 index 00000000..56f8d76d --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Basis.ts @@ -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; +} diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Catalog.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Catalog.ts new file mode 100644 index 00000000..414aa7b5 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Catalog.ts @@ -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; +} diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_CostBasis.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_CostBasis.ts new file mode 100644 index 00000000..cc674a00 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_CostBasis.ts @@ -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; +} diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Equipment.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Equipment.ts new file mode 100644 index 00000000..4d0b4f65 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Equipment.ts @@ -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; +} diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Estimate.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Estimate.ts new file mode 100644 index 00000000..a66edde9 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Estimate.ts @@ -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 = + '최종 계산 실행 전입니다. 설계수량과 요율을 확정하세요.'; + return root; +} diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Final.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Final.ts new file mode 100644 index 00000000..c41ce0d6 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Final.ts @@ -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; +} diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Indirect.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Indirect.ts new file mode 100644 index 00000000..caca5f60 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Indirect.ts @@ -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 = + '승인된 제경비 규칙으로 최종 계산을 실행하세요.'; + return root; +} diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Quantity.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Quantity.ts new file mode 100644 index 00000000..1ffd9667 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_Quantity.ts @@ -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; +} diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_UnitCost.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_UnitCost.ts new file mode 100644 index 00000000..50867452 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Tab_UnitCost.ts @@ -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; +} diff --git a/package.json b/package.json index f42253f3..b5e485ef 100644 --- a/package.json +++ b/package.json @@ -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}\""