auto: 2026-09-14 10:03 (ESD_LAPTOP)

This commit is contained in:
2026-09-14 10:03:21 +09:00
parent 996300752d
commit 273132f8f6
9 changed files with 638 additions and 6 deletions
@@ -29,6 +29,7 @@ from B09_Estimation.B09_Estimation_MachineProductivity_Dump import (
dump_title_code, dump_title_code,
loading_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_PriceBook import Money3
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
from B09_Estimation.B09_Estimation_UnitPrice import ( from B09_Estimation.B09_Estimation_UnitPrice import (
@@ -148,6 +149,7 @@ def _composite_row(
row.labor_krw = line.labor row.labor_krw = line.labor
row.expense_krw = line.expense row.expense_krw = line.expense
row.add_note("quantity", f"묶음 {len(item.composite_parts)}조각 합계") row.add_note("quantity", f"묶음 {len(item.composite_parts)}조각 합계")
_mark_manual_materials(row, [code for code, _ in row.parts], unit_prices, result)
return row return row
@@ -551,9 +553,23 @@ def _leaf_row(
row.material_krw = line.material row.material_krw = line.material
row.labor_krw = line.labor row.labor_krw = line.labor
row.expense_krw = line.expense row.expense_krw = line.expense
_mark_manual_materials(row, [price_code], unit_prices, result)
return row 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 저녁 비었다.** 걸려 있던 셋(구조물터파기·되메우기·잔토처리)이 #: ⚠ **2026-09-09 저녁 비었다.** 걸려 있던 셋(구조물터파기·되메우기·잔토처리)이
@@ -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
+4
View File
@@ -275,6 +275,8 @@ async def _build_for(project_id: UUID, dump_haul_m: tuple[str, ...] = ()):
# 유가 지역 — 안 고르면 전국평균(품셈 8-1-7 5호 「해당지역의 가격」). # 유가 지역 — 안 고르면 전국평균(품셈 8-1-7 5호 「해당지역의 가격」).
# 기계 수송비 — 거리·도로 구분이 있어야 선다(산림품셈 10-4 [주]). # 기계 수송비 — 거리·도로 구분이 있어야 선다(산림품셈 10-4 [주]).
from B09_Estimation.B09_Estimation_Edits import edited_build, edits_key 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 = ( args = (
ranges, ranges,
@@ -288,6 +290,8 @@ async def _build_for(project_id: UUID, dump_haul_m: tuple[str, ...] = ()):
# 조종원 시간당 노임 자르는 자리 — 실무마다 다름(명세 7장 정정). 안 정하면 원 미만. # 조종원 시간당 노임 자르는 자리 — 실무마다 다름(명세 7장 정정). 안 정하면 원 미만.
str(settings.get("operator_wage_digits") or ""), str(settings.get("operator_wage_digits") or ""),
tuple(sorted(set(dump_haul_m), key=Decimal)), tuple(sorted(set(dump_haul_m), key=Decimal)),
# 자재 수동 단가(PLAN 1장 Ⓐ) — 코드 키만 조립에 얹음. 없으면 종전 벌 그대로.
material_prices_key(settings.get(MATERIAL_PRICES_KEY)),
) )
# 사용자가 고친 값(PLAN 12장 2차) — 없으면 기본 조립 그 벌 그대로. # 사용자가 고친 값(PLAN 12장 2차) — 없으면 기본 조립 그 벌 그대로.
return edited_build(args, edits_key(settings.get("edits"))) return edited_build(args, edits_key(settings.get("edits")))
@@ -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": "자재 단가를 저장하지 못했습니다."},
)
@@ -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<MaterialPricesDto> {
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));
});
},
};
+23 -6
View File
@@ -145,6 +145,10 @@ class UnitPriceBuild:
#: 표에서 온 갈래는 마스터 갈래 키에 있어야 하고(짝 시험), 식이 낸 갈래는 여기 적혀 있어야 함 #: 표에서 온 갈래는 마스터 갈래 키에 있어야 하고(짝 시험), 식이 낸 갈래는 여기 적혀 있어야 함
#: (덤프 운반·적재 10-12 `#L…m`·`#적재` — 본문 식이라 마스터 표에 갈래가 없음). #: (덤프 운반·적재 10-12 `#L…m`·`#적재` — 본문 식이라 마스터 표에 갈래가 없음).
formula_variants: dict[str, str] = field(default_factory=dict) 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: 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, labor_surcharge_choices: dict[str, str] | None = None,
operator_wage_digits: int = 0, operator_wage_digits: int = 0,
dump_haul_m: tuple[Decimal, ...] = (), dump_haul_m: tuple[Decimal, ...] = (),
material_prices: tuple[tuple[str, str, str], ...] = (),
) -> UnitPriceBuild: ) -> UnitPriceBuild:
"""자원 축을 일위대가(`B`)로 조립한다. """자원 축을 일위대가(`B`)로 조립한다.
@@ -788,6 +793,15 @@ def build_unit_prices(
build.incomplete_machines = _add_machine_layers( build.incomplete_machines = _add_machine_layers(
build.book, machine_codes, fuel_region, operator_wage_digits 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`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은 # 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은
# 품이 달라 한 일위대가로 뭉치면 어느 것도 안 맞는다. # 품이 달라 한 일위대가로 뭉치면 어느 것도 안 맞는다.
@@ -834,10 +848,7 @@ def build_unit_prices(
# ⚠ **붙을 상세를 먼저 모으고, 하나도 없으면 제목도 안 세운다.** # ⚠ **붙을 상세를 먼저 모으고, 하나도 없으면 제목도 안 세운다.**
# 제목만 세워 두면 「상세 줄이 없어 단가를 못 조립」하는 빈 일위대가가 남는다 # 제목만 세워 두면 「상세 줄이 없어 단가를 못 조립」하는 빈 일위대가가 남는다
# (기계 층이 안 선 기종만 참조하는 공종에서 실제로 생겼음). # (기계 층이 안 선 기종만 참조하는 공종에서 실제로 생겼음).
attachable = [ attachable = [(row, _ref_of(row)) for row in rows]
(row, row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}")
for row in rows
]
attachable = [(row, ref) for row, ref in attachable if ref in build.book.titles] attachable = [(row, ref) for row, ref in attachable if ref in build.book.titles]
# ⚠ **맞췄으나 단가 층이 없는 줄**(카탈로그 밖 `AR-` 자원 · 층을 못 세운 기계)은 조용히 # ⚠ **맞췄으나 단가 층이 없는 줄**(카탈로그 밖 `AR-` 자원 · 층을 못 세운 기계)은 조용히
# 빼지 않는다 — 못 맞춘 줄처럼 드러내고, 기계 몫이면 단가를 막는다(2026-09-13 축 C). # 빼지 않는다 — 못 맞춘 줄처럼 드러내고, 기계 몫이면 단가를 막는다(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: def _share_of(row) -> Decimal:
"""그 줄이 차지하는 몫(0~1). 배분율이 없으면 1 — 종전과 같다.""" """그 줄이 차지하는 몫(0~1). 배분율이 없으면 1 — 종전과 같다."""
ratio = getattr(row, "group_ratio_pct", None) ratio = getattr(row, "group_ratio_pct", None)
@@ -1114,8 +1130,7 @@ def _covered_ratio_pct(
ratio = getattr(row, "group_ratio_pct", None) ratio = getattr(row, "group_ratio_pct", None)
if ratio is None or ratio in seen: if ratio is None or ratio in seen:
continue continue
ref = row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}" if _ref_of(row) in attached_refs:
if ref in attached_refs:
seen.add(ratio) seen.add(ratio)
covered += ratio covered += ratio
return covered return covered
@@ -1177,6 +1192,7 @@ def cached_build(
labor_surcharge: tuple[tuple[str, str], ...] = (), labor_surcharge: tuple[tuple[str, str], ...] = (),
operator_wage_digits: str = "", operator_wage_digits: str = "",
dump_haul_m: tuple[str, ...] = (), dump_haul_m: tuple[str, ...] = (),
material_prices: tuple[tuple[str, str, str], ...] = (),
) -> UnitPriceBuild: ) -> UnitPriceBuild:
"""조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다. """조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다.
@@ -1205,6 +1221,7 @@ def cached_build(
labor_surcharge_choices=dict(labor_surcharge), labor_surcharge_choices=dict(labor_surcharge),
operator_wage_digits=parse_operator_wage_digits(operator_wage_digits), operator_wage_digits=parse_operator_wage_digits(operator_wage_digits),
dump_haul_m=tuple(Decimal(value) for value in dump_haul_m), dump_haul_m=tuple(Decimal(value) for value in dump_haul_m),
material_prices=material_prices,
) )
+4
View File
@@ -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_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_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_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_audit import note_api_call, record_call_burst
from common_util.common_util_auth import ( from common_util.common_util_auth import (
require_company, 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_progress_router, dependencies=protected_with_company)
app.include_router(b09_edits_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_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) app.include_router(dev_unlock_router, dependencies=protected_with_company)
@@ -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" # 말뚝 직경46㎝ — 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
+1
View File
@@ -12,6 +12,7 @@ export const ui_locales_b4 = {
B09_Estimation_Tab_Execution: ["실행예산", "Execution Budget"], B09_Estimation_Tab_Execution: ["실행예산", "Execution Budget"],
B09_Estimation_Tab_Progress: ["기성", "Progress Payment"], B09_Estimation_Tab_Progress: ["기성", "Progress Payment"],
B09_Estimation_Tab_Completion: ["준공", "Completion"], B09_Estimation_Tab_Completion: ["준공", "Completion"],
B09_Estimation_Tab_MaterialPrices: ["자재 단가", "Material Prices"],
B08_Quantity_Side_BenchCut_Fill: [ B08_Quantity_Side_BenchCut_Fill: [
"제안값 넣기 — KDS 표준치수 토사 0.5 m", "제안값 넣기 — KDS 표준치수 토사 0.5 m",
"Insert suggested value — KDS standard soil 0.5 m", "Insert suggested value — KDS standard soil 0.5 m",