Merge remote-tracking branch 'origin/dev' into main_laptop_1

This commit is contained in:
2026-09-13 20:00:38 +09:00
12 changed files with 1018 additions and 63 deletions
@@ -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")
+12 -1
View File
@@ -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;
}
@@ -50,4 +50,4 @@
- 실무 레시피 350개 중 품셈 근거문구 보유는 82개뿐이다. 근거 없는 268개는 현행값으로 임의 확정하지 않는다.
- 과거 임도품셈 절 번호가 현행 산림사업 표준품셈에서 크게 바뀌었다. 번호만 같은 다른 절을 자동 연결하지 않는다.
- JSON의 `수량문자_확인`은 후보 절 본문에 같은 숫자가 있는지를 세는 1차 검색값이다. `일치` 확정은 위 두 항목처럼 조건과 행을 직접 대조한 경우만 한다.
- 후속 수동 대조는 근거 절이 확인된 11개부터 하고, 복수 절 참조 69개는 과거판-현행판 절 대응표가 있어야 안전하다.
- 후속 판 대응표는 `33_임도품셈_판_대응표.md``30_원자료/임도품셈_판_대응표.csv`에 작성했다. 확정 2·추정 11·미확인 1이며, 추정·미확인은 자동 치환에 쓰지 않는다.
@@ -0,0 +1,28 @@
# 과거 임도품셈 ↔ 2026 현행 산림사업 표준품셈 대응표
작성일: 2026-09-13
산출물: `30_원자료/임도품셈_판_대응표.csv`
현행 기준: 산림청고시 제2025-82호, 2026-01-01 시행
## 결과
STmate 레시피에 남은 임도계 품셈 인용 **14개를 전수 등록**했다.
| 판정 | 수 | 뜻 |
|---|---:|---|
| 확정 | 2 | 명칭·적용조건과 관측 수량을 현행 원문에서 직접 대조 |
| 추정 | 11 | 명칭·공종 맥락으로 현행 후보를 특정했으나 과거 원문판 또는 수량 대조가 부족 |
| 미확인 | 1 | 현행 후보가 작업방식까지 같다는 근거가 없음 |
확정은 `2-3 제근 → 9-21 제근`과, STmate에서 `4-16-2`로 인용한 **절취없는 단끊기**`5-16-1 단끊기` 두 건이다. 후자는 보통인부 `0.34·0.36·1.17인/100m`가 현행 세 공정과 각각 일치하는 범위에서만 확정했다.
## 코드 사용 규칙
- 대응표의 현행 절은 `work_item_master_2026-01-01.json``FP-*` 코드와 표 ID를 함께 기록했다.
- 과거 한 절이 현행 여러 절로 나뉜 후보는 슬래시로 모두 보존했다. 대표 코드 하나를 임의 선택하지 않는다.
- `추정``미확인`은 코드 자동 치환에 사용하지 않는다. 값도 옮기지 않고 확인 대기로 세운다.
- 과거 판본 원문이 확보되면 표 제목·주석·수량을 대조해 `추정 → 확정` 또는 `미확인`으로 갱신한다.
## 경계
저장소에는 2026 현행판만 있고 STmate가 인용한 과거 임도품셈 원문판은 없다. 따라서 같은 이름이라는 이유만으로 확정하지 않았다. 특히 `7-3 노면정리`는 현행 `3-4-3`의 인력식 노면정지·노면굴기와 작업방식이 달라 현행 절을 `미확인`으로 남겼다.
@@ -0,0 +1,15 @@
과거판,과거_절,과거_인용명,STmate_적용공종,현행판,현행_절,현행_FP코드,현행_명칭,현행_표ID,판정,근거와_경계
"판본 미확인(STmate 2024~2025 출력)","번호 미기재","되메우기 및 다짐","구조물 되메우기(사질양토) 기계100%(굴삭기 0.7㎥)","산림청고시 2025-82호(2026-01-01)","9-14-1","FP-09-14-01","되메우기","F0279","추정","공종명과 적용맥락 일치. 과거 절 번호·원문 미확보"
"판본 미확인(STmate 2024~2025 출력)","5-1-2 사","암터파기","구조물터파기(육상암 0~1m) 기계100%(굴삭기 0.7㎥)","산림청고시 2025-82호(2026-01-01)","9-13-7","FP-09-13-07","육상 암절취(01m)","F0267","추정","구조물·육상암·0~1m 조건 일치. 수량 전수 대조 전"
"판본 미확인(STmate 2024~2025 출력)","7-3","노면정리(건설품셈 기계 시공 적용)","노면정리","산림청고시 2025-82호(2026-01-01)","미확인","미확인","미확인","미확인","미확인","현행 3-4-3은 노면정지·노면굴기 인력 보수표라 기계식 노면정리와 동일하다고 볼 수 없음"
"판본 미확인(STmate 2024~2025 출력)","4-16-2","선떼붙이기공","단끊기","산림청고시 2025-82호(2026-01-01)","5-16 / 5-16-1 / 5-16-2","FP-05-16 / FP-05-16-01 / FP-05-16-02","선떼붙이기공 / 단끊기 / 선떼붙이기","F0121 / F0122","추정","과거 한 절이 현행 부모와 두 하위 절로 분리된 후보. 단끊기 일부만 별도 확정"
"판본 미확인(STmate 2024~2025 출력)","4-16-2","절취없는 단끊기","단끊기 절취없음","산림청고시 2025-82호(2026-01-01)","5-16-1","FP-05-16-01","단끊기","F0121","확정","보통인부 0.34·0.36·1.17인/100m가 현행 세 공정과 각각 일치. 적용범위는 절취없는 구성에 한정"
"판본 미확인(STmate 2024~2025 출력)","3-6 가","임도성토면 다짐","성토사면다짐 B/H0.7+콤팩터","산림청고시 2025-82호(2026-01-01)","9-17-1","FP-09-17-01","비탈면 다짐","미확인","추정","성토사면과 현행 비탈면 다짐의 기능상 후보. 현행 마스터에 직접 표가 없어 수량 대조 필요"
"판본 미확인(STmate 2024~2025 출력)","9-3","임도성토면다짐","성토사면다짐 굴삭기0.7㎥","산림청고시 2025-82호(2026-01-01)","9-17-1","FP-09-17-01","비탈면 다짐","미확인","추정","같은 STmate 자료 안에서도 과거 절 인용이 3-6과 9-3으로 갈림. 과거 판본부터 확인 필요"
"판본 미확인(STmate 2024~2025 출력)","4-16-4","파종공","씨뿌리기(성토면) 줄파종","산림청고시 2025-82호(2026-01-01)","5-18","FP-05-18","씨뿌리기(줄)","F0125","추정","줄파종·씨뿌리기 명칭과 적용맥락 일치. 과거 원문·수량 미대조"
"판본 미확인(STmate 2024~2025 출력)","3-3-2","굴삭기 적용 암절취","암절취 굴삭기0.7㎥+대형브레카","산림청고시 2025-82호(2026-01-01)","9-4 / 9-4-1 / 9-4-2","FP-09-04 / FP-09-04-01 / FP-09-04-02","암절취 / 암파쇄 / 집토","F0241 / F0242","추정","현행은 암절취를 암파쇄+집토 단계합산형으로 분리. 과거 한 절과 일대다 후보"
"판본 미확인(STmate 2024~2025 출력)","2-3","제근(100㎡당)","제근","산림청고시 2025-82호(2026-01-01)","9-21","FP-09-21","제근","F0294","확정","밀림 보통인부 0.05인이 현행 9-21과 직접 일치"
"판본 미확인(STmate 2024~2025 출력)","5-1-1 나","암절취","측구터파기(암) 기계100%(굴삭기 0.4㎥)","산림청고시 2025-82호(2026-01-01)","9-12-2","FP-09-12-02","암절취","F0259","추정","측구터파기·암 조건으로 현행 절 후보 특정. 수량·장비조건 미대조"
"판본 미확인(STmate 2024~2025 출력)","3-7 가","층따기","층따기","산림청고시 2025-82호(2026-01-01)","9-18","FP-09-18","층따기","F0287","추정","명칭 직접 일치하지만 과거 원문판이 없어 절 동일성 확정 불가"
"판본 미확인(STmate 2024~2025 출력)","3-3-1","토사 굴삭기 적용","토사절취(흙깎기) 굴삭기0.7㎥","산림청고시 2025-82호(2026-01-01)","9-3-2","FP-09-03-02","기계","F0240","추정","현행 9-3 토사깍기의 기계 하위 절 후보. 수량·토질조건 미대조"
"판본 미확인(STmate 2024~2025 출력)","4-39","쇄석·혼합석 부설","혼합석부설 T=0.1m","산림청고시 2025-82호(2026-01-01)","11-4","FP-11-04","쇄석․혼합석 부설","F0328","추정","명칭 직접 일치하지만 두께·장비·수량 조건 미대조"
1 과거판 과거_절 과거_인용명 STmate_적용공종 현행판 현행_절 현행_FP코드 현행_명칭 현행_표ID 판정 근거와_경계
2 판본 미확인(STmate 2024~2025 출력) 번호 미기재 되메우기 및 다짐 구조물 되메우기(사질양토) 기계100%(굴삭기 0.7㎥) 산림청고시 2025-82호(2026-01-01) 9-14-1 FP-09-14-01 되메우기 F0279 추정 공종명과 적용맥락 일치. 과거 절 번호·원문 미확보
3 판본 미확인(STmate 2024~2025 출력) 5-1-2 사 암터파기 구조물터파기(육상암 0~1m) 기계100%(굴삭기 0.7㎥) 산림청고시 2025-82호(2026-01-01) 9-13-7 FP-09-13-07 육상 암절취(0~1m) F0267 추정 구조물·육상암·0~1m 조건 일치. 수량 전수 대조 전
4 판본 미확인(STmate 2024~2025 출력) 7-3 노면정리(건설품셈 기계 시공 적용) 노면정리 산림청고시 2025-82호(2026-01-01) 미확인 미확인 미확인 미확인 미확인 현행 3-4-3은 노면정지·노면굴기 인력 보수표라 기계식 노면정리와 동일하다고 볼 수 없음
5 판본 미확인(STmate 2024~2025 출력) 4-16-2 선떼붙이기공 단끊기 산림청고시 2025-82호(2026-01-01) 5-16 / 5-16-1 / 5-16-2 FP-05-16 / FP-05-16-01 / FP-05-16-02 선떼붙이기공 / 단끊기 / 선떼붙이기 F0121 / F0122 추정 과거 한 절이 현행 부모와 두 하위 절로 분리된 후보. 단끊기 일부만 별도 확정
6 판본 미확인(STmate 2024~2025 출력) 4-16-2 절취없는 단끊기 단끊기 절취없음 산림청고시 2025-82호(2026-01-01) 5-16-1 FP-05-16-01 단끊기 F0121 확정 보통인부 0.34·0.36·1.17인/100m가 현행 세 공정과 각각 일치. 적용범위는 절취없는 구성에 한정
7 판본 미확인(STmate 2024~2025 출력) 3-6 가 임도성토면 다짐 성토사면다짐 B/H0.7+콤팩터 산림청고시 2025-82호(2026-01-01) 9-17-1 FP-09-17-01 비탈면 다짐 미확인 추정 성토사면과 현행 비탈면 다짐의 기능상 후보. 현행 마스터에 직접 표가 없어 수량 대조 필요
8 판본 미확인(STmate 2024~2025 출력) 9-3 임도성토면다짐 성토사면다짐 굴삭기0.7㎥ 산림청고시 2025-82호(2026-01-01) 9-17-1 FP-09-17-01 비탈면 다짐 미확인 추정 같은 STmate 자료 안에서도 과거 절 인용이 3-6과 9-3으로 갈림. 과거 판본부터 확인 필요
9 판본 미확인(STmate 2024~2025 출력) 4-16-4 파종공 씨뿌리기(성토면) 줄파종 산림청고시 2025-82호(2026-01-01) 5-18 FP-05-18 씨뿌리기(줄) F0125 추정 줄파종·씨뿌리기 명칭과 적용맥락 일치. 과거 원문·수량 미대조
10 판본 미확인(STmate 2024~2025 출력) 3-3-2 굴삭기 적용 암절취 암절취 굴삭기0.7㎥+대형브레카 산림청고시 2025-82호(2026-01-01) 9-4 / 9-4-1 / 9-4-2 FP-09-04 / FP-09-04-01 / FP-09-04-02 암절취 / 암파쇄 / 집토 F0241 / F0242 추정 현행은 암절취를 암파쇄+집토 단계합산형으로 분리. 과거 한 절과 일대다 후보
11 판본 미확인(STmate 2024~2025 출력) 2-3 제근(100㎡당) 제근 산림청고시 2025-82호(2026-01-01) 9-21 FP-09-21 제근 F0294 확정 밀림 보통인부 0.05인이 현행 9-21과 직접 일치
12 판본 미확인(STmate 2024~2025 출력) 5-1-1 나 암절취 측구터파기(암) 기계100%(굴삭기 0.4㎥) 산림청고시 2025-82호(2026-01-01) 9-12-2 FP-09-12-02 암절취 F0259 추정 측구터파기·암 조건으로 현행 절 후보 특정. 수량·장비조건 미대조
13 판본 미확인(STmate 2024~2025 출력) 3-7 가 층따기 층따기 산림청고시 2025-82호(2026-01-01) 9-18 FP-09-18 층따기 F0287 추정 명칭 직접 일치하지만 과거 원문판이 없어 절 동일성 확정 불가
14 판본 미확인(STmate 2024~2025 출력) 3-3-1 토사 굴삭기 적용 토사절취(흙깎기) 굴삭기0.7㎥ 산림청고시 2025-82호(2026-01-01) 9-3-2 FP-09-03-02 기계 F0240 추정 현행 9-3 토사깍기의 기계 하위 절 후보. 수량·토질조건 미대조
15 판본 미확인(STmate 2024~2025 출력) 4-39 쇄석·혼합석 부설 혼합석부설 T=0.1m 산림청고시 2025-82호(2026-01-01) 11-4 FP-11-04 쇄석․혼합석 부설 F0328 추정 명칭 직접 일치하지만 두께·장비·수량 조건 미대조
@@ -102,7 +102,7 @@
| `jb_shapes.py` · `jb_block_map.py` · `jb_expansion_diff.py` | JB 43개 형태·블록·확장 차이 |
| `rate_grid_defs.py` · `settings_axis_map.py` | 폼에서 그리드·선택축 추출 |
후속 레시피 분석은 `20_분석/31_일위대가_레시피_사전.md` `20_분석/32_특수산식과_현행품셈_대조.md` 본다.
후속 레시피 분석은 `20_분석/31_일위대가_레시피_사전.md`부터 `20_분석/33_임도품셈_판_대응표.md`까지 본다.
## 다음
@@ -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