Files
Aislo/B09_Estimation/B09_Estimation_Store.py
T
eomsangdonandClaude Opus 5.5 1a4c1b5178 feat(B09 설계내역서): 내역 잇기 서버 길 끼움 — 잇기 풀기 · 이 짝 잊기 · 줄마다 수량 갈래 · 잇기 판 (PLAN 707-4)
- Router 에 Router_Link 끼움
- 이은 자동 줄 was = 불러온 줄 · price 안 줄 · 자동 줄 덮개 · 같은 부모 앞 뒤 줄 키 → 풀기 = 제자리
- link/undo · link/forget 길 · suggest 답에 남은 줄 · 빈 자리 · 이은 줄(구조물 묶음에 딸린 줄 뺌)
- 시험 test_707_4 4 더함(풀기 차례 바꿔도 제자리 · 줄마다 갈래 · 판 · 일반 계정 회사 기억 못 지움)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:58:12 +09:00

396 lines
18 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 = 빈칸 · 없으면 기본)
"daega_cover": {블록 cover: {줄 cid: {qty?, labor?, material?, expense?}}}
(702-2 일위대가 고친 칸 덮개 — 수만 · 셈 = `B09_Estimation_Calc_Labor`)
+ 복사본 `K:…` 의 `@copy` {from · name · spec}(703-2 · 글만)
+ 붙인 줄 `@add` [블록 줄 fixed · pct](705-2 · `clean_added`)
"labor_cover": {노무 키: 일당} · "machine_cover": {기계 키: {labor,
material, expense}}(707-3 노임 · 기계 단가 덮개 · 공사 몫 · [업데이트] 가
책만 새로 굳혀 덮개는 남음 · 셈 = `B09_Estimation_Calc_Base`)
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, machine_cover
from B09_Estimation.B09_Estimation_Calc_Bill import SECTIONS
from B09_Estimation.B09_Estimation_Calc_Labor import ADD, COVER_KEYS, PARTS
try: # 252 공기 초안(draft 폴더 · 지우면 공기 값 없이)
from B09_Estimation.draft.B09_Draft_Schedule_Source import clean_schedule
except ImportError: # pragma: no cover
clean_schedule = None
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 ref_rows(user: dict[str, Any] | None) -> list[dict[str, Any]]:
"""연결 있는 줄 모음 — 설계내역서 덮개 줄 + 거리이정표 운반 연결
(251-5 · `haul_refs{줄 키: ref}` · 서버 셈 때만 붙임) ·
같은 줄 키에 사용자 연결 덮개가 있으면 그것만."""
rows = [r for r in ((user or {}).get("bill") or {}).get("rows") or [] if isinstance(r, dict)]
covered = {r.get("key") for r in rows if "ref" in r}
haul = (user or {}).get("haul_refs") or {}
extra = [
{"key": key, "name": "", "ref": ref}
for key, ref in haul.items()
if key not in covered and isinstance(ref, dict)
]
return [*rows, *extra]
def _clean_ref(ref: Any) -> dict[str, Any] | None:
"""손 줄 연결 — dg · sg = 호표 키 · logic = M01 로직(입력값 · 켜쌓기율 · 고름) ·
dg `lib` = 서고 사본 폴더(204-2 · [저장] 때 안 쓰는 사본 지움)."""
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"]:
lib = ref.get("lib") if tab == "dg" else None
out = {"tab": tab, "key": ref["key"], **({"lib": lib} if isinstance(lib, str) else {})}
given, own = ref.get("given"), ref.get("own") # 263-2 운반 값 사본 손 값 · 덮은 칸
if isinstance(lib, str) and isinstance(given, dict):
out["given"] = {
str(lid): {str(k): v for k, v in vals.items() if isinstance(v, (str, int, float))}
for lid, vals in given.items()
if isinstance(vals, dict)
}
if isinstance(lib, str) and isinstance(own, list):
out["own"] = [str(x)[:200] for x in own if isinstance(x, str)][:200]
return out
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
out = {"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
cycle = ref.get("cycle") # 253-2 운반 사이클 입력 · 덮은 칸
if isinstance(cycle, dict):
out["cycle"] = {str(k): _num(v) for k, v in cycle.items() if _num(v) is not None}
own = ref.get("own") if isinstance(ref.get("own"), list) else []
out["own"] = [str(x)[:40] for x in own if isinstance(x, str)][:40]
return out
return None
def _fixed_cells(row: dict[str, Any]) -> dict[str, Any]:
"""707-1 고정 단가 칸 `price` {노 · 재 · 경 수(빈 = 0)} · 불러온 수량 `src_qty` — 있는 것만."""
out: dict[str, Any] = {}
if isinstance(row.get("price"), dict):
out["price"] = {
p: _num(row["price"].get(p)) or 0.0 for p in ("labor", "material", "expense")
}
if _num(row.get("src_qty")) is not None:
out["src_qty"] = _num(row["src_qty"])
return out
def _clean_was(raw: Any) -> dict[str, Any] | None:
"""707-4 이은 자동 줄 되돌릴 것 — 불러온 줄(hand 먼저) · 자동 줄 덮개 · 앞 · 뒤 줄 키."""
if not isinstance(raw, dict) or not isinstance(raw.get("rows"), list):
return None
rows = _clean_v2({"rows": raw["rows"]}).get("rows") or []
if not rows or not rows[0]["key"].startswith("hand:"):
return None
old = raw.get("old")
old = (_clean_v2({"rows": [old]}).get("rows") or [None])[0] if isinstance(old, dict) else None
near = {k: [x for x in raw.get(k) or [] if isinstance(x, str)][:500] for k in ("prev", "next")}
return {"rows": rows, "old": old, **near}
def _clean_v2(raw: dict[str, Any]) -> dict[str, Any]:
"""184 덮개 v2 — 표 차례(겹친 키 · `fee` 버림) · 새 줄(`hand:`) = 모든 칸 ·
자동 줄 = 고친 칸만(값 · null = 일부러 빈칸 · 못 쓰는 값 = 칸 버림) · `gone` = 자동 줄 키 ·
고정 단가 칸 · 불러온 수량(707-1) = 항목 줄만 · 새 줄 비고 `note` 글."""
rows, seen = [], set()
for row in raw.get("rows") if isinstance(raw.get("rows"), list) else []:
key = row.get("key") if isinstance(row, dict) else None
if not isinstance(key, str) or not key or key in seen or key == "fee":
continue
seen.add(key)
parent = row.get("parent")
out: dict[str, Any] = {"key": key}
if isinstance(parent, str) and parent:
out["parent"] = parent
if key.startswith("hand:"):
group = row.get("kind") == "group"
if group:
out["kind"] = "group"
if row.get("cost") == "direct":
out["cost"] = "direct"
out.update({k: _text(row.get(k)) for k in ("name", "spec", "unit")})
out["qty"] = None if group else _num(row.get("qty"))
out["ref"] = None if group else _clean_ref(row.get("ref"))
if not group:
out.update(_fixed_cells(row))
if _text(row.get("note")):
out["note"] = _text(row["note"])
else:
for k in ("name", "spec", "unit"):
if k in row:
out[k] = None if row[k] is None else _text(row[k])
if "qty" in row and (row["qty"] is None or _num(row["qty"]) is not None):
out["qty"] = _num(row["qty"])
if "ref" in row and (row["ref"] is None or _clean_ref(row["ref"]) is not None):
out["ref"] = _clean_ref(row["ref"])
out.update(_fixed_cells(row))
if was := _clean_was(row.get("was")):
out["was"] = was
rows.append(out)
given = raw.get("gone") if isinstance(raw.get("gone"), list) else []
gone = list(
dict.fromkeys(k for k in given if isinstance(k, str) and k[:5] not in ("", "hand:"))
)
return {"v": 2, "rows": rows, "gone": gone} if rows or gone else {}
def clean_bill(raw: Any) -> dict[str, Any]:
"""설계내역서 덮개 — 184 v2(`_clean_v2`) · 옛 꼴(166-2 수량 덮어쓰기(수만) · 손 줄(`hand:` 키 ·
아는 공종만) · 182 레벨 = `kind`(group) · `parent`(`hand:` 키))."""
raw = raw if isinstance(raw, dict) else {}
if raw.get("v") == 2:
return _clean_v2(raw)
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_cover(raw: Any) -> dict[str, Any]:
"""702-2 고친 칸 덮개 — 열쇠 글 · 칸 = qty · 노 · 재 · 경 수만 · 빈 줄 · 빈 블록 버림 ·
복사본 표지 `@copy`(703-2) = from · name · spec 글만(from 없으면 버림)."""
out: dict[str, Any] = {}
for block, lines in (raw if isinstance(raw, dict) else {}).items():
kept = {}
for cid, cell in (lines if isinstance(lines, dict) else {}).items():
cell = cell if isinstance(cell, dict) else {}
if cid == ADD:
if got := clean_added(lines.get(ADD)):
kept[cid] = got
continue
if cid == "@copy":
if _text(cell.get("from")):
kept[cid] = {k: _text(cell[k]) for k in ("from", "name", "spec") if k in cell}
continue
got = {k: _num(cell[k]) for k in COVER_KEYS if _num(cell.get(k)) is not None}
if got:
kept[str(cid)[:200]] = got
if kept:
out[str(block)[:200]] = kept
return out
ADD_MAX = 500
def clean_added(raw: Any) -> list[dict[str, Any]]:
"""붙인 줄(705-2) — fixed = 이름 · 규격 · 단위 · 수량 · 붙인 단가(own) · 이음 키(link) · pct = 비목 · 밑 ·
율 · cid `@add|…` 만 · 모르는 칸 버림 · 숫자 아닌 단가 = 0."""
out: list[dict[str, Any]] = []
for ln in raw[:ADD_MAX] if isinstance(raw, list) else []:
if not isinstance(ln, dict) or not str(ln.get("cid") or "").startswith(f"{ADD}|"):
continue
head = {k: _text(ln.get(k))[:200] for k in ("cid", "name", "spec", "unit")}
if ln.get("kind") == "pct" and ln.get("part") in PARTS:
base = str(ln.get("base") or "")
if base.removeprefix("part:") in PARTS:
out.append({**head, "kind": "pct", "part": ln["part"], "base": base,
"qty": _num(ln.get("qty"))}) # fmt: skip
continue
if ln.get("kind") != "fixed":
continue
own, link = ln.get("own") or {}, ln.get("link") or {}
out.append({**head, "kind": "fixed", "qty": _num(ln.get("qty")),
"own": {p: _num(own.get(p)) or 0 for p in PARTS if isinstance(own, dict)},
"link": {k: _text(link.get(k))[:40] or None for k in ("labor", "material", "machine")
if isinstance(link, dict)}}) # fmt: skip
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"))
out = {"materials": materials, "estimate": estimate, **({"bill": bill} if bill else {})}
if cover := clean_cover(raw.get("daega_cover")):
out["daega_cover"] = cover
given = raw.get("labor_cover") if isinstance(raw.get("labor_cover"), dict) else {}
if labor := {str(k)[:200]: _num(v) for k, v in given.items() if _num(v) is not None}:
out["labor_cover"] = labor
if machine := machine_cover(raw.get("machine_cover")):
out["machine_cover"] = {str(k)[:200]: v for k, v in machine.items()}
if clean_schedule and (schedule := clean_schedule(raw.get("schedule"))):
out["schedule"] = schedule
return out
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("."))