Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
216 lines
8.8 KiB
Python
216 lines
8.8 KiB
Python
"""B09 원가 저장 자리(PLAN 144-1) — 프로젝트 안 `B09_Estimation/v1/`.
|
|
|
|
price_book.json 굳힌 단가 한 벌 {"made": 시각, "rows": {키: 입력 줄},
|
|
"driver_factor": …, "fx": […]}
|
|
— [단가 갱신] 때만 M01 로 새로 ·
|
|
[저장] 때 책에 없는 새 줄만 더함(있는 줄 안 바꿈)
|
|
user_values.json 사용자 값 {"materials": {자재 키: {applied?, supply?, quote?, quote_page?,
|
|
pick?}}, "estimate": {…},
|
|
"bill": {"v": 2, "rows": [표 차례 · 고친 칸 · 새 줄], "gone": [키]}}
|
|
(184 설계내역서 덮개 — 꼴은 `B09_Estimation_Calc_Bill` 머리 ·
|
|
옛 꼴 {"qty", "rows"}(166-2 · 182)도 그대로 다듬음)
|
|
— 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본)
|
|
|
|
quotes/<자재 키>/ 견적서 파일(166-4 · 여러 개) — 폴더가 목록(키의 못 쓰는 글자는 %XX) ·
|
|
읽을 때 materials[키].files = 이름들 · 사용자 값으로는 저장 안 함
|
|
|
|
결과(표)는 저장하지 않음 — GET 때 서버가 셈(B08 꼴).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import unquote
|
|
|
|
from B09_Estimation.B09_Estimation_Calc_Base import SOURCES, SUPPLY
|
|
from B09_Estimation.B09_Estimation_Calc_Bill import SECTIONS
|
|
|
|
FOLDER = ("B09_Estimation", "v1")
|
|
BOOK = "price_book.json"
|
|
USER = "user_values.json"
|
|
QUOTES = "quotes"
|
|
#: 폴더 · 파일 이름에 못 쓰는 글자(Windows)
|
|
BAD_NAME = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
|
|
|
#: 원가계산 사용자 입력 칸 — 요율 고르는 값 · 손입력(엑셀 총괄 · 원가계산서 · 내역 손입력 칸)
|
|
ESTIMATE_FIELDS = (
|
|
"estimated_price", # 추정가격(원) — 요율 구간 고름
|
|
"duration_days", # 공사기간(일)
|
|
"work_type", # 공종(글)
|
|
"environment_kind", # 환경보전비 공종 — road 도로 · 그 밖 = 그 밖의 토목(166-1)
|
|
"safety_base", # 산업안전보건관리비 기초액(원)
|
|
"profit_adjust", # 이윤 보정(원 · 빼는 값)
|
|
"vat_adjust", # 부가가치세 조정(원)
|
|
"indirect_material", # 간접재료비(원)
|
|
"scrap", # 작업설 부산물(원 · 빼는 값)
|
|
"procurement_fee_pct", # 조달수수료율(%)
|
|
)
|
|
|
|
TEXT_FIELDS = ("work_type", "environment_kind")
|
|
|
|
|
|
def folder(project_root: str | Path) -> Path:
|
|
return Path(project_root).joinpath(*FOLDER)
|
|
|
|
|
|
def _read(path: Path) -> dict[str, Any] | None:
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return None
|
|
return data if isinstance(data, dict) else None
|
|
|
|
|
|
def _write(path: Path, data: dict[str, Any]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temp = path.with_suffix(".tmp")
|
|
temp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
os.replace(temp, path)
|
|
|
|
|
|
def read_book(project_root: str | Path) -> dict[str, Any] | None:
|
|
book = _read(folder(project_root) / BOOK)
|
|
if book is None or not isinstance(book.get("rows"), dict):
|
|
return None
|
|
return book
|
|
|
|
|
|
def write_book(project_root: str | Path, book: dict[str, Any]) -> None:
|
|
_write(folder(project_root) / BOOK, book)
|
|
|
|
|
|
def _num(value: Any) -> float | None:
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
return None
|
|
return float(value) if math.isfinite(value) else None
|
|
|
|
|
|
def _text(value: Any) -> str:
|
|
return str(value)[:200] if isinstance(value, (str, int, float)) else ""
|
|
|
|
|
|
def _clean_ref(ref: Any) -> dict[str, Any] | None:
|
|
"""손 줄 연결 — dg · sg = 호표 키 · logic = M01 로직(입력값 · 켜쌓기율 · 고름)."""
|
|
if not isinstance(ref, dict):
|
|
return None
|
|
tab = ref.get("tab")
|
|
if tab in ("dg", "sg") and isinstance(ref.get("key"), str) and ref["key"]:
|
|
return {"tab": tab, "key": ref["key"]}
|
|
if tab == "logic" and isinstance(ref.get("code"), str) and ref["code"]:
|
|
given = ref.get("given") if isinstance(ref.get("given"), dict) else {}
|
|
picks = ref.get("picks") if isinstance(ref.get("picks"), dict) else None
|
|
return {"tab": "logic", "code": ref["code"], "name": _text(ref.get("name")),
|
|
"given": {str(k): v for k, v in given.items() if isinstance(v, (str, int, float))},
|
|
"rate": _num(ref.get("rate")), "picks": picks} # fmt: skip
|
|
return None
|
|
|
|
|
|
def clean_bill(raw: Any) -> dict[str, Any]:
|
|
"""설계내역서 손질(166-2) — 수량 덮어쓰기(수만) · 손 줄(`hand:` 키 · 아는 공종만) ·
|
|
182 레벨 = `kind`(group) · `parent`(`hand:` 키)."""
|
|
raw = raw if isinstance(raw, dict) else {}
|
|
qty = raw.get("qty") if isinstance(raw.get("qty"), dict) else {}
|
|
codes = {code for code, _name, _parent in SECTIONS}
|
|
rows = []
|
|
for row in raw.get("rows") if isinstance(raw.get("rows"), list) else []:
|
|
if not isinstance(row, dict) or not str(row.get("key") or "").startswith("hand:"):
|
|
continue
|
|
if row.get("section") not in codes:
|
|
continue
|
|
group = row.get("kind") == "group" # 182 묶음 줄 = 이름만
|
|
parent = str(row.get("parent") or "")
|
|
rows.append(
|
|
{
|
|
"key": str(row["key"]),
|
|
"section": row["section"],
|
|
"after": _text(row.get("after")),
|
|
**({"kind": "group"} if group else {}),
|
|
**({"parent": parent} if parent.startswith("hand:") else {}),
|
|
**{k: _text(row.get(k)) for k in ("name", "spec", "unit")},
|
|
"qty": None if group else _num(row.get("qty")),
|
|
"ref": None if group else _clean_ref(row.get("ref")),
|
|
}
|
|
)
|
|
out: dict[str, Any] = {}
|
|
kept = {str(k): _num(v) for k, v in qty.items() if _num(v) is not None}
|
|
if kept:
|
|
out["qty"] = kept
|
|
if rows:
|
|
out["rows"] = rows
|
|
return out
|
|
|
|
|
|
def clean_user(raw: Any) -> dict[str, Any]:
|
|
"""사용자 값 다듬기 — 모르는 칸 버림 · 수 칸은 수 · null(빈칸)만."""
|
|
raw = raw if isinstance(raw, dict) else {}
|
|
materials: dict[str, dict[str, Any]] = {}
|
|
for key, cell in (raw.get("materials") or {}).items():
|
|
if not isinstance(cell, dict):
|
|
continue
|
|
out: dict[str, Any] = {}
|
|
if "applied" in cell:
|
|
out["applied"] = _num(cell["applied"])
|
|
if cell.get("supply") in SUPPLY:
|
|
out["supply"] = cell["supply"]
|
|
if "quote" in cell:
|
|
out["quote"] = _num(cell["quote"])
|
|
if isinstance(cell.get("quote_page"), str) and cell["quote_page"].strip():
|
|
out["quote_page"] = cell["quote_page"].strip()
|
|
if cell.get("pick") in SOURCES:
|
|
out["pick"] = cell["pick"]
|
|
if out:
|
|
materials[str(key)] = out
|
|
estimate: dict[str, Any] = {}
|
|
given = raw.get("estimate") if isinstance(raw.get("estimate"), dict) else {}
|
|
for field in ESTIMATE_FIELDS:
|
|
if field not in given:
|
|
continue
|
|
value = given[field]
|
|
estimate[field] = (str(value) if value else None) if field in TEXT_FIELDS else _num(value)
|
|
rates = given.get("rates")
|
|
if isinstance(rates, dict):
|
|
estimate["rates"] = {str(k): _num(v) for k, v in rates.items()}
|
|
bill = clean_bill(raw.get("bill"))
|
|
return {"materials": materials, "estimate": estimate, **({"bill": bill} if bill else {})}
|
|
|
|
|
|
def read_user(project_root: str | Path) -> dict[str, Any]:
|
|
user = clean_user(_read(folder(project_root) / USER))
|
|
base = folder(project_root) / QUOTES
|
|
for sub in sorted(base.iterdir()) if base.is_dir() else []:
|
|
names = [f.name for f in quote_files(sub)]
|
|
if names:
|
|
user["materials"].setdefault(unquote(sub.name), {})["files"] = names
|
|
return user
|
|
|
|
|
|
def write_user(project_root: str | Path, user: dict[str, Any]) -> dict[str, Any]:
|
|
clean = clean_user(user)
|
|
_write(folder(project_root) / USER, clean)
|
|
return clean
|
|
|
|
|
|
def quote_dir(project_root: str | Path, key: str) -> Path:
|
|
"""자재 키 → 견적서 폴더 — 못 쓰는 글자 · % 는 %XX(되풀면 키)."""
|
|
part = re.sub(r"[%]|" + BAD_NAME.pattern, lambda m: f"%{ord(m.group()):02X}", key)
|
|
if not part.strip(" ."):
|
|
raise ValueError("자재 키가 비었습니다.")
|
|
return folder(project_root) / QUOTES / part
|
|
|
|
|
|
def safe_name(name: str) -> str:
|
|
"""올린 파일 이름 → 저장 이름(경로 떼고 · 못 쓰는 글자 _ · 앞뒤 점 · 빈칸 뺌)."""
|
|
return BAD_NAME.sub("_", re.split(r"[\\/]", name)[-1]).strip(" .")
|
|
|
|
|
|
def quote_files(path: Path) -> list[Path]:
|
|
"""견적서 폴더 파일(올리는 중 임시 `.…upload` 뺌) · 이름 차례."""
|
|
if not path.is_dir():
|
|
return []
|
|
return sorted(f for f in path.iterdir() if f.is_file() and not f.name.startswith("."))
|