auto: 2026-09-14 10:03 (ESD_LAPTOP)
This commit is contained in:
@@ -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
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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" # 말뚝 직경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
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user