diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index e18a4905..74ce9347 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -29,6 +29,7 @@ from B09_Estimation.B09_Estimation_MachineProductivity_Dump import ( dump_title_code, loading_title_code, ) +from B09_Estimation.B09_Estimation_MaterialPrices import manual_count from B09_Estimation.B09_Estimation_PriceBook import Money3 from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at from B09_Estimation.B09_Estimation_UnitPrice import ( @@ -148,6 +149,7 @@ def _composite_row( row.labor_krw = line.labor row.expense_krw = line.expense row.add_note("quantity", f"묶음 {len(item.composite_parts)}조각 합계") + _mark_manual_materials(row, [code for code, _ in row.parts], unit_prices, result) return row @@ -551,9 +553,23 @@ def _leaf_row( row.material_krw = line.material row.labor_krw = line.labor row.expense_krw = line.expense + _mark_manual_materials(row, [price_code], unit_prices, result) return row +def _mark_manual_materials( + row: BillRow, codes: list[str], unit_prices: UnitPriceBuild, result: BillResult +) -> None: + """자재 수동 단가가 닿은 줄 — 금액은 세우되 「미확정 N건」(구조물도 수동 단가와 같은 통로).""" + manual = getattr(unit_prices, "manual_materials", {}) + count = sum(manual_count(unit_prices.book, code, manual) for code in codes) + if not count: + return + row.unconfirmed += count + row.add_note("unit_price_krw", f"⚠ 자재 수동 단가 {count}건 미확정") + result.unconfirmed.append({"name": row.name, "code": codes[0], "count": count}) + + #: 수량 산식이 **사용자 확정을 기다리는** 공종 — 금액은 세우되 그 사실을 함께 싣는다. #: #: ⚠ **2026-09-09 저녁 비었다.** 걸려 있던 셋(구조물터파기·되메우기·잔토처리)이 diff --git a/B09_Estimation/B09_Estimation_MaterialPrices.py b/B09_Estimation/B09_Estimation_MaterialPrices.py new file mode 100644 index 00000000..8cf7fcca --- /dev/null +++ b/B09_Estimation/B09_Estimation_MaterialPrices.py @@ -0,0 +1,217 @@ +"""B09 원가계산 — **자재 수동 단가** 한 벌 (PLAN 1장 Ⓐ · 2026-09-14 브레인 판정). + + 저장 자리 산출 조건 `estimation.material_prices` + {키: {price_krw, source, entered_at, name, spec, unit}} + 키 자원 코드가 있으면 **코드**(`AR-M-…`) · 코드 없는 자재만 「이름 규격」 + (명세 2장 「잇기는 코드로」 · 11장 「규격은 조인 키」) + ⚠ 이름·규격·단위는 **보이기용** — 맞추는 데 안 씀. 이름이 고쳐져도 단가가 안 끊김 + 얹는 자리 조립 때 자재 제목(M)을 세움 — 6번 슬롯 = 수동 단가 · 쪽수 칸 = 출처 + → 자원 축 자재 줄이 그 제목에 붙어 일위대가 재료비가 섬 + 넣은 날 값·출처가 같으면 다시 저장해도 안 바뀜(구조물도 수동 단가 `save_manual` 과 같은 꼴) + 미확정 수동 단가가 닿은 내역 줄마다 「미확정 N건」(구조물도 수동 단가·폐기물과 같은 통로) + +⚠ 값이 없거나 0 이하인 칸은 받지 않음 — 0 원으로 채우면 「단가 없음」이 금액 0 으로 숨음. +⚠ 코드 없는 「이름 규격」 줄은 자재총괄 몫(다음 단계) — 조립에는 코드 줄만 얹음. + 이름 줄에 나중에 코드가 붙으면 코드 키로 옮겨 붙일 자리 — 아직 안 만듦(브레인 판정). +""" + +from __future__ import annotations + +import json +import os +import re +from decimal import Decimal, InvalidOperation +from functools import lru_cache +from typing import Any + +from B09_Estimation.B09_Estimation_PriceBook import ( + PRICE_SLOT_COUNT, + PriceBook, + PriceKind, + PriceTitle, +) + +MATERIAL_PRICES_KEY = "material_prices" +#: 조립에 얹는 키 — 자원 목록 자재 코드(명세 §2 ③ 모양). +_RE_MATERIAL_CODE = re.compile(r"^AR-M-[0-9a-f]{8}$") +#: 출처를 안 적었을 때 쪽수 칸 글. +MANUAL_SOURCE = "수동 입력" +_TEXT_FIELDS = ("source", "name", "spec", "unit") + + +class MaterialPriceError(ValueError): + """받을 수 없는 단가 — 저장하지 않고 까닭을 돌려줌.""" + + +def _price(value: Any) -> Decimal | None: + try: + price = Decimal(str(value).replace(",", "").strip()) + except (InvalidOperation, ValueError): + return None + return price if price.is_finite() and price > 0 else None + + +def is_code_key(key: str) -> bool: + """조립에 얹는 코드 키인가 — 아니면 「이름 규격」 키.""" + return bool(_RE_MATERIAL_CODE.match(key)) + + +def normalize(raw: Any) -> dict[str, dict[str, str]]: + """저장본을 받을 수 있는 줄만 — 값이 없거나 0 이하면 버림.""" + prices: dict[str, dict[str, str]] = {} + if not isinstance(raw, dict): + return prices + for key, entry in raw.items(): + price = _price(entry.get("price_krw")) if isinstance(entry, dict) else None + if not str(key).strip() or price is None: + continue + prices[str(key)] = { + "price_krw": str(price), + "entered_at": str(entry.get("entered_at") or ""), + **{field: str(entry.get(field) or "") for field in _TEXT_FIELDS}, + } + return prices + + +def build_key(raw: Any) -> tuple[tuple[str, str, str], ...]: + """조립 캐시 키 — 코드 키만 (키, 단가, 출처). 같은 값이면 같은 벌.""" + return tuple( + sorted( + (key, entry["price_krw"], entry["source"]) + for key, entry in normalize(raw).items() + if is_code_key(key) + ) + ) + + +def merge(stored: Any, changes: list[dict[str, Any]], today: str) -> dict[str, dict[str, str]]: + """바꿀 것 여럿을 한 번에 — `price_krw` 가 비면 그 줄을 지움(단가 없음으로 돌아감).""" + prices = normalize(stored) + for change in changes: + key = str(change.get("key") or "").strip() + if not key: + raise MaterialPriceError("자재 키가 비었습니다") + raw_price = change.get("price_krw") + if raw_price is None or str(raw_price).strip() == "": + prices.pop(key, None) + continue + price = _price(raw_price) + if price is None: + raise MaterialPriceError(f"단가는 0 보다 큰 수여야 합니다: {raw_price}") + old = prices.get(key) or {} + source = str(change.get("source") or "").strip() + same = old.get("price_krw") == str(price) and old.get("source") == source + prices[key] = { + "price_krw": str(price), + "entered_at": old["entered_at"] if same and old.get("entered_at") else today, + "source": source, + **{ + field: str(change.get(field) or old.get(field) or "") + for field in ("name", "spec", "unit") + }, + } + return prices + + +@lru_cache(maxsize=1) +def material_catalog_rows() -> dict[str, dict[str, str]]: + """자원 목록(`AR-M-`) 코드 → 이름·규격·단위 — 제목을 세우고 화면에 보일 글.""" + from B09_Estimation.B09_Estimation_ResourceAxis_Join import EXT_CATALOG_FILE + + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + path = os.path.join(root, "resources", "data_resource_catalog", EXT_CATALOG_FILE) + if not os.path.isfile(path): + return {} + with open(path, encoding="utf-8") as handle: + entries = json.load(handle).get("entries") or [] + return { + str(row["code"]): { + "name": str(row.get("name") or ""), + "spec": str(row.get("spec") or ""), + "unit": str(row.get("unit") or ""), + } + for row in entries + if is_code_key(str(row.get("code") or "")) + } + + +def add_material_titles( + book: PriceBook, prices: tuple[tuple[str, str, str], ...] +) -> dict[str, str]: + """코드 키 수동 단가로 자재 제목(M)을 세움 — 돌려주는 값 = 코드 → 출처(미확정 셈에 씀). + + 자원 목록에 없는 코드는 안 세움 — 화면 목록이 「단가표에서 사라진 코드」로 드러냄(`listing`). + """ + catalog = material_catalog_rows() + manual: dict[str, str] = {} + for code, price, source in prices: + row = catalog.get(code) + if row is None or code in book.titles: + continue + slots: list[Decimal | None] = [None] * PRICE_SLOT_COUNT + pages: list[str | None] = [None] * PRICE_SLOT_COUNT + slots[-1] = Decimal(price) + pages[-1] = source or MANUAL_SOURCE + book.add_title( + PriceTitle( + code=code, + kind=PriceKind.MATERIAL, + name=row["name"], + spec=row["spec"], + unit=row["unit"], + slots=slots, + slot_pages=pages, + ) + ) + manual[code] = source or MANUAL_SOURCE + return manual + + +def manual_count(book: PriceBook, code: str | None, manual: dict[str, str]) -> int: + """그 단가가 밟는 **수동 단가 자재 수**(같은 자재는 한 번) — 내역 줄 「미확정 N건」.""" + if not code or not manual: + return 0 + found: set[str] = set() + seen: set[str] = set() + stack = [code] + while stack: + current = stack.pop() + if current in seen: + continue + seen.add(current) + if current in manual: + found.add(current) + stack.extend(detail.ref_code for detail in book.details.get(current, [])) + return len(found) + + +def listing(build: Any, stored: Any) -> list[dict[str, Any]]: + """화면 줄 — 자원 축이 쓰는 자재(코드) + 저장만 있고 자원 축에서 사라진 줄. + + ⚠ 사라진 줄도 조용히 안 버림 — 「단가표에서 사라진 코드」로 보이고 사용자가 지움. + """ + prices = normalize(stored) + catalog = material_catalog_rows() + uses: dict[str, list[str]] = getattr(build, "material_uses", {}) or {} + rows: list[dict[str, Any]] = [] + for code in sorted(uses, key=lambda c: (catalog.get(c, {}).get("name", ""), c)): + info = catalog.get(code, {}) + entry = prices.get(code) or {} + rows.append( + { + "key": code, + "name": info.get("name") or entry.get("name", ""), + "spec": info.get("spec") or entry.get("spec", ""), + "unit": info.get("unit") or entry.get("unit", ""), + "work_items": sorted(uses[code]), + "price_krw": entry.get("price_krw"), + "source": entry.get("source", ""), + "entered_at": entry.get("entered_at", ""), + "missing": False, + } + ) + for key, entry in sorted(prices.items()): + if key in uses or not is_code_key(key): + continue + rows.append({"key": key, **entry, "work_items": [], "missing": True}) + return rows diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 869e314e..041a9477 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -275,6 +275,8 @@ async def _build_for(project_id: UUID, dump_haul_m: tuple[str, ...] = ()): # 유가 지역 — 안 고르면 전국평균(품셈 8-1-7 5호 「해당지역의 가격」). # 기계 수송비 — 거리·도로 구분이 있어야 선다(산림품셈 10-4 [주]). from B09_Estimation.B09_Estimation_Edits import edited_build, edits_key + from B09_Estimation.B09_Estimation_MaterialPrices import MATERIAL_PRICES_KEY + from B09_Estimation.B09_Estimation_MaterialPrices import build_key as material_prices_key args = ( ranges, @@ -288,6 +290,8 @@ async def _build_for(project_id: UUID, dump_haul_m: tuple[str, ...] = ()): # 조종원 시간당 노임 자르는 자리 — 실무마다 다름(명세 7장 정정). 안 정하면 원 미만. str(settings.get("operator_wage_digits") or ""), tuple(sorted(set(dump_haul_m), key=Decimal)), + # 자재 수동 단가(PLAN 1장 Ⓐ) — 코드 키만 조립에 얹음. 없으면 종전 벌 그대로. + material_prices_key(settings.get(MATERIAL_PRICES_KEY)), ) # 사용자가 고친 값(PLAN 12장 2차) — 없으면 기본 조립 그 벌 그대로. return edited_build(args, edits_key(settings.get("edits"))) diff --git a/B09_Estimation/B09_Estimation_Router_MaterialPrices.py b/B09_Estimation/B09_Estimation_Router_MaterialPrices.py new file mode 100644 index 00000000..234c9491 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Router_MaterialPrices.py @@ -0,0 +1,102 @@ +"""B09 원가계산 라우터 — **자재 수동 단가** 읽기·저장 (PLAN 1장 Ⓐ · `…_MaterialPrices`). + +⚠ 저장은 단가·출처만 — 금액은 다음 조회 때 서버가 그 값으로 **다시 조립**함 + (브라우저 값을 받아 적지 않음). +""" + +from __future__ import annotations + +import logging +from datetime import date +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from B09_Estimation.B09_Estimation_MaterialPrices import ( + MATERIAL_PRICES_KEY, + MaterialPriceError, + listing, + merge, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B09 Estimation Material Prices"]) + + +class MaterialPriceChange(BaseModel): + """단가 칸 하나 — `price_krw` 가 비면 그 줄을 지움(단가 없음으로 돌아감).""" + + key: str + price_krw: Any = None + source: str = "" + name: str = "" + spec: str = "" + unit: str = "" + + +class MaterialPriceRequest(BaseModel): + changes: list[MaterialPriceChange] = Field(default_factory=list) + + +async def _rows(project_id: UUID) -> dict[str, Any]: + from B09_Estimation.B09_Estimation_Router import _build_for, _project_root_of + from common_util.common_util_project_settings import estimation_settings + + root = await _project_root_of(project_id) + settings = estimation_settings(root) if root else {} + build = await _build_for(project_id) + rows = listing(build, settings.get(MATERIAL_PRICES_KEY)) + return { + "status": "success", + "rows": rows, + "unconfirmed_count": sum(1 for row in rows if row.get("price_krw")), + } + + +@router.get("/{project_id}/estimation/material-prices") +async def get_material_prices(project_id: UUID) -> JSONResponse: + """단가 칸을 낼 자재 줄 — 자원 축 자재(코드) + 자원 축에서 사라진 저장 줄.""" + try: + return JSONResponse(content=await _rows(project_id)) + except Exception: + logger.exception("B09 자재 수동 단가 조회 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "자재 단가 목록을 못 만들었습니다."}, + ) + + +@router.put("/{project_id}/estimation/material-prices") +async def put_material_prices(project_id: UUID, payload: MaterialPriceRequest) -> JSONResponse: + """단가 저장 — 0 이하·수가 아닌 값은 받지 않음 · 값·출처가 같으면 넣은 날 그대로.""" + from B09_Estimation.B09_Estimation_Router import _project_root_of + from common_util.common_util_project_settings import estimation_settings, save_section + + root = await _project_root_of(project_id) + if root is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + try: + prices = merge( + estimation_settings(root).get(MATERIAL_PRICES_KEY), + [change.model_dump() for change in payload.changes], + date.today().isoformat(), + ) + except MaterialPriceError as error: + return JSONResponse(status_code=422, content={"status": "error", "message": str(error)}) + try: + save_section( + root, "estimation", {MATERIAL_PRICES_KEY: prices}, replace_keys=(MATERIAL_PRICES_KEY,) + ) + return JSONResponse(content=await _rows(project_id)) + except Exception: + logger.exception("B09 자재 수동 단가 저장 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "자재 단가를 저장하지 못했습니다."}, + ) diff --git a/B09_Estimation/B09_Estimation_UI_Tab_MaterialPrices.ts b/B09_Estimation/B09_Estimation_UI_Tab_MaterialPrices.ts new file mode 100644 index 00000000..4c6e66e5 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_MaterialPrices.ts @@ -0,0 +1,179 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_MaterialPrices.ts + * 자재 단가 탭 — 자재 수동 단가 칸 (PLAN 1장 Ⓐ · 랩탑 메인 · 2026-09-14 브레인 판정) + * + * - 줄 = 일위대가 자원 축이 쓰는 자재(코드 `AR-M-…`) · 코드 · 명칭 · 규격 · 단위 · 쓰인 공종 · + * 단가 · 출처 · 넣은 날. 넣은 줄 = 빨간 테두리(수동 단가 = 미확정 · 구조물도·폐기물과 같은 꼴). + * - [저장] = 바뀐 줄만 서버로. 단가 칸을 비우면 그 줄을 지움(단가 없음으로 돌아감) · 0 이하는 서버가 거절. + * - ⚠ 금액은 서버가 다시 조립 — 여기서 곱하지 않음. 저장 뒤 내역서·일위대가 탭은 새로 받음. + * - 자원 축에서 사라진 코드의 저장 줄도 조용히 안 버림 — 경고와 함께 보이고 지울 수 있음. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { showToast } from "@ui/ui_template_elements"; +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; +import { L, el, hint, injectSheetStyles, plainTable, won } from "./B09_Estimation_UI_Sheet"; + +interface MaterialPriceRow { + key: string; + name: string; + spec: string; + unit: string; + work_items: string[]; + price_krw: string | null; + source: string; + entered_at: string; + missing: boolean; +} + +interface MaterialPricesDto { + status: string; + message?: string; + rows: MaterialPriceRow[]; + unconfirmed_count: number; +} + +interface Change { + key: string; + price_krw: string; + source: string; + name: string; + spec: string; + unit: string; +} + +async function request(projectId: string, init: RequestInit = {}): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/material-prices`, + { credentials: "include", ...init }, + ); + const body = (await response.json()) as MaterialPricesDto; + if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); + return body; +} + +function input(value: string, width: string, placeholder = ""): HTMLInputElement { + const node = el("input", "b09s-edit-input"); + node.value = value; + node.placeholder = placeholder; + node.style.width = width; + return node; +} + +function draw(ctx: B09TabContext, projectId: string, data: MaterialPricesDto): void { + const drafts: Array<{ + row: MaterialPriceRow; + price: HTMLInputElement; + source: HTMLInputElement; + }> = []; + const save = el("button", "b09s-undo", "저장"); + save.type = "button"; + const bar = el("div", "b09s-bar"); + bar.append(el("span", "b09s-title", L("B09_Estimation_Tab_MaterialPrices")), save); + if (data.unconfirmed_count > 0) { + bar.append(el("span", "b09s-badge", `수동 단가 ${data.unconfirmed_count}건 — 미확정`)); + } + + const { wrap, tbody } = plainTable([ + "코드", + "명칭", + "규격", + "단위", + "쓰인 공종", + "단가(원)", + "출처", + "넣은 날", + ]); + for (const row of data.rows) { + const tr = el("tr", row.price_krw ? "is-manual" : ""); + const price = input(row.price_krw ?? "", "96px", "비움 = 단가 없음"); + price.inputMode = "decimal"; + price.title = row.price_krw ? won(row.price_krw) : ""; + const source = input(row.source, "160px", "견적 업체·물가지 쪽"); + const priceCell = el("td", "b09s-num"); + priceCell.append(price); + const sourceCell = el("td"); + sourceCell.append(source); + tr.append( + el("td", "", row.key), + el("td", "", row.name), + el("td", "", row.spec), + el("td", "", row.unit), + el( + "td", + "b09s-note", + row.missing + ? "⚠ 단가표에서 사라진 코드 — 단가를 비우고 저장해 지움" + : row.work_items.join(", "), + ), + priceCell, + sourceCell, + el("td", "", row.entered_at), + ); + tbody.append(tr); + drafts.push({ row, price, source }); + } + + save.addEventListener("click", () => { + const changes: Change[] = drafts + .filter( + ({ row, price, source }) => + price.value.trim() !== (row.price_krw ?? "") || source.value.trim() !== row.source, + ) + .map(({ row, price, source }) => ({ + key: row.key, + price_krw: price.value.trim(), + source: source.value.trim(), + name: row.name, + spec: row.spec, + unit: row.unit, + })); + if (changes.length === 0) return; + save.disabled = true; + request(projectId, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ changes }), + }) + .then((next) => { + showToast(L("B09_Sheet_Saved"), "success"); + ctx.body.replaceChildren(); + draw(ctx, projectId, next); + }) + .catch((error: Error) => { + save.disabled = false; + showToast(`${L("B09_Sheet_SaveFailed")} ${error.message}`, "error"); + }); + }); + + ctx.body.append(bar, wrap); + if (data.rows.length === 0) ctx.body.append(hint("일위대가가 쓰는 자재 코드가 없음")); + ctx.body.append( + hint( + "수동 단가는 6번 슬롯(적용 단가)으로 서고, 닿은 내역 줄마다 「미확정」으로 셈 — 자재단가대비표에도 보임", + ), + hint("값·출처가 같으면 다시 저장해도 넣은 날은 그대로"), + ); +} + +export const materialPricesTab: B09Tab = { + key: "material_prices", + label: () => L("B09_Estimation_Tab_MaterialPrices"), + render(ctx) { + injectSheetStyles(); + if (!ctx.projectId) { + ctx.body.append(hint(L("B09_Sheet_NoProject"))); + return; + } + const projectId = ctx.projectId; + ctx.body.append(hint(L("B09_Sheet_Loading"))); + request(projectId) + .then((data) => { + ctx.body.replaceChildren(); + draw(ctx, projectId, data); + }) + .catch((error: Error) => { + ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true)); + }); + }, +}; diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 5544a6ec..01ec0a06 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -145,6 +145,10 @@ class UnitPriceBuild: #: 표에서 온 갈래는 마스터 갈래 키에 있어야 하고(짝 시험), 식이 낸 갈래는 여기 적혀 있어야 함 #: (덤프 운반·적재 10-12 `#L…m`·`#적재` — 본문 식이라 마스터 표에 갈래가 없음). formula_variants: dict[str, str] = field(default_factory=dict) + #: 수동 단가로 선 자재 제목 → 출처 — 내역 줄 「미확정 N건」(PLAN 1장 Ⓐ). + manual_materials: dict[str, str] = field(default_factory=dict) + #: 자원 축 자재 코드 → 쓰는 공종들 — 「자재 단가」 탭이 단가 칸을 낼 줄. + material_uses: dict[str, list[str]] = field(default_factory=dict) def _add_labor_titles(book: PriceBook, wages: dict[str, Decimal]) -> None: @@ -622,6 +626,7 @@ def build_unit_prices( labor_surcharge_choices: dict[str, str] | None = None, operator_wage_digits: int = 0, dump_haul_m: tuple[Decimal, ...] = (), + material_prices: tuple[tuple[str, str, str], ...] = (), ) -> UnitPriceBuild: """자원 축을 일위대가(`B`)로 조립한다. @@ -788,6 +793,15 @@ def build_unit_prices( build.incomplete_machines = _add_machine_layers( build.book, machine_codes, fuel_region, operator_wage_digits ) + # 자재 수동 단가(PLAN 1장 Ⓐ) — 코드 키만 제목으로 섬. 안 넣으면 자재 줄은 종전처럼 안 붙음. + from B09_Estimation.B09_Estimation_MaterialPrices import add_material_titles + + build.manual_materials = add_material_titles(build.book, material_prices) + for row in axis.rows: + if row.resource_kind == "material": + uses = build.material_uses.setdefault(row.resource_code, []) + if row.work_item_code not in uses: + uses.append(row.work_item_code) # 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은 # 품이 달라 한 일위대가로 뭉치면 어느 것도 안 맞는다. @@ -834,10 +848,7 @@ def build_unit_prices( # ⚠ **붙을 상세를 먼저 모으고, 하나도 없으면 제목도 안 세운다.** # 제목만 세워 두면 「상세 줄이 없어 단가를 못 조립」하는 빈 일위대가가 남는다 # (기계 층이 안 선 기종만 참조하는 공종에서 실제로 생겼음). - attachable = [ - (row, row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}") - for row in rows - ] + attachable = [(row, _ref_of(row)) for row in rows] attachable = [(row, ref) for row, ref in attachable if ref in build.book.titles] # ⚠ **맞췄으나 단가 층이 없는 줄**(카탈로그 밖 `AR-` 자원 · 층을 못 세운 기계)은 조용히 # 빼지 않는다 — 못 맞춘 줄처럼 드러내고, 기계 몫이면 단가를 막는다(2026-09-13 축 C). @@ -1093,6 +1104,11 @@ def _has_full_formula( ) +def _ref_of(row) -> str: + """자원 줄이 붙을 단가표 제목 — 노임·자재는 코드 그대로, 기계는 시간당 층(`X-`).""" + return f"X-{row.resource_code}" if row.resource_kind == "machine" else row.resource_code + + def _share_of(row) -> Decimal: """그 줄이 차지하는 몫(0~1). 배분율이 없으면 1 — 종전과 같다.""" ratio = getattr(row, "group_ratio_pct", None) @@ -1114,8 +1130,7 @@ def _covered_ratio_pct( ratio = getattr(row, "group_ratio_pct", None) if ratio is None or ratio in seen: continue - ref = row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}" - if ref in attached_refs: + if _ref_of(row) in attached_refs: seen.add(ratio) covered += ratio return covered @@ -1177,6 +1192,7 @@ def cached_build( labor_surcharge: tuple[tuple[str, str], ...] = (), operator_wage_digits: str = "", dump_haul_m: tuple[str, ...] = (), + material_prices: tuple[tuple[str, str, str], ...] = (), ) -> UnitPriceBuild: """조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다. @@ -1205,6 +1221,7 @@ def cached_build( labor_surcharge_choices=dict(labor_surcharge), operator_wage_digits=parse_operator_wage_digits(operator_wage_digits), dump_haul_m=tuple(Decimal(value) for value in dump_haul_m), + material_prices=material_prices, ) diff --git a/main.py b/main.py index a682f00f..ad281e75 100644 --- a/main.py +++ b/main.py @@ -71,6 +71,9 @@ from B09_Estimation.B09_Estimation_Router_Progress import router as b09_progress from B09_Estimation.B09_Estimation_Router_CostSheet import router as b09_cost_sheet_router from B09_Estimation.B09_Estimation_Router_Edits import router as b09_edits_router from B09_Estimation.B09_Estimation_Router_Factors import router as b09_factors_router +from B09_Estimation.B09_Estimation_Router_MaterialPrices import ( + router as b09_material_prices_router, +) from common_util.common_util_audit import note_api_call, record_call_burst from common_util.common_util_auth import ( require_company, @@ -648,6 +651,7 @@ app.include_router(b09_execution_router, dependencies=protected_with_company) app.include_router(b09_progress_router, dependencies=protected_with_company) app.include_router(b09_edits_router, dependencies=protected_with_company) app.include_router(b09_factors_router, dependencies=protected_with_company) +app.include_router(b09_material_prices_router, dependencies=protected_with_company) # 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). # 그 위에 서버가 환경까지 한 번 더 본다. app.include_router(dev_unlock_router, dependencies=protected_with_company) diff --git a/resources/tester/test_b09_material_prices.py b/resources/tester/test_b09_material_prices.py new file mode 100644 index 00000000..2524a8d2 --- /dev/null +++ b/resources/tester/test_b09_material_prices.py @@ -0,0 +1,92 @@ +"""자재 수동 단가(PLAN 1장 Ⓐ · 2026-09-14 브레인 판정). + +지키는 것 + ① 키 = 코드(`AR-M-…`) · 코드 없는 자재만 「이름 규격」 — 조립엔 코드 키만 얹음 + ② 0 이하·수 아님은 안 받음 · 비우면 지움 · 값·출처가 같으면 넣은 날 그대로 + ③ 넣으면 자재 제목(6번 슬롯)이 서고 자원 축 자재 줄이 붙어 재료비가 섬 — 안 넣으면 종전 벌 그대로 + ④ 수동 단가가 닿은 내역 줄 = 금액은 서되 「미확정 N건」 +""" + +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_MaterialPrices import ( # noqa: E402 + MaterialPriceError, + build_key, + listing, + merge, +) +from B09_Estimation.B09_Estimation_UnitPrice import cached_build # noqa: E402 + +STAKE = "AR-M-dce99e46" # 말뚝 직경4~6㎝ — FP-05-15 에 2개/단위 + + +def test_키와_넣은_날() -> None: + first = merge({}, [{"key": STAKE, "price_krw": "1,500", "source": "견적 A"}], "2026-09-14") + assert first[STAKE]["price_krw"] == "1500" and first[STAKE]["entered_at"] == "2026-09-14" + same = merge(first, [{"key": STAKE, "price_krw": "1500", "source": "견적 A"}], "2026-09-20") + assert same[STAKE]["entered_at"] == "2026-09-14" # 다시 저장해도 안 바뀜 + moved = merge(first, [{"key": STAKE, "price_krw": "1600", "source": "견적 A"}], "2026-09-20") + assert moved[STAKE]["entered_at"] == "2026-09-20" + assert merge(first, [{"key": STAKE, "price_krw": ""}], "2026-09-20") == {} + for bad in ("0", "-3", "abc"): + with pytest.raises(MaterialPriceError): + merge({}, [{"key": STAKE, "price_krw": bad}], "2026-09-14") + named = merge(first, [{"key": "각재 50×50", "price_krw": "900000"}], "2026-09-14") + assert build_key(named) == ((STAKE, "1500", "견적 A"),) # 이름 키는 조립에 안 얹음 + + +def test_넣으면_재료비가_서고_안_넣으면_종전_그대로() -> None: + base = cached_build() + assert base.book.resolve("B-FP-05-15").material == 0 + assert STAKE not in base.book.titles and not base.manual_materials + built = cached_build(material_prices=((STAKE, "1500", "견적 A"),)) + title = built.book.titles[STAKE] + assert (title.slots[5], title.slot_pages[5], title.unit) == (Decimal("1500"), "견적 A", "개") + assert built.book.resolve("B-FP-05-15").material == Decimal("3000") # 2개 × 1,500 + assert built.book.resolve("B-FP-05-15").labor == base.book.resolve("B-FP-05-15").labor + assert "FP-05-15" in built.material_uses[STAKE] + + +def test_내역_줄은_미확정으로_선다() -> None: + from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill + + built = cached_build(material_prices=((STAKE, "1500", "견적 A"),)) + payload = { + "work_items": [ + { + "work_item_code": "FP-05-15", + "name": "말뚝박기", + "unit": built.book.titles["B-FP-05-15"].unit, + "quantity": 10, + "in_bill": True, + } + ], + "materials": [], + } + row = next(r for r in build_bill(payload, build=built).rows if r.name == "말뚝박기") + assert row.material_krw == Decimal("30000") and row.unconfirmed == 1 + result = build_bill(payload, build=built) + assert result.unconfirmed == [{"name": "말뚝박기", "code": "B-FP-05-15", "count": 1}] + plain = build_bill(payload, build=cached_build()) + assert not plain.unconfirmed + + +def test_목록은_자원_축_자재와_사라진_저장_줄() -> None: + built = cached_build() + stored = { + STAKE: {"price_krw": "1500"}, + "AR-M-00000000": {"price_krw": "7", "name": "옛 자재"}, + } + rows = {row["key"]: row for row in listing(built, stored)} + assert rows[STAKE]["name"] == "말뚝" and rows[STAKE]["price_krw"] == "1500" + assert rows["AR-M-00000000"]["missing"] is True # 조용히 안 버림 + assert rows["AR-M-b0853497"]["price_krw"] is None diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index fa7bafaa..2a8405ae 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -12,6 +12,7 @@ export const ui_locales_b4 = { B09_Estimation_Tab_Execution: ["실행예산", "Execution Budget"], B09_Estimation_Tab_Progress: ["기성", "Progress Payment"], B09_Estimation_Tab_Completion: ["준공", "Completion"], + B09_Estimation_Tab_MaterialPrices: ["자재 단가", "Material Prices"], B08_Quantity_Side_BenchCut_Fill: [ "제안값 넣기 — KDS 표준치수 토사 0.5 m", "Insert suggested value — KDS standard soil 0.5 m",