feat(b08): 구조물도 일위대가 줄 고치기 — 줄 더하기·빼기·고르개·단가 수동, 저장 자리 둘
- 줄 조합은 양식+프로젝트(종류별), [내 라이브러리에 저장] 때 양식에 실림 - 수동 단가는 프로젝트만(값·출처·넣은 날짜) — 빨간 테두리 + 「미확정 N건」 - 고르개는 이 프로젝트 단가표에서 품셈·자원을 낱말로 찾음 - 다른 양식을 가져오면 그 종류의 줄 조합·수동 단가도 비움 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
@@ -111,11 +111,17 @@ def _write(folder: Path, item: dict[str, Any]) -> None:
|
||||
(folder / f"{item['code']}.json").write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def save_personal(folder: Path, template: dict[str, Any], overrides: dict[str, Any] | None) -> str:
|
||||
"""[내 라이브러리에 저장] — 양식 + 프로젝트에서 고친 식을 **개인 단에 한 벌**로 씀. 코드.
|
||||
def save_personal(
|
||||
folder: Path,
|
||||
template: dict[str, Any],
|
||||
overrides: dict[str, Any] | None,
|
||||
unit_price_rows: list[dict[str, Any]] | None = None,
|
||||
) -> str:
|
||||
"""[내 라이브러리에 저장] — 양식 + 고친 식·줄 조합을 **개인 단에 한 벌**로 씀. 코드.
|
||||
|
||||
⚠ 반대 방향(작업본 → 개인 단)이라 프로젝트는 안 바꿈(브레인 판정 Ⓑ).
|
||||
⚠ 개인 단에 같은 종류가 있으면 **그 코드로 덮어씀** — 지금은 종류당 하나(판정 Ⓐ).
|
||||
⛔ 수동 단가는 안 실음 — 프로젝트의 값이라 양식에 실으면 남의 프로젝트로 감(브레인 판정).
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import overridden_rows
|
||||
|
||||
@@ -129,6 +135,8 @@ def save_personal(folder: Path, template: dict[str, Any], overrides: dict[str, A
|
||||
for row in overridden_rows(template, overrides)
|
||||
]
|
||||
item = {k: v for k, v in template.items() if k != "imported_from"}
|
||||
if unit_price_rows is not None:
|
||||
item["unit_price"] = {**(item.get("unit_price") or {}), "rows": unit_price_rows}
|
||||
_write(folder, {**item, "code": code, "library_tier": "personal", "rows": rows})
|
||||
return code
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
일위대가를 먼저 세우고 그 단위당 금액을 씀. 레시피 150/350 이 2단 이상(명세 16장).
|
||||
깊이 **5단**까지(PLAN 10장 판정) · 돌면 막힘. B-FP·X·L 쪽 재귀는 단가표가 이미 함.
|
||||
⛔ 하위 양식은 **프로젝트에 박힌 것과 프로그램 기본**에서만 찾음 — 개인·회사 단은 안 읽음(4장 Ⓑ).
|
||||
⚠ **저장 자리는 둘**(브레인 판정 2026-09-13) — 줄 더하기·빼기·고르개로 고친
|
||||
**줄 조합은 양식+프로젝트**(`ROWS_KEY`, 종류별 · [내 라이브러리에 저장] 때 양식에 실림) ·
|
||||
**수동 단가는 프로젝트만**(`MANUAL_KEY` · 양식에 실으면 내 단가가 남의 프로젝트로 감).
|
||||
수동 단가 줄은 「미확정」으로 셈.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,6 +25,15 @@ from typing import Any
|
||||
SUB_STRUCTURE_PREFIX = "B-AX-ST-"
|
||||
#: 재귀 깊이 한도 — 맨 윗 표가 1단(PLAN 10장 「재귀 깊이 = 5단」).
|
||||
MAX_DEPTH = 5
|
||||
#: 산출 조건 자리 — 고친 줄 조합 `{type_id: [rows]}` · 수동 단가 `{type_id: {seq: 값}}`.
|
||||
ROWS_KEY = "structure_unit_price_rows"
|
||||
MANUAL_KEY = "structure_manual_prices"
|
||||
#: 고르개 갈래 — 품셈(일위대가·단가산출) · 자원(자재·노임·시간당 중기).
|
||||
SEARCH_KINDS = {
|
||||
"work": ("unit_price", "price_basis"),
|
||||
"resource": ("material", "labor", "machine_hourly"),
|
||||
}
|
||||
_MONEY_KEYS = ("material", "labor", "expense")
|
||||
_UNIT_ALIASES = {"m2": "㎡", "m3": "㎥", "M2": "㎡", "M3": "㎥", "M": "m"}
|
||||
|
||||
|
||||
@@ -34,6 +47,8 @@ class _Context:
|
||||
book: Any
|
||||
find_variant: Callable[[str, str], str | None]
|
||||
library: dict[str, dict[str, Any]]
|
||||
#: 맨 윗 표의 수동 단가 `{seq: 값}` — 하위 양식에는 안 씀(그 종류의 줄이 아님).
|
||||
manual: dict[str, dict[str, Any]]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -153,6 +168,7 @@ def _assemble(
|
||||
sums = {"material": Decimal(0), "labor": Decimal(0), "expense": Decimal(0)}
|
||||
exact = Money3()
|
||||
blocked = 0
|
||||
unconfirmed = 0
|
||||
for row in spec.get("rows") or []:
|
||||
out: dict[str, Any] = {
|
||||
"seq": row.get("seq"),
|
||||
@@ -193,7 +209,18 @@ def _assemble(
|
||||
ref, why = _ref_of(row, values, ctx.find_variant)
|
||||
out["ref_code"] = ref or ""
|
||||
priced = None
|
||||
if ref:
|
||||
manual = ctx.manual.get(str(row.get("seq"))) if depth == 1 else None
|
||||
if manual:
|
||||
# 수동 단가가 단가표보다 이김 — 사람이 일부러 넣은 값. 대신 「미확정」으로 셈.
|
||||
money = Money3(*(Decimal(str(manual.get(key) or 0)) for key in _MONEY_KEYS))
|
||||
priced = _Priced(money, out["name"], out["spec"], out["unit"])
|
||||
out.update(
|
||||
manual=True,
|
||||
manual_source=manual.get("source") or "",
|
||||
manual_entered_at=manual.get("entered_at") or "",
|
||||
)
|
||||
unconfirmed += 1
|
||||
elif ref:
|
||||
try:
|
||||
priced = _price(ref, row, ctx, depth, seen)
|
||||
if out["unit"] and priced.unit and priced.unit != out["unit"]:
|
||||
@@ -238,6 +265,8 @@ def _assemble(
|
||||
"total": float(round_at(sum(sums.values()), OutputPlace.UNIT_PRICE_TOTAL)),
|
||||
"blocked": blocked,
|
||||
"complete": blocked == 0,
|
||||
#: 수동 단가로 선 줄 수 — 화면 「미확정 N건」 배지.
|
||||
"unconfirmed": unconfirmed,
|
||||
}
|
||||
return table, exact
|
||||
|
||||
@@ -248,14 +277,92 @@ def unit_price_table(
|
||||
book: Any,
|
||||
find_variant: Callable[[str, str], str | None],
|
||||
library: Iterable[dict[str, Any]] = (),
|
||||
manual: dict[str, dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""장 하나의 일위대가 표. 양식에 `unit_price` 가 없으면 `None`.
|
||||
|
||||
수량 — `from_row`(원단위 줄 차례 → 그 줄의 단위당 값) 또는 박힌 `quantity`.
|
||||
코드 — `ref_code`(단가표 코드 그대로) 또는 `work_item_code` + `variant_from`(제원 칸 → 갈래).
|
||||
`library` — 하위 구조물 일위대가(`B-AX-ST-*`)를 찾을 양식들(프로젝트에 박힌 것 + 프로그램 기본).
|
||||
`manual` — 이 프로젝트의 수동 단가 `{seq: {material, labor, expense, source, entered_at}}`.
|
||||
"""
|
||||
ctx = _Context(book, find_variant, {str(t["code"]): t for t in library if t.get("code")})
|
||||
ctx = _Context(
|
||||
book, find_variant, {str(t["code"]): t for t in library if t.get("code")}, manual or {}
|
||||
)
|
||||
code = str(template.get("code") or "")
|
||||
assembled = _assemble(template, sheet, ctx, 1, (code,) if code else ())
|
||||
return assembled[0] if assembled else None
|
||||
|
||||
|
||||
def with_rows(template: dict[str, Any], rows: list[dict[str, Any]] | None) -> dict[str, Any]:
|
||||
"""고친 줄 조합을 얹은 양식 — 고친 적 없으면 양식 줄. 줄 없는 양식도 빈 표로 세움."""
|
||||
spec = template.get("unit_price") or {}
|
||||
picked = (spec.get("rows") or []) if rows is None else rows
|
||||
return {**template, "unit_price": {**spec, "rows": picked}}
|
||||
|
||||
|
||||
def save_rows(
|
||||
current: dict[str, Any], type_id: str, template: dict[str, Any], rows: list[dict[str, Any]]
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
"""줄 조합 저장본 — 양식과 같으면 그 종류를 지움(양식대로). (새 저장본, 바뀜)."""
|
||||
merged = {key: value for key, value in current.items() if key != type_id}
|
||||
if rows != ((template.get("unit_price") or {}).get("rows") or []):
|
||||
merged[type_id] = rows
|
||||
return merged, merged != current
|
||||
|
||||
|
||||
def save_manual(
|
||||
current: dict[str, Any],
|
||||
type_id: str,
|
||||
rows: list[dict[str, Any]],
|
||||
prices: dict[str, dict[str, Any]],
|
||||
today: str,
|
||||
) -> dict[str, Any]:
|
||||
"""수동 단가 저장본 — **남은 줄의 것만** 둠(뺀 줄의 값이 같은 차례 새 줄에 붙지 않게).
|
||||
|
||||
넣은 날짜는 값·출처가 그대로면 옛 날짜를 둠 — 다시 저장했다고 날짜가 새로 서면 안 됨.
|
||||
"""
|
||||
seqs = {str(row.get("seq")) for row in rows}
|
||||
before = current.get(type_id) or {}
|
||||
kept = {}
|
||||
for seq, price in prices.items():
|
||||
if str(seq) not in seqs:
|
||||
continue
|
||||
old = before.get(str(seq)) or {}
|
||||
same = all(old.get(key) == price.get(key) for key in (*_MONEY_KEYS, "source"))
|
||||
kept[str(seq)] = {**price, "entered_at": old.get("entered_at") if same else today}
|
||||
merged = {key: value for key, value in current.items() if key != type_id}
|
||||
if kept:
|
||||
merged[type_id] = kept
|
||||
return merged
|
||||
|
||||
|
||||
def search_titles(book: Any, query: str, kind: str, limit: int = 50) -> list[dict[str, Any]]:
|
||||
"""고르개 — 단가표에서 낱말이 **모두** 든(코드·이름·규격) 항목. 단가가 섰는지도 함께."""
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PriceBookError
|
||||
|
||||
kinds = SEARCH_KINDS[kind]
|
||||
words = query.lower().split()
|
||||
found: list[dict[str, Any]] = []
|
||||
for title in book.titles.values() if words else ():
|
||||
if len(found) >= limit:
|
||||
break
|
||||
if title.kind.value not in kinds:
|
||||
continue
|
||||
text = f"{title.code} {title.name} {title.spec}".lower()
|
||||
if all(word in text for word in words):
|
||||
try:
|
||||
money = book.resolve(title.code)
|
||||
total: float | None = float(money.material + money.labor + money.expense)
|
||||
except PriceBookError:
|
||||
total = None
|
||||
found.append(
|
||||
{
|
||||
"code": title.code,
|
||||
"name": title.name,
|
||||
"spec": title.spec,
|
||||
"unit": _unit(title.unit),
|
||||
"price": total,
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
@@ -323,9 +323,9 @@ async def put_structure_library_import(
|
||||
payload: LibraryImportRequest,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> JSONResponse:
|
||||
"""고른 항목을 **프로젝트 작업본에 박고** 그 종류의 고친 식을 비움.
|
||||
"""고른 항목을 **프로젝트 작업본에 박고** 그 종류의 고친 식·줄 조합·수동 단가를 비움.
|
||||
|
||||
⚠ 고친 식을 비우는 까닭 — 새 양식과 줄 차례가 안 맞을 수 있음. 묻는 것은 화면 몫.
|
||||
⚠ 비우는 까닭 — 셋 다 줄 차례에 묶였는데 새 양식과 차례가 안 맞을 수 있음. 묻는 것은 화면 몫.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
|
||||
find_item,
|
||||
@@ -333,6 +333,7 @@ async def put_structure_library_import(
|
||||
tier_dirs,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import OVERRIDES_KEY
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import MANUAL_KEY, ROWS_KEY
|
||||
from common_util.common_util_project_settings import quantity_settings, save_section
|
||||
|
||||
project_root = await _project_root(project_id)
|
||||
@@ -346,23 +347,22 @@ async def put_structure_library_import(
|
||||
content={"status": "error", "message": "가져올 양식 항목을 찾지 못했습니다."},
|
||||
)
|
||||
await asyncio.to_thread(import_item, project_root, item, payload.tier)
|
||||
current = quantity_settings(project_root).get(OVERRIDES_KEY) or {}
|
||||
cleared = len(current.get(payload.type_id) or {})
|
||||
if cleared:
|
||||
rest = {key: rows for key, rows in current.items() if key != payload.type_id}
|
||||
await asyncio.to_thread(
|
||||
save_section,
|
||||
project_root,
|
||||
"quantity",
|
||||
{OVERRIDES_KEY: rest},
|
||||
replace_keys=[OVERRIDES_KEY],
|
||||
)
|
||||
settings = quantity_settings(project_root)
|
||||
current = {key: settings.get(key) or {} for key in (OVERRIDES_KEY, ROWS_KEY, MANUAL_KEY)}
|
||||
touched = [key for key, value in current.items() if payload.type_id in value]
|
||||
if touched:
|
||||
rest = {
|
||||
key: {k: v for k, v in current[key].items() if k != payload.type_id} for key in touched
|
||||
}
|
||||
await asyncio.to_thread(save_section, project_root, "quantity", rest, replace_keys=touched)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "success",
|
||||
"code": payload.code,
|
||||
"imported_from": payload.tier,
|
||||
"cleared_formulas": cleared,
|
||||
"cleared_formulas": len(current[OVERRIDES_KEY].get(payload.type_id) or {}),
|
||||
"cleared_unit_price_rows": payload.type_id in current[ROWS_KEY],
|
||||
"cleared_manual_prices": len(current[MANUAL_KEY].get(payload.type_id) or {}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -404,6 +404,7 @@ async def put_structure_library_personal(
|
||||
save_personal,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import OVERRIDES_KEY, template_of
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import ROWS_KEY
|
||||
from common_util.common_util_project_settings import quantity_settings
|
||||
|
||||
folder = _personal_dir(session)
|
||||
@@ -421,8 +422,11 @@ async def put_structure_library_personal(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "양식이 있는 구조물도 장을 찾지 못했습니다."},
|
||||
)
|
||||
overrides = (quantity_settings(project_root).get(OVERRIDES_KEY) or {}).get(type_id)
|
||||
code = await asyncio.to_thread(save_personal, folder, template, overrides)
|
||||
settings = quantity_settings(project_root)
|
||||
overrides = (settings.get(OVERRIDES_KEY) or {}).get(type_id)
|
||||
# ⛔ 수동 단가(`MANUAL_KEY`)는 안 넘김 — 프로젝트의 값(브레인 판정).
|
||||
rows = (settings.get(ROWS_KEY) or {}).get(type_id)
|
||||
code = await asyncio.to_thread(save_personal, folder, template, overrides, rows)
|
||||
return JSONResponse(content={"status": "success", "code": code, "edited": len(overrides or {})})
|
||||
|
||||
|
||||
@@ -447,51 +451,193 @@ async def _price_build(project_id: UUID) -> Any:
|
||||
return await _build_for(project_id)
|
||||
|
||||
|
||||
async def _sheet_template(
|
||||
project_id: UUID, project_root: str, sheet_key: str
|
||||
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
||||
"""장 하나와 그 양식(프로젝트에 박힌 것 → 프로그램 기본)."""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import template_of
|
||||
|
||||
sheets = (await _sheets_of(project_id, project_root)).get("sheets") or []
|
||||
picked = next((s for s in sheets if s.get("key") == sheet_key), None)
|
||||
if picked is None:
|
||||
return None, None
|
||||
return picked, template_of(str(picked.get("type_id") or ""), project_templates(project_root))
|
||||
|
||||
|
||||
def _no_sheet() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "그 구조물도 장을 찾지 못했습니다."},
|
||||
)
|
||||
|
||||
|
||||
async def _price_build_or_error(project_id: UUID) -> tuple[Any, JSONResponse | None]:
|
||||
try:
|
||||
return await _price_build(project_id), None
|
||||
except Exception:
|
||||
logger.exception("B08 구조물도 일위대가 — 단가표 조립 실패: project_id=%s", project_id)
|
||||
return None, JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "단가표를 조립하지 못했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/quantity/structure-sheets/unit-price")
|
||||
async def get_structure_unit_price(project_id: UUID, sheet_key: str) -> JSONResponse:
|
||||
"""장 하나의 하단 **일위대가 표**(미리보기) — 줄 조합은 양식, 단가는 B09 단가표.
|
||||
"""장 하나의 하단 **일위대가 표**(미리보기) — 줄 조합은 양식(+고친 것), 단가는 B09 단가표.
|
||||
|
||||
⚠ 장 조회와 창구를 나눔 — 단가표 첫 조립이 십여 초라 장 조회를 늦추지 않게.
|
||||
⚠ 값을 여기서 정본으로 적지 않음 — 보이기만(내역 금액은 B09 가 셈).
|
||||
⚠ `editor` — 화면이 고칠 줄 조합·양식 줄·수동 단가. 값(금액)은 늘 이 서버 계산만 씀.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
|
||||
available_templates,
|
||||
project_templates,
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import available_templates
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import (
|
||||
MANUAL_KEY,
|
||||
ROWS_KEY,
|
||||
unit_price_table,
|
||||
with_rows,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import template_of
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import unit_price_table
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import find_variant_code
|
||||
from common_util.common_util_project_settings import quantity_settings
|
||||
|
||||
project_root = await _project_root(project_id)
|
||||
if project_root is None:
|
||||
return _not_found()
|
||||
sheets = (await _sheets_of(project_id, project_root)).get("sheets") or []
|
||||
picked = next((s for s in sheets if s.get("key") == sheet_key), None)
|
||||
picked, template = await _sheet_template(project_id, project_root, sheet_key)
|
||||
if picked is None:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "그 구조물도 장을 찾지 못했습니다."},
|
||||
)
|
||||
template = template_of(str(picked.get("type_id") or ""), project_templates(project_root))
|
||||
if not template or not template.get("unit_price"):
|
||||
return _no_sheet()
|
||||
if not template:
|
||||
return JSONResponse(content={"status": "success", "unit_price": None})
|
||||
try:
|
||||
build = await _price_build(project_id)
|
||||
except Exception:
|
||||
logger.exception("B08 구조물도 일위대가 — 단가표 조립 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "단가표를 조립하지 못했습니다."},
|
||||
)
|
||||
build, error = await _price_build_or_error(project_id)
|
||||
if error:
|
||||
return error
|
||||
settings = quantity_settings(project_root)
|
||||
row_edits = settings.get(ROWS_KEY) or {}
|
||||
type_id = str(template.get("type_id") or "")
|
||||
manual = (settings.get(MANUAL_KEY) or {}).get(type_id) or {}
|
||||
effective = with_rows(template, row_edits.get(type_id))
|
||||
# 하위 양식도 그 종류에서 고친 줄 조합으로 풂 — 수동 단가는 맨 윗 표만.
|
||||
library = [
|
||||
with_rows(t, row_edits.get(t.get("type_id"))) for t in available_templates(project_root)
|
||||
]
|
||||
table = await asyncio.to_thread(
|
||||
unit_price_table,
|
||||
template,
|
||||
effective,
|
||||
picked,
|
||||
build.book,
|
||||
lambda code, value: find_variant_code(code, value, build),
|
||||
available_templates(project_root),
|
||||
library,
|
||||
manual,
|
||||
)
|
||||
return JSONResponse(content={"status": "success", "unit_price": table})
|
||||
editor = {
|
||||
"rows": effective["unit_price"]["rows"],
|
||||
"default_rows": (template.get("unit_price") or {}).get("rows") or [],
|
||||
"edited": type_id in row_edits,
|
||||
"manual_prices": manual,
|
||||
}
|
||||
return JSONResponse(content={"status": "success", "unit_price": table, "editor": editor})
|
||||
|
||||
|
||||
class UnitPriceRowEdit(BaseModel):
|
||||
"""일위대가 줄 하나 — 수량은 원단위 줄(`from_row`) 또는 박힌 값, 코드는 고르개가 채움."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
seq: int = Field(ge=1)
|
||||
name: str | None = Field(default=None, max_length=200)
|
||||
spec: str | None = Field(default=None, max_length=200)
|
||||
unit: str | None = Field(default=None, max_length=20)
|
||||
from_row: int | None = Field(default=None, ge=1)
|
||||
quantity: float | None = None
|
||||
ref_code: str | None = Field(default=None, max_length=100)
|
||||
work_item_code: str | None = Field(default=None, max_length=100)
|
||||
variant_from: str | None = Field(default=None, max_length=50)
|
||||
sub_vars: dict[str, float | str] | None = None
|
||||
|
||||
|
||||
class ManualPrice(BaseModel):
|
||||
"""수동 단가 — 단위당 3분할과 출처. 넣은 날짜는 서버가 붙임."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
material: float = Field(default=0, ge=0)
|
||||
labor: float = Field(default=0, ge=0)
|
||||
expense: float = Field(default=0, ge=0)
|
||||
source: str = Field(default="", max_length=200)
|
||||
|
||||
|
||||
class UnitPriceEditRequest(BaseModel):
|
||||
"""한 화면에서 고친 일위대가 — **저장 자리는 둘**(줄 조합 = 양식+프로젝트 · 단가 = 프로젝트)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
sheet_key: str
|
||||
rows: list[UnitPriceRowEdit] = Field(max_length=100)
|
||||
manual_prices: dict[str, ManualPrice] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@router.put("/{project_id}/quantity/structure-sheets/unit-price")
|
||||
async def put_structure_unit_price(project_id: UUID, payload: UnitPriceEditRequest) -> JSONResponse:
|
||||
"""고친 줄 조합·수동 단가를 산출 조건에 저장. 금액은 화면이 다시 조회해 서버 계산으로 받음."""
|
||||
from datetime import date
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import (
|
||||
MANUAL_KEY,
|
||||
ROWS_KEY,
|
||||
save_manual,
|
||||
save_rows,
|
||||
)
|
||||
from common_util.common_util_project_settings import quantity_settings, save_section
|
||||
|
||||
rows = [row.model_dump(exclude_none=True) for row in payload.rows]
|
||||
if len({row["seq"] for row in rows}) != len(rows):
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={"status": "error", "message": "일위대가 줄 차례가 겹칩니다."},
|
||||
)
|
||||
project_root = await _project_root(project_id)
|
||||
if project_root is None:
|
||||
return _not_found()
|
||||
picked, template = await _sheet_template(project_id, project_root, payload.sheet_key)
|
||||
if picked is None or template is None:
|
||||
return _no_sheet()
|
||||
type_id = str(template.get("type_id") or "")
|
||||
settings = quantity_settings(project_root)
|
||||
merged_rows, changed = save_rows(settings.get(ROWS_KEY) or {}, type_id, template, rows)
|
||||
prices = {seq: price.model_dump() for seq, price in payload.manual_prices.items()}
|
||||
merged_manual = save_manual(
|
||||
settings.get(MANUAL_KEY) or {}, type_id, rows, prices, date.today().isoformat()
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
save_section,
|
||||
project_root,
|
||||
"quantity",
|
||||
{ROWS_KEY: merged_rows, MANUAL_KEY: merged_manual},
|
||||
replace_keys=[ROWS_KEY, MANUAL_KEY],
|
||||
)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "success",
|
||||
"changed_rows": changed,
|
||||
"edited": type_id in merged_rows,
|
||||
"manual_prices": len(merged_manual.get(type_id) or {}),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/quantity/structure-sheets/price-search")
|
||||
async def get_structure_price_search(
|
||||
project_id: UUID, q: str, kind: Literal["work", "resource"] = "work"
|
||||
) -> JSONResponse:
|
||||
"""고르개 — 이 프로젝트 단가표에서 품셈(일위대가)·자원(자재·노임·중기)을 낱말로 찾음."""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import search_titles
|
||||
|
||||
build, error = await _price_build_or_error(project_id)
|
||||
if error:
|
||||
return error
|
||||
items = await asyncio.to_thread(search_titles, build.book, q[:100], kind)
|
||||
return JSONResponse(content={"status": "success", "items": items})
|
||||
|
||||
|
||||
@router.get("/{project_id}/quantity/structure-sheets")
|
||||
|
||||
@@ -124,6 +124,15 @@ const CSS = `
|
||||
.b08-sheet__formula select.is-changed { outline: 1px solid var(--color-accent, #6c8ebf); }
|
||||
.b08-sheet__formula .b08-sheet__digits { flex: 0 0 3.2rem; min-width: 3.2rem; }
|
||||
.b08-sheet__actions { display: flex; gap: 8px; align-items: center; }
|
||||
/* 수동 단가 — 빨간 테두리 + 「미확정 N건」 배지(PLAN 확정 ⑦). */
|
||||
.b08-unit__manual { outline: 2px solid var(--color-danger, #d9534f); outline-offset: -2px; }
|
||||
.b08-unit__badge { margin-left: 8px; padding: 0 6px; border-radius: 8px; color: #fff;
|
||||
background: var(--color-danger, #d9534f); font-size: 12px; white-space: nowrap; }
|
||||
.b08-unit-edit { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
|
||||
.b08-unit-edit td { white-space: nowrap; }
|
||||
.b08-unit-edit__picker { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
||||
.b08-unit-edit__results { display: flex; flex-direction: column; align-items: flex-start;
|
||||
gap: 2px; width: 100%; max-height: 14rem; overflow: auto; }
|
||||
@media (max-width: 900px) {
|
||||
.b08-sheet { flex-direction: column; }
|
||||
.b08-sheet__aside { flex-basis: auto; width: 100%; }
|
||||
@@ -430,7 +439,9 @@ export function renderStructureSheets(projectId: string | null): HTMLElement {
|
||||
},
|
||||
)
|
||||
: null;
|
||||
const unitPrice = sheet.library_item ? unitPriceSection(projectId, sheet.key) : null;
|
||||
const unitPrice = sheet.library_item
|
||||
? unitPriceSection(projectId, sheet.key, sheet.rows)
|
||||
: null;
|
||||
pane.replaceChildren(sheetBody(sheet, editor, unitPrice), aside);
|
||||
};
|
||||
sheets.forEach((sheet, index) => {
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_StructureSheet_UnitPrice.ts
|
||||
* 구조물도 장 아래 **일위대가 표**(미리보기) — PLAN 3장 하단 ①②.
|
||||
* 구조물도 장 아래 **일위대가 표**(미리보기) — PLAN 3장 하단 ①②③.
|
||||
*
|
||||
* ⚠ 값을 셈하지 않음 — 서버(`…/structure-sheets/unit-price`)가 B09 단가표로 낸 금액을 적기만.
|
||||
* ⚠ 장 조회와 따로 받음 — 단가표 첫 조립이 십여 초라 표가 늦게 차도 위 수량표는 먼저 보임.
|
||||
* ⚠ 막힌 줄은 0 이 아니라 까닭을 적고, 하나라도 있으면 합계 앞에 「미완」.
|
||||
* ⚠ 수동 단가 줄은 금액 칸 빨간 테두리 + 머리 「미확정 N건」 배지(PLAN 확정 ⑦).
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { el, num } from "./B08_Quantity_UI_StructureSheet_Formula";
|
||||
import {
|
||||
unitPriceEditor,
|
||||
type UnitPriceEditorData,
|
||||
} from "./B08_Quantity_UI_StructureSheet_UnitPriceEdit";
|
||||
|
||||
interface UnitPriceRow {
|
||||
seq: number;
|
||||
@@ -23,6 +28,9 @@ interface UnitPriceRow {
|
||||
labor?: number;
|
||||
expense?: number;
|
||||
total?: number;
|
||||
manual?: boolean;
|
||||
manual_source?: string;
|
||||
manual_entered_at?: string;
|
||||
}
|
||||
|
||||
interface UnitPriceTable {
|
||||
@@ -36,6 +44,7 @@ interface UnitPriceTable {
|
||||
total: number;
|
||||
blocked: number;
|
||||
complete: boolean;
|
||||
unconfirmed: number;
|
||||
}
|
||||
|
||||
/** 금액 칸 — 0.1원 자리까지(금액란 규칙). 못 푼 줄은 빈칸. */
|
||||
@@ -43,13 +52,17 @@ function won(value: number | undefined): string {
|
||||
return value === undefined ? "" : num(value, 1);
|
||||
}
|
||||
|
||||
/** 장 아래 일위대가 칸 — 받는 동안 안내를 두고, 오면 표로 갈음. */
|
||||
export function unitPriceSection(projectId: string, sheetKey: string): HTMLElement {
|
||||
/** 장 아래 일위대가 칸 — 받는 동안 안내를 두고, 오면 표로 갈음. 저장 뒤엔 다시 받음. */
|
||||
export function unitPriceSection(
|
||||
projectId: string,
|
||||
sheetKey: string,
|
||||
sheetRows: { no: number; name: string; unit: string }[],
|
||||
): HTMLElement {
|
||||
const wrap = el("div", "b08-grid");
|
||||
wrap.append(
|
||||
el("p", "b08-grid__caption", "일위대가(미리보기) 불러오는 중… 단가표 첫 조립은 십여 초"),
|
||||
);
|
||||
void (async () => {
|
||||
const load = async (): Promise<void> => {
|
||||
wrap.replaceChildren(
|
||||
el("p", "b08-grid__caption", "일위대가(미리보기) 불러오는 중… 단가표 첫 조립은 십여 초"),
|
||||
);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/unit-price?sheet_key=${encodeURIComponent(sheetKey)}`,
|
||||
@@ -57,15 +70,38 @@ export function unitPriceSection(projectId: string, sheetKey: string): HTMLEleme
|
||||
);
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
unit_price?: UnitPriceTable | null;
|
||||
editor?: UnitPriceEditorData;
|
||||
message?: string;
|
||||
};
|
||||
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
const table = payload.unit_price;
|
||||
if (!table) {
|
||||
const editor = payload.editor;
|
||||
if (!table || !editor) {
|
||||
wrap.replaceChildren(el("p", "b08-grid__caption", "이 양식에는 일위대가 줄이 아직 없음"));
|
||||
return;
|
||||
}
|
||||
wrap.replaceChildren(...render(table));
|
||||
const open = el("button", "", editor.edited ? "줄 고치기 (고친 조합)" : "줄 고치기");
|
||||
open.type = "button";
|
||||
open.style.alignSelf = "flex-start";
|
||||
const view = table.rows.length
|
||||
? render(table)
|
||||
: [el("p", "b08-grid__caption", "일위대가 줄이 아직 없음 — [줄 고치기]로 더함")];
|
||||
open.addEventListener("click", () => {
|
||||
open.disabled = true;
|
||||
const panel = unitPriceEditor({
|
||||
projectId,
|
||||
sheetKey,
|
||||
sheetRows,
|
||||
data: editor,
|
||||
onSaved: () => void load(),
|
||||
onClose: () => {
|
||||
panel.remove();
|
||||
open.disabled = false;
|
||||
},
|
||||
});
|
||||
wrap.append(panel);
|
||||
});
|
||||
wrap.replaceChildren(...view, open);
|
||||
} catch (error) {
|
||||
wrap.replaceChildren(
|
||||
el(
|
||||
@@ -75,7 +111,8 @@ export function unitPriceSection(projectId: string, sheetKey: string): HTMLEleme
|
||||
),
|
||||
);
|
||||
}
|
||||
})();
|
||||
};
|
||||
void load();
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -88,6 +125,9 @@ function render(table: UnitPriceTable): HTMLElement[] {
|
||||
"b08-sheet__head",
|
||||
`일위대가 ${table.code} · ${table.unit}당 ${total} (미리보기 — 내역 금액은 원가계산이 셈)`,
|
||||
);
|
||||
if (table.unconfirmed) {
|
||||
head.append(el("span", "b08-unit__badge", `미확정 ${table.unconfirmed}건`));
|
||||
}
|
||||
const scroller = el("div", "b08-grid__scroll");
|
||||
const grid = el("table", "b08-grid__table b08-grid__table--summary");
|
||||
const headRow = document.createElement("tr");
|
||||
@@ -109,19 +149,24 @@ function render(table: UnitPriceTable): HTMLElement[] {
|
||||
const tbody = document.createElement("tbody");
|
||||
for (const row of table.rows) {
|
||||
const tr = document.createElement("tr");
|
||||
const note = row.skipped ? `안 섬 — ${row.reason}` : row.reason ? `⚠ ${row.reason}` : "";
|
||||
const manual = row.manual
|
||||
? `수동 단가 — ${row.manual_source || "출처 없음"} · ${row.manual_entered_at ?? ""}`
|
||||
: "";
|
||||
const note = row.skipped ? `안 섬 — ${row.reason}` : row.reason ? `⚠ ${row.reason}` : manual;
|
||||
// 단가표 이름에 갈래가 이미 들었거나 규격 칸이 코드 자체면 겹쳐 적지 않음.
|
||||
const spec =
|
||||
row.spec && !row.name.includes(row.spec) && !row.ref_code.includes(row.spec) ? row.spec : "";
|
||||
const money = [row.material, row.labor, row.expense, row.total].map((value) => {
|
||||
const cell = el("td", row.manual ? "b08-unit__manual" : "", won(value));
|
||||
if (row.manual) cell.title = manual;
|
||||
return cell;
|
||||
});
|
||||
tr.append(
|
||||
el("td", "", spec ? `${row.name} (${spec})` : row.name),
|
||||
el("td", "", row.ref_code),
|
||||
el("td", "", row.quantity === null ? "" : num(row.quantity, 3)),
|
||||
el("td", "", row.unit),
|
||||
el("td", "", won(row.material)),
|
||||
el("td", "", won(row.labor)),
|
||||
el("td", "", won(row.expense)),
|
||||
el("td", "", won(row.total)),
|
||||
...money,
|
||||
el("td", "", note),
|
||||
);
|
||||
tbody.append(tr);
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts
|
||||
* 구조물도 일위대가 **줄 고치기** — 줄 더하기·빼기 · 고르개(품셈·자원 찾기) · 수동 단가. PLAN 3장 ③.
|
||||
*
|
||||
* ⚠ 저장 자리는 둘(브레인 판정) — 줄 조합은 양식+프로젝트, 수동 단가는 프로젝트만. 한 번의 [저장]이
|
||||
* 둘을 함께 보내고 서버가 갈라 적음. [내 라이브러리에 저장]은 줄 조합만 실음.
|
||||
* ⚠ 금액은 여기서 셈하지 않음 — 저장 뒤 표를 다시 받아 서버 계산(B09 단가표)으로 그림.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { el } from "./B08_Quantity_UI_StructureSheet_Formula";
|
||||
|
||||
export interface UnitPriceSpecRow {
|
||||
seq: number;
|
||||
name?: string;
|
||||
spec?: string;
|
||||
unit?: string;
|
||||
from_row?: number;
|
||||
quantity?: number;
|
||||
ref_code?: string;
|
||||
work_item_code?: string;
|
||||
variant_from?: string;
|
||||
sub_vars?: Record<string, number | string>;
|
||||
}
|
||||
|
||||
export interface ManualPrice {
|
||||
material: number;
|
||||
labor: number;
|
||||
expense: number;
|
||||
source: string;
|
||||
entered_at?: string;
|
||||
}
|
||||
|
||||
export interface UnitPriceEditorData {
|
||||
rows: UnitPriceSpecRow[];
|
||||
default_rows: UnitPriceSpecRow[];
|
||||
edited: boolean;
|
||||
manual_prices: Record<string, ManualPrice>;
|
||||
}
|
||||
|
||||
interface SearchItem {
|
||||
code: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
price: number | null;
|
||||
}
|
||||
|
||||
interface EditorOptions {
|
||||
projectId: string;
|
||||
sheetKey: string;
|
||||
sheetRows: { no: number; name: string; unit: string }[];
|
||||
data: UnitPriceEditorData;
|
||||
onSaved: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const MONEY: [keyof Omit<ManualPrice, "source" | "entered_at">, string][] = [
|
||||
["material", "재료비"],
|
||||
["labor", "노무비"],
|
||||
["expense", "경비"],
|
||||
];
|
||||
|
||||
function sheetUrl(projectId: string, tail: string): string {
|
||||
return `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets${tail}`;
|
||||
}
|
||||
|
||||
function input(value: string | number | undefined, width: string, type = "text"): HTMLInputElement {
|
||||
const node = document.createElement("input");
|
||||
node.type = type;
|
||||
node.value = value === undefined ? "" : String(value);
|
||||
node.style.width = width;
|
||||
return node;
|
||||
}
|
||||
|
||||
function codeText(row: UnitPriceSpecRow): string {
|
||||
if (row.ref_code) return row.ref_code;
|
||||
if (row.work_item_code) {
|
||||
return row.variant_from
|
||||
? `${row.work_item_code} (갈래: ${row.variant_from})`
|
||||
: row.work_item_code;
|
||||
}
|
||||
return "코드 없음";
|
||||
}
|
||||
|
||||
/** 줄 고치기 칸 — 로컬에서만 고치다 [저장]에 서버로. */
|
||||
export function unitPriceEditor(options: EditorOptions): HTMLElement {
|
||||
const { projectId, sheetKey, sheetRows, data, onSaved, onClose } = options;
|
||||
let rows: UnitPriceSpecRow[] = structuredClone(data.rows);
|
||||
const manual: Record<string, ManualPrice> = structuredClone(data.manual_prices);
|
||||
let picking: number | null = null;
|
||||
|
||||
const wrap = el("div", "b08-unit-edit");
|
||||
const tbody = document.createElement("tbody");
|
||||
const picker = el("div", "b08-unit-edit__picker");
|
||||
const status = el("span", "b08-grid__caption");
|
||||
|
||||
const render = (): void => {
|
||||
tbody.replaceChildren(...rows.map((row, index) => rowOf(row, index)));
|
||||
picker.hidden = picking === null;
|
||||
};
|
||||
|
||||
const rowOf = (row: UnitPriceSpecRow, index: number): HTMLTableRowElement => {
|
||||
const tr = document.createElement("tr");
|
||||
const name = input(row.name, "9rem");
|
||||
name.addEventListener("input", () => (row.name = name.value || undefined));
|
||||
|
||||
// 수량 — 원단위 줄을 따르거나 박힌 값.
|
||||
const source = document.createElement("select");
|
||||
source.append(new Option("박힌 값", ""));
|
||||
for (const line of sheetRows) {
|
||||
source.append(new Option(`원단위 ${line.no}. ${line.name} (${line.unit})`, String(line.no)));
|
||||
}
|
||||
source.value = row.from_row ? String(row.from_row) : "";
|
||||
const fixed = input(row.quantity, "5rem", "number");
|
||||
fixed.step = "any";
|
||||
fixed.disabled = Boolean(row.from_row);
|
||||
source.addEventListener("change", () => {
|
||||
row.from_row = source.value ? Number(source.value) : undefined;
|
||||
if (row.from_row) delete row.quantity;
|
||||
fixed.disabled = Boolean(row.from_row);
|
||||
});
|
||||
fixed.addEventListener("input", () => {
|
||||
row.quantity = fixed.value === "" ? undefined : Number(fixed.value);
|
||||
});
|
||||
const quantity = el("td", "");
|
||||
quantity.append(source, fixed);
|
||||
|
||||
const unit = input(row.unit, "3rem");
|
||||
unit.addEventListener("input", () => (row.unit = unit.value || undefined));
|
||||
|
||||
const code = el("td", "", codeText(row));
|
||||
const find = el("button", "", "찾기");
|
||||
find.type = "button";
|
||||
find.title = "품셈·자원을 찾아 이 줄의 코드로 넣음";
|
||||
find.addEventListener("click", () => {
|
||||
picking = index;
|
||||
render();
|
||||
picker.querySelector("input")?.focus();
|
||||
});
|
||||
code.append(" ", find);
|
||||
|
||||
// 수동 단가 — 넣으면 빨간 테두리 · 표 머리에 「미확정」으로 셈. 프로젝트에만 저장됨.
|
||||
const price = el("td", "");
|
||||
const key = String(row.seq);
|
||||
const on = document.createElement("input");
|
||||
on.type = "checkbox";
|
||||
on.checked = key in manual;
|
||||
on.title = "수동 단가 — 이 프로젝트에만 저장(라이브러리로 안 감)";
|
||||
price.append(on, " 수동");
|
||||
const fields = MONEY.map(([field, label]) => {
|
||||
const box = input(manual[key]?.[field], "5.5rem", "number");
|
||||
box.min = "0";
|
||||
box.step = "any";
|
||||
box.placeholder = label;
|
||||
box.title = `${label} 단가(단위당)`;
|
||||
box.addEventListener("input", () => {
|
||||
if (manual[key]) manual[key][field] = Number(box.value || 0);
|
||||
});
|
||||
return box;
|
||||
});
|
||||
const origin = input(manual[key]?.source, "7rem");
|
||||
origin.placeholder = "출처";
|
||||
origin.addEventListener("input", () => {
|
||||
if (manual[key]) manual[key].source = origin.value;
|
||||
});
|
||||
const sync = (): void => {
|
||||
for (const box of [...fields, origin]) {
|
||||
box.disabled = !on.checked;
|
||||
box.classList.toggle("b08-unit__manual", on.checked);
|
||||
}
|
||||
};
|
||||
on.addEventListener("change", () => {
|
||||
if (on.checked) {
|
||||
manual[key] = { material: 0, labor: 0, expense: 0, source: "" };
|
||||
fields.forEach((box, i) => (manual[key][MONEY[i][0]] = Number(box.value || 0)));
|
||||
manual[key].source = origin.value;
|
||||
} else {
|
||||
delete manual[key];
|
||||
}
|
||||
sync();
|
||||
});
|
||||
sync();
|
||||
price.append(...fields, origin);
|
||||
if (manual[key]?.entered_at) price.append(` ${manual[key].entered_at}`);
|
||||
|
||||
const remove = el("button", "", "빼기");
|
||||
remove.type = "button";
|
||||
remove.addEventListener("click", () => {
|
||||
rows = rows.filter((_, i) => i !== index);
|
||||
delete manual[key];
|
||||
picking = null;
|
||||
render();
|
||||
});
|
||||
const actions = el("td", "");
|
||||
actions.append(remove);
|
||||
|
||||
const nameCell = el("td", "");
|
||||
nameCell.append(name);
|
||||
const unitCell = el("td", "");
|
||||
unitCell.append(unit);
|
||||
tr.append(el("td", "", String(row.seq)), nameCell, quantity, unitCell, code, price, actions);
|
||||
return tr;
|
||||
};
|
||||
|
||||
// 고르개 — 이 프로젝트 단가표에서 낱말로 찾음.
|
||||
const kind = document.createElement("select");
|
||||
kind.append(new Option("품셈(일위대가)", "work"), new Option("자원(자재·노임·중기)", "resource"));
|
||||
const query = input("", "12rem");
|
||||
query.placeholder = "이름·코드·규격 낱말";
|
||||
const go = el("button", "", "찾기");
|
||||
go.type = "button";
|
||||
const shut = el("button", "", "닫기");
|
||||
shut.type = "button";
|
||||
const results = el("div", "b08-unit-edit__results");
|
||||
const search = async (): Promise<void> => {
|
||||
if (!query.value.trim()) return;
|
||||
results.replaceChildren(el("span", "b08-grid__caption", "찾는 중…"));
|
||||
try {
|
||||
const response = await fetch(
|
||||
sheetUrl(
|
||||
projectId,
|
||||
`/price-search?q=${encodeURIComponent(query.value)}&kind=${kind.value}`,
|
||||
),
|
||||
{ credentials: "include" },
|
||||
);
|
||||
const payload = (await response.json()) as { items?: SearchItem[]; message?: string };
|
||||
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
const items = payload.items ?? [];
|
||||
results.replaceChildren(
|
||||
...(items.length ? items.map(choice) : [el("span", "b08-grid__caption", "없음")]),
|
||||
);
|
||||
} catch (error) {
|
||||
results.replaceChildren(
|
||||
el("span", "b08-grid__caption", `못 찾음 — ${error instanceof Error ? error.message : ""}`),
|
||||
);
|
||||
}
|
||||
};
|
||||
const choice = (item: SearchItem): HTMLElement => {
|
||||
const price = item.price === null ? "단가 안 섬" : `${item.price.toLocaleString("ko-KR")}원`;
|
||||
const button = el(
|
||||
"button",
|
||||
"",
|
||||
`${item.code} · ${item.name}${item.spec ? ` ${item.spec}` : ""} · ${item.unit} · ${price}`,
|
||||
);
|
||||
button.type = "button";
|
||||
button.addEventListener("click", () => {
|
||||
const row = picking === null ? undefined : rows[picking];
|
||||
if (!row) return;
|
||||
row.ref_code = item.code;
|
||||
row.name = item.name;
|
||||
row.spec = item.spec || undefined;
|
||||
row.unit = item.unit || undefined;
|
||||
delete row.work_item_code;
|
||||
delete row.variant_from;
|
||||
delete row.sub_vars;
|
||||
picking = null;
|
||||
render();
|
||||
});
|
||||
return button;
|
||||
};
|
||||
go.addEventListener("click", () => void search());
|
||||
query.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") void search();
|
||||
});
|
||||
shut.addEventListener("click", () => {
|
||||
picking = null;
|
||||
render();
|
||||
});
|
||||
picker.append(kind, query, go, shut, results);
|
||||
|
||||
const grid = el("table", "b08-grid__table");
|
||||
const head = document.createElement("tr");
|
||||
for (const label of ["차례", "이름", "수량", "단위", "코드", "단가 수동(단위당)", ""]) {
|
||||
head.append(el("th", "", label));
|
||||
}
|
||||
const thead = document.createElement("thead");
|
||||
thead.append(head);
|
||||
grid.append(thead, tbody);
|
||||
const scroller = el("div", "b08-grid__scroll");
|
||||
scroller.append(grid);
|
||||
|
||||
const add = el("button", "", "줄 더하기");
|
||||
add.type = "button";
|
||||
add.addEventListener("click", () => {
|
||||
// 양식 줄 차례와도 안 겹치게 — 뺀 양식 줄의 차례를 새 줄이 물려받지 않음.
|
||||
const seq = Math.max(0, ...rows.map((r) => r.seq), ...data.default_rows.map((r) => r.seq)) + 1;
|
||||
rows.push({ seq, name: "", quantity: 1 });
|
||||
render();
|
||||
});
|
||||
const reset = el("button", "", "양식대로");
|
||||
reset.type = "button";
|
||||
reset.title = "줄 조합을 양식 원래대로 — [저장]해야 반영";
|
||||
reset.addEventListener("click", () => {
|
||||
rows = structuredClone(data.default_rows);
|
||||
picking = null;
|
||||
render();
|
||||
});
|
||||
const save = el("button", "b08-spec__save", "일위대가 저장");
|
||||
save.type = "button";
|
||||
save.addEventListener("click", () => {
|
||||
void (async () => {
|
||||
save.disabled = true;
|
||||
status.textContent = "저장 중…";
|
||||
try {
|
||||
// 넣은 날짜는 서버가 붙임 — 값·출처만 보냄.
|
||||
const prices = Object.fromEntries(
|
||||
Object.entries(manual).map(([seq, { entered_at: _date, ...price }]) => [seq, price]),
|
||||
);
|
||||
const response = await fetch(sheetUrl(projectId, "/unit-price"), {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sheet_key: sheetKey, rows, manual_prices: prices }),
|
||||
});
|
||||
const payload = (await response.json().catch(() => ({}))) as { message?: string };
|
||||
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
onSaved();
|
||||
} catch (error) {
|
||||
status.textContent = `저장 못 함 — ${error instanceof Error ? error.message : ""}`;
|
||||
save.disabled = false;
|
||||
}
|
||||
})();
|
||||
});
|
||||
const close = el("button", "b08-quantity__tab", "고친 것 버리고 닫기");
|
||||
close.type = "button";
|
||||
close.addEventListener("click", onClose);
|
||||
|
||||
const actions = el("div", "b08-sheet__actions");
|
||||
actions.append(add, save, reset, close, status);
|
||||
wrap.append(
|
||||
el(
|
||||
"p",
|
||||
"b08-grid__caption",
|
||||
"줄 조합은 이 양식+프로젝트에 저장(내 라이브러리로 갈 수 있음) · 수동 단가는 이 프로젝트에만 저장",
|
||||
),
|
||||
scroller,
|
||||
picker,
|
||||
actions,
|
||||
);
|
||||
render();
|
||||
return wrap;
|
||||
}
|
||||
@@ -210,6 +210,77 @@ def test_재귀는_5단까지() -> None:
|
||||
assert six["complete"] is False # 윗 표 → d1 → … → d5 = 6단
|
||||
|
||||
|
||||
def test_수동_단가는_막힌_줄을_세우고_미확정으로_센다() -> None:
|
||||
"""PLAN 3장 ③ — 모르터(코드 미정)에 수동 단가 · 0.02349㎥ × 재료 80,000 = 1,879.2."""
|
||||
manual = {
|
||||
"2": {
|
||||
"material": 80000,
|
||||
"labor": 0,
|
||||
"expense": 0,
|
||||
"source": "견적",
|
||||
"entered_at": "2026-09-13",
|
||||
}
|
||||
}
|
||||
table = unit_price_table(TEMPLATE, _sheet(), BOOK, _variant, (), manual)
|
||||
mortar = next(row for row in table["rows"] if row["seq"] == 2)
|
||||
assert mortar["manual"] is True and mortar["manual_source"] == "견적"
|
||||
assert mortar["material"] == pytest.approx(1879.2) and mortar["reason"] == ""
|
||||
assert table["blocked"] == 0 and table["complete"] is True and table["unconfirmed"] == 1
|
||||
# 단가표에 값이 있는 줄도 수동 단가가 이김 — 그래도 미확정.
|
||||
manual["1"] = {"material": 1, "labor": 0, "expense": 0}
|
||||
again = unit_price_table(TEMPLATE, _sheet(), BOOK, _variant, (), manual)
|
||||
assert again["rows"][0]["total"] == pytest.approx(2.6) and again["unconfirmed"] == 2
|
||||
|
||||
|
||||
def test_줄_조합_저장본은_양식과_같으면_지우고_수동_단가는_남은_줄만() -> None:
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import (
|
||||
save_manual,
|
||||
save_rows,
|
||||
with_rows,
|
||||
)
|
||||
|
||||
template = {"type_id": "masonry_wet", **TEMPLATE}
|
||||
rows = TEMPLATE["unit_price"]["rows"]
|
||||
edited = [*rows[:2], {"seq": 4, "name": "추가", "quantity": 1.0, "ref_code": "B-FP-12-25"}]
|
||||
merged, changed = save_rows({"other": []}, "masonry_wet", template, edited)
|
||||
assert changed and merged["masonry_wet"] == edited and "other" in merged
|
||||
back, changed = save_rows(merged, "masonry_wet", template, [dict(row) for row in rows])
|
||||
assert changed and back == {"other": []}
|
||||
assert with_rows(template, edited)["unit_price"]["rows"] == edited
|
||||
assert with_rows({"code": "x"}, None)["unit_price"]["rows"] == []
|
||||
|
||||
price = {"material": 10.0, "labor": 0.0, "expense": 0.0, "source": "견적"}
|
||||
first = save_manual({}, "masonry_wet", edited, {"2": price, "3": price}, "2026-09-01")
|
||||
assert list(first["masonry_wet"]) == ["2"] # 뺀 줄(3)의 값은 안 남음
|
||||
same = save_manual(first, "masonry_wet", edited, {"2": price}, "2026-09-13")
|
||||
assert same["masonry_wet"]["2"]["entered_at"] == "2026-09-01" # 값이 같으면 옛 날짜
|
||||
moved = save_manual(
|
||||
first, "masonry_wet", edited, {"2": {**price, "material": 11.0}}, "2026-09-13"
|
||||
)
|
||||
assert moved["masonry_wet"]["2"]["entered_at"] == "2026-09-13"
|
||||
assert save_manual(first, "masonry_wet", edited, {}, "2026-09-13") == {}
|
||||
|
||||
|
||||
def test_고르개는_갈래별로_낱말이_모두_든_항목과_단가를_낸다() -> None:
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import search_titles
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PriceBook, PriceKind, PriceTitle
|
||||
|
||||
book = PriceBook()
|
||||
slots = [None] * 5
|
||||
book.add_title(
|
||||
PriceTitle("M-001", PriceKind.MATERIAL, "시멘트", "40kg", "포", [*slots, Decimal(5000)])
|
||||
)
|
||||
book.add_title(PriceTitle("M-002", PriceKind.MATERIAL, "시멘트 모르터", "1:3", "m3"))
|
||||
book.add_title(PriceTitle("B-FP-01", PriceKind.UNIT_PRICE, "모르터 비비기", "", "m3"))
|
||||
found = search_titles(book, "시멘트", "resource")
|
||||
assert [item["code"] for item in found] == ["M-001", "M-002"]
|
||||
assert found[0]["price"] == pytest.approx(5000.0) and found[1]["price"] is None
|
||||
assert found[1]["unit"] == "㎥"
|
||||
assert [item["code"] for item in search_titles(book, "모르터 1:3", "resource")] == ["M-002"]
|
||||
assert [item["code"] for item in search_titles(book, "모르터", "work")] == ["B-FP-01"]
|
||||
assert search_titles(book, " ", "work") == []
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
root = tmp_path / "project"
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""구조물도 일위대가 줄 고치기 창구 (2026-09-13, PLAN 3장 ③).
|
||||
|
||||
겨누는 것
|
||||
① 줄 더하기·빼기를 저장하면 표가 그 조합으로 서고, 양식대로 보내면 저장본이 지워짐
|
||||
② 수동 단가는 막힌 줄을 세우고 「미확정」으로 셈 · 넣은 날짜는 서버가 붙임
|
||||
③ ⛔ 저장 자리는 둘 — [내 라이브러리에 저장]은 줄 조합만 싣고 수동 단가는 안 실음
|
||||
④ 다른 양식을 가져오면 그 종류의 줄 조합·수동 단가가 비워짐
|
||||
⑤ 고르개가 실제 단가표에서 품셈·자원을 찾음 · 차례가 겹치면 거절
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import B08_Quantity.B08_Quantity_Engine_StructureLibrary as library_module # noqa: E402
|
||||
import B08_Quantity.B08_Quantity_Router_Material as material_module # noqa: E402
|
||||
import B08_Quantity.B08_Quantity_Router_StructureSheet as router_module # noqa: E402
|
||||
from B05_Profile.B05_Profile_Structures_Repository import save_structures # noqa: E402
|
||||
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import load_template # noqa: E402
|
||||
from common_util.common_util_auth import verify_session # noqa: E402
|
||||
from common_util.common_util_project_settings import quantity_settings # noqa: E402
|
||||
|
||||
PROJECT_ID = "55555555-5555-5555-5555-555555555555"
|
||||
SHEETS = f"/api/projects/{PROJECT_ID}/quantity/structure-sheets"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def project(tmp_path: Path) -> Path:
|
||||
root = tmp_path / "project"
|
||||
root.mkdir()
|
||||
wall = StructureInstance.model_validate(
|
||||
{
|
||||
"type_id": "masonry_wet",
|
||||
"placement": "interval",
|
||||
"start_m": 0.0,
|
||||
"end_m": 10.0,
|
||||
"options": {"height_m": 2.5, "back_len_cm": 45},
|
||||
}
|
||||
)
|
||||
save_structures(str(root), [wall], base_revision=0)
|
||||
return root
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(project: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
async def fake_root(project_id):
|
||||
return str(project)
|
||||
|
||||
async def no_route(project_id):
|
||||
return {}
|
||||
|
||||
async def real_build(project_id):
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import cached_build
|
||||
|
||||
return cached_build()
|
||||
|
||||
monkeypatch.setattr(library_module, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
|
||||
monkeypatch.setattr(router_module, "_project_root", fake_root)
|
||||
monkeypatch.setattr(router_module, "_price_build", real_build)
|
||||
monkeypatch.setattr(material_module, "_section_modes", no_route)
|
||||
monkeypatch.setattr(material_module, "_ground_types", no_route)
|
||||
app = FastAPI()
|
||||
app.include_router(router_module.router)
|
||||
app.dependency_overrides[verify_session] = lambda: {"company_id": 7, "user_id": 42}
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _key(client: TestClient) -> str:
|
||||
return client.get(SHEETS).json()["sheets"][0]["key"]
|
||||
|
||||
|
||||
def _table(client: TestClient, key: str) -> dict:
|
||||
response = client.get(f"{SHEETS}/unit-price", params={"sheet_key": key})
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
def test_줄을_고치고_수동_단가를_넣으면_저장_자리가_둘로_갈린다(
|
||||
client: TestClient, project: Path, tmp_path: Path
|
||||
) -> None:
|
||||
key = _key(client)
|
||||
before = _table(client, key)
|
||||
assert before["editor"]["edited"] is False and before["unit_price"]["unconfirmed"] == 0
|
||||
rows = before["editor"]["rows"]
|
||||
# 기초잡석(3) 빼고 · 고정 수량 1.5 줄 더하기 · 모르터(2)에 수동 단가.
|
||||
edited = [
|
||||
rows[0],
|
||||
rows[1],
|
||||
{"seq": 4, "name": "추가 잡석", "quantity": 1.5, "ref_code": "B-FP-12-25"},
|
||||
]
|
||||
saved = client.put(
|
||||
f"{SHEETS}/unit-price",
|
||||
json={
|
||||
"sheet_key": key,
|
||||
"rows": edited,
|
||||
"manual_prices": {
|
||||
"2": {"material": 80000, "source": "견적 3곳 평균"},
|
||||
"3": {"labor": 1},
|
||||
},
|
||||
},
|
||||
)
|
||||
assert saved.status_code == 200, saved.text
|
||||
assert saved.json()["edited"] is True and saved.json()["manual_prices"] == 1
|
||||
|
||||
after = _table(client, key)
|
||||
table = after["unit_price"]
|
||||
assert [row["seq"] for row in table["rows"]] == [1, 2, 4]
|
||||
mortar = table["rows"][1]
|
||||
assert mortar["manual"] is True and mortar["manual_entered_at"]
|
||||
assert table["unconfirmed"] == 1 and table["complete"] is True
|
||||
assert table["rows"][2]["quantity"] == pytest.approx(1.5) and table["rows"][2]["total"] > 0
|
||||
|
||||
# 저장 자리 둘 — 줄 조합은 종류별, 수동 단가는 프로젝트 칸.
|
||||
settings = quantity_settings(str(project))
|
||||
assert [row["seq"] for row in settings["structure_unit_price_rows"]["masonry_wet"]] == [1, 2, 4]
|
||||
assert settings["structure_manual_prices"]["masonry_wet"]["2"]["source"] == "견적 3곳 평균"
|
||||
|
||||
# ⛔ 내 라이브러리에는 줄 조합만 — 수동 단가 흔적이 없음.
|
||||
mine = client.put(f"{SHEETS}/library/personal", json={"sheet_key": key})
|
||||
assert mine.status_code == 200, mine.text
|
||||
folder = tmp_path / "storage" / "7" / "42" / "library"
|
||||
item = json.loads((folder / f"{mine.json()['code']}.json").read_text(encoding="utf-8"))
|
||||
assert [row["seq"] for row in item["unit_price"]["rows"]] == [1, 2, 4]
|
||||
assert "80000" not in json.dumps(item) and "견적" not in json.dumps(item)
|
||||
|
||||
# 양식대로 되돌리면 줄 조합 저장본이 지워짐.
|
||||
back = client.put(
|
||||
f"{SHEETS}/unit-price",
|
||||
json={"sheet_key": key, "rows": after["editor"]["default_rows"], "manual_prices": {}},
|
||||
)
|
||||
assert back.json()["edited"] is False
|
||||
assert "masonry_wet" not in quantity_settings(str(project))["structure_unit_price_rows"]
|
||||
|
||||
|
||||
def test_다른_양식을_가져오면_줄_조합과_수동_단가가_비워진다(
|
||||
client: TestClient, project: Path
|
||||
) -> None:
|
||||
key = _key(client)
|
||||
rows = load_template("masonry_wet")["unit_price"]["rows"][:2]
|
||||
client.put(
|
||||
f"{SHEETS}/unit-price",
|
||||
json={"sheet_key": key, "rows": rows, "manual_prices": {"2": {"material": 1}}},
|
||||
)
|
||||
taken = client.put(
|
||||
f"{SHEETS}/library/import",
|
||||
json={
|
||||
"type_id": "masonry_wet",
|
||||
"tier": "program",
|
||||
"code": load_template("masonry_wet")["code"],
|
||||
},
|
||||
)
|
||||
assert taken.status_code == 200, taken.text
|
||||
assert (
|
||||
taken.json()["cleared_unit_price_rows"] is True
|
||||
and taken.json()["cleared_manual_prices"] == 1
|
||||
)
|
||||
settings = quantity_settings(str(project))
|
||||
assert "masonry_wet" not in settings["structure_unit_price_rows"]
|
||||
assert "masonry_wet" not in settings["structure_manual_prices"]
|
||||
|
||||
|
||||
def test_고르개와_차례_겹침_거절(client: TestClient) -> None:
|
||||
found = client.get(f"{SHEETS}/price-search", params={"q": "FP-12-25", "kind": "work"})
|
||||
assert found.status_code == 200, found.text
|
||||
assert any(item["code"] == "B-FP-12-25" for item in found.json()["items"])
|
||||
resource = client.get(f"{SHEETS}/price-search", params={"q": "보통인부", "kind": "resource"})
|
||||
assert resource.json()["items"], "노임 보통인부가 자원 갈래에 있어야 함"
|
||||
|
||||
twice = [{"seq": 1, "name": "a", "quantity": 1}, {"seq": 1, "name": "b", "quantity": 1}]
|
||||
bad = client.put(f"{SHEETS}/unit-price", json={"sheet_key": _key(client), "rows": twice})
|
||||
assert bad.status_code == 422
|
||||
Reference in New Issue
Block a user