feat(B09): 263-2 사전조합 · 구조물 조합 운반 = 담은 로직 입력 + 거리 값 사본

- 모달 = 담은 로직 이름별 입력(library-inputs) · 거리 칸 거리에서 · 손 값(own) 이김
- 사본 폴더 = 종류@층@주인@키~값해시8 · 같은 값 = 같은 사본 · 다른 거리 = 따로 · 옛 이름 읽힘
- 셈 때 서버가 거리로 사본 다시 고름(resolve_hauls · fix_keys) · [저장] = 바뀐 연결 저장 값에도 · prune
- 연결 {tab dg, key, lib, given, own} 저장 꼴 · 원본 읽기 전 모양 검사

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
This commit is contained in:
2026-10-11 16:50:28 +09:00
co-authored by Claude Opus 5.5
parent d909d6aabe
commit 2c8c715cc2
7 changed files with 631 additions and 85 deletions
+255 -25
View File
@@ -10,20 +10,43 @@
대가 블록 = `Labor_Source.daega_inputs`(`libs` {폴더: 블록 id}) ·
단가 수요 = `Linked.linked_resource_keys` · 줄 연결 = {tab: dg, key: 블록 id, lib: 폴더} ·
[저장] 때 줄이 안 쓰는 사본은 지움(`prune`) · 층 읽기 권한 = 부르는 쪽(`M02_Layer_Access.grant`).
운반 값 사본(263-2) — 운반공 줄 연결
`{tab: dg, key, lib, given{줄 id: {입력: 값}}, own[줄 id|입력]}` ·
담은 로직 줄(id = `{참조 id}#{차례}` · 맨 위 줄 = `#{차례}`)의 거리 칸
(운반거리 · Cm · 사이클시간 · t2) =
거리이정표 거리로 셈(짝 `B08_Quantity_Haul_Cycle`) · 그 밖 = 손 값 → 값 묶음 `값` ·
폴더 = `{종류}@{층}@{주인}@{키}~{값 해시 8}`(값 없으면 해시 없음 · 옛 이름 그대로 읽힘) ·
같은 항목 같은 값 = 같은 사본 ·
manifest `원값` = 덮기 전 값(다른 값 사본을 이 사본에서 다시 만듦) ·
단가 묶음 = 로직 · 자재 키로만 모음 → 값이 바뀌어도 그대로 ·
셈 때(`resolve_hauls`) 서버가 거리로 값을 다시 셈 → 다른 해시면 새 사본 ·
줄 연결 lib · key 바꿔 셈.
"""
from __future__ import annotations
import hashlib
import json
import re
import shutil
from pathlib import Path
from typing import Any
from B08_Quantity.B08_Quantity_Haul_Cycle import FILL, cycle_inputs, cycle_time
from B09_Estimation.B09_Estimation_Store import folder as store_folder
from B09_Estimation.B09_Estimation_Store import ref_rows
from common_util.common_util_json import atomic_write_json
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete.M02_Item_Snapshot import MANIFEST_NAME, build_snapshot, now_text, origin_of
from M02_MasterTemplete.M02_Item_Snapshot import (
MANIFEST_NAME,
VALUES_FILE,
build_snapshot,
now_text,
origin_of,
seed_values,
)
from M02_MasterTemplete.M02_Item_Source import (
LABOR_FILE,
PRICE_FILE,
@@ -35,17 +58,28 @@ from M02_MasterTemplete.M02_Item_Source import (
LIBRARY = "library"
KINDS = ("combo", "precombo")
#: 폴더 이름 — 종류 · 층 · 주인 · 키(영문 · 숫자 · `-` `_`)
NAME = re.compile(r"^(combo|precombo)@(system|company|personal)@[0-9-]+@[A-Za-z0-9_-]{1,40}$")
#: 폴더 이름 — 종류 · 층 · 주인 · 키(영문 · 숫자 · `-` `_`) · 값 해시(263-2 · 없을 수 있음)
NAME = re.compile(
r"^(combo|precombo)@(system|company|personal)@[0-9-]+@[A-Za-z0-9_-]{1,40}(~[0-9a-f]{8})?$"
)
def library_dir(project_root: str | Path) -> Path:
return store_folder(project_root) / LIBRARY
def folder_name(kind: str, layer: str, owner: str | None, key: str) -> str:
"""고른 서고 것 → 사본 폴더 이름 — 규칙 밖이면 ValueError."""
def tag(values: dict[str, Any]) -> str:
"""값 묶음 해시 8 — 같은 값 = 같은 표지."""
text = json.dumps(values, sort_keys=True, ensure_ascii=False)
return hashlib.sha1(text.encode("utf-8")).hexdigest()[:8]
def folder_name(
kind: str, layer: str, owner: str | None, key: str, values: dict[str, Any] | None = None
) -> str:
"""고른 서고 것 → 사본 폴더 이름(값 있으면 `~해시`) — 규칙 밖이면 ValueError."""
name = f"{kind}@{layer}@{(owner or '-').replace('/', '-')}@{key}"
name += f"~{tag(values)}" if values else ""
if not NAME.match(name):
raise ValueError(f"서고 항목 모양이 틀림 「{name}」")
return name
@@ -65,6 +99,130 @@ def _precombo_files(layer: str, owner: str | None, key: str) -> dict[str, Any]:
}
def _build(kind: str, layer: str, owner: str | None, key: str) -> tuple[dict, dict]:
"""서고 원본 → (사본 파일들, 원본 표지)."""
if kind not in KINDS:
raise ValueError(f"서고 종류는 {', '.join(KINDS)} 중 하나")
folder_name(kind, layer, owner, key) # 모양 먼저(원본 읽기 전)
if kind == "combo":
item = {"layer": layer, "owner": owner, "key": key}
item["name"] = item_name(item)
return build_snapshot(item), origin_of(item)
files = _precombo_files(layer, owner, key)
return files, files.pop("_원본")
# ── 운반 값 사본(263-2) ────────────────────────────────────────────────
def _logic(code: str) -> dict[str, Any]:
try:
return dict(m01.loaded()[1].logic(code) or {})
except Exception: # noqa: BLE001 — 없는 로직 = 입력 없음
return {}
def _line_at(labor: Any, lid: str) -> dict[str, Any] | None:
rid, _, at = lid.rpartition("#")
doc = (labor or {}).get("줄") if not rid else ((labor or {}).get("참조사본") or {}).get(rid)
lines = doc.get("담은로직") if isinstance(doc, dict) else None
if not at.isdigit() or not isinstance(lines, list) or int(at) >= len(lines):
return None
line = lines[int(at)]
return line if isinstance(line, dict) and line.get("로직") else None
def lines_of(files: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
"""사본 안 로직 줄 `(줄 id, 줄)` — 맨 위 줄 → 굳힌 참조 문서 줄."""
labor = files.get(LABOR_FILE) or {}
docs = [("", labor.get("줄")), *((labor.get("참조사본") or {}).items())]
return [
(f"{rid}#{i}", one)
for rid, doc in docs
for i, one in enumerate((doc or {}).get("담은로직") or [])
if isinstance(one, dict) and one.get("로직")
]
def haul_values(
lines: list[tuple[str, dict[str, Any]]], given: Any, own: Any, dist: Any
) -> dict[str, dict[str, Any]]:
"""값 묶음 `{줄 id: {입력: 값}}` — 거리 칸 = 거리로 셈(`own` 이면 손 값) · 그 밖 = 손 값."""
given = given if isinstance(given, dict) else {}
own = set(own if isinstance(own, list) else [])
got = cycle_time(cycle_inputs(None, dist)) if isinstance(dist, dict) else None
out: dict[str, dict[str, Any]] = {}
for lid, line in lines:
hand = given.get(lid) if isinstance(given.get(lid), dict) else {}
vals = {}
for name in (str(i.get("이름")) for i in _logic(str(line["로직"])).get("입력") or []):
if got and name in FILL and f"{lid}|{name}" not in own:
vals[name] = got[FILL[name]]
elif isinstance(hand.get(name), (str, int, float)):
vals[name] = hand[name]
if vals:
out[lid] = vals
return out
def _apply(files: dict[str, Any], kind: str, values: dict[str, Any]) -> dict[str, Any]:
"""값 덮기 → 덮기 전 값(`원값` · 없던 칸 = None)."""
labor = files.get(LABOR_FILE)
old: dict[str, Any] = {}
for lid, vals in values.items():
if (line := _line_at(labor, lid)) is None:
continue
given = dict(line.get("입력값") or {})
old[lid] = {k: given.get(k) for k in vals}
line["입력값"] = {**given, **vals}
if kind == "combo" and old:
files[VALUES_FILE] = seed_values(labor)
return old
def _restore(files: dict[str, Any], kind: str, old: Any) -> None:
"""`원값` 되돌림 — 덮기 전 사본 내용."""
labor = files.get(LABOR_FILE)
for lid, vals in (old if isinstance(old, dict) else {}).items():
if (line := _line_at(labor, lid)) is None:
continue
given = dict(line.get("입력값") or {})
for k, v in vals.items():
if v is None:
given.pop(k, None)
else:
given[k] = v
line["입력값"] = given
if kind == "combo" and old:
files[VALUES_FILE] = seed_values(labor)
def _write(project_root: str | Path, name: str, files: dict, manifest: dict, values: dict) -> None:
target = library_dir(project_root) / name
old = _apply(files, str(manifest.get("종류")), values)
if target.is_dir(): # 끊긴 쓰기(manifest 없음) 자리
shutil.rmtree(target)
target.mkdir(parents=True)
for file, content in files.items():
if isinstance(content, bytes):
(target / file).write_bytes(content)
else:
atomic_write_json(target / file, content)
extra = {"값": values, "원값": old} if values else {}
atomic_write_json(target / MANIFEST_NAME, {**manifest, **extra}) # 마지막 — 있으면 다 쓴 폴더
def _read_copy(folder: Path) -> tuple[dict[str, Any], dict[str, Any]] | None:
"""다 쓴 사본 → (원값 되돌린 파일들, manifest(값 · 원값 뺌)) — 없으면 None."""
manifest = read_json(folder / MANIFEST_NAME)
if not NAME.match(folder.name) or not isinstance(manifest, dict):
return None
files: dict[str, Any] = {}
for path in sorted(folder.iterdir()):
if path.is_file() and path.name != MANIFEST_NAME:
files[path.name] = read_json(path) if path.suffix == ".json" else path.read_bytes()
_restore(files, str(manifest.get("종류")), manifest.get("원값"))
return files, {k: v for k, v in manifest.items() if k not in ("값", "원값")}
def import_copy(
project_root: str | Path,
kind: str,
@@ -72,33 +230,105 @@ def import_copy(
owner: str | None,
key: str,
who: dict[str, Any] | None = None,
haul: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""서고 것 하나를 프로젝트 사본으로 — `{폴더, 만듦}` · 이미 있으면 그대로(만듦 거짓) ·
haul = 운반 값(263-2 · `{given, own, dist}`) ·
원본이 없으면 LookupError · 모양이 틀리면 ValueError."""
if kind not in KINDS:
raise ValueError(f"서고 종류는 {', '.join(KINDS)} 중 하나")
name = folder_name(kind, layer, owner, key)
target = library_dir(project_root) / name
if isinstance(read_json(target / MANIFEST_NAME), dict):
haul = haul or {}
lib = haul.get("lib") # 있는 사본에서(굳힌 내용 · 같은 항목만)
if isinstance(lib, str) and lib.split("~")[0] == folder_name(kind, layer, owner, key):
if name := resolve(project_root, {**haul, "lib": lib}, haul.get("dist")):
return {"폴더": name, "만듦": False}
files, origin = _build(kind, layer, owner, key)
values = haul_values(lines_of(files), haul.get("given"), haul.get("own"), haul.get("dist"))
name = folder_name(kind, layer, owner, key, values)
if isinstance(read_json(library_dir(project_root) / name / MANIFEST_NAME), dict):
return {"폴더": name, "만듦": False}
if kind == "combo":
item = {"layer": layer, "owner": owner, "key": key}
item["name"] = item_name(item)
files, origin = build_snapshot(item), origin_of(item)
else:
files = _precombo_files(layer, owner, key)
origin = files.pop("_원본")
if target.is_dir(): # 끊긴 쓰기(manifest 없음) 자리
shutil.rmtree(target)
target.mkdir(parents=True)
for file, content in files.items():
atomic_write_json(target / file, content)
stamp = now_text()
manifest = {"종류": kind, "원본": origin, "가져온날": stamp, "가져온이": who}
atomic_write_json(target / MANIFEST_NAME, manifest) # 마지막 — 있으면 다 쓴 폴더
manifest = {"종류": kind, "원본": origin, "가져온날": now_text(), "가져온이": who}
_write(project_root, name, files, manifest, values)
return {"폴더": name, "만듦": True}
def library_inputs(
project_root: str | Path, kind: str, layer: str, owner: str | None, key: str, lib: Any = None
) -> list[dict[str, Any]]:
"""담은 로직 입력 · 값(263-2 · 읽기만) — 있는 사본(`lib`)이면 그 덮기 전 값 ·
없으면 서고 원본."""
got = None
if isinstance(lib, str) and NAME.match(lib):
got = _read_copy(library_dir(project_root) / lib)
files = got[0] if got else _build(kind, layer, owner, key)[0]
out = []
for lid, line in lines_of(files):
logic = _logic(str(line["로직"]))
out.append(
{
"id": lid,
"로직": line["로직"],
"이름": logic.get("이름") or line["로직"],
"입력": logic.get("입력") or [],
"값": line.get("입력값") or {},
}
)
return out
def resolve(project_root: str | Path, ref: dict[str, Any], dist: Any) -> str | None:
"""운반 연결 사본 → 지금 거리 · 손 값의 사본 폴더(없으면 그 사본에서 새로 만듦) ·
그 사본이 지워졌으면(prune 뒤 옛 연결) 같은 바탕 사본 → 서고 원본에서 · 못 하면 None."""
lib = str(ref.get("lib") or "")
if not NAME.match(lib):
return None
base = lib.split("~")[0]
got = _read_copy(library_dir(project_root) / lib)
if got is None: # 같은 바탕 사본
same = [f for f, _m in copies(project_root) if f.name.split("~")[0] == base]
got = _read_copy(same[0]) if same else None
if got is None:
kind, layer, owner, key = base.split("@")
owner = None if owner == "-" else owner.replace("-", "/")
try:
got = _build(kind, layer, owner, key)
except (LookupError, ValueError):
return None
got = (got[0], {"종류": kind, "원본": got[1], "가져온날": now_text(), "가져온이": None})
files, manifest = got
values = haul_values(lines_of(files), ref.get("given"), ref.get("own"), dist)
name = base + (f"~{tag(values)}" if values else "")
target = library_dir(project_root) / name / MANIFEST_NAME
if not isinstance(read_json(target), dict):
_write(project_root, name, files, manifest, values)
return name
def resolve_hauls(project_root: str | Path, user: dict[str, Any]) -> dict[str, Any]:
"""셈 사용자 값 — 운반공 줄 서고 사본 연결(덮개 줄 · 거리이정표 연결)을 지금 거리의 사본으로."""
dist = user.get("haul_dist") or {}
def fix(ref: Any, key: Any) -> Any:
if key not in dist or not (isinstance(ref, dict) and ref.get("tab") == "dg"):
return ref
name = resolve(project_root, ref, dist[key]) if ref.get("lib") else None
return {**ref, "lib": name} if name and name != ref["lib"] else ref
bill = user.get("bill") if isinstance(user.get("bill"), dict) else {}
rows = [
{**r, "ref": fix(r["ref"], r.get("key"))} if isinstance(r, dict) and "ref" in r else r
for r in bill.get("rows") or []
]
hauls = {k: fix(ref, k) for k, ref in (user.get("haul_refs") or {}).items()}
return {**user, "bill": {**bill, "rows": rows}, "haul_refs": hauls}
def fix_keys(user: dict[str, Any], libs: dict[str, str]) -> None:
"""사본 폴더 → 대가 블록 id — 운반 연결 key 를 바뀐 사본 블록으로(제자리)."""
for row in ref_rows(user):
ref = row.get("ref")
if isinstance(ref, dict) and ref.get("tab") == "dg" and ref.get("lib") in libs:
ref["key"] = libs[ref["lib"]]
def copies(project_root: str | Path) -> list[tuple[Path, dict[str, Any]]]:
"""다 쓴 사본 `(폴더, manifest)` — 폴더 이름 차례."""
base = library_dir(project_root)
+50 -9
View File
@@ -12,7 +12,12 @@
POST /api/projects/{id}/estimation/library {kind, layer, owner, key}
설계내역서 연결 고르기(204-2) — 서고 구조물조합 · 사전조합을 프로젝트 사본으로
(있으면 그대로) → {폴더, 만듦, key = 대가 블록 id, payload} ·
층 읽기 권한 = `M02_Layer_Access` · 줄 연결은 화면 캐시 → [저장] 때 안 쓰는 사본 지움(`Bill_Library.prune`)
층 읽기 권한 = `M02_Layer_Access` · 줄 연결은 화면 캐시 →
[저장] 때 안 쓰는 사본 지움(`Bill_Library.prune`) ·
운반공 줄 = {given, own, dist} 같이 → 거리 값 사본(263-2 · 폴더 `~값 해시`)
POST /api/projects/{id}/estimation/library-inputs {kind, layer, owner, key, lib?}
「운반 단가」 모달(263-2) — 담은 로직 줄 입력 · 값(읽기만 · 권한 = library 길과 같음)
운반 사본 연결 = 셈 때마다 거리로 사본 다시 고름(`Bill_Library.resolve_hauls` · `fix_keys`).
저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) ·
단가 = `B09_Estimation_Prices` ·
@@ -134,15 +139,17 @@ def haul_user(rows: list[dict[str, Any]]) -> dict[str, Any]:
def calc_user(root: Path) -> dict[str, Any]:
"""셈 · 수요 · 사본 정리용 사용자 값 — 저장 값 + 거리이정표 운반 연결 · 거리(화면엔 안 보냄)."""
return {**store.read_user(root), **haul_user(haul_routes(root))}
"""셈 · 수요 · 사본 정리용 사용자 값 — 저장 값 + 거리이정표 운반 연결 · 거리(화면엔 안 보냄) ·
운반 사본 연결 = 지금 거리의 사본(263-2)."""
user = {**store.read_user(root), **haul_user(haul_routes(root))}
return library.resolve_hauls(root, user)
async def _demand(root: Path) -> list[str]:
"""단가 수요 키 — 연결된 사본 + 설계내역서 손 줄 로직(166-2) +
거리이정표 운반 연결(251-5)."""
user = await asyncio.to_thread(calc_user, root) # 운반 값 사본 먼저(263-2)
keys = await asyncio.to_thread(linked_resource_keys, root)
user = await asyncio.to_thread(calc_user, root)
hand = await asyncio.to_thread(sangeun.hand_resource_keys, user)
return [*keys, *(k for k in hand if k not in keys)]
@@ -158,11 +165,12 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]:
book = await asyncio.to_thread(store.read_book, root)
inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"])
hauls = await asyncio.to_thread(haul_routes, root)
inputs["bill"] = bill_items({**b08, **haul_user(hauls), "haul_routes": hauls})
user = await asyncio.to_thread(store.read_user, root)
calc = await asyncio.to_thread(library.resolve_hauls, root, {**user, **haul_user(hauls)})
inputs["bill"] = bill_items({**b08, **calc, "haul_routes": hauls})
inputs["hopyo"] = hopyo_prices(root)
inputs["daega"] = await asyncio.to_thread(daega_inputs, root)
user = await asyncio.to_thread(store.read_user, root)
calc = {**user, **haul_user(hauls)}
library.fix_keys(calc, inputs["daega"].get("libs") or {}) # 263-2 바뀐 사본 = 그 블록
sheets, summary = await asyncio.to_thread(compute_twice, root, inputs, calc)
if schedule_draft:
schedule_draft.attach(inputs, sheets, user)
@@ -191,7 +199,11 @@ async def estimation(project_id: UUID) -> dict[str, Any]:
async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> dict[str, Any]:
root = await _project_root(project_id)
user = await asyncio.to_thread(store.write_user, root, body.get("user"))
calc = {**user, **haul_user(await asyncio.to_thread(haul_routes, root))}
calc = await asyncio.to_thread(calc_user, root) # 263-2 운반 값 사본 = 지금 거리
if (rows := calc["bill"].get("rows")) != ((user.get("bill") or {}).get("rows") or []):
# 바뀐 사본 연결 = 저장 값에도(응답 user → 화면 캐시 · 다음 [저장]이 옛 lib 안 씀)
bill = {**user["bill"], "rows": rows}
await asyncio.to_thread(store.write_user, root, {**user, "bill": bill})
await asyncio.to_thread(library.prune, root, calc) # 204-2 줄이 안 잇는 서고 사본
keys = await _demand(root)
book = await asyncio.to_thread(store.read_book, root)
@@ -251,6 +263,11 @@ class LibraryBody(BaseModel):
layer: str
owner: str | None = None
key: str
# 263-2 운반공 줄 — 손 값 {줄 id: {입력: 값}} · 덮은 칸 · 그 행 거리 · 있는 사본
given: dict[str, Any] | None = None
own: list[str] | None = None
dist: dict[str, Any] | None = None
lib: str | None = None
@router.post("/{project_id}/estimation/library")
@@ -261,7 +278,14 @@ async def import_library(
root = await _project_root(project_id)
try:
made = await asyncio.to_thread(
library.import_copy, root, body.kind, got.layer, got.owner, body.key, importer(session)
library.import_copy,
root,
body.kind,
got.layer,
got.owner,
body.key,
importer(session),
{"given": body.given, "own": body.own, "dist": body.dist, "lib": body.lib},
)
except LookupError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
@@ -272,5 +296,22 @@ async def import_library(
return {**made, "key": key, "payload": out}
@router.post("/{project_id}/estimation/library-inputs")
async def library_inputs(
project_id: UUID, body: LibraryBody, session: dict[str, Any] = Depends(verify_session)
) -> dict[str, Any]:
got = access.grant(session, body.layer, body.owner)
root = await _project_root(project_id)
try:
lines = await asyncio.to_thread(
library.library_inputs, root, body.kind, got.layer, got.owner, body.key, body.lib
)
except LookupError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
return {"lines": lines}
# 166-4 견적서 파일 길 — 같은 머리 · 같은 권한
router.include_router(quotes_router)
+11 -1
View File
@@ -122,7 +122,17 @@ def _clean_ref(ref: Any) -> dict[str, Any] | 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
return {"tab": tab, "key": ref["key"], **({"lib": lib} if isinstance(lib, str) else {})}
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
+125 -33
View File
@@ -2,11 +2,13 @@
* B09_Estimation_UI_Haul_Modal.ts
* 「운반 단가」 모달(PLAN 253-3 · 263-1 · 요구 162-1 · 164-1) — 운반공 줄 하나(거리이정표 행 하나)의 단가연결.
* 위 = 고른 항목 이름 · [변경](단가연결 한 목록 `openPriceLink` — 단가산출 · 사전조합 · 구조물 조합 · 호표) ·
* 왼 = 고른 항목이 요구하는 입력만(단가산출 = 로직 입력 `fetchLogicInputs` · 호표 = 입력 없음) ·
* 왼 = 고른 항목이 요구하는 입력만(단가산출 = 로직 입력 `fetchLogicInputs` · 호표 = 입력 없음 ·
* 사전조합 · 구조물 조합 = 담은 로직 이름별 입력 `fetchLibInputs` · 263-2) ·
* 오른 = 계산 절차 — 거리 줄 · 사이클 식 줄(TS 짝 `B08_Quantity_Haul_Cycle` · 8-2-8 기본값 · 거리 칸 있을 때만) +
* 로직 산출 줄(서버 `POST …/estimation/logic-preview` · 값 바꾸면 다시 · 최신 응답만).
* 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 거리에서 채움(「거리에서」) · 손으로 고치면 덮음(`own`) ·
* 빈칸으로 지우면 다시 거리에서. 옛 사이클 손 값(`cycle`) = 칸 없음 · [적용]하면 지움(기본값).
* 조합 [적용] = 거리 · 손 값 사본(서버 · 같은 값 = 같은 사본) · 연결 {tab dg, key, lib, given, own}.
* 연결 없는 줄도 같은 모달(빈칸 → [고르기]) · [적용] = 부르는 쪽 `apply`(캐시 → [저장] 때 서버 정본).
* 여는 곳 = B08 거리이정표 표 우클릭 · B09 설계내역서 운반공 줄 단가연결.
* ========================================================================== */
@@ -27,8 +29,12 @@ import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor
import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values";
import type { Row } from "./B09_Estimation_Calc_Base";
import {
fetchLibInputs,
importLib,
openPriceLink,
parseLib,
type LibLine,
type LibPick,
type Picked,
type PriceLinkOpts,
} from "./B09_Estimation_UI_Tabs_Bill_Link";
@@ -98,11 +104,18 @@ const KIND_TEXT: Record<string, string> = {
export function openHaulModal(o: HaulModalOpts): void {
const logicRef = o.ref?.tab === "logic" ? o.ref : null;
const old = (logicRef?.cycle as Row) || {};
const libRef = o.ref?.tab === "dg" && o.ref.lib ? parseLib(String(o.ref.lib)) : null;
const st = {
/** 고른 것 — 단가산출 아닌 것(조합 · 호표) */
other: (o.ref && !logicRef
? { kind: o.ref.tab, ref: o.ref, from: { name: o.ref.name ?? o.ref.key } }
: null) as Picked | null,
other: (libRef
? { ...libRef, name: String(o.ref?.name ?? ""), unit: "" }
: o.ref && !logicRef
? { kind: o.ref.tab, ref: o.ref, from: { name: o.ref.name ?? o.ref.key } }
: null) as Picked | null,
/** 조합 담은 로직 줄(263-2) · 손 값 {줄 id: {입력: 값}} · 덮은 칸 `줄 id|입력` */
lines: [] as LibLine[],
libGiven: structuredClone((libRef && (o.ref?.given as Row)) || {}) as Record<string, Row>,
libOwn: new Set<string>((libRef && (o.ref?.own as string[])) || []),
code: String(logicRef?.code ?? ""),
name: String(logicRef?.name ?? ""),
given: { ...((logicRef?.given as Row) || {}) } as Row,
@@ -135,8 +148,18 @@ export function openHaulModal(o: HaulModalOpts): void {
const names = (): string[] => st.inputs.map((i) => i.이름);
const filled = (): Row => fillGiven(ref(), names(), o.dist) as Row;
const isLib = (p: Picked | null): p is LibPick & { name: string; unit: string } =>
p?.kind === "combo" || p?.kind === "precombo";
/** 거리 칸 값(거리 없으면 undefined · 서버 짝 `Bill_Library.haul_values`) */
const fromDist = (name: string): unknown =>
o.dist ? fillGiven({ given: {}, own: [], cycle: {} }, [name], o.dist)[name] : undefined;
const redraw = (): void => {
const fills = st.code !== "" && names().some((n) => n in CYCLE_FILL);
const fills =
(st.code !== "" && names().some((n) => n in CYCLE_FILL)) ||
(isLib(st.other) &&
!!o.dist &&
st.lines.some((l) => l.입력.some((i) => i.이름 in CYCLE_FILL)));
const input = cycleInputs(ref(), o.dist);
const head2 = fills
? [
@@ -147,6 +170,14 @@ export function openHaulModal(o: HaulModalOpts): void {
: [];
window.clearTimeout(timer);
const my = ++seq;
if (isLib(st.other)) {
steps.textContent = [
...head2,
`${KIND_TEXT[st.other.kind]} — [적용]하면 거리 · 손 값 사본(같은 값 = 같은 사본)`,
"단가 = [저장] 뒤 서버 셈",
].join("\n");
return;
}
if (!st.code) {
const what = st.other
? `${KIND_TEXT[st.other.kind] ?? ""} — 입력 없음 · 그대로 연결`
@@ -180,36 +211,73 @@ export function openHaulModal(o: HaulModalOpts): void {
});
const shows: Array<() => void> = [];
/** 입력 칸 하나 — auto = 거리에서 채울 값(아니면 undefined) · set(null = 비움) */
const valueRow = (
input: ItemInput,
auto: () => unknown,
hand: () => unknown,
set: (value: unknown) => void,
): HTMLElement => {
const now = auto();
const field = createValueControl(
input,
((now === undefined ? hand() : now) as string | number | null) ?? null,
(next, bad) => {
if (bad) return;
const empty = next === null || next === "";
const num = typeof next === "string" && Number.isFinite(Number(next));
set(empty ? null : num ? Number(next) : next);
update();
},
);
field.control.setAttribute("data-labor-input", input.이름);
const tag = el("span", { className: "m02-labin__muted" });
shows.push(() => {
const value = auto();
tag.textContent = value === undefined ? "" : FROM_DIST;
if (value !== undefined && document.activeElement !== field.control)
field.control.value = String(value ?? "");
});
return row(label(input.이름, input.단위 || ""), [field.control, tag, field.error]);
};
const logicRows = (): HTMLElement[] =>
st.inputs.map((input) => {
const fill = input.이름 in CYCLE_FILL && !st.own.has(input.이름);
const value = fill ? filled()[input.이름] : st.given[input.이름];
const field = createValueControl(
const name = input.이름;
const fill = name in CYCLE_FILL;
return valueRow(
input,
(value as string | number | null) ?? null,
(next, bad) => {
if (bad) return;
if (next === null || next === "") {
delete st.given[input.이름];
st.own.delete(input.이름);
} else {
st.given[input.이름] =
typeof next === "string" && Number.isFinite(Number(next)) ? Number(next) : next;
if (input.이름 in CYCLE_FILL) st.own.add(input.이름);
}
update();
() => (fill && !st.own.has(name) ? filled()[name] : undefined),
() => st.given[name],
(value) => {
if (value === null) delete st.given[name];
else st.given[name] = value;
if (fill && value !== null) st.own.add(name);
else st.own.delete(name);
},
);
field.control.setAttribute("data-labor-input", input.이름);
const tag = el("span", { className: "m02-labin__muted" });
shows.push(() => {
const auto = input.이름 in CYCLE_FILL && !st.own.has(input.이름);
tag.textContent = auto ? FROM_DIST : "";
if (auto && document.activeElement !== field.control)
field.control.value = String(filled()[input.이름] ?? "");
});
return row(label(input.이름, input.단위 || ""), [field.control, tag, field.error]);
});
/** 조합 = 담은 로직 이름별 입력(263-2) */
const libRows = (): HTMLElement[] =>
st.lines.flatMap((line) => [
el("h5", { text: `${line.이름} (${line.로직})`, attrs: { style: "margin:8px 0 2px" } }),
...line.입력.map((input) => {
const name = input.이름;
const id = `${line.id}|${name}`;
const fill = name in CYCLE_FILL;
return valueRow(
input,
() => (fill && !st.libOwn.has(id) ? fromDist(name) : undefined),
() => st.libGiven[line.id]?.[name] ?? line.값[name],
(value) => {
const mine = (st.libGiven[line.id] ??= {});
if (value === null) delete mine[name];
else mine[name] = value;
if (fill && value !== null) st.libOwn.add(id);
else st.libOwn.delete(id);
},
);
}),
]);
/** 값 바뀜 — 칸은 그대로 두고 표시 · 계산 절차만 */
const update = (): void => {
@@ -224,7 +292,11 @@ export function openHaulModal(o: HaulModalOpts): void {
? st.inputs.length
? logicRows()
: [muted("입력 없음")]
: [muted(st.other ? "입력 없음 — 그대로 연결" : "연결 없음 — [고르기]")];
: isLib(st.other)
? st.lines.length
? libRows()
: [muted("담은 로직 입력 없음")]
: [muted(st.other ? "입력 없음 — 그대로 연결" : "연결 없음 — [고르기]")];
left.replaceChildren(
el("h4", { text: "옵션 값", attrs: { style: "margin:12px 0 4px" } }),
...lines,
@@ -256,11 +328,25 @@ export function openHaulModal(o: HaulModalOpts): void {
(error: unknown) => showToast(`로직 입력을 못 읽음 — ${String(error)}`, "error"),
);
};
/** 조합 담은 로직 입력 읽고 왼쪽 새로 */
const useLib = (p: LibPick): void => {
fetchLibInputs(p).then(
(lines) => {
st.lines = lines;
refresh();
},
(error: unknown) => showToast(`조합 입력을 못 읽음 — ${String(error)}`, "error"),
);
};
const usePicked = (p: Picked): void => {
if (p.kind === "logic") return useLogic(p.code, p.name);
st.other = p;
st.code = "";
st.inputs = [];
st.lines = [];
st.libGiven = {};
st.libOwn.clear();
if (isLib(p)) useLib(p);
refresh();
};
const change = createButton({
@@ -275,8 +361,11 @@ export function openHaulModal(o: HaulModalOpts): void {
onClick: () => {
const p = st.other;
if (!st.code && !p) return showToast("연결할 것을 먼저 고르세요", "error");
if (p && (p.kind === "combo" || p.kind === "precombo")) void importLib(o, p);
else if (p && "ref" in p) o.apply(p.ref, p.from);
if (isLib(p)) {
const haul = { given: st.libGiven, own: [...st.libOwn], dist: o.dist };
const { kind, layer, owner, key, lib } = p;
void importLib(o, { kind, layer, owner, key, lib, haul });
} else if (p && "ref" in p) o.apply(p.ref, p.from);
else {
const next: Row = {
tab: "logic",
@@ -305,7 +394,10 @@ export function openHaulModal(o: HaulModalOpts): void {
}),
);
if (st.code) useLogic(st.code, st.name);
else refresh();
else {
if (libRef) useLib(libRef);
refresh();
}
},
});
}
@@ -16,6 +16,7 @@ import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { showToast } from "@ui/ui_template_elements";
import { loadRows } from "@ui/ui_template_item_picker";
import { itemName, type PickerLayer } from "@ui/ui_template_item_picker_data";
import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
import { openLaborInput } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Input";
import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick";
@@ -61,13 +62,55 @@ export function askInputs(apply: ApplyRef, code: string, name: string, old?: Row
);
}
/** 단가연결 목록에서 고른 것(263-1 · 「운반 단가」 모달이 받아 보임) */
/** 단가연결 목록에서 고른 것(263-1 · 「운반 단가」 모달이 받아 보임) ·
* lib = 있는 사본 폴더 · haul = 운반 값(263-2 · 손 값 {줄 id: {입력: 값}} · 덮은 칸 · 그 행 거리) */
export type LibPick = {
kind: "combo" | "precombo";
layer: string;
owner: string | null;
key: string;
lib?: string;
haul?: { given: Row; own: string[]; dist: Dist };
};
/** 담은 로직 줄 입력 · 값(263-2 · `POST …/estimation/library-inputs`) */
export interface LibLine {
id: string;
로직: string;
이름: string;
입력: ItemInput[];
값: Row;
}
/** 사본 폴더 이름 → 서고 항목(`종류@층@주인@키~해시` · 짝 `Bill_Library.folder_name`) */
export function parseLib(lib: string): LibPick | null {
const [kind, layer, owner, key] = lib.split("~")[0].split("@");
if ((kind !== "combo" && kind !== "precombo") || !key) return null;
return { kind, layer, owner: owner === "-" ? null : owner.replace("-", "/"), key, lib };
}
async function postLib(path: string, body: Row): Promise<Row> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (!projectId) throw new Error("프로젝트 없음");
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/${path}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const got = (await response.json().catch(() => null)) as Row | null;
if (!response.ok || !got) {
const detail = got?.detail;
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
}
return got;
}
/** 서고 항목 담은 로직 입력 · 값(있는 사본이면 그 덮기 전 값) */
export async function fetchLibInputs(lib: LibPick): Promise<LibLine[]> {
const { kind, layer, owner, key } = lib;
const got = await postLib("library-inputs", { kind, layer, owner, key, lib: lib.lib ?? null });
return (got.lines as LibLine[]) || [];
}
export type Picked =
| { kind: "logic"; code: string; name: string }
| (LibPick & { name: string; unit: string })
@@ -80,27 +123,17 @@ export async function importLib(
lib: LibPick,
): Promise<void> {
const apply = o.apply;
const pick = { kind: lib.kind, layer: lib.layer, owner: lib.owner, key: lib.key };
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (!projectId) return;
const { kind, layer, owner, key } = lib;
const pick = { kind, layer, owner, key, ...(lib.lib && { lib: lib.lib }), ...lib.haul };
showToast("가져오는 중…", "info");
try {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/library`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(pick),
});
const got = (await response.json().catch(() => null)) as Row | null;
if (!response.ok || !got) {
const detail = got?.detail;
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
}
const got = await postLib("library", pick);
if (!got.key) throw new Error("사본은 들여왔으나 호표를 못 만듦");
const { user: _user, ...rest } = got.payload as Row;
o.onPayload?.(rest);
const from = ((rest.sheets?.daega as Row[]) || []).find((r) => r.key === got.key);
apply({ tab: "dg", key: got.key, lib: got.폴더 }, from);
const hand = lib.haul ? { given: lib.haul.given, own: lib.haul.own } : {};
apply({ tab: "dg", key: got.key, lib: got.폴더, ...hand }, from);
showToast(
got.만듦 ? "새 호표로 연결함 — [저장]해야 연결이 남음" : "가져온 호표로 연결함",
"success",
+1 -1
View File
@@ -37,7 +37,7 @@ def test_왼쪽_칸_고른_로직_입력만():
def test_변경_한_목록():
modal = _src("B09_Estimation/B09_Estimation_UI_Haul_Modal.ts")
assert "openPriceLink({ ...o, onPick: usePicked })" in modal and "importLib(o, p)" in modal
assert "openPriceLink({ ...o, onPick: usePicked })" in modal and "importLib(o, {" in modal
link = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts")
assert "o.onPick ??" in link and "openHaulModal({ ...link, ref" in link
milestone = _src("B08_Quantity/B08_Quantity_UI_Milestone.ts")
@@ -0,0 +1,140 @@
"""263-2 운반 값 사본 — 사전조합 · 구조물 조합 담은 로직 거리 칸 = 거리이정표 거리 ·
같은 값 = 사본 하나 · 다른 거리 = 사본 둘 · 거리 바뀐 뒤 셈 = 새 사본 · 옛 사본 prune ·
옛 이름(해시 없음) 폴더 읽힘 · 손 값(own) 이김."""
import shutil
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Haul_Cycle import cycle_time # noqa: E402
from B09_Estimation import B09_Estimation_Bill_Library as library # noqa: E402
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402
from B09_Estimation.B09_Estimation_Store import _clean_ref # noqa: E402
from M02_MasterTemplete import M02_Labor_Ref as refs # noqa: E402
KEY = "SC-999263"
DOC = {
"종류": "사전조합",
"키": KEY,
"이름": "시험 덤프운반 조합",
"단위": "㎥",
"담은로직": [
{"로직": "GF000284", "입력값": {"재료": "토사", "지역": "전국평균",
"보정작업": "해당 없음"}, "수량": {"손": 1}},
{"로직": "GF000134", "입력값": {"qt": 15, "f": 1, "E": 0.9, "지역": "전국평균"},
"수량": {"손": 1}},
],
} # fmt: skip
NEAR = {"L1": 10, "L2": 2}
FAR = {"L1": 30, "L2": 2}
LID = f"{refs.ref_id({'층': 'system', '주인': None, '키': KEY})}#0"
LID2 = LID[:-1] + "1"
@pytest.fixture(autouse=True)
def fake_precombo(monkeypatch):
real = refs.read_ref
monkeypatch.setattr(refs, "read_ref", lambda ref: DOC if ref.get("키") == KEY else real(ref))
def _import(root, dist, given=None, own=None):
haul = {"given": given, "own": own, "dist": dist}
return library.import_copy(root, "precombo", "system", None, KEY, None, haul)["폴더"]
def _given(root, folder):
files = library._read_copy(library.library_dir(root) / folder)
manifest = library.read_json(library.library_dir(root) / folder / library.MANIFEST_NAME)
return manifest.get("값") or {}, files
def test_거리_칸_채움_같은_값_사본_하나_다른_거리_둘(tmp_path):
a = _import(tmp_path, NEAR)
b = _import(tmp_path, NEAR)
c = _import(tmp_path, FAR)
assert a == b and a != c and "~" in a
values, _ = _given(tmp_path, a)
assert values[LID] == {"운반거리": 12} # L1 + L2
assert values[LID2] == {"Cm": cycle_time(NEAR)["cm"]}
# 사본 안 로직 줄 입력값 = 덮은 값 · 원값 = 덮기 전(없던 칸)
labor = library.read_json(library.library_dir(tmp_path) / c / library.LABOR_FILE)
doc = labor["참조사본"][LID.split("#")[0]]
assert (
doc["담은로직"][0]["입력값"]["운반거리"] == 32
and doc["담은로직"][0]["입력값"]["재료"] == "토사"
)
# 대가 블록 둘(값 다름 = 블록 다름)
libs = daega_inputs(tmp_path)["libs"]
assert set(libs) == {a, c} and libs[a] != libs[c]
def test_손_값_이김(tmp_path):
own = [f"{LID}|운반거리"]
folder = _import(tmp_path, NEAR, {LID: {"운반거리": 5, "재료": "암절취"}}, own)
values, _ = _given(tmp_path, folder)
assert values[LID] == {"운반거리": 5, "재료": "암절취"}
lines = library.library_inputs(tmp_path, "precombo", "system", None, KEY, folder)
first = next(x for x in lines if x["id"] == LID)
assert first["로직"] == "GF000284" and first["값"]["재료"] == "토사" # 덮기 전 값
assert [i["이름"] for i in first["입력"]] == ["재료", "운반거리", "지역", "보정작업"]
def test_거리_바뀐_뒤_셈_새_사본_옛_사본_prune(tmp_path):
old = _import(tmp_path, NEAR)
ref = {"tab": "dg", "key": "DG:x", "lib": old, "given": {}, "own": []}
user = {"haul_refs": {"haul:r-u1": ref}, "haul_dist": {"haul:r-u1": FAR}}
calc = library.resolve_hauls(tmp_path, user)
new = calc["haul_refs"]["haul:r-u1"]["lib"]
assert new != old and new == _import(tmp_path, FAR)
libs = daega_inputs(tmp_path)["libs"]
library.fix_keys(calc, libs)
assert calc["haul_refs"]["haul:r-u1"]["key"] == libs[new]
assert library.prune(tmp_path, calc) == [old]
# 같은 거리 다시 = 그대로
assert library.resolve_hauls(tmp_path, user)["haul_refs"]["haul:r-u1"]["lib"] == new
# 설계내역서 덮개 줄도 같이
bill = {"bill": {"rows": [{"key": "haul:r-u1", "ref": ref}]}, "haul_dist": {"haul:r-u1": NEAR}}
assert library.resolve_hauls(tmp_path, bill)["bill"]["rows"][0]["ref"]["lib"] == old
def test_옛_이름_폴더_읽힘(tmp_path):
hashed = _import(tmp_path, NEAR)
plain = library.import_copy(tmp_path, "precombo", "system", None, KEY)["폴더"]
assert "~" not in plain and library.NAME.match(plain)
ref = {"tab": "dg", "key": "DG:x", "lib": plain}
assert library.resolve(tmp_path, ref, NEAR) == hashed # 옛 사본에서 거리 값 사본
shutil.rmtree(library.library_dir(tmp_path) / hashed)
assert library.resolve(tmp_path, ref, NEAR) == hashed # 없으면 옛 사본에서 다시 만듦
assert library.resolve(tmp_path, ref, None) == plain # 거리 없음 = 그대로
assert {f.name for f, _m in library.copies(tmp_path)} == {plain, hashed}
def test_연결_손_값_저장_꼴():
ref = {"tab": "dg", "key": "DG:x", "lib": "precombo@system@-@SC-1~abcdef12",
"given": {LID: {"운반거리": 5, "x": [1]}}, "own": [f"{LID}|운반거리", 3]} # fmt: skip
got = _clean_ref(ref)
assert got["given"] == {LID: {"운반거리": 5}} and got["own"] == [f"{LID}|운반거리"]
assert "given" not in _clean_ref({"tab": "dg", "key": "DG:x"})
def test_있는_사본에서_다시_적용(tmp_path, monkeypatch):
near = _import(tmp_path, NEAR)
monkeypatch.setattr(refs, "read_ref", lambda ref: None) # 원본 지워짐 = 굳힌 사본에서
haul = {"given": {}, "own": [], "dist": FAR, "lib": near}
got = library.import_copy(tmp_path, "precombo", "system", None, KEY, None, haul)
values, _ = _given(tmp_path, got["폴더"])
assert got["폴더"] != near and values[LID] == {"운반거리": 32}
def test_모달_조합_입력():
modal = (ROOT / "B09_Estimation/B09_Estimation_UI_Haul_Modal.ts").read_text("utf-8")
for want in ("fetchLibInputs(p)", "libRows()", "st.libOwn.has(id)", "parseLib(",
"importLib(o, { kind, layer, owner, key, lib, haul })"): # fmt: skip
assert want in modal, want
link = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts").read_text("utf-8")
assert '"library-inputs"' in link and "...lib.haul" in link