feat(B09): 일위대가 탭 — 목록표+본표 2단, 원천 표시, 층 파고들기
**API 2종** (`B09_Estimation_Router.py`)
- `GET …/estimation/unit-prices` — **목록표** + **산출 요약**. 요약을 같이 보내는 까닭은
사용자가 「무엇이 안 선 상태인가」를 화면에서 알아야 하기 때문임(자재 카탈로그
미확보로 구조물 계열이 안 섬).
- `GET …/estimation/unit-prices/{code}` — **본표**. 줄마다 원천(자재5·노임6·기계경비105·
일위대가103·단가산출104)과 **파고들기 가능 여부**가 붙음. 없는 코드는 404.
- 품셈 3 MB 를 요청마다 다시 안 읽게 `cached_build()` 로 한 번만 조립.
**화면** (`B09_Estimation_UI_Page.ts`)
- 일위대가 탭 활성화. **목록표(위) + 본표(아래) 2단** — 9-3 「제목+상세 한 쌍」이 화면에도
그대로 섬.
- 본표 줄마다 `원천(번호)` 표시, **기계 줄을 누르면 그 시간당 사용료 본표로 파고듦**
(거기서 취득가·연료·조종원까지 보임). 값을 못 믿을 때 사람이 하는 일이 이것임.
- **재료·노무·경비 3분할 + 합계 줄**, `TC = NC + GC + JC` 성립 여부를 화면 문구로 냄.
- **산출 요약을 화면에 표시** — 자재가 없어 구조물 계열이 못 선다는 것을 그 자리에 적음.
locale 은 **B09 키만** 추가(16줄), 공용 파일 다른 줄 무수정.
⚠ 화면 조작 검증은 다음 단계 — `tsc` 는 통과했고(남은 오류 1건은 메인 창 B08 파일),
백엔드 재시작·클릭 검증은 이어서 함.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,7 +26,14 @@ from B09_Estimation.B09_Estimation_Engine_Cost import (
|
||||
proposed_profit_adjustment,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_Rates import RateLookupError
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PriceBookError
|
||||
from B09_Estimation.B09_Estimation_Statutory import STATUTORY_ITEMS
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import (
|
||||
build_summary,
|
||||
cached_build,
|
||||
detail_of,
|
||||
list_unit_prices,
|
||||
)
|
||||
from common_util.common_util_workflow_state import complete_stage
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
@@ -154,6 +161,45 @@ async def list_items(project_id: UUID) -> JSONResponse:
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/estimation/unit-prices")
|
||||
async def list_unit_price_titles(project_id: UUID) -> JSONResponse:
|
||||
"""일위대가 **목록표** — 「무엇이 있나」 한 줄씩 + 산출 요약.
|
||||
|
||||
요약을 같이 보내는 까닭은 사용자가 **「무엇이 안 선 상태인가」를 화면에서**
|
||||
알아야 하기 때문이다(자재 카탈로그 미확보로 구조물 계열이 안 섬).
|
||||
"""
|
||||
try:
|
||||
build = cached_build()
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "success",
|
||||
"summary": build_summary(build),
|
||||
"rows": list_unit_prices(build),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B09 일위대가 목록 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "일위대가 목록을 못 만들었습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/estimation/unit-prices/{code}")
|
||||
async def get_unit_price_detail(project_id: UUID, code: str) -> JSONResponse:
|
||||
"""일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천·파고들기 표시가 붙는다."""
|
||||
try:
|
||||
return JSONResponse(content={"status": "success", **detail_of(cached_build(), code)})
|
||||
except PriceBookError as error:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(error)})
|
||||
except Exception:
|
||||
logger.exception("B09 일위대가 본표 실패: project_id=%s, code=%s", project_id, code)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "일위대가 본표를 못 만들었습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/estimation/confirm")
|
||||
async def confirm_estimation(project_id: UUID) -> JSONResponse:
|
||||
"""원가계산 단계 확정 — 워크플로 stage 6(ESTIMATION)을 COMPLETE 로 전이한다."""
|
||||
|
||||
@@ -49,6 +49,47 @@ interface CostSheetDto {
|
||||
suggested_profit_adjustment_krw?: string;
|
||||
}
|
||||
|
||||
interface UnitPriceRow {
|
||||
code: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
material: string;
|
||||
labor: string;
|
||||
expense: string;
|
||||
total: string;
|
||||
}
|
||||
|
||||
interface UnitPriceListDto {
|
||||
status: string;
|
||||
summary: { titles: number; unit_prices: number; machine_hourly: number; notes: string[] };
|
||||
rows: UnitPriceRow[];
|
||||
}
|
||||
|
||||
interface UnitPriceDetailRow extends UnitPriceRow {
|
||||
ref_code: string;
|
||||
source_label: string;
|
||||
source_index: number;
|
||||
drillable: boolean;
|
||||
quantity: string;
|
||||
unit_total: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
interface UnitPriceDetailDto {
|
||||
status: string;
|
||||
code: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
material: string;
|
||||
labor: string;
|
||||
expense: string;
|
||||
total: string;
|
||||
sum_matches: boolean;
|
||||
rows: UnitPriceDetailRow[];
|
||||
}
|
||||
|
||||
/** 좌측 입력 상태 — 화면이 들고 있는 값. 저장은 [확정] 때만. */
|
||||
interface CostFormState {
|
||||
direct_material_krw: string;
|
||||
@@ -128,6 +169,10 @@ function injectStyles(): void {
|
||||
.b09-sheet tr.is-total td { font-weight: 600; background: var(--color-surface); }
|
||||
.b09-sheet tr.is-adopted td { background: var(--color-surface); }
|
||||
.b09-sheet tr.is-dropped td { color: var(--color-text-secondary); text-decoration: line-through; }
|
||||
.b09-clickable { cursor: pointer; }
|
||||
.b09-clickable:hover td { background: var(--color-surface); }
|
||||
.b09-up-list { max-height: 45%; }
|
||||
.b09-up-detail { border-top: 2px solid var(--color-border); padding-top: 6px; }
|
||||
.b09-empty { padding: var(--space-lg, 16px); color: var(--color-text-secondary); font-size: var(--font-size-sm, 13px); }
|
||||
`;
|
||||
document.head.append(style);
|
||||
@@ -199,6 +244,152 @@ function buildCostSheetTable(sheet: CostSheetDto): HTMLElement {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** 일위대가 **목록표** — 「무엇이 있나」. 고르면 아래에 본표가 뜬다(9-3 제목+상세). */
|
||||
function buildUnitPriceList(
|
||||
list: UnitPriceListDto,
|
||||
selected: string | null,
|
||||
onPick: (code: string) => void,
|
||||
): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b09-sheet b09-up-list";
|
||||
|
||||
const caption = document.createElement("div");
|
||||
caption.className = "b09-hint";
|
||||
caption.textContent = `${L("B09_Estimation_UP_List")} · ${list.summary.unit_prices}`;
|
||||
wrap.append(caption);
|
||||
|
||||
const table = document.createElement("table");
|
||||
const head = document.createElement("tr");
|
||||
for (const [key, left] of [
|
||||
["B09_Estimation_Col_Name", true],
|
||||
["B09_Estimation_Col_Unit", true],
|
||||
["B09_Estimation_Col_Material", false],
|
||||
["B09_Estimation_Col_Labor", false],
|
||||
["B09_Estimation_Col_Expense", false],
|
||||
["B09_Estimation_Col_Total", false],
|
||||
] as Array<[keyof typeof ui_locales, boolean]>) {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = L(key);
|
||||
if (left) th.className = "b09-left";
|
||||
head.append(th);
|
||||
}
|
||||
const thead = document.createElement("thead");
|
||||
thead.append(head);
|
||||
table.append(thead);
|
||||
|
||||
const body = document.createElement("tbody");
|
||||
for (const row of list.rows) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "b09-clickable";
|
||||
if (row.code === selected) tr.classList.add("is-adopted");
|
||||
tr.addEventListener("click", () => onPick(row.code));
|
||||
|
||||
const name = document.createElement("td");
|
||||
name.className = "b09-left";
|
||||
name.textContent = row.name;
|
||||
const unit = document.createElement("td");
|
||||
unit.className = "b09-left";
|
||||
unit.textContent = row.unit;
|
||||
tr.append(name, unit);
|
||||
for (const value of [row.material, row.labor, row.expense, row.total]) {
|
||||
const cell = document.createElement("td");
|
||||
cell.textContent = formatWon(value);
|
||||
tr.append(cell);
|
||||
}
|
||||
body.append(tr);
|
||||
}
|
||||
table.append(body);
|
||||
wrap.append(table);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** 일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천과 파고들기가 붙는다. */
|
||||
function buildUnitPriceDetail(
|
||||
detail: UnitPriceDetailDto,
|
||||
onDrill: (code: string) => void,
|
||||
): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b09-sheet b09-up-detail";
|
||||
|
||||
const caption = document.createElement("div");
|
||||
caption.className = "b09-hint";
|
||||
caption.textContent =
|
||||
`${L("B09_Estimation_UP_Detail")} · ${detail.name}` +
|
||||
(detail.spec ? ` (${detail.spec})` : "") +
|
||||
` · ${formatWon(detail.total)}` +
|
||||
` · ${detail.sum_matches ? L("B09_Estimation_UP_SumOk") : L("B09_Estimation_UP_SumBad")}`;
|
||||
wrap.append(caption);
|
||||
|
||||
const table = document.createElement("table");
|
||||
const head = document.createElement("tr");
|
||||
for (const [key, left] of [
|
||||
["B09_Estimation_Col_Name", true],
|
||||
["B09_Estimation_Col_Spec", true],
|
||||
["B09_Estimation_Col_Source", true],
|
||||
["B09_Estimation_Col_Unit", true],
|
||||
["B09_Estimation_Col_Qty", false],
|
||||
["B09_Estimation_Col_Material", false],
|
||||
["B09_Estimation_Col_Labor", false],
|
||||
["B09_Estimation_Col_Expense", false],
|
||||
["B09_Estimation_Col_Total", false],
|
||||
] as Array<[keyof typeof ui_locales, boolean]>) {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = L(key);
|
||||
if (left) th.className = "b09-left";
|
||||
head.append(th);
|
||||
}
|
||||
const thead = document.createElement("thead");
|
||||
thead.append(head);
|
||||
table.append(thead);
|
||||
|
||||
const body = document.createElement("tbody");
|
||||
for (const row of detail.rows) {
|
||||
const tr = document.createElement("tr");
|
||||
if (row.drillable) {
|
||||
tr.className = "b09-clickable";
|
||||
tr.title = L("B09_Estimation_UP_Drill");
|
||||
tr.addEventListener("click", () => onDrill(row.ref_code));
|
||||
}
|
||||
const name = document.createElement("td");
|
||||
name.className = "b09-left";
|
||||
name.textContent = row.drillable ? `▸ ${row.name}` : row.name;
|
||||
const spec = document.createElement("td");
|
||||
spec.className = "b09-left";
|
||||
spec.textContent = row.spec;
|
||||
const source = document.createElement("td");
|
||||
source.className = "b09-left";
|
||||
source.textContent = `${row.source_label} (${row.source_index})`;
|
||||
const unit = document.createElement("td");
|
||||
unit.className = "b09-left";
|
||||
unit.textContent = row.unit;
|
||||
tr.append(name, spec, source, unit);
|
||||
for (const value of [row.quantity, row.material, row.labor, row.expense, row.total]) {
|
||||
const cell = document.createElement("td");
|
||||
cell.textContent = formatWon(value);
|
||||
tr.append(cell);
|
||||
}
|
||||
body.append(tr);
|
||||
}
|
||||
|
||||
const sum = document.createElement("tr");
|
||||
sum.className = "is-total";
|
||||
const label = document.createElement("td");
|
||||
label.className = "b09-left";
|
||||
label.colSpan = 5;
|
||||
label.textContent = L("B09_Estimation_Col_Total");
|
||||
sum.append(label);
|
||||
for (const value of [detail.material, detail.labor, detail.expense, detail.total]) {
|
||||
const cell = document.createElement("td");
|
||||
cell.textContent = formatWon(value);
|
||||
sum.append(cell);
|
||||
}
|
||||
body.append(sum);
|
||||
|
||||
table.append(body);
|
||||
wrap.append(table);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 좌측 패널
|
||||
* -------------------------------------------------------------------------- */
|
||||
@@ -310,7 +501,7 @@ function renderRateVersion(box: HTMLElement, sheet: CostSheetDto | null): void {
|
||||
const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [
|
||||
["cost_sheet", "B09_Estimation_Tab_CostSheet", true],
|
||||
["boq", "B09_Estimation_Tab_Boq", false],
|
||||
["unit_price", "B09_Estimation_Tab_UnitPrice", false],
|
||||
["unit_price", "B09_Estimation_Tab_UnitPrice", true],
|
||||
["price_basis", "B09_Estimation_Tab_PriceBasis", false],
|
||||
["machine", "B09_Estimation_Tab_Machine", false],
|
||||
["duration", "B09_Estimation_Tab_Duration", false],
|
||||
@@ -371,6 +562,27 @@ async function fetchCostSheet(projectId: string, form: CostFormState): Promise<C
|
||||
return (await response.json()) as CostSheetDto;
|
||||
}
|
||||
|
||||
async function fetchUnitPriceList(projectId: string): Promise<UnitPriceListDto> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices`,
|
||||
{ credentials: "include" },
|
||||
);
|
||||
if (!response.ok) throw new Error(`unit price list failed: ${response.status}`);
|
||||
return (await response.json()) as UnitPriceListDto;
|
||||
}
|
||||
|
||||
async function fetchUnitPriceDetail(
|
||||
projectId: string,
|
||||
code: string,
|
||||
): Promise<UnitPriceDetailDto> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices/${encodeURIComponent(code)}`,
|
||||
{ credentials: "include" },
|
||||
);
|
||||
if (!response.ok) throw new Error(`unit price detail failed: ${response.status}`);
|
||||
return (await response.json()) as UnitPriceDetailDto;
|
||||
}
|
||||
|
||||
async function confirmEstimationStage(projectId: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/confirm`,
|
||||
@@ -389,6 +601,9 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
const form: CostFormState = { ...INITIAL_FORM };
|
||||
let activeTab = "cost_sheet";
|
||||
let sheet: CostSheetDto | null = null;
|
||||
let unitPriceList: UnitPriceListDto | null = null;
|
||||
let unitPriceDetail: UnitPriceDetailDto | null = null;
|
||||
let selectedUnitPrice: string | null = null;
|
||||
|
||||
const main = document.createElement("div");
|
||||
main.className = "b09-main";
|
||||
@@ -398,8 +613,58 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
body.style.display = "flex";
|
||||
body.style.flexDirection = "column";
|
||||
|
||||
/** 일위대가 본표를 불러 다시 그린다 — 기계 줄을 누르면 그 층으로 파고든다. */
|
||||
const openUnitPrice = async (code: string): Promise<void> => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
unitPriceDetail = await fetchUnitPriceDetail(projectId, code);
|
||||
selectedUnitPrice = code;
|
||||
drawBody();
|
||||
} catch {
|
||||
showToast(L("B09_Estimation_UP_Load_Failed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const drawUnitPriceTab = (): void => {
|
||||
if (!unitPriceList) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "b09-empty";
|
||||
empty.textContent = L("B09_Estimation_Tab_Pending");
|
||||
body.append(empty);
|
||||
return;
|
||||
}
|
||||
// 산출 요약을 **화면에도** 낸다 — 무엇이 안 선 상태인지 사용자가 알아야 한다.
|
||||
for (const note of unitPriceList.summary.notes) {
|
||||
const line = document.createElement("div");
|
||||
line.className = "b09-hint";
|
||||
line.textContent = note;
|
||||
body.append(line);
|
||||
}
|
||||
body.append(
|
||||
buildUnitPriceList(unitPriceList, selectedUnitPrice, (code) => {
|
||||
void openUnitPrice(code);
|
||||
}),
|
||||
);
|
||||
if (unitPriceDetail) {
|
||||
body.append(
|
||||
buildUnitPriceDetail(unitPriceDetail, (code) => {
|
||||
void openUnitPrice(code);
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const hint = document.createElement("div");
|
||||
hint.className = "b09-empty";
|
||||
hint.textContent = L("B09_Estimation_UP_Pick");
|
||||
body.append(hint);
|
||||
}
|
||||
};
|
||||
|
||||
const drawBody = (): void => {
|
||||
body.replaceChildren();
|
||||
if (activeTab === "unit_price") {
|
||||
drawUnitPriceTab();
|
||||
return;
|
||||
}
|
||||
if (activeTab !== "cost_sheet") {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "b09-empty";
|
||||
@@ -428,6 +693,14 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
activeTab = key;
|
||||
drawTabs();
|
||||
drawBody();
|
||||
if (key === "unit_price" && !unitPriceList && projectId) {
|
||||
void fetchUnitPriceList(projectId)
|
||||
.then((data) => {
|
||||
unitPriceList = data;
|
||||
drawBody();
|
||||
})
|
||||
.catch(() => showToast(L("B09_Estimation_UP_Load_Failed"), "error"));
|
||||
}
|
||||
});
|
||||
const old = main.querySelector(".b09-tabs");
|
||||
if (old) old.replaceWith(bar);
|
||||
|
||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from functools import lru_cache
|
||||
|
||||
from B09_Estimation.B09_Estimation_Guards import check_surcharge_once
|
||||
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
|
||||
@@ -177,8 +178,11 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
|
||||
공종 하나에 붙은 자원 줄들을 그 공종의 상세로 삼는다. 자원이 하나도 안 붙은
|
||||
공종은 **빈 줄로 세우지 않고 건너뛴다** — 0 원 일위대가가 내역에 서면 안 된다.
|
||||
"""
|
||||
master = load_work_item_master()
|
||||
if axis is None:
|
||||
axis = build_resource_axis(load_work_item_master(), load_combined_catalog())
|
||||
axis = build_resource_axis(master, load_combined_catalog())
|
||||
# 일위대가 이름은 **공종명**이어야 한다 — 코드만 보이면 사람이 못 읽는다.
|
||||
names = {w["work_item_code"]: w.get("name", "") for w in master.get("work_items", [])}
|
||||
|
||||
build = UnitPriceBuild()
|
||||
wages = load_operator_wages()
|
||||
@@ -197,7 +201,13 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
|
||||
continue
|
||||
unit = next((r.amount_unit for r in rows if r.amount_unit), "")
|
||||
build.book.add_title(
|
||||
PriceTitle(code=title_code, kind=PriceKind.UNIT_PRICE, name=work_item_code, unit=unit)
|
||||
PriceTitle(
|
||||
code=title_code,
|
||||
kind=PriceKind.UNIT_PRICE,
|
||||
name=names.get(work_item_code) or work_item_code,
|
||||
spec=work_item_code,
|
||||
unit=unit,
|
||||
)
|
||||
)
|
||||
added = 0
|
||||
for row in rows:
|
||||
@@ -230,3 +240,122 @@ def verify_surcharge_once(
|
||||
surcharge_rate_percent=surcharge_rate_percent,
|
||||
label=code,
|
||||
)
|
||||
|
||||
|
||||
#: 상세 줄이 **어느 층에서 왔는지** 보이는 표시 (PLAN 9-3, ESTX `LinkIndex` 와 같은 축).
|
||||
SOURCE_INDEX: dict[PriceKind, int] = {
|
||||
PriceKind.MATERIAL: 5,
|
||||
PriceKind.LABOR: 6,
|
||||
PriceKind.MACHINE_BASE: 105,
|
||||
PriceKind.MACHINE_HOURLY: 105,
|
||||
PriceKind.UNIT_PRICE: 103,
|
||||
PriceKind.PRICE_BASIS: 104,
|
||||
PriceKind.LUMPSUM: 0,
|
||||
}
|
||||
SOURCE_LABEL: dict[PriceKind, str] = {
|
||||
PriceKind.MATERIAL: "자재",
|
||||
PriceKind.LABOR: "노임",
|
||||
PriceKind.MACHINE_BASE: "기계경비",
|
||||
PriceKind.MACHINE_HOURLY: "기계경비",
|
||||
PriceKind.UNIT_PRICE: "일위대가",
|
||||
PriceKind.PRICE_BASIS: "단가산출",
|
||||
PriceKind.LUMPSUM: "일식·견적",
|
||||
}
|
||||
|
||||
#: 상세를 파고들 수 있는 층 — 이 종류의 줄을 누르면 그 본표가 열린다.
|
||||
DRILLABLE_KINDS = frozenset({PriceKind.MACHINE_HOURLY, PriceKind.UNIT_PRICE, PriceKind.PRICE_BASIS})
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def cached_build() -> UnitPriceBuild:
|
||||
"""조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다."""
|
||||
return build_unit_prices()
|
||||
|
||||
|
||||
def build_summary(build: UnitPriceBuild) -> dict:
|
||||
"""산출 요약 — **화면에도 낸다.** 사용자가 「무엇이 안 선 상태인가」를 알아야 한다."""
|
||||
kinds: dict[str, int] = {}
|
||||
for title in build.book.titles.values():
|
||||
kinds[title.kind.value] = kinds.get(title.kind.value, 0) + 1
|
||||
return {
|
||||
"titles": len(build.book.titles),
|
||||
"unit_prices": kinds.get(PriceKind.UNIT_PRICE.value, 0),
|
||||
"machine_hourly": kinds.get(PriceKind.MACHINE_HOURLY.value, 0),
|
||||
"skipped_work_items": len(build.skipped),
|
||||
"incomplete_machines": len(build.incomplete_machines),
|
||||
"kinds": kinds,
|
||||
# ⚠ 지금 상태를 화면에 그대로 알린다 (PLAN 9-6 미결).
|
||||
"notes": [
|
||||
"자재 카탈로그가 아직 없어 **구조물 계열 일위대가가 서지 않습니다** — "
|
||||
"지금 선 것은 노무·기계 성분뿐입니다(연료만 자재로 섭니다).",
|
||||
"사급 잡자재 단가는 미결입니다 — 값을 지어내지 않고 비워 둡니다.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def list_unit_prices(build: UnitPriceBuild) -> list[dict]:
|
||||
"""목록표 — 「무엇이 있나」 한 줄씩."""
|
||||
rows: list[dict] = []
|
||||
for code, title in sorted(build.book.titles.items()):
|
||||
if title.kind is not PriceKind.UNIT_PRICE:
|
||||
continue
|
||||
money = build.book.resolve(code)
|
||||
rows.append(
|
||||
{
|
||||
"code": code,
|
||||
"name": title.name,
|
||||
"spec": title.spec,
|
||||
"unit": title.unit,
|
||||
"material": str(money.material),
|
||||
"labor": str(money.labor),
|
||||
"expense": str(money.expense),
|
||||
"total": str(money.total),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def detail_of(build: UnitPriceBuild, code: str) -> dict:
|
||||
"""본표 — 「그것이 무엇으로 이루어졌나」. 줄마다 원천과 파고들기 여부를 함께 낸다."""
|
||||
title = build.book.title(code)
|
||||
money = build.book.resolve(code)
|
||||
rows: list[dict] = []
|
||||
for detail in build.book.details.get(code, []):
|
||||
child = build.book.title(detail.ref_code)
|
||||
unit_money = build.book.resolve(detail.ref_code)
|
||||
line = unit_money.scaled(detail.quantity)
|
||||
rows.append(
|
||||
{
|
||||
"ref_code": detail.ref_code,
|
||||
"name": child.name,
|
||||
"spec": child.spec,
|
||||
"unit": child.unit,
|
||||
"source_index": SOURCE_INDEX.get(child.kind, 0),
|
||||
"source_label": SOURCE_LABEL.get(child.kind, ""),
|
||||
"drillable": child.kind in DRILLABLE_KINDS,
|
||||
"quantity": str(detail.quantity),
|
||||
"unit_material": str(unit_money.material),
|
||||
"unit_labor": str(unit_money.labor),
|
||||
"unit_expense": str(unit_money.expense),
|
||||
"unit_total": str(unit_money.total),
|
||||
"material": str(line.material),
|
||||
"labor": str(line.labor),
|
||||
"expense": str(line.expense),
|
||||
"total": str(line.total),
|
||||
"note": detail.note,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"code": code,
|
||||
"name": title.name,
|
||||
"spec": title.spec,
|
||||
"unit": title.unit,
|
||||
"kind": title.kind.value,
|
||||
"material": str(money.material),
|
||||
"labor": str(money.labor),
|
||||
"expense": str(money.expense),
|
||||
"total": str(money.total),
|
||||
# TC = NC + GC + JC 가 성립하는지 화면이 스스로 보이게 한다.
|
||||
"sum_matches": money.total == money.material + money.labor + money.expense,
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
@@ -669,6 +669,22 @@ export const ui_locales_b2 = {
|
||||
"Failed to confirm the cost estimate stage.",
|
||||
],
|
||||
B09_Estimation_Tab_Pending: ["준비 중", "Coming soon"],
|
||||
B09_Estimation_UP_List: ["일위대가 목록표", "Unit Price Index"],
|
||||
B09_Estimation_UP_Detail: ["일위대가표", "Unit Price Sheet"],
|
||||
B09_Estimation_UP_Pick: ["목록에서 항목을 고르세요.", "Pick an item from the index."],
|
||||
B09_Estimation_UP_Drill: ["펼쳐 보기", "Open"],
|
||||
B09_Estimation_Col_Name: ["명칭", "Name"],
|
||||
B09_Estimation_Col_Spec: ["규격", "Spec"],
|
||||
B09_Estimation_Col_Unit: ["단위", "Unit"],
|
||||
B09_Estimation_Col_Qty: ["수량", "Qty"],
|
||||
B09_Estimation_Col_Source: ["원천", "Source"],
|
||||
B09_Estimation_Col_Material: ["재료비", "Material"],
|
||||
B09_Estimation_Col_Labor: ["노무비", "Labor"],
|
||||
B09_Estimation_Col_Expense: ["경비", "Expense"],
|
||||
B09_Estimation_Col_Total: ["합계", "Total"],
|
||||
B09_Estimation_UP_SumOk: ["합계 = 재료+노무+경비 일치", "Total = M+L+E ✓"],
|
||||
B09_Estimation_UP_SumBad: ["⚠ 합계가 재료+노무+경비와 다릅니다", "⚠ Total ≠ M+L+E"],
|
||||
B09_Estimation_UP_Load_Failed: ["일위대가를 못 불러왔습니다.", "Failed to load unit prices."],
|
||||
|
||||
/* --- B10_Payment 결재 --- */
|
||||
B10_Payment_Title: ["결재", "Payment"],
|
||||
|
||||
Reference in New Issue
Block a user