diff --git a/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py b/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py index 6a89f027..e23e3f9a 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py @@ -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 diff --git a/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py b/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py index 68671920..40d383ab 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py @@ -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 diff --git a/B08_Quantity/B08_Quantity_Router_StructureSheet.py b/B08_Quantity/B08_Quantity_Router_StructureSheet.py index 28178e7a..a80d9d49 100644 --- a/B08_Quantity/B08_Quantity_Router_StructureSheet.py +++ b/B08_Quantity/B08_Quantity_Router_StructureSheet.py @@ -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") diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet.ts index 596933b0..ebea1de3 100644 --- a/B08_Quantity/B08_Quantity_UI_StructureSheet.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet.ts @@ -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) => { diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPrice.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPrice.ts index 9cf11d0e..084e14f0 100644 --- a/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPrice.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPrice.ts @@ -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 => { + 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); diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts new file mode 100644 index 00000000..66ff8295 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts @@ -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; +} + +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; +} + +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, 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 = 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 => { + 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; +} diff --git a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/32_특수산식과_현행품셈_대조.md b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/32_특수산식과_현행품셈_대조.md index 1fad0aa4..db8b5400 100644 --- a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/32_특수산식과_현행품셈_대조.md +++ b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/32_특수산식과_현행품셈_대조.md @@ -50,4 +50,4 @@ - 실무 레시피 350개 중 품셈 근거문구 보유는 82개뿐이다. 근거 없는 268개는 현행값으로 임의 확정하지 않는다. - 과거 임도품셈 절 번호가 현행 산림사업 표준품셈에서 크게 바뀌었다. 번호만 같은 다른 절을 자동 연결하지 않는다. - JSON의 `수량문자_확인`은 후보 절 본문에 같은 숫자가 있는지를 세는 1차 검색값이다. `일치` 확정은 위 두 항목처럼 조건과 행을 직접 대조한 경우만 한다. -- 후속 수동 대조는 근거 절이 확인된 11개부터 하고, 복수 절 참조 69개는 과거판-현행판 절 대응표가 있어야 안전하다. +- 후속 판 대응표는 `33_임도품셈_판_대응표.md`와 `30_원자료/임도품셈_판_대응표.csv`에 작성했다. 확정 2·추정 11·미확인 1이며, 추정·미확인은 자동 치환에 쓰지 않는다. diff --git a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/33_임도품셈_판_대응표.md b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/33_임도품셈_판_대응표.md new file mode 100644 index 00000000..ef696dc9 --- /dev/null +++ b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/33_임도품셈_판_대응표.md @@ -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`의 인력식 노면정지·노면굴기와 작업방식이 달라 현행 절을 `미확인`으로 남겼다. diff --git a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/30_원자료/임도품셈_판_대응표.csv b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/30_원자료/임도품셈_판_대응표.csv new file mode 100644 index 00000000..2fa7e724 --- /dev/null +++ b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/30_원자료/임도품셈_판_대응표.csv @@ -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","육상 암절취(0~1m)","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","추정","명칭 직접 일치하지만 두께·장비·수량 조건 미대조" diff --git a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/README.md b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/README.md index 07053762..b03cf8c8 100644 --- a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/README.md +++ b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/README.md @@ -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`까지 본다. ## 다음 diff --git a/resources/tester/test_b08_structure_unit_price.py b/resources/tester/test_b08_structure_unit_price.py index 5cb92450..10246d27 100644 --- a/resources/tester/test_b08_structure_unit_price.py +++ b/resources/tester/test_b08_structure_unit_price.py @@ -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" diff --git a/resources/tester/test_b08_structure_unit_price_edit.py b/resources/tester/test_b08_structure_unit_price_edit.py new file mode 100644 index 00000000..c80f9de0 --- /dev/null +++ b/resources/tester/test_b08_structure_unit_price_edit.py @@ -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