Merge remote-tracking branches 'origin/sub_laptop_3' and 'origin/sub_laptop_5' into main_laptop_1
This commit is contained in:
@@ -191,7 +191,7 @@ export function sharedStructures(shared: DesignPageShared): StructuresSection {
|
||||
onPipeSelect: (chainageM) => on().onPipeSelect?.(chainageM),
|
||||
onReveal: () => shared.revealLeft(),
|
||||
},
|
||||
{ includeDetail: () => shared.view() === "cross" },
|
||||
{ includeDetail: () => shared.view() === "cross", projectId: shared.projectId },
|
||||
);
|
||||
shared.parts.structures = shared.structures;
|
||||
return shared.structures;
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface LinkItem {
|
||||
단위?: string;
|
||||
/** 자재 알약 — 연결하면 이 정보가 칸 연결에 들어감 */
|
||||
자재?: LinkMaterial;
|
||||
/** 호스트가 정한 알약 — 자재 모드에서도 [목록에서 빼기]가 안 뜸(예: 선택 입력 카드가 자리를 고른 입력) */
|
||||
고정?: boolean;
|
||||
}
|
||||
|
||||
export interface LinkOptions {
|
||||
@@ -208,7 +210,7 @@ export function mountLinks(
|
||||
};
|
||||
b.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
const inList = opts?.모드 === "자재" && items.some((i) => i.키 === item.키);
|
||||
const inList = opts?.모드 === "자재" && !item.고정 && items.some((i) => i.키 === item.키);
|
||||
if (ctx.readOnly || !(hit || inList)) return;
|
||||
const o = ctx.root.getBoundingClientRect(); // 메뉴는 부품 안 절대 위치 — 누른 자리에 뜨게
|
||||
menu.open(e.clientX - o.left, e.clientY - o.top, [
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface StructureOptionField {
|
||||
export interface StructuresSectionOptions {
|
||||
/** 참이면 `phase: "detail"` 옵션(뒷길이·돌규격·형식 …)도 폼에 그린다. */
|
||||
includeDetail?: boolean | (() => boolean);
|
||||
/** 집계 항목 고르기 모달이 프로젝트 복사본을 고치려면 필요 — 없으면 원본 보기만(25-2d) */
|
||||
projectId?: string | null;
|
||||
}
|
||||
|
||||
/** B05 배치 폼에 그릴 옵션인가 — 상세(detail)는 B06/B07 몫이라 숨긴다. */
|
||||
|
||||
@@ -497,7 +497,11 @@ export function createStructuresSection(
|
||||
summary.bind({
|
||||
pick: () => {
|
||||
// canCopy — 개인 층에 쓸 수 없는 사용자의 복사는 서버가 막은 까닭을 모달이 그대로 보임
|
||||
void openItemPicker({ current: summary.get(), canCopy: true }).then((result) => {
|
||||
void openItemPicker({
|
||||
current: summary.get(),
|
||||
canCopy: true,
|
||||
projectId: sectionOptions.projectId ?? null,
|
||||
}).then((result) => {
|
||||
if (result.action === "cancel") return;
|
||||
summary.set(result.action === "apply" ? result.item : null);
|
||||
commitSummary();
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""프로젝트 집계 항목 복사본 읽기 · 쓰기 · 새로 가져오기 · 미리 가져오기 · 계산 (PLAN 25-2b).
|
||||
|
||||
복사본 = `{프로젝트}/templates/items/{폴더}/` (`M02_Item_Snapshot`). 파일마다 판 = 내용 해시 —
|
||||
쓸 때 판이 다르면 `Stale`(409). 고치면 manifest 에 `고침` 을 남겨 구조물에 안 쓰여도 안 지움.
|
||||
옛 복사본(새 파일 없음)은 읽을 때 채움(`complete_old`). 계산은 복사본만 씀(마스터 현재값 안 읽음).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_summary_item import snapshot_folder_name
|
||||
from M02_MasterTemplete import M02_Item_Prices as prices
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import (
|
||||
MANIFEST_NAME,
|
||||
PICKS_FILE,
|
||||
VALUES_FILE,
|
||||
build_snapshot,
|
||||
complete_old,
|
||||
items_dir,
|
||||
now_text,
|
||||
write_snapshot,
|
||||
)
|
||||
from M02_MasterTemplete.M02_Item_Source import (
|
||||
LABOR_FILE,
|
||||
LINK_FILE,
|
||||
PRICE_FILE,
|
||||
build_source,
|
||||
read_json,
|
||||
version_of,
|
||||
)
|
||||
|
||||
#: 길의 종류 이름 → 복사본 파일
|
||||
KINDS = {
|
||||
"도면": "도면.json",
|
||||
"면적": "면적.json",
|
||||
"재료": "재료.json",
|
||||
"자재조합": "자재조합.json",
|
||||
"입력연결": LINK_FILE,
|
||||
"입력값": VALUES_FILE,
|
||||
"고른자재": PICKS_FILE,
|
||||
}
|
||||
#: 뼈대 검사에 쓰는 층 양식 종류(도면 · 면적산출 · 재료산출)
|
||||
SKELETON = {"도면": "structure", "면적": "area", "재료": "material"}
|
||||
SEATS = ("면적", "재료", "선택", None)
|
||||
_FOLDER = re.compile(r"^(system|company|personal)_[0-9A-Za-z-]+_SS\d{6}$")
|
||||
_MT = re.compile(r"^MT\d{6}$")
|
||||
|
||||
|
||||
class Stale(ValueError):
|
||||
def __init__(self, current: str):
|
||||
super().__init__("다른 곳에서 먼저 고쳤습니다 — 다시 읽고 고치세요.")
|
||||
self.current = current
|
||||
|
||||
|
||||
def folder_of(project_root: str | Path, folder: str) -> Path:
|
||||
"""복사본 폴더 — 이름 모양이 틀리거나 없으면 LookupError(404)."""
|
||||
if not _FOLDER.match(folder):
|
||||
raise LookupError(f"복사본 폴더 이름 모양이 아님 「{folder}」")
|
||||
target = items_dir(project_root) / folder
|
||||
if not (target / MANIFEST_NAME).is_file():
|
||||
raise LookupError(f"복사본이 없음 「{folder}」")
|
||||
complete_old(target)
|
||||
return target
|
||||
|
||||
|
||||
def source_changed(manifest: dict[str, Any]) -> bool | None:
|
||||
"""지금 원본(층 · 마스터)을 다시 모은 판이 가져온 판과 다른가 — 원본이 없어졌으면 None."""
|
||||
try:
|
||||
source = build_source(manifest["원본"])
|
||||
except (LookupError, ValueError, KeyError):
|
||||
return None
|
||||
return version_of(source) != manifest.get("원본판")
|
||||
|
||||
|
||||
def list_items(project_root: str | Path) -> list[dict[str, Any]]:
|
||||
base = items_dir(project_root)
|
||||
out = []
|
||||
for child in sorted(base.iterdir()) if base.is_dir() else []:
|
||||
if not child.is_dir() or not _FOLDER.match(child.name):
|
||||
continue
|
||||
if not (child / MANIFEST_NAME).is_file():
|
||||
continue
|
||||
complete_old(child)
|
||||
manifest = read_json(child / MANIFEST_NAME) or {}
|
||||
origin = manifest.get("원본") or {}
|
||||
changed = source_changed(manifest)
|
||||
out.append(
|
||||
{
|
||||
"폴더": child.name,
|
||||
"이름": origin.get("name"),
|
||||
"원본": origin,
|
||||
"원본판": manifest.get("원본판"),
|
||||
"가져온날": manifest.get("가져온날"),
|
||||
"마스터바뀜": bool(changed),
|
||||
"원본없음": changed is None,
|
||||
"구조물": manifest.get("구조물") or [],
|
||||
"고침": bool(manifest.get("고침")),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def read_file(project_root: str | Path, folder: str, kind: str) -> dict[str, Any]:
|
||||
target = folder_of(project_root, folder)
|
||||
content = read_json(target / KINDS[kind])
|
||||
return {"종류": kind, "판": version_of(content), "내용": content}
|
||||
|
||||
|
||||
# ── 모양 검사 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _check_values(content: Any) -> None:
|
||||
if not isinstance(content, dict):
|
||||
raise ValueError("입력값은 {로직키: {입력이름: {값, 확인}}} 이어야 합니다.")
|
||||
for logic, inputs in content.items():
|
||||
if not isinstance(inputs, dict):
|
||||
raise ValueError(f"입력값 「{logic}」 은 {{입력이름: {{값, 확인}}}} 이어야 합니다.")
|
||||
for name, one in inputs.items():
|
||||
if not isinstance(one, dict) or set(one) - {"값", "확인"}:
|
||||
raise ValueError(f"입력값 「{logic}|{name}」 은 {{값, 확인}} 이어야 합니다.")
|
||||
if not isinstance(one.get("확인", False), bool):
|
||||
raise ValueError(f"입력값 「{logic}|{name}」 확인은 참 · 거짓이어야 합니다.")
|
||||
if isinstance(one.get("값"), (dict, list)):
|
||||
raise ValueError(f"입력값 「{logic}|{name}」 값은 수 · 글 · 빈 값이어야 합니다.")
|
||||
|
||||
|
||||
def _check_picks(content: Any) -> None:
|
||||
if not isinstance(content, dict):
|
||||
raise ValueError("고른자재는 {로직키: {호표 차례: MT키}} 이어야 합니다.")
|
||||
for logic, picks in content.items():
|
||||
if not isinstance(picks, dict):
|
||||
raise ValueError(f"고른자재 「{logic}」 은 {{호표 차례: MT키}} 이어야 합니다.")
|
||||
for at, key in picks.items():
|
||||
if not str(at).isdigit() or not isinstance(key, str) or not _MT.match(key):
|
||||
raise ValueError(f"고른자재 「{logic}」 {at} → 「{key}」 — 차례는 수 · 값은 MT 키")
|
||||
|
||||
|
||||
def _check_links(content: Any) -> None:
|
||||
rows = content.get("줄") if isinstance(content, dict) else None
|
||||
if not isinstance(rows, list):
|
||||
raise ValueError("입력연결은 {판, 줄: [...]} 이어야 합니다.")
|
||||
for row in rows:
|
||||
if not isinstance(row, dict) or not row.get("로직") or not row.get("입력"):
|
||||
raise ValueError("입력연결 줄은 {로직, 입력, 자리, 알약?} 이어야 합니다.")
|
||||
if row.get("자리") not in SEATS:
|
||||
raise ValueError(f"입력연결 자리 「{row.get('자리')}」 — 면적 · 재료 · 선택 · 빈 값")
|
||||
|
||||
|
||||
def check(kind: str, content: Any) -> None:
|
||||
if kind == "입력값":
|
||||
_check_values(content)
|
||||
elif kind == "고른자재":
|
||||
_check_picks(content)
|
||||
elif kind == "입력연결":
|
||||
_check_links(content)
|
||||
elif kind in SKELETON:
|
||||
layers.check_skeleton(SKELETON[kind], content)
|
||||
elif not isinstance(content, dict):
|
||||
raise ValueError(f"{kind} 은 JSON 객체여야 합니다.")
|
||||
|
||||
|
||||
def write_file(
|
||||
project_root: str | Path,
|
||||
folder: str,
|
||||
kind: str,
|
||||
version: str | None,
|
||||
content: Any,
|
||||
recalc: Callable[[dict], dict] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""판이 같을 때만 씀 — 면적 · 재료는 `recalc`(서버 계산값)를 거친 것을 씀."""
|
||||
target = folder_of(project_root, folder)
|
||||
check(kind, content)
|
||||
current = version_of(read_json(target / KINDS[kind]))
|
||||
if version != current:
|
||||
raise Stale(current)
|
||||
if recalc is not None and kind in ("면적", "재료"):
|
||||
content = recalc(content)
|
||||
atomic_write_json(target / KINDS[kind], content)
|
||||
manifest = read_json(target / MANIFEST_NAME) or {}
|
||||
atomic_write_json(target / MANIFEST_NAME, {**manifest, "고침": True, "고친날": now_text()})
|
||||
return {"종류": kind, "판": version_of(content)}
|
||||
|
||||
|
||||
# ── 새로 가져오기 · 미리 가져오기 ─────────────────────────────────────
|
||||
|
||||
|
||||
def _kept_values(values: Any, labor: dict[str, Any]) -> dict[str, Any]:
|
||||
"""같은 로직 · 같은 입력 이름만 살림."""
|
||||
names = {
|
||||
str(row.get("키")): {spec["이름"] for spec in row.get("입력", [])}
|
||||
for row in labor.get("로직", [])
|
||||
}
|
||||
out: dict[str, Any] = {}
|
||||
for logic, inputs in (values or {}).items():
|
||||
kept = {k: v for k, v in (inputs or {}).items() if k in names.get(logic, set())}
|
||||
if kept:
|
||||
out[logic] = kept
|
||||
return out
|
||||
|
||||
|
||||
def _kept_picks(picks: Any, labor: dict[str, Any]) -> dict[str, Any]:
|
||||
"""같은 로직 · 있는 호표 차례만 살림."""
|
||||
lines = {str(row.get("키")): len(row.get("호표", [])) for row in labor.get("로직", [])}
|
||||
out: dict[str, Any] = {}
|
||||
for logic, chosen in (picks or {}).items():
|
||||
kept = {k: v for k, v in (chosen or {}).items() if int(k) < lines.get(logic, 0)}
|
||||
if kept:
|
||||
out[logic] = kept
|
||||
return out
|
||||
|
||||
|
||||
def refresh(project_root: str | Path, folder: str) -> dict[str, Any]:
|
||||
"""층 · 마스터에서 다시 가져옴 — 입력값 · 고른자재는 같은 키면 살림."""
|
||||
target = folder_of(project_root, folder)
|
||||
manifest = read_json(target / MANIFEST_NAME) or {}
|
||||
values = read_json(target / VALUES_FILE)
|
||||
picks = read_json(target / PICKS_FILE)
|
||||
files = build_snapshot(manifest["원본"])
|
||||
labor = files.get(LABOR_FILE) or {}
|
||||
files[VALUES_FILE] = _kept_values(values, labor)
|
||||
files[PICKS_FILE] = _kept_picks(picks, labor)
|
||||
for old in target.glob("*.json"): # 원본에서 빠진 파일(도면 없앰 등)은 지움
|
||||
if old.name != MANIFEST_NAME and old.name not in files:
|
||||
old.unlink()
|
||||
for name, content in files.items():
|
||||
atomic_write_json(target / name, content)
|
||||
source = {k: v for k, v in files.items() if k not in (VALUES_FILE, PICKS_FILE)}
|
||||
stamp = now_text()
|
||||
fresh = {**manifest, "판": version_of(source), "원본판": version_of(source), "가져온날": stamp}
|
||||
atomic_write_json(target / MANIFEST_NAME, fresh)
|
||||
return {"폴더": folder, "가져온날": stamp, "원본판": fresh["원본판"]}
|
||||
|
||||
|
||||
def prefetch(project_root: str | Path, item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""구조물에 아직 안 이은 항목을 미리 가져옴 — 있으면 그대로 · 없으면 새로(쓰는 구조물 없음)."""
|
||||
folder = snapshot_folder_name(item)
|
||||
target = items_dir(project_root) / folder
|
||||
manifest = read_json(target / MANIFEST_NAME)
|
||||
made = not isinstance(manifest, dict)
|
||||
if made:
|
||||
manifest = write_snapshot(target, item, [], {"미리가져옴": now_text()})
|
||||
return {"폴더": folder, "만듦": made, "가져온날": manifest.get("가져온날")}
|
||||
|
||||
|
||||
def calc(project_root: str | Path, folder: str) -> dict[str, Any]:
|
||||
"""복사본(입력값 · 고른자재 · 단가 · 일위대가)만으로 계산 — 마스터 현재값을 안 읽음."""
|
||||
target = folder_of(project_root, folder)
|
||||
labor = read_json(target / LABOR_FILE)
|
||||
if not isinstance(labor, dict):
|
||||
raise LookupError(f"일위대가 조합이 없는 항목 「{folder}」 — 계산할 것이 없음")
|
||||
return prices.calc(
|
||||
labor,
|
||||
read_json(target / PRICE_FILE) or {},
|
||||
read_json(target / VALUES_FILE) or {},
|
||||
read_json(target / PICKS_FILE) or {},
|
||||
)
|
||||
@@ -0,0 +1,249 @@
|
||||
"""집계 항목 복사본의 단가 묶음 `단가.json` · 복사본만으로 계산 (PLAN 25-1b · 25-2b · 25-2e).
|
||||
|
||||
단가 묶음 = 그 항목 일위대가 조합이 담은 로직을 계산할 때 닿을 수 있는 마스터 줄 전부(닫힌 묶음):
|
||||
- 담은 로직 · 로직이 부르는 하위 로직(`로직(GC…)`) 끝까지
|
||||
- 식이 가리키는 요소(인력 · 기계 · 재료 · 유가전력 · 요율 · 환율 …) · 준용 대상
|
||||
- 찾기 표(`찾기(QC…)` · `찾기(QF…)` 소요량 · 계수)
|
||||
- 재료 고르기 조건의 후보 자재품목 줄 · 품셈재료 연결 · 지역 · 계약종별 후보
|
||||
원문번호 틀의 `{입력}` 은 그 입력의 고르기 값으로 모두 끼워 모음(고르기가 없으면 그 테이블 전부 ·
|
||||
자재품목만은 너무 커서 뺌). 줄은 마스터 파일 차례 그대로 담음 — 고르기의 「첫 줄」 이 같게.
|
||||
|
||||
`단가.json` = `{파일 이름: {그룹 · 머리 칸 …, 줄|표: [줄]}}` — `master_formula.Master` 가
|
||||
그대로 읽는 꼴.
|
||||
계산(`calc`)은 이 묶음과 `입력값.json` · `고른자재.json` 만 씀 — 마스터 현재값을 읽지 않음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
|
||||
mf = m01.cm.mf
|
||||
mk = m01.mk
|
||||
mcb = m01.mcb
|
||||
mm = mf.mp.mm # 품셈재료 · 자재품목 후보(`master_material`)
|
||||
|
||||
#: 틀을 못 풀었을 때 통째로 담지 않는 테이블 — 자재품목(3만 줄)
|
||||
_TOO_BIG = ("MT",)
|
||||
|
||||
|
||||
def master() -> tuple[dict[str, dict], Any]:
|
||||
"""(마스터 파일 묶음, 색인) — 읽기 전용(시험은 `m01.FOLDER` 를 사본으로 바꿈)."""
|
||||
return m01.loaded()
|
||||
|
||||
|
||||
def _texts(row: dict) -> list[str]:
|
||||
out = [step["식"] for step in row.get("중간", [])]
|
||||
if "결과" in row:
|
||||
out.append(row["결과"]["식"])
|
||||
for item in row.get("호표", []):
|
||||
out.append(item["수량"])
|
||||
if not isinstance(item.get("요소"), dict):
|
||||
out.append(item["요소"])
|
||||
out += [extra["식"] for extra in row.get("덧줄", [])]
|
||||
return out
|
||||
|
||||
|
||||
class _Collector:
|
||||
def __init__(self, whole: Any):
|
||||
self.whole = whole
|
||||
self.keys: set[str] = set()
|
||||
self.logics: set[str] = set()
|
||||
self.missing: list[str] = []
|
||||
|
||||
def refs(self, row: dict, template: str) -> list[str]:
|
||||
"""원문번호 틀 → 닿을 수 있는 요소 참조 — 고르기로 끼우고, 못 풀면 그 테이블 전부."""
|
||||
if "{" not in template:
|
||||
return [template]
|
||||
expanded = mf._expand(row, template, self.whole)
|
||||
if expanded is not None:
|
||||
return expanded
|
||||
tid = mk.id_of(template)
|
||||
if not tid or tid in _TOO_BIG:
|
||||
self.missing.append(f"{row.get('키')} 「{template}」 틀을 못 풂")
|
||||
return []
|
||||
return list(self.whole.index.get(tid, {}))
|
||||
|
||||
def add(self, ref: str) -> None:
|
||||
try:
|
||||
row = self.whole.get(ref)
|
||||
except mf.FormulaError:
|
||||
return # 없는 요소 — 계산이 같은 까닭으로 멈춤(마스터와 같게)
|
||||
key = str(row.get("키"))
|
||||
if key in self.keys:
|
||||
return
|
||||
self.keys.add(key)
|
||||
if row.get("준용") not in (None, ""):
|
||||
self.add(str(row["준용"]))
|
||||
link = row.get("연결") if key[:2] == mf.LINKED else None
|
||||
if isinstance(link, str):
|
||||
self.add(link)
|
||||
elif isinstance(link, dict):
|
||||
self.widely(link, row.get("단위"))
|
||||
|
||||
def widely(self, cond: dict, unit: Any) -> None:
|
||||
"""지역 · 계약종별 「입력」 조건 — 입력값과 상관없이 닿을 수 있는 후보 전부."""
|
||||
want = mm.plain(cond.get("이름"))
|
||||
if cond.get("계약종별") == "입력":
|
||||
for key, one in self.whole.index.get(mm.PRICED, {}).items():
|
||||
if one.get("구분") == "전력" and mm.plain(one.get("이름")).startswith(want):
|
||||
self.keys.add(key)
|
||||
return
|
||||
spec, unit_p = mm.plain_spec(cond.get("규격")), mm.plain_unit(unit)
|
||||
for root, _, s, u, key in mm._market_index(self.whole):
|
||||
if root.endswith(want) and u == unit_p and (not spec or s == spec):
|
||||
self.keys.add(key)
|
||||
|
||||
def pick(self, row: dict, cond: dict, unit: Any) -> None:
|
||||
"""재료 고르기 조건 — 대표 줄 · 후보 줄(`{입력}` 은 고르기 값으로 끼움)."""
|
||||
if cond.get("지역") == "입력" or cond.get("계약종별") == "입력":
|
||||
self.widely(cond, unit)
|
||||
return
|
||||
holes = sorted(
|
||||
{h for v in cond.values() if isinstance(v, str) for h in mf._HOLE.findall(v)}
|
||||
)
|
||||
variants = [cond]
|
||||
if holes:
|
||||
env_sets = mf._expand(row, "|".join(f"{{{h}}}" for h in holes), self.whole)
|
||||
if env_sets is None:
|
||||
variants = [mf.static(cond)]
|
||||
else:
|
||||
variants = []
|
||||
for filled in env_sets:
|
||||
env = dict(zip(holes, filled.split("|")))
|
||||
variants.append(
|
||||
{k: mf.fill(v, env) if isinstance(v, str) else v for k, v in cond.items()}
|
||||
)
|
||||
for one in variants:
|
||||
if one.get("대표"):
|
||||
self.add(str(one["대표"]))
|
||||
if not one.get("구분") and not one.get("검색어"):
|
||||
continue
|
||||
for key in mf.candidates(self.whole, one, None, None):
|
||||
self.add(key)
|
||||
|
||||
def logic(self, key: str) -> None:
|
||||
if key in self.logics:
|
||||
return
|
||||
try:
|
||||
row = self.whole.logic(key)
|
||||
except mf.FormulaError as error:
|
||||
self.missing.append(f"{key} · {error}")
|
||||
return
|
||||
self.logics.add(key)
|
||||
self.keys.add(key)
|
||||
for text in _texts(row):
|
||||
try:
|
||||
node = mf.parse(text)
|
||||
except mf.FormulaError:
|
||||
continue # 못 읽는 식 — 계산도 같은 까닭으로 멈춤
|
||||
for part in mf.walk(node):
|
||||
kind = part[0]
|
||||
if kind == "ref" or kind == "find":
|
||||
for ref in self.refs(row, part[1]):
|
||||
self.add(ref)
|
||||
elif kind == "logic":
|
||||
for ref in self.refs(row, part[1]):
|
||||
self.logic(ref)
|
||||
for item in row.get("호표", []):
|
||||
if isinstance(item.get("요소"), dict):
|
||||
self.pick(row, item["요소"], item.get("단위"))
|
||||
|
||||
|
||||
def collect(logic_keys: list[str]) -> dict[str, Any]:
|
||||
"""담은 로직의 닫힌 단가 묶음 — `{파일 이름: 파일(줄은 모은 것만 · 차례 그대로)}`."""
|
||||
files, whole = master()
|
||||
collector = _Collector(whole)
|
||||
for key in logic_keys:
|
||||
collector.logic(key)
|
||||
out: dict[str, Any] = {}
|
||||
for name, data in files.items():
|
||||
slot = m01.items_key(data)
|
||||
rows = [row for row in data.get(slot) or [] if str(row.get("키")) in collector.keys]
|
||||
if rows:
|
||||
out[name] = {**{k: v for k, v in data.items() if k != slot}, slot: rows}
|
||||
if collector.missing:
|
||||
out["_빠진"] = sorted(set(collector.missing))
|
||||
return out
|
||||
|
||||
|
||||
def to_json(value: Any) -> Any:
|
||||
"""Decimal 을 JSON 수로 — 정수는 int · 아니면 원래 자릿수 그대로(float 로 흔들지 않음)."""
|
||||
return json.loads(json.dumps(value, ensure_ascii=False, default=_number))
|
||||
|
||||
|
||||
def _number(value: Any) -> Any:
|
||||
if isinstance(value, Decimal):
|
||||
text = str(value) # 「14480.0」 은 소수 그대로 — 끝수 · 표기가 마스터와 같게
|
||||
return int(value) if "." not in text and "E" not in text.upper() else float(value)
|
||||
raise TypeError(type(value).__name__)
|
||||
|
||||
|
||||
def load_prices(prices: dict[str, Any]) -> Any:
|
||||
"""`단가.json` → 계산 색인(수는 Decimal · 마스터 적재와 같게)."""
|
||||
raw = json.dumps({k: v for k, v in prices.items() if not k.startswith("_")}, ensure_ascii=False)
|
||||
files = json.loads(raw, parse_float=Decimal, parse_int=Decimal)
|
||||
return mf.Master(files)
|
||||
|
||||
|
||||
# ── 계산(25-2e) ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def missing_inputs(snapshot: Any, labor: dict[str, Any], values: dict[str, Any]) -> list[dict]:
|
||||
"""담은 로직마다 값이 없는(null · 안 적음) 입력 — `{로직, 입력}`."""
|
||||
out = []
|
||||
for key in mcb.logic_keys(labor.get("줄") or {}):
|
||||
try:
|
||||
row = snapshot.logic(key)
|
||||
except mf.FormulaError:
|
||||
continue
|
||||
mine = values.get(key) or {}
|
||||
for spec in row.get("입력", []):
|
||||
got = mine.get(spec["이름"])
|
||||
if not isinstance(got, dict) or got.get("값") in (None, ""):
|
||||
out.append({"로직": key, "입력": spec["이름"]})
|
||||
return out
|
||||
|
||||
|
||||
def calc(
|
||||
labor: dict[str, Any], prices: dict[str, Any], values: dict[str, Any], picks: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""복사본만으로 계산 — 입력이 빠진 로직은 안 돌리고 `멈춤` · 세 비목 · 계는 돈 로직 합."""
|
||||
snapshot = load_prices(prices)
|
||||
gaps = missing_inputs(snapshot, labor, values)
|
||||
blocked = {gap["로직"] for gap in gaps}
|
||||
sums = dict.fromkeys(mf.COST_ITEMS, Decimal(0))
|
||||
lines, stuck = [], []
|
||||
for at, key in enumerate(mcb.logic_keys(labor.get("줄") or {}), start=1):
|
||||
head: dict[str, Any] = {"차례": at, "로직": key}
|
||||
if key in blocked:
|
||||
names = [gap["입력"] for gap in gaps if gap["로직"] == key]
|
||||
lines.append({**head, "ok": False, "까닭": f"입력 없음 — {', '.join(names)}"})
|
||||
stuck.append(key)
|
||||
continue
|
||||
given = {name: one.get("값") for name, one in (values.get(key) or {}).items()}
|
||||
try:
|
||||
with mf.mp.choosing((picks or {}).get(key)):
|
||||
got = mf.run(snapshot, key, given)
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as error:
|
||||
lines.append({**head, "ok": False, "까닭": str(error) or type(error).__name__})
|
||||
stuck.append(key)
|
||||
continue
|
||||
if "결과" in got:
|
||||
lines.append({**head, "ok": True, "갈래": "결과", "결과": got["결과"]})
|
||||
continue
|
||||
for name in mf.COST_ITEMS:
|
||||
sums[name] += got[name]
|
||||
lines.append(
|
||||
{
|
||||
**head,
|
||||
"ok": True,
|
||||
"갈래": "돈",
|
||||
**{k: got[k] for k in mf.COST_ITEMS},
|
||||
"계": got["계"],
|
||||
}
|
||||
)
|
||||
return to_json({"줄": lines, **sums, "계": sum(sums.values()), "빠진입력": gaps, "멈춤": stuck})
|
||||
@@ -1,19 +1,19 @@
|
||||
"""구조물 집계표 항목 스냅샷 — 프로젝트 `templates/items/{폴더}/` (PLAN 21-1 · 21-3).
|
||||
"""구조물 집계표 항목 스냅샷 — 프로젝트 `templates/items/{폴더}/` (PLAN 21-1 · 21-3 · 25-2b).
|
||||
|
||||
[저장](`PUT /route/structures` · `PUT /drainage/pipe-points`) 뒤 서버가 맞춤:
|
||||
- 구조물 · 관 지점이 가리키는 항목(`summary_item`)마다 원본 층에서 내용을 복사
|
||||
(`집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA · `재료.json` SM ·
|
||||
`자재조합.json` UM 줄 · `일위대가.json` UA 줄 + 담은 로직 줄 · `manifest.json`)
|
||||
(`M02_Item_Source.build_source` — 집계 · 도면 · 면적 · 재료 · 자재조합 · 일위대가 · 입력연결 ·
|
||||
단가) + 프로젝트 값 `입력값.json`(처음엔 빈 값 · 자동값은 안 적음) · `고른자재.json` ·
|
||||
`manifest.json`
|
||||
- 이미 있는 폴더는 내용을 다시 쓰지 않음 — 원본(마스터 · 회사 · 개인)이 바뀌어도 프로젝트
|
||||
값은 지정한 때 그대로 · `manifest` 의 쓰는 구조물 목록만 새로
|
||||
- 아무 구조물도 안 쓰는 폴더는 지움(지정 바뀜 · 해제 · 구조물 삭제)
|
||||
원본 층 자리 = `M02_Template_Layers` 경로(시스템 층은 지금 파일) · 일위대가는 M01 시스템 것.
|
||||
값은 지정한 때 그대로 · `manifest` 의 쓰는 구조물 목록만 새로([새로 가져오기]는 사용자가)
|
||||
- 아무 구조물도 안 쓰는 폴더는 지움(지정 바뀜 · 해제 · 구조물 삭제) — 단 프로젝트에서 고친
|
||||
흔적(`manifest.고침`)이 있으면 남김
|
||||
원본 층 자리 = `M02_Template_Layers` 경로(시스템 층은 지금 파일) · 일위대가 · 단가는 M01 시스템 것.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
@@ -25,97 +25,54 @@ from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pip
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_summary_item import snapshot_folder_name
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from M02_MasterTemplete.M02_Item_Source import (
|
||||
LINK_FILE,
|
||||
PRICE_FILE,
|
||||
build_source,
|
||||
read_json,
|
||||
version_of,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ITEMS_DIRNAME = "items"
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
TABLE_NAME = "구조물집계표"
|
||||
UM_FILE = "자재조합.json"
|
||||
UA_FILE = "일위대가조합.json" # M01 시스템(`resources/master_data/`)
|
||||
#: 열 id → 스냅샷 파일(구조물 도면 · 면적산출 · 재료산출)
|
||||
PART_FILES = (("structure", "도면.json"), ("area", "면적.json"), ("material", "재료.json"))
|
||||
VALUES_FILE = "입력값.json" # {로직키: {입력이름: {값, 확인}}}
|
||||
PICKS_FILE = "고른자재.json" # {로직키: {호표 차례: MT키}}
|
||||
|
||||
|
||||
def items_dir(project_root: str | Path) -> Path:
|
||||
return layers.project_dir(project_root) / ITEMS_DIRNAME
|
||||
|
||||
|
||||
def layer_root(item: dict[str, Any]) -> Path:
|
||||
"""항목 원본 층 뿌리 — 시스템 · 회사 · 개인(21-2 층 폴더 함수가 오면 그리로)."""
|
||||
if item["layer"] == "system":
|
||||
return layers.system_dir()
|
||||
if item["layer"] == "company":
|
||||
return layers.company_dir(item["owner"])
|
||||
company, user = str(item["owner"]).split("/")
|
||||
return layers.personal_dir(company, user)
|
||||
|
||||
|
||||
def _read(path: Path) -> Any:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _row(document: Any, key: str) -> dict[str, Any] | None:
|
||||
rows = document.get("줄") if isinstance(document, dict) else None
|
||||
for row in rows or []:
|
||||
if isinstance(row, dict) and row.get("키") == key:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _labor_rows(ua_key: str) -> dict[str, Any] | None:
|
||||
"""UA 줄 + 담은 로직 줄(M01 시스템 · 없는 로직은 빼고 키만 `빠진로직`)."""
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
|
||||
ua = _row(_read(Path(m01.FOLDER) / UA_FILE), ua_key)
|
||||
if ua is None:
|
||||
return None
|
||||
wanted = [str(entry.get("로직")) for entry in ua.get("담은로직") or [] if entry.get("로직")]
|
||||
found: dict[str, dict[str, Any]] = {}
|
||||
raw: dict[str, Any] = {} # M01 적재본은 수가 Decimal — 줄은 원본 파일 JSON 그대로 담음
|
||||
for key in wanted:
|
||||
try:
|
||||
file = m01.logic(key)["file"]
|
||||
except m01.StoreError:
|
||||
continue
|
||||
if file not in raw:
|
||||
raw[file] = _read(Path(m01.FOLDER) / file)
|
||||
row = _row(raw[file], key)
|
||||
if row is not None:
|
||||
found[key] = row
|
||||
return {
|
||||
"줄": ua,
|
||||
"로직": [found[key] for key in wanted if key in found],
|
||||
"빠진로직": [key for key in wanted if key not in found],
|
||||
}
|
||||
def now_text() -> str:
|
||||
return datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def build_snapshot(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""원본 층에서 항목 내용을 모음 — `{파일 이름: 내용}` · SS 줄이 없으면 LookupError."""
|
||||
root = layer_root(item)
|
||||
row = _row(_read(root / layers.COMBO_FILE), item["key"])
|
||||
if row is None:
|
||||
raise LookupError(f"집계 항목 {item['key']} 이 {item['layer']} 층에 없음")
|
||||
column_id = str(row.get("열") or "")
|
||||
table = _read(root / "table" / f"{TABLE_NAME}.json") or {}
|
||||
column = next((c for c in table.get("열") or [] if c.get("id") == column_id), None)
|
||||
files: dict[str, Any] = {"집계.json": {"줄": row, "열": column}}
|
||||
for kind, name in PART_FILES:
|
||||
part = _read(root / kind / f"{column_id}.json") if column_id else None
|
||||
if part is not None:
|
||||
files[name] = part
|
||||
if row.get("자재조합"):
|
||||
um = _row(_read(root / UM_FILE), str(row["자재조합"]))
|
||||
if um is not None:
|
||||
files["자재조합.json"] = um
|
||||
if row.get("일위대가"):
|
||||
labor = _labor_rows(str(row["일위대가"]))
|
||||
if labor is not None:
|
||||
files["일위대가.json"] = labor
|
||||
return files
|
||||
"""원본 내용 + 빈 프로젝트 값 — `{파일 이름: 내용}` · SS 줄이 없으면 LookupError."""
|
||||
return {**build_source(item), VALUES_FILE: {}, PICKS_FILE: {}}
|
||||
|
||||
|
||||
def write_snapshot(target: Path, item: dict[str, Any], users: list[str], extra: dict) -> dict:
|
||||
"""폴더에 새로 씀 — manifest 는 마지막(있으면 다 쓴 폴더 · 끊기면 다음 저장이 다시 씀)."""
|
||||
files = build_snapshot(item)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
for name, content in files.items():
|
||||
atomic_write_json(target / name, content)
|
||||
source = {k: v for k, v in files.items() if k not in (VALUES_FILE, PICKS_FILE)}
|
||||
stamp = now_text()
|
||||
manifest = {
|
||||
"원본": item,
|
||||
"판": version_of(source),
|
||||
"원본판": version_of(source),
|
||||
"적용일": stamp,
|
||||
"가져온날": stamp,
|
||||
"구조물": users,
|
||||
**extra,
|
||||
}
|
||||
atomic_write_json(target / MANIFEST_NAME, manifest)
|
||||
return manifest
|
||||
|
||||
|
||||
def assignments(project_root: str | Path) -> dict[str, tuple[dict[str, Any], list[str]]]:
|
||||
@@ -134,11 +91,6 @@ def assignments(project_root: str | Path) -> dict[str, tuple[dict[str, Any], lis
|
||||
return found
|
||||
|
||||
|
||||
def _version(files: dict[str, Any]) -> str:
|
||||
raw = json.dumps(files, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||
return hashlib.sha256(raw).hexdigest()[:16]
|
||||
|
||||
|
||||
def sync_snapshots(project_root: str | Path) -> dict[str, list[str]]:
|
||||
"""지정 항목 스냅샷을 맞춤 — `{만듦, 지움, 못만듦}` 폴더 이름."""
|
||||
base = items_dir(project_root)
|
||||
@@ -147,37 +99,30 @@ def sync_snapshots(project_root: str | Path) -> dict[str, list[str]]:
|
||||
failed: list[str] = []
|
||||
for folder, (item, users) in sorted(wanted.items()):
|
||||
target = base / folder
|
||||
manifest = _read(target / MANIFEST_NAME)
|
||||
manifest = read_json(target / MANIFEST_NAME)
|
||||
if isinstance(manifest, dict):
|
||||
if manifest.get("구조물") != users:
|
||||
atomic_write_json(target / MANIFEST_NAME, {**manifest, "구조물": users})
|
||||
continue
|
||||
try:
|
||||
files = build_snapshot(item)
|
||||
write_snapshot(target, item, users, {})
|
||||
except (LookupError, ValueError) as error:
|
||||
logger.warning("집계 항목 스냅샷을 못 만듦 (%s): %s", folder, error)
|
||||
failed.append(folder)
|
||||
continue
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
for name, content in files.items():
|
||||
atomic_write_json(target / name, content)
|
||||
# manifest 는 마지막 — 있으면 다 쓴 폴더(도중에 끊기면 다음 저장이 다시 씀)
|
||||
atomic_write_json(
|
||||
target / MANIFEST_NAME,
|
||||
{
|
||||
"원본": item,
|
||||
"판": _version(files),
|
||||
"적용일": datetime.now().isoformat(timespec="seconds"),
|
||||
"구조물": users,
|
||||
},
|
||||
)
|
||||
made.append(folder)
|
||||
removed: list[str] = []
|
||||
if base.is_dir():
|
||||
for child in sorted(base.iterdir()):
|
||||
if child.is_dir() and child.name not in wanted:
|
||||
shutil.rmtree(child)
|
||||
removed.append(child.name)
|
||||
if not child.is_dir() or child.name in wanted:
|
||||
continue
|
||||
manifest = read_json(child / MANIFEST_NAME)
|
||||
if isinstance(manifest, dict) and manifest.get("고침"):
|
||||
if manifest.get("구조물"): # 고친 복사본은 남기되 쓰는 구조물은 비움
|
||||
atomic_write_json(child / MANIFEST_NAME, {**manifest, "구조물": []})
|
||||
continue
|
||||
shutil.rmtree(child)
|
||||
removed.append(child.name)
|
||||
return {"만듦": made, "지움": removed, "못만듦": failed}
|
||||
|
||||
|
||||
@@ -191,7 +136,44 @@ def safe_sync(project_root: str | Path) -> None:
|
||||
logger.exception("집계 항목 스냅샷 맞춤 실패 (저장은 유지): %s", project_root)
|
||||
|
||||
|
||||
def complete_old(folder: Path) -> bool:
|
||||
"""옛 스냅샷(21-3 · 새 파일 없음)을 채움 — 입력연결 · 단가는 지금 원본에서 · 값은 빈 것.
|
||||
|
||||
옛 내용 파일(집계 · 도면 …)은 그대로 둠. 채웠으면 True(manifest 에 `옮겨받음` 날).
|
||||
"""
|
||||
manifest = read_json(folder / MANIFEST_NAME)
|
||||
if not isinstance(manifest, dict):
|
||||
return False
|
||||
lacking = [
|
||||
name
|
||||
for name in (LINK_FILE, PRICE_FILE, VALUES_FILE, PICKS_FILE)
|
||||
if not (folder / name).is_file()
|
||||
]
|
||||
if not lacking and "원본판" in manifest:
|
||||
return False
|
||||
source = (
|
||||
build_source(manifest["원본"]) if (LINK_FILE in lacking or PRICE_FILE in lacking) else {}
|
||||
)
|
||||
for name in lacking:
|
||||
atomic_write_json(folder / name, source.get(name, {}))
|
||||
kept = {
|
||||
name: read_json(folder / name)
|
||||
for name in sorted(p.name for p in folder.glob("*.json"))
|
||||
if name not in (MANIFEST_NAME, VALUES_FILE, PICKS_FILE)
|
||||
}
|
||||
atomic_write_json(
|
||||
folder / MANIFEST_NAME,
|
||||
{
|
||||
**manifest,
|
||||
"가져온날": manifest.get("가져온날") or manifest.get("적용일"),
|
||||
"원본판": manifest.get("원본판") or version_of(kept),
|
||||
"옮겨받음": now_text(),
|
||||
},
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def read_snapshot(project_root: str | Path, item: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""프로젝트 스냅샷의 `집계.json`(`{줄, 열}`) — 없으면 None."""
|
||||
data = _read(items_dir(project_root) / snapshot_folder_name(item) / "집계.json")
|
||||
data = read_json(items_dir(project_root) / snapshot_folder_name(item) / "집계.json")
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""집계 항목 원본 — 층(시스템 · 회사 · 개인)에서 항목 내용을 모음 (PLAN 21-1 · 25-1b · 25-2b).
|
||||
|
||||
모으는 것 = `{파일 이름: 내용}`:
|
||||
`집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA · `재료.json` SM ·
|
||||
`자재조합.json` UM 줄 · `일위대가.json` UA 줄 + 담은 로직 줄(M01 시스템) ·
|
||||
`입력연결.json` 층의 `inputs/<열id>.json`(없으면 빈 연결) ·
|
||||
`단가.json` 계산에 닿는 마스터 줄 전부
|
||||
`원본판` = 모은 내용의 해시 — 지금 층 · 마스터에서 다시 모은 판과 대어 「마스터가 바뀜」 을 앎.
|
||||
프로젝트가 고치는 값(`입력값.json` · `고른자재.json`)은 원본이 아니라 여기서 안 모음.
|
||||
원본 층 자리 = `M02_Template_Layers` 경로 · 일위대가 · 단가는 M01 시스템 것.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
TABLE_NAME = "구조물집계표"
|
||||
UM_FILE = "자재조합.json"
|
||||
UA_FILE = "일위대가조합.json" # M01 시스템(`resources/master_data/`)
|
||||
#: 열 id → 스냅샷 파일(구조물 도면 · 면적산출 · 재료산출)
|
||||
PART_FILES = (("structure", "도면.json"), ("area", "면적.json"), ("material", "재료.json"))
|
||||
LINK_FILE = "입력연결.json"
|
||||
PRICE_FILE = "단가.json"
|
||||
LABOR_FILE = "일위대가.json"
|
||||
|
||||
|
||||
def layer_root(item: dict[str, Any]) -> Path:
|
||||
"""항목 원본 층 뿌리 — 시스템 · 회사 · 개인."""
|
||||
if item["layer"] == "system":
|
||||
return layers.system_dir()
|
||||
if item["layer"] == "company":
|
||||
return layers.company_dir(item["owner"])
|
||||
company, user = str(item["owner"]).split("/")
|
||||
return layers.personal_dir(company, user)
|
||||
|
||||
|
||||
def read_json(path: Path) -> Any:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def row_of(document: Any, key: str) -> dict[str, Any] | None:
|
||||
rows = document.get("줄") if isinstance(document, dict) else None
|
||||
for row in rows or []:
|
||||
if isinstance(row, dict) and row.get("키") == key:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def item_name(item: dict[str, Any]) -> str:
|
||||
"""층 SS 줄의 보이는 이름(종류 · 분류1~3) — 없으면 LookupError."""
|
||||
row = row_of(read_json(layer_root(item) / layers.COMBO_FILE), str(item.get("key")))
|
||||
if row is None:
|
||||
raise LookupError(f"집계 항목 {item.get('key')} 이 {item.get('layer')} 층에 없음")
|
||||
parts = (row.get(k) for k in ("종류", "분류1", "분류2", "분류3"))
|
||||
return " ".join(str(p) for p in parts if p) or str(row["키"])
|
||||
|
||||
|
||||
def version_of(content: Any) -> str:
|
||||
raw = json.dumps(content, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||
return hashlib.sha256(raw).hexdigest()[:16]
|
||||
|
||||
|
||||
def labor_rows(ua_key: str) -> dict[str, Any] | None:
|
||||
"""UA 줄 + 담은 로직 줄(M01 시스템 · 없는 로직은 빼고 키만 `빠진로직`)."""
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
|
||||
ua = row_of(read_json(Path(m01.FOLDER) / UA_FILE), ua_key)
|
||||
if ua is None:
|
||||
return None
|
||||
wanted = [str(entry.get("로직")) for entry in ua.get("담은로직") or [] if entry.get("로직")]
|
||||
found: dict[str, dict[str, Any]] = {}
|
||||
raw: dict[str, Any] = {} # M01 적재본은 수가 Decimal — 줄은 원본 파일 JSON 그대로 담음
|
||||
for key in wanted:
|
||||
try:
|
||||
file = m01.logic(key)["file"]
|
||||
except m01.StoreError:
|
||||
continue
|
||||
if file not in raw:
|
||||
raw[file] = read_json(Path(m01.FOLDER) / file)
|
||||
row = row_of(raw[file], key)
|
||||
if row is not None:
|
||||
found[key] = row
|
||||
return {
|
||||
"줄": ua,
|
||||
"로직": [found[key] for key in wanted if key in found],
|
||||
"빠진로직": [key for key in wanted if key not in found],
|
||||
}
|
||||
|
||||
|
||||
def input_links(root: Path, column_id: str) -> dict[str, Any]:
|
||||
"""층의 항목 연결 `inputs/<열id>.json`(sub4 `_Inputs.read_file`) · 없으면 빈 연결."""
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
if not column_id:
|
||||
return {"판": None, "줄": []}
|
||||
with store.at(root):
|
||||
found = inputs.read_file(column_id)
|
||||
if isinstance(found, dict) and isinstance(found.get("줄"), list):
|
||||
return found
|
||||
return {"판": None, "줄": []}
|
||||
|
||||
|
||||
def build_source(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""원본 층 · 마스터에서 항목 내용을 모음 — SS 줄이 없으면 LookupError."""
|
||||
from M02_MasterTemplete import M02_Item_Prices as prices
|
||||
|
||||
root = layer_root(item)
|
||||
row = row_of(read_json(root / layers.COMBO_FILE), item["key"])
|
||||
if row is None:
|
||||
raise LookupError(f"집계 항목 {item['key']} 이 {item['layer']} 층에 없음")
|
||||
column_id = str(row.get("열") or "")
|
||||
table = read_json(root / "table" / f"{TABLE_NAME}.json") or {}
|
||||
column = next((c for c in table.get("열") or [] if c.get("id") == column_id), None)
|
||||
files: dict[str, Any] = {"집계.json": {"줄": row, "열": column}}
|
||||
for kind, name in PART_FILES:
|
||||
part = read_json(root / kind / f"{column_id}.json") if column_id else None
|
||||
if part is not None:
|
||||
files[name] = part
|
||||
if row.get("자재조합"):
|
||||
um = row_of(read_json(root / UM_FILE), str(row["자재조합"]))
|
||||
if um is not None:
|
||||
files[UM_FILE] = um
|
||||
labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None
|
||||
if labor is not None:
|
||||
files[LABOR_FILE] = labor
|
||||
files[LINK_FILE] = input_links(root, column_id)
|
||||
logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])]
|
||||
files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys)) if logic_keys else {}
|
||||
return files
|
||||
@@ -333,3 +333,73 @@ export const fetchSources = (id: string): Promise<unknown> => call(`/projects/${
|
||||
/** 설계값을 채운 표 문서 — 서버가 그때그때 채움 · 저장 안 함 */
|
||||
export const readFilled = (id: string, name: string): Promise<TemplateDoc> =>
|
||||
call(`/projects/${enc(id)}/tables/${enc(name)}/filled`);
|
||||
|
||||
/* --- 선택 입력 (PLAN 25-1b) — 항목(집계표 열 하나) 일위대가의 로직별 입력 목록 · 입력 자리 연결 ---
|
||||
* 길은 층 항목 길(`/layers/{층}/items/{SS 키}/…`) · 층 폴더 저장은 연결만(값은 안 받음). */
|
||||
|
||||
/** 층 항목 목록 한 줄 — 열 id ↔ SS 키 */
|
||||
export interface ItemRow {
|
||||
key: string;
|
||||
열: string;
|
||||
일위대가: string | null;
|
||||
}
|
||||
|
||||
/** 로직의 입력 하나 — 고르기(선택지) · 범위 · 자동값은 보이기만 */
|
||||
export interface ItemInput {
|
||||
이름: string;
|
||||
설명?: string;
|
||||
단위?: string;
|
||||
고르기?: unknown[];
|
||||
범위?: unknown;
|
||||
자동값?: unknown;
|
||||
}
|
||||
|
||||
export interface ItemLogic {
|
||||
로직: string;
|
||||
이름: string;
|
||||
결과단위?: string;
|
||||
입력: ItemInput[];
|
||||
}
|
||||
|
||||
export interface ItemInputs {
|
||||
일위대가: string | null;
|
||||
이름?: string;
|
||||
로직: ItemLogic[];
|
||||
빠진로직: string[];
|
||||
}
|
||||
|
||||
/** 입력 자리 — 면적산출 알약 · 재료산출 알약 · 선택 컨테이너 · 안 씀(null) */
|
||||
export type InputSeat = "면적" | "재료" | "선택";
|
||||
|
||||
export interface InputLine {
|
||||
로직: string;
|
||||
입력: string;
|
||||
자리: InputSeat | null;
|
||||
알약?: string;
|
||||
}
|
||||
|
||||
export interface InputLinks {
|
||||
판: string | null;
|
||||
줄: InputLine[];
|
||||
}
|
||||
|
||||
const itemPath = (layer: Layer, key: string, tail: string): string =>
|
||||
`/layers/${layer}/items/${enc(key)}/${tail}`;
|
||||
|
||||
export const fetchItemRows = (layer: Layer): Promise<ItemRow[]> =>
|
||||
call<ItemRow[]>(`/items?layer=${layer}`);
|
||||
|
||||
export const fetchItemInputs = (layer: Layer, key: string): Promise<ItemInputs> =>
|
||||
call(itemPath(layer, key, "inputs"));
|
||||
|
||||
export const readInputLinks = (layer: Layer, key: string): Promise<InputLinks> =>
|
||||
call(itemPath(layer, key, "input-links"));
|
||||
|
||||
/** 판이 다르면 409(`StaleError`) · 없는 키는 422 */
|
||||
export const saveInputLinks = (
|
||||
layer: Layer,
|
||||
key: string,
|
||||
판: string | null,
|
||||
줄: InputLine[],
|
||||
): Promise<{ 판: string }> =>
|
||||
call(itemPath(layer, key, "input-links"), { method: "PUT", body: JSON.stringify({ 판, 줄 }) });
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/* 「선택 입력」 카드 — 로직마다 묶은 입력 줄 + 줄마다 자리 고르기. 색은 테마 토큰. */
|
||||
|
||||
.m02-inputs__summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4) var(--spacing-12);
|
||||
align-items: baseline;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-inputs__count {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 카드 본문이 남는 높이를 채우고 넘치면 안에서 스크롤 */
|
||||
.m02-inputs__body {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
min-height: var(--m02-struct-body-min);
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
padding: var(--spacing-8) 0;
|
||||
}
|
||||
|
||||
.m02-inputs__logic {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m02-inputs__logic-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4) var(--spacing-8);
|
||||
align-items: baseline;
|
||||
margin: 0;
|
||||
padding-bottom: var(--spacing-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body-sm);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-inputs__logic-title small,
|
||||
.m02-inputs__row small {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.m02-inputs__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4) var(--spacing-8);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
.m02-inputs__info {
|
||||
display: flex;
|
||||
flex: 1 1 12rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px var(--spacing-8);
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-inputs__name {
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-inputs__desc,
|
||||
.m02-inputs__hint {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
/* 자동값 — 흐리게(입력 값이 아니라 보기용 첫 선택지) */
|
||||
.m02-inputs__auto {
|
||||
flex-basis: 100%;
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.m02-inputs__seat {
|
||||
flex: none;
|
||||
max-width: 100%;
|
||||
padding: 4px var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-inputs__seat:disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── 프로젝트에서 값 받기(PLAN 25-2d) — 값 · 확인 표시 카드 ── */
|
||||
.m02-inputval__wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 0;
|
||||
min-height: var(--m02-struct-body-min, 16rem);
|
||||
min-width: 0;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-inputval {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-inputval__places,
|
||||
.m02-inputval__missing {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-inputval__logic {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-inputval__logic-name {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-caption);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-inputval__row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(6rem, 2fr) minmax(8rem, 3fr);
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-inputval__name {
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-caption);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-inputval__cell {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4) var(--spacing-8);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-inputval__control {
|
||||
flex: 1 1 6rem;
|
||||
min-width: 0;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
/* 자동값 = 저장값이 아님 — 기울임 + 보조 글자색(글자 대비 4.5 이상 유지) */
|
||||
.m02-inputval__value.is-auto,
|
||||
.m02-inputval__control.is-auto,
|
||||
.m02-inputval__control::placeholder {
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.m02-inputval__state {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-inputval__state.is-confirmed {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m02-inputval__error {
|
||||
flex: 1 0 100%;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-inputval__control[aria-invalid="true"] {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
"""M02 항목 선택 입력(PLAN 25-1b · 25-2a) — 입력 목록 · 항목 연결 파일 · 알약.
|
||||
|
||||
항목 = 구조물집계표조합(SS) 한 줄 → 그 열의 일위대가(UA) 조합 → 담은 단가산출 로직마다 입력 전부.
|
||||
로직마다 갈라 보이고 이름이 같아도 합치지 않음(로직마다 값이 다를 수 있음) · 입력 목록은 그 로직
|
||||
줄의 `입력` 그대로(하위 로직으로 넘겨지는 입력은 맨 위 로직 입력으로만 · M01 조합 미리 보기와
|
||||
같음) · 자동값 = `master_auto.values`(보기용 · 저장 안 함).
|
||||
|
||||
항목 연결 파일 = 층 폴더 `inputs/<열id>.json` = `{판, 줄: [{로직, 입력, 자리, 알약?}]}` —
|
||||
판은 저장마다 1 씩 늘고, 연결만 적음(값은 프로젝트 몫). 자리 면적 · 재료 = 그 시트의 알약
|
||||
(키 `입력|{로직}|{입력}`) · 선택 = 「선택 입력」 카드 · null = 안 씀.
|
||||
M01 은 읽기만(`M01_MasterData_Store` · `_Store_Combo` · `master_auto`) · 권한은 부르는 쪽 몫.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
PLACES = ("면적", "재료", "선택", None)
|
||||
PILL_HEAD = "입력"
|
||||
_SPEC = ("이름", "설명", "단위", "고르기", "범위")
|
||||
|
||||
|
||||
def _num(value: Any) -> Any:
|
||||
"""Decimal → JSON 수(M01 길과 같은 규칙) · 목록 · 묶음 안까지."""
|
||||
if isinstance(value, Decimal):
|
||||
return int(value) if value == value.to_integral_value() else float(value)
|
||||
if isinstance(value, list):
|
||||
return [_num(v) for v in value]
|
||||
if isinstance(value, dict):
|
||||
return {k: _num(v) for k, v in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def pill_key(logic: str, name: str) -> str:
|
||||
return f"{PILL_HEAD}|{logic}|{name}"
|
||||
|
||||
|
||||
def _item(key: str) -> dict[str, Any]:
|
||||
"""지금 층(`store.at`)의 항목 줄 — 없으면 404."""
|
||||
row = next((r for r in store.read_combos().get("줄", []) if r.get("키") == key), None)
|
||||
if row is None:
|
||||
raise store.StoreError(404, f"없는 항목 「{key}」")
|
||||
return row
|
||||
|
||||
|
||||
def _combo_row(ua: str) -> dict[str, Any] | None:
|
||||
rows = m01.read(combo_store.FILE)[0].get("줄") or []
|
||||
return next((r for r in rows if str(r.get("키")) == ua), None)
|
||||
|
||||
|
||||
def combo_logics(ua: str | None) -> tuple[dict[str, Any] | None, list[dict], list[str]]:
|
||||
"""(조합 줄, [로직 줄 …], 빠진 로직 키) — 조합이 없거나 못 찾으면 (None, [], [])."""
|
||||
if not ua:
|
||||
return None, [], []
|
||||
row = _combo_row(str(ua))
|
||||
if row is None:
|
||||
return None, [], []
|
||||
whole = m01.loaded()[1]
|
||||
found, missing = [], []
|
||||
for one in combo_store.mcb.items(row):
|
||||
code = str(one.get("로직") or "") if isinstance(one, dict) else ""
|
||||
try:
|
||||
found.append(whole.logic(code))
|
||||
except combo_store.cm.mf.FormulaError:
|
||||
missing.append(code)
|
||||
return row, found, missing
|
||||
|
||||
|
||||
def inputs(key: str) -> dict[str, Any]:
|
||||
"""25-1b 입력 목록 — `{일위대가, 이름, 로직: [{로직, 이름, 결과단위, 입력: [...]}], 빠진로직}`.
|
||||
일위대가 없는 항목은 로직 빈 목록."""
|
||||
ua = _item(key).get("일위대가")
|
||||
row, logics, missing = combo_logics(ua)
|
||||
whole = m01.loaded()[1]
|
||||
out = []
|
||||
for logic in logics:
|
||||
auto = m01.mau.values(whole, logic)
|
||||
specs = []
|
||||
for spec in logic.get("입력") or []:
|
||||
one = {k: spec[k] for k in _SPEC if k in spec}
|
||||
one.setdefault("설명", None)
|
||||
one.setdefault("단위", None)
|
||||
specs.append({**one, "자동값": auto.get(spec["이름"])})
|
||||
out.append(
|
||||
{
|
||||
"로직": logic.get("키"),
|
||||
"이름": logic.get("이름"),
|
||||
"결과단위": logic.get("결과단위"),
|
||||
"입력": specs,
|
||||
}
|
||||
)
|
||||
return _num(
|
||||
{
|
||||
"일위대가": ua or None,
|
||||
"이름": row.get("이름") if row else None,
|
||||
"로직": out,
|
||||
"빠진로직": missing,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ── 항목 연결 파일 ─────────────────────────────────────
|
||||
|
||||
|
||||
def read_file(column: str) -> dict[str, Any] | None:
|
||||
"""지금 층의 `inputs/<열id>.json` 그대로 — 없으면 None(열 지우기 · 복사 · 스냅샷이 씀)."""
|
||||
path = store.inputs_path(column)
|
||||
return json.loads(path.read_bytes()) if path.is_file() else None
|
||||
|
||||
|
||||
def write_file(column: str, doc: dict[str, Any]) -> None:
|
||||
"""지금 층에 연결 파일을 그대로 씀(복사용 — 검사 · 판 비교 없음)."""
|
||||
path = store.inputs_path(column)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(path, doc)
|
||||
|
||||
|
||||
def _known(key: str) -> dict[str, set[str]]:
|
||||
"""{로직 키: {입력 이름}} — 그 항목 조합이 지금 담은 것."""
|
||||
_, logics, _ = combo_logics(_item(key).get("일위대가"))
|
||||
return {str(lg.get("키")): {str(s.get("이름")) for s in lg.get("입력") or []} for lg in logics}
|
||||
|
||||
|
||||
def read_links(key: str) -> dict[str, Any]:
|
||||
"""항목 연결 — 없으면 `{판: null, 줄: []}` · 조합이 바뀌어 사라진 로직 · 입력 줄은
|
||||
`사라짐: true`."""
|
||||
doc = read_file(_item(key)["열"]) or {"판": None, "줄": []}
|
||||
known = _known(key)
|
||||
rows = []
|
||||
for row in doc.get("줄") or []:
|
||||
gone = str(row.get("입력")) not in known.get(str(row.get("로직")), set())
|
||||
rows.append({**row, "사라짐": True} if gone else row)
|
||||
return {"판": doc.get("판"), "줄": rows}
|
||||
|
||||
|
||||
def _clean_rows(key: str, rows: Any) -> list[dict[str, Any]]:
|
||||
"""없는 로직 · 입력 · 모르는 자리 · 같은 입력 두 번은 422 · 알약 키는 서버가 붙임."""
|
||||
if not isinstance(rows, list):
|
||||
raise store.StoreError(422, "「줄」 이 목록 아님")
|
||||
known = _known(key)
|
||||
out, seen, errors = [], set(), []
|
||||
for at, row in enumerate(rows, start=1):
|
||||
if not isinstance(row, dict):
|
||||
errors.append(f"줄{at} · 묶음 아님")
|
||||
continue
|
||||
logic, name, place = str(row.get("로직") or ""), str(row.get("입력") or ""), row.get("자리")
|
||||
if logic not in known:
|
||||
errors.append(f"줄{at} · 이 항목 일위대가에 없는 로직 「{logic}」")
|
||||
elif name not in known[logic]:
|
||||
errors.append(f"줄{at} · 로직 {logic} 에 없는 입력 「{name}」")
|
||||
elif place not in PLACES:
|
||||
errors.append(f"줄{at} · 모르는 자리 「{place}」 — 면적 · 재료 · 선택 · null")
|
||||
elif (logic, name) in seen:
|
||||
errors.append(f"줄{at} · 같은 입력 두 번 「{logic}|{name}」")
|
||||
else:
|
||||
seen.add((logic, name))
|
||||
one: dict[str, Any] = {"로직": logic, "입력": name, "자리": place}
|
||||
if place in ("면적", "재료"):
|
||||
one["알약"] = pill_key(logic, name)
|
||||
out.append(one)
|
||||
if errors:
|
||||
raise store.StoreError(422, {"errors": errors})
|
||||
return out
|
||||
|
||||
|
||||
def write_links(key: str, version: Any, rows: Any) -> dict[str, Any]:
|
||||
"""항목 연결 저장 — 받은 `판` 이 지금 판(없으면 null)과 다르면 409 · 저장마다 판 + 1."""
|
||||
column = _item(key)["열"]
|
||||
clean = _clean_rows(key, rows)
|
||||
with store._LOCK:
|
||||
have = (read_file(column) or {}).get("판")
|
||||
if version != have:
|
||||
raise store.StoreError(409, {"stale": [f"inputs/{column}"], "판": have})
|
||||
doc = {"판": int(have or 0) + 1, "줄": clean}
|
||||
write_file(column, doc)
|
||||
return doc
|
||||
|
||||
|
||||
# ── 알약 — 면적산출 · 재료산출 시트 ───────────────────────
|
||||
|
||||
|
||||
def pills(key: str, place: str) -> dict[str, Any]:
|
||||
"""그 시트 알약 — 면적이면 조합 물량 알약(M01 `combo_inputs` · 권한 무관 읽기) + 자리를
|
||||
그 시트로 이은 입력 알약 · 재료면 이은 입력 알약만. 입력 알약 = `{키, 이름, 단위, 로직, 입력}` —
|
||||
이름은 로직마다 갈라 「입력 · 로직 이름」(같은 이름 입력이 여러 로직에 있어도 안 합침)."""
|
||||
if place not in ("면적", "재료"):
|
||||
raise store.StoreError(422, f"모르는 자리 「{place}」 — 면적 · 재료")
|
||||
ua = _item(key).get("일위대가")
|
||||
quantity: list[dict[str, Any]] = []
|
||||
if place == "면적" and ua and _combo_row(str(ua)):
|
||||
quantity = combo_store.combo_inputs(str(ua))["물량"]
|
||||
_, logics, _ = combo_logics(ua)
|
||||
by_key = {str(lg.get("키")): lg for lg in logics}
|
||||
linked = []
|
||||
for row in read_links(key)["줄"]:
|
||||
if row.get("자리") != place or row.get("사라짐"):
|
||||
continue
|
||||
logic = by_key[str(row["로직"])]
|
||||
spec = next(s for s in logic.get("입력") or [] if s.get("이름") == row["입력"])
|
||||
short = combo_store._TAIL.sub("", str(logic.get("이름") or "")) or row["로직"]
|
||||
linked.append(
|
||||
{
|
||||
"키": pill_key(row["로직"], row["입력"]),
|
||||
"이름": f"{row['입력']} · {short}",
|
||||
"단위": spec.get("단위"),
|
||||
"로직": row["로직"],
|
||||
"입력": row["입력"],
|
||||
}
|
||||
)
|
||||
return _num({"물량": quantity, "입력": linked})
|
||||
|
||||
|
||||
def copy_between(source: Path, target: Path, source_column: str, target_column: str) -> bool:
|
||||
"""다른 층(또는 같은 층) 열의 연결 파일을 새 열로 — 판은 1 부터 · 없으면 아무것도 안 함."""
|
||||
with store.at(source):
|
||||
doc = read_file(source_column)
|
||||
if doc is None:
|
||||
return False
|
||||
with store.at(target):
|
||||
write_file(target_column, {"판": 1, "줄": doc.get("줄") or []})
|
||||
return True
|
||||
@@ -0,0 +1,573 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Inputs.ts
|
||||
* 구조물집계표 아래 넷째 카드 「선택 입력」(PLAN 25-2c) — 고른 열(항목) 일위대가의 로직마다 묶어
|
||||
* 입력 목록을 보이고, 입력마다 자리(면적산출 알약 · 재료산출 알약 · 선택 · 안 씀)를 사용자가 고름.
|
||||
*
|
||||
* · 처음은 모두 「안 씀」 — 프로그램이 추측해 나누지 않음 · 이름이 같은 입력도 로직마다 따로.
|
||||
* · 마스터 층에서는 값을 받지 않음 — 자리 연결만(값은 프로젝트 복사본 몫 · 25-2b).
|
||||
* · 저장은 집계표 [저장]과 같은 단추 흐름 — 여기는 고른 것을 초안으로 들고 있다가 `save()` 가 서버로 보냄
|
||||
* (자동저장 없음). 고름이 바뀌면 `onDirty` 로 메인에 알림(떠날 때 물음).
|
||||
* · 면적 · 재료로 고른 입력은 그 시트 편집 화면 알약 줄에 뜸(`inputPills` → `pillsFor`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
import {
|
||||
fetchItemInputs,
|
||||
fetchItemRows,
|
||||
getActiveLayer,
|
||||
readInputLinks,
|
||||
saveInputLinks,
|
||||
type InputLinks,
|
||||
type InputSeat,
|
||||
type ItemInput,
|
||||
type ItemInputs,
|
||||
type ItemLogic,
|
||||
type ItemRow,
|
||||
type Layer,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
autoText,
|
||||
choicesOf,
|
||||
countOf,
|
||||
inputKey,
|
||||
linesOf,
|
||||
pickedGroups,
|
||||
pillsOf,
|
||||
rangeOf,
|
||||
seatsOf,
|
||||
SEAT_OPTIONS,
|
||||
valueHint,
|
||||
type SeatMap,
|
||||
type ValueDoc,
|
||||
} from "./M02_MasterTemplete_Inputs_Model";
|
||||
import "./M02_MasterTemplete_Inputs.css";
|
||||
|
||||
const TEXT = {
|
||||
title: "선택 입력",
|
||||
count: "로직 {logics} · 입력 {inputs}",
|
||||
chosen: "면적산출 {area} · 재료산출 {material} · 선택 {pick}",
|
||||
auto: "자동값",
|
||||
noItem: "집계표를 먼저 저장하면 입력 목록이 나옴",
|
||||
noCombo: "일위대가를 먼저 고름 — 집계표 일위대가 줄에서 고름",
|
||||
unsavedCombo: "고른 일위대가는 집계표를 저장하면 입력 목록이 나옴",
|
||||
noLogic: "이 일위대가에 담긴 로직이 없음",
|
||||
missing: "빠진 로직 {value}",
|
||||
failed: "입력 목록을 못 받음 — {value}",
|
||||
loading: "입력 목록을 받는 중…",
|
||||
seat: "자리",
|
||||
};
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
const fill = (text: string, values: Record<string, string | number>): string =>
|
||||
text.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? ""));
|
||||
|
||||
/** 열 하나의 초안 — 서버 목록 · 판 · 고른 자리 · 바뀜 */
|
||||
interface Draft {
|
||||
key: string;
|
||||
판: string | null;
|
||||
list: ItemInputs;
|
||||
seats: SeatMap;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
export interface InputsCardOptions {
|
||||
/** 이 층에서 고칠 수 있나 — 아니면 자리 고르기가 잠김 */
|
||||
isAdmin: boolean;
|
||||
/** 고름이 바뀜 — 메인의 「저장 안 한 고침」 표시 */
|
||||
onDirty: () => void;
|
||||
}
|
||||
|
||||
export interface InputsCardHandle {
|
||||
root: HTMLElement;
|
||||
/** 고른 열 — `combo` = 집계표에 지금 고른 일위대가(저장 전일 수 있음) · null 이면 카드 비움 */
|
||||
show: (colId: string | null, combo: string | null) => Promise<void>;
|
||||
/** 고른 게 저장 안 됐나 */
|
||||
dirty: () => boolean;
|
||||
/** 바뀐 열마다 서버에 저장 — 실패하면 던짐(판이 다르면 `StaleError`) */
|
||||
save: () => Promise<void>;
|
||||
/** 항목 목록(열 ↔ 키)을 다시 받게 함 — 집계표 [저장] 뒤 */
|
||||
invalidate: () => void;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export function createInputsCard(opts: InputsCardOptions): InputsCardHandle {
|
||||
const drafts = new Map<string, Draft>();
|
||||
let rows: Promise<ItemRow[]> | null = null;
|
||||
let seq = 0;
|
||||
|
||||
const summary = el("div", { className: "m02-inputs__summary" });
|
||||
const body = el("div", { className: "m02-inputs__body" });
|
||||
const root = el("section", {
|
||||
className: "m02-struct__card m02-inputs ui-collapsible ui-sidebar-section ui-sidebar-card",
|
||||
children: [el("h3", { className: "ui-collapsible__title", text: TEXT.title }), summary, body],
|
||||
});
|
||||
|
||||
const note = (text: string): HTMLElement => el("p", { className: "m02-struct__empty", text });
|
||||
const clear = (): void => {
|
||||
summary.replaceChildren();
|
||||
body.replaceChildren();
|
||||
};
|
||||
|
||||
const itemRows = (): Promise<ItemRow[]> =>
|
||||
(rows ??= fetchItemRows(getActiveLayer()).catch((error: unknown) => {
|
||||
rows = null;
|
||||
throw error;
|
||||
}));
|
||||
|
||||
function paintSummary(draft: Draft): void {
|
||||
const count = countOf(draft.list);
|
||||
// 목록에 있는 입력의 자리만 셈 — 서버 목록에서 빠진 입력의 옛 고름은 안 셈(저장 때도 빠짐)
|
||||
const listed = new Set(
|
||||
draft.list.로직.flatMap((l) => l.입력.map((i) => inputKey(l.로직, i.이름))),
|
||||
);
|
||||
const seats = [...draft.seats].filter(([key]) => listed.has(key)).map(([, seat]) => seat);
|
||||
const by = (seat: InputSeat): number => seats.filter((s) => s === seat).length;
|
||||
summary.replaceChildren(
|
||||
el("span", { className: "m02-inputs__count", text: fill(TEXT.count, count) }),
|
||||
el("span", {
|
||||
className: "m02-inputs__chosen",
|
||||
text: fill(TEXT.chosen, { area: by("면적"), material: by("재료"), pick: by("선택") }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function paint(draft: Draft): void {
|
||||
paintSummary(draft);
|
||||
const groups = draft.list.로직.map((logic) =>
|
||||
el("div", {
|
||||
className: "m02-inputs__logic",
|
||||
attrs: { "data-logic": logic.로직 },
|
||||
children: [
|
||||
el("h4", {
|
||||
className: "m02-inputs__logic-title",
|
||||
children: [
|
||||
el("span", { text: logic.이름 }),
|
||||
...(logic.결과단위 ? [el("small", { text: logic.결과단위 })] : []),
|
||||
el("small", { className: "m02-inputs__logic-key", text: logic.로직 }),
|
||||
],
|
||||
}),
|
||||
...logic.입력.map((input) => {
|
||||
const key = inputKey(logic.로직, input.이름);
|
||||
const select = el("select", {
|
||||
className: "m02-inputs__seat",
|
||||
attrs: { "aria-label": `${logic.이름} ${input.이름} ${TEXT.seat}` },
|
||||
children: SEAT_OPTIONS.map((o) =>
|
||||
el("option", { text: o.label, attrs: { value: o.value } }),
|
||||
),
|
||||
}) as HTMLSelectElement;
|
||||
select.value = draft.seats.get(key) ?? "";
|
||||
select.disabled = !opts.isAdmin;
|
||||
select.addEventListener("change", () => {
|
||||
if (select.value) draft.seats.set(key, select.value as InputSeat);
|
||||
else draft.seats.delete(key);
|
||||
draft.dirty = true;
|
||||
paintSummary(draft);
|
||||
opts.onDirty();
|
||||
});
|
||||
const hint = valueHint(input);
|
||||
const auto = autoText(input);
|
||||
return el("div", {
|
||||
className: "m02-inputs__row",
|
||||
attrs: { "data-input": input.이름 },
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m02-inputs__info",
|
||||
children: [
|
||||
el("span", { className: "m02-inputs__name", text: input.이름 }),
|
||||
...(input.단위 ? [el("small", { text: input.단위 })] : []),
|
||||
...(input.설명
|
||||
? [el("small", { className: "m02-inputs__desc", text: input.설명 })]
|
||||
: []),
|
||||
...(hint ? [el("small", { className: "m02-inputs__hint", text: hint })] : []),
|
||||
...(auto
|
||||
? [
|
||||
el("small", {
|
||||
className: "m02-inputs__auto",
|
||||
text: `${TEXT.auto} ${auto}`,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
select,
|
||||
],
|
||||
});
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const missing = draft.list.빠진로직;
|
||||
body.replaceChildren(
|
||||
...(groups.length ? groups : [note(TEXT.noLogic)]),
|
||||
...(missing.length ? [note(fill(TEXT.missing, { value: missing.join(", ") }))] : []),
|
||||
);
|
||||
}
|
||||
|
||||
async function show(colId: string | null, combo: string | null): Promise<void> {
|
||||
const mine = ++seq;
|
||||
clear();
|
||||
if (!colId) return;
|
||||
const kept = drafts.get(colId);
|
||||
if (kept) return paint(kept);
|
||||
body.replaceChildren(note(TEXT.loading));
|
||||
try {
|
||||
const layer: Layer = getActiveLayer();
|
||||
const row = (await itemRows()).find((r) => r.열 === colId);
|
||||
if (mine !== seq) return;
|
||||
if (!row) return void body.replaceChildren(note(combo ? TEXT.unsavedCombo : TEXT.noItem));
|
||||
if (!row.일위대가)
|
||||
return void body.replaceChildren(note(combo ? TEXT.unsavedCombo : TEXT.noCombo));
|
||||
const [list, links] = await Promise.all([
|
||||
fetchItemInputs(layer, row.key),
|
||||
readInputLinks(layer, row.key),
|
||||
]);
|
||||
if (mine !== seq) return;
|
||||
if (!list.일위대가) return void body.replaceChildren(note(TEXT.noCombo));
|
||||
const draft: Draft = {
|
||||
key: row.key,
|
||||
판: links.판,
|
||||
list,
|
||||
seats: seatsOf(links),
|
||||
dirty: false,
|
||||
};
|
||||
drafts.set(colId, draft);
|
||||
paint(draft);
|
||||
} catch (error) {
|
||||
if (mine === seq) body.replaceChildren(note(fill(TEXT.failed, { value: why(error) })));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
show,
|
||||
dirty: () => [...drafts.values()].some((d) => d.dirty),
|
||||
save: async () => {
|
||||
const layer = getActiveLayer();
|
||||
for (const draft of drafts.values()) {
|
||||
if (!draft.dirty) continue;
|
||||
const got = await saveInputLinks(
|
||||
layer,
|
||||
draft.key,
|
||||
draft.판,
|
||||
linesOf(draft.list, draft.seats),
|
||||
);
|
||||
draft.판 = got.판;
|
||||
draft.dirty = false;
|
||||
}
|
||||
},
|
||||
invalidate: () => {
|
||||
rows = null;
|
||||
for (const [id, draft] of drafts) if (!draft.dirty) drafts.delete(id); // 서버 것으로 다시 읽음
|
||||
},
|
||||
destroy: () => {
|
||||
seq += 1;
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 그 열의 면적산출 · 재료산출 편집 화면 알약 — 자리를 그 시트로 고른 입력(프로젝트 층은 25-2b 몫이라 없음) */
|
||||
export async function inputPills(column: string, seat: "면적" | "재료"): Promise<LinkItem[]> {
|
||||
const layer = getActiveLayer();
|
||||
if (layer === "project") return [];
|
||||
try {
|
||||
const row = (await fetchItemRows(layer)).find((r) => r.열 === column);
|
||||
if (!row?.일위대가) return [];
|
||||
const [list, links] = await Promise.all([
|
||||
fetchItemInputs(layer, row.key),
|
||||
readInputLinks(layer, row.key),
|
||||
]);
|
||||
return pillsOf(list, links, seat);
|
||||
} catch {
|
||||
return []; // 서버 길이 아직 없거나 못 읽어도 시트 편집은 그대로
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 고르기 모달 카드 ─────────────────────────────
|
||||
* 같은 카드 틀 · 같은 입력 목록인데 여기서는 자리 고르기 대신 **값**을 받음: 자리가 「선택」 인 입력만
|
||||
* 로직마다 묶어, 고르기 = 목록 · 범위 = 숫자 · 자동값은 흐리게 · 「확인함」 표시. 자동값은 저장값이 아님
|
||||
* (확인 안 한 입력은 계산 때 「빠진 입력」). 값은 로직마다 따로 — 이름이 같아도 합치지 않음.
|
||||
* mode "view" 보기만 + [선택 입력 수정] · mode "value" 값 받기(범위 밖은 저장 막음) */
|
||||
|
||||
export interface InputsValueData {
|
||||
list: ItemInputs;
|
||||
link: InputLinks | null;
|
||||
values: ValueDoc;
|
||||
}
|
||||
|
||||
export interface InputsValueOptions {
|
||||
mode: "view" | "value";
|
||||
/** mode "view" — [선택 입력 수정] */
|
||||
onEdit?: () => void;
|
||||
/** mode "value" — 값이 바뀔 때마다 */
|
||||
onChange?: (values: ValueDoc) => void;
|
||||
}
|
||||
|
||||
export interface InputsValueHandle {
|
||||
root: HTMLElement;
|
||||
/** 자료를 바꿔 끼움 — `null` 은 못 읽은 경우(까닭 글 `note`) */
|
||||
set: (data: InputsValueData | null, note?: string) => void;
|
||||
/** 지금 값(확인한 것 · 값이 든 것만) */
|
||||
getValues: () => ValueDoc;
|
||||
/** 범위 밖 값이 없나 */
|
||||
isValid: () => boolean;
|
||||
/** [선택 입력 수정] 글 — 아직 안 가져온 항목이면 「프로젝트로 가져와 고침」 */
|
||||
setEditLabel: (label: string) => void;
|
||||
}
|
||||
|
||||
const VALUE_TEXT = {
|
||||
edit: "선택 입력 수정",
|
||||
none: "선택으로 이은 입력이 없음",
|
||||
auto: "자동값",
|
||||
unconfirmed: "확인 안 함",
|
||||
confirmed: "✓ 확인함",
|
||||
confirmBox: "확인",
|
||||
outOfRange: "범위 {value} 밖",
|
||||
summary: "선택 {value}",
|
||||
places: "면적산출 알약 {area} · 재료산출 알약 {material}",
|
||||
noList: "입력 목록을 못 읽음 — {value}",
|
||||
};
|
||||
|
||||
type ValueEntry = ValueDoc[string][string];
|
||||
|
||||
export function createInputsValueCard(opts: InputsValueOptions): InputsValueHandle {
|
||||
let data: InputsValueData | null = null;
|
||||
let values: ValueDoc = {};
|
||||
let invalid = 0;
|
||||
|
||||
const summary = el("span", { className: "m02-struct__no" });
|
||||
const edit = createButton({ label: VALUE_TEXT.edit, variant: "ghost", size: "sm" });
|
||||
edit.hidden = opts.mode !== "view";
|
||||
if (opts.onEdit) edit.addEventListener("click", opts.onEdit);
|
||||
const places = el("p", { className: "m02-inputval__places" });
|
||||
const body = el("div", { className: "m02-struct__sheet m02-inputval" });
|
||||
const root = el("section", {
|
||||
className: "m02-struct__card ui-collapsible ui-sidebar-section ui-sidebar-card",
|
||||
children: [
|
||||
el("h3", { className: "ui-collapsible__title", text: TEXT.title }),
|
||||
el("div", { className: "m02-struct__subhead", children: [summary, edit] }),
|
||||
el("div", { className: "m02-inputval__wrap", children: [places, body] }),
|
||||
],
|
||||
});
|
||||
|
||||
const groups = (): ReturnType<typeof pickedGroups> =>
|
||||
data ? pickedGroups(data.list, data.link) : [];
|
||||
const one = (logic: string, name: string): ValueEntry =>
|
||||
values[logic]?.[name] ?? { 값: null, 확인: false };
|
||||
const isEmpty = (value: string | number | null): boolean => value === null || value === "";
|
||||
const put = (logic: string, name: string, next: ValueEntry): void => {
|
||||
(values[logic] ??= {})[name] = next;
|
||||
};
|
||||
const autoOf = (input: ItemInput): string | number | null => {
|
||||
const auto = input.자동값;
|
||||
return typeof auto === "string" || typeof auto === "number" ? auto : null;
|
||||
};
|
||||
|
||||
const paintSummary = (): void => {
|
||||
let total = 0;
|
||||
let confirmed = 0;
|
||||
for (const { logic, inputs } of groups())
|
||||
for (const input of inputs) {
|
||||
total += 1;
|
||||
if (one(logic.로직, input.이름).확인) confirmed += 1;
|
||||
}
|
||||
summary.textContent = data ? fill(VALUE_TEXT.summary, { value: `${confirmed}/${total}` }) : "";
|
||||
};
|
||||
|
||||
/** 값 칸 하나 — 자동값은 흐리게(기울임 · placeholder · 첫 줄) */
|
||||
const valueCell = (logic: ItemLogic, input: ItemInput): HTMLElement => {
|
||||
const stored = one(logic.로직, input.이름);
|
||||
const auto = autoOf(input);
|
||||
const state = el("span", { className: "m02-inputval__state" });
|
||||
const paintState = (): void => {
|
||||
const now = one(logic.로직, input.이름);
|
||||
state.textContent = now.확인 ? VALUE_TEXT.confirmed : VALUE_TEXT.unconfirmed;
|
||||
state.classList.toggle("is-confirmed", now.확인);
|
||||
};
|
||||
if (opts.mode === "view") {
|
||||
const text = isEmpty(stored.값) ? auto : stored.값;
|
||||
const shown = el("span", {
|
||||
className: `m02-inputval__value${isEmpty(stored.값) ? " is-auto" : ""}`,
|
||||
text: text === null ? "—" : String(text),
|
||||
attrs: isEmpty(stored.값) ? { title: VALUE_TEXT.auto } : {},
|
||||
});
|
||||
paintState();
|
||||
return el("div", { className: "m02-inputval__cell", children: [shown, state] });
|
||||
}
|
||||
|
||||
const emit = (): void => {
|
||||
paintState();
|
||||
paintSummary();
|
||||
opts.onChange?.(values);
|
||||
};
|
||||
const range = rangeOf(input);
|
||||
const choices = choicesOf(input);
|
||||
const control = (
|
||||
choices.length
|
||||
? el("select", { className: "ui-select ui-select--sm m02-inputval__control" })
|
||||
: el("input", {
|
||||
className: "ui-input m02-inputval__control",
|
||||
attrs: {
|
||||
type: range ? "number" : "text",
|
||||
...(range?.min !== undefined ? { min: String(range.min) } : {}),
|
||||
...(range?.max !== undefined ? { max: String(range.max) } : {}),
|
||||
...(range ? { step: "any" } : {}),
|
||||
placeholder: auto === null ? "" : String(auto),
|
||||
},
|
||||
})
|
||||
) as HTMLInputElement | HTMLSelectElement;
|
||||
const error = el("span", { className: "m02-inputval__error" });
|
||||
const box = el("input", {
|
||||
attrs: {
|
||||
type: "checkbox",
|
||||
title: VALUE_TEXT.confirmBox,
|
||||
"aria-label": VALUE_TEXT.confirmBox,
|
||||
},
|
||||
}) as HTMLInputElement;
|
||||
|
||||
if (control instanceof HTMLSelectElement) {
|
||||
control.append(
|
||||
el("option", {
|
||||
attrs: { value: "" },
|
||||
text: auto === null ? "—" : `${VALUE_TEXT.auto} ${String(auto)}`,
|
||||
}),
|
||||
...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })),
|
||||
);
|
||||
}
|
||||
control.value = isEmpty(stored.값) ? "" : String(stored.값);
|
||||
control.classList.toggle("is-auto", isEmpty(stored.값));
|
||||
box.checked = stored.확인;
|
||||
|
||||
const outside = (raw: string): boolean => {
|
||||
if (!range || raw.trim() === "") return false;
|
||||
const value = Number(raw);
|
||||
return (
|
||||
!Number.isFinite(value) ||
|
||||
(range.min !== undefined && value < range.min) ||
|
||||
(range.max !== undefined && value > range.max)
|
||||
);
|
||||
};
|
||||
let wasBad = false;
|
||||
const changed = (): void => {
|
||||
const raw = control.value;
|
||||
const bad = outside(raw);
|
||||
if (bad !== wasBad) invalid += bad ? 1 : -1;
|
||||
wasBad = bad;
|
||||
control.setAttribute("aria-invalid", bad ? "true" : "false");
|
||||
error.textContent = bad
|
||||
? fill(VALUE_TEXT.outOfRange, { value: `${range?.min ?? ""}~${range?.max ?? ""}` })
|
||||
: "";
|
||||
control.classList.toggle("is-auto", raw === "");
|
||||
const value: string | number | null =
|
||||
raw === "" ? null : range ? Number(raw) : (choices.find((c) => c.값 === raw)?.raw ?? raw);
|
||||
// 값을 넣으면 확인 · 비우면 확인 풀림(자동값은 저장값이 아님)
|
||||
put(logic.로직, input.이름, { 값: bad ? null : value, 확인: raw !== "" && !bad });
|
||||
box.checked = raw !== "" && !bad;
|
||||
emit();
|
||||
};
|
||||
control.addEventListener(control instanceof HTMLSelectElement ? "change" : "input", changed);
|
||||
// 확인 상자 — 비운 채 켜면 자동값을 그대로 받아들임 · 끄면 확인만 풀림
|
||||
box.addEventListener("change", () => {
|
||||
const now = one(logic.로직, input.이름);
|
||||
if (box.checked && isEmpty(now.값) && auto !== null) {
|
||||
control.value = String(auto);
|
||||
control.classList.remove("is-auto");
|
||||
put(logic.로직, input.이름, { 값: range ? Number(auto) : auto, 확인: true });
|
||||
} else if (box.checked && isEmpty(now.값)) {
|
||||
box.checked = false; // 받아들일 값이 없음
|
||||
} else {
|
||||
put(logic.로직, input.이름, { ...now, 확인: box.checked });
|
||||
}
|
||||
emit();
|
||||
});
|
||||
paintState();
|
||||
return el("div", { className: "m02-inputval__cell", children: [control, box, state, error] });
|
||||
};
|
||||
|
||||
const paint = (note?: string): void => {
|
||||
invalid = 0;
|
||||
paintSummary();
|
||||
if (!data) {
|
||||
places.textContent = "";
|
||||
body.replaceChildren(
|
||||
el("p", {
|
||||
className: "m02-struct__empty",
|
||||
text: note ? fill(VALUE_TEXT.noList, { value: note }) : VALUE_TEXT.none,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const seats = [...seatsOf(data.link).values()];
|
||||
const area = seats.filter((seat) => seat === "면적").length;
|
||||
const material = seats.filter((seat) => seat === "재료").length;
|
||||
places.textContent = area + material ? fill(VALUE_TEXT.places, { area, material }) : "";
|
||||
const shown = groups();
|
||||
const missing = data.list.빠진로직.length
|
||||
? [
|
||||
el("p", {
|
||||
className: "m02-inputval__missing",
|
||||
text: fill(TEXT.missing, { value: data.list.빠진로직.join(", ") }),
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
if (!shown.length) {
|
||||
body.replaceChildren(
|
||||
el("p", { className: "m02-struct__empty", text: VALUE_TEXT.none }),
|
||||
...missing,
|
||||
);
|
||||
return;
|
||||
}
|
||||
body.replaceChildren(
|
||||
...shown.map(({ logic, inputs }) =>
|
||||
el("section", {
|
||||
className: "m02-inputval__logic",
|
||||
attrs: { "data-logic": logic.로직 },
|
||||
children: [
|
||||
el("h4", {
|
||||
className: "m02-inputval__logic-name",
|
||||
text: logic.이름 || logic.로직,
|
||||
attrs: { title: `${logic.로직}${logic.결과단위 ? ` · ${logic.결과단위}` : ""}` },
|
||||
}),
|
||||
...inputs.map((input) =>
|
||||
el("div", {
|
||||
className: "m02-inputval__row",
|
||||
attrs: { "data-input": inputKey(logic.로직, input.이름) },
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m02-inputval__name",
|
||||
text: input.단위 ? `${input.이름} (${input.단위})` : input.이름,
|
||||
attrs: input.설명 ? { title: input.설명 } : {},
|
||||
}),
|
||||
valueCell(logic, input),
|
||||
],
|
||||
}),
|
||||
),
|
||||
],
|
||||
}),
|
||||
),
|
||||
...missing,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
root,
|
||||
set: (next, note) => {
|
||||
data = next;
|
||||
values = next ? structuredClone(next.values) : {};
|
||||
paint(note);
|
||||
},
|
||||
getValues: () => {
|
||||
// 확인한 것 · 값이 든 것만 — 입력 목록에서 없어진 입력은 그대로 둠(다음 가져오기가 정함)
|
||||
const out: ValueDoc = {};
|
||||
for (const [logic, byName] of Object.entries(values))
|
||||
for (const [name, entry] of Object.entries(byName))
|
||||
if (entry.확인 || !isEmpty(entry.값)) (out[logic] ??= {})[name] = entry;
|
||||
return out;
|
||||
},
|
||||
isValid: () => invalid === 0,
|
||||
setEditLabel: (label) => {
|
||||
(edit.querySelector(".ui-btn__label") ?? edit).textContent = label;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Inputs_Model.ts
|
||||
* 「선택 입력」 카드의 셈(화면 없음) — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록 · 값 글.
|
||||
* 약속 = PLAN 25-1b: 입력 키 `{로직키}|{입력이름}` · 알약 키 `입력|{로직키}|{입력이름}` ·
|
||||
* 같은 이름의 입력도 로직마다 따로. 처음은 모두 「안 씀」 — 프로그램이 추측해 나누지 않음.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
import type {
|
||||
InputLine,
|
||||
InputLinks,
|
||||
InputSeat,
|
||||
ItemInput,
|
||||
ItemInputs,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
|
||||
/** 화면 글 ↔ 자리 — 「안 씀」 = 빈 글 */
|
||||
export const SEAT_OPTIONS: { value: "" | InputSeat; label: string }[] = [
|
||||
{ value: "", label: "안 씀" },
|
||||
{ value: "면적", label: "면적산출 알약" },
|
||||
{ value: "재료", label: "재료산출 알약" },
|
||||
{ value: "선택", label: "선택" },
|
||||
];
|
||||
|
||||
export const inputKey = (logic: string, name: string): string => `${logic}|${name}`;
|
||||
export const pillKey = (logic: string, name: string): string => `입력|${logic}|${name}`;
|
||||
|
||||
/** 자리 고름 표 — 키 = 입력 키 · 값 = 자리(안 쓰면 키 없음) */
|
||||
export type SeatMap = Map<string, InputSeat>;
|
||||
|
||||
export function seatsOf(links: InputLinks | null): SeatMap {
|
||||
const out: SeatMap = new Map();
|
||||
for (const line of links?.줄 ?? [])
|
||||
if (line.자리) out.set(inputKey(line.로직, line.입력), line.자리);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 저장할 줄 — 자리를 고른 것만(없는 것 = 안 씀) · 면적 · 재료는 알약 키를 같이 */
|
||||
export function linesOf(list: ItemInputs, seats: SeatMap): InputLine[] {
|
||||
const out: InputLine[] = [];
|
||||
for (const logic of list.로직)
|
||||
for (const input of logic.입력) {
|
||||
const seat = seats.get(inputKey(logic.로직, input.이름));
|
||||
if (!seat) continue;
|
||||
out.push({
|
||||
로직: logic.로직,
|
||||
입력: input.이름,
|
||||
자리: seat,
|
||||
...(seat === "선택" ? {} : { 알약: pillKey(logic.로직, input.이름) }),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const countOf = (list: ItemInputs): { logics: number; inputs: number } => ({
|
||||
logics: list.로직.length,
|
||||
inputs: list.로직.reduce((sum, logic) => sum + logic.입력.length, 0),
|
||||
});
|
||||
|
||||
/** 그 시트(면적산출 · 재료산출)의 알약 — 고른 자리가 그 시트인 입력만 · 「로직 이름 · 입력 이름」 */
|
||||
export function pillsOf(
|
||||
list: ItemInputs,
|
||||
links: InputLinks | null,
|
||||
seat: "면적" | "재료",
|
||||
): LinkItem[] {
|
||||
const seats = seatsOf(links);
|
||||
const out: LinkItem[] = [];
|
||||
for (const logic of list.로직)
|
||||
for (const input of logic.입력) {
|
||||
if (seats.get(inputKey(logic.로직, input.이름)) !== seat) continue;
|
||||
out.push({
|
||||
키: pillKey(logic.로직, input.이름),
|
||||
이름: `${logic.이름} · ${input.이름}`,
|
||||
...(input.단위 ? { 단위: input.단위 } : {}),
|
||||
고정: true,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const text = (value: unknown): string =>
|
||||
value === null || value === undefined
|
||||
? ""
|
||||
: typeof value === "object"
|
||||
? JSON.stringify(value)
|
||||
: String(value);
|
||||
|
||||
/** 고르기 · 범위 표시 — 선택지 목록이면 「a / b / c」 · 범위(배열 둘 · {최소, 최대})면 「최소 ~ 최대」 */
|
||||
export function valueHint(input: ItemInput): string {
|
||||
if (Array.isArray(input.고르기) && input.고르기.length) return input.고르기.map(text).join(" / ");
|
||||
const range = input.범위;
|
||||
if (Array.isArray(range) && range.length >= 2) return `${text(range[0])} ~ ${text(range[1])}`;
|
||||
if (range && typeof range === "object") {
|
||||
const r = range as Record<string, unknown>;
|
||||
const lo = r.최소 ?? r.min;
|
||||
const hi = r.최대 ?? r.max;
|
||||
if (lo !== undefined || hi !== undefined) return `${text(lo)} ~ ${text(hi)}`;
|
||||
}
|
||||
return text(range);
|
||||
}
|
||||
|
||||
/** 자동값 글 — 없으면 빈 글 */
|
||||
export const autoText = (input: ItemInput): string => text(input.자동값);
|
||||
|
||||
/* ── 프로젝트에서 값 받기(PLAN 25-2d) — 자리 「선택」 인 입력만 로직마다 묶어 값 · 확인 표시 ── */
|
||||
|
||||
/** `입력값.json` — `{로직키: {입력이름: {값, 확인}}}` (서버 검사 모양 · 값은 수 · 글 · 빈 값) */
|
||||
export type ValueDoc = Record<
|
||||
string,
|
||||
Record<string, { 값: string | number | null; 확인: boolean }>
|
||||
>;
|
||||
|
||||
const number = (value: unknown): number | undefined =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
|
||||
/** 범위 모양 둘(`[최소, 최대]` · `{최소, 최대}`)을 하나로 — 범위가 아니면 null */
|
||||
export function rangeOf(input: ItemInput): { min?: number; max?: number } | null {
|
||||
const raw = input.범위;
|
||||
if (Array.isArray(raw)) return { min: number(raw[0]), max: number(raw[1]) };
|
||||
if (raw && typeof raw === "object") {
|
||||
const r = raw as Record<string, unknown>;
|
||||
return { min: number(r.최소 ?? r.min), max: number(r.최대 ?? r.max) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 고르기 목록 → 화면 값 · 이름 짝 — `raw` 는 원래 모양(숫자 목록이면 숫자로 저장) */
|
||||
export function choicesOf(input: ItemInput): { 값: string; 이름: string; raw: string | number }[] {
|
||||
return (input.고르기 ?? []).map((one) => {
|
||||
const object =
|
||||
typeof one === "object" && one !== null ? (one as Record<string, unknown>) : null;
|
||||
const raw = (object ? (object.값 ?? object.이름 ?? "") : one) as string | number;
|
||||
return { 값: String(raw), 이름: object ? String(object.이름 ?? raw) : String(one), raw };
|
||||
});
|
||||
}
|
||||
|
||||
/** 자리가 「선택」 인 입력만 로직 차례로 묶음 — 값 받기 카드가 보이는 입력 */
|
||||
export function pickedGroups(
|
||||
list: ItemInputs,
|
||||
links: InputLinks | null,
|
||||
): { logic: ItemInputs["로직"][number]; inputs: ItemInput[] }[] {
|
||||
const picked = new Set(
|
||||
[...seatsOf(links)].filter(([, seat]) => seat === "선택").map(([key]) => key),
|
||||
);
|
||||
return list.로직
|
||||
.map((logic) => ({
|
||||
logic,
|
||||
inputs: logic.입력.filter((input) => picked.has(inputKey(logic.로직, input.이름))),
|
||||
}))
|
||||
.filter((group) => group.inputs.length > 0);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_ItemCards.ts
|
||||
* 항목 카드 — 도면 정보 · 면적산출 · 재료산출(보기만 + [수정]) + 덧붙는 카드(선택 입력 …).
|
||||
* M02 구조물 설계 컨테이너(`M02_MasterTemplete_Structure.ts`)에서 떼어 냄(계획서 25-2d) — 항목 고르기
|
||||
* 모달(`ui_template_item_picker_detail.ts`)이 **같은 카드**를 쓰려고. 문서를 어디서 읽는지는
|
||||
* 자료 자리(`ItemSource`)가 정함 · 카드는 몰라도 됨. 모양(클래스 · DOM)은 옮기기 전과 같음.
|
||||
* 면적산출 · 재료산출 = 스프레드시트 · 부품은 카드를 열 때만 `import()`(다른 페이지 번들에 안 섞임).
|
||||
* 덧붙는 카드는 `extraCards` 로 끼움 — 선택 입력 카드(`M02_MasterTemplete_InputsCard.ts`)가 그 자리.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { StructureDoc, StructureNumbers } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
mountDrawingTemplate,
|
||||
type DrawingTemplateDoc,
|
||||
type DrawingTemplateHandle,
|
||||
} from "./M02_MasterTemplete_Drawing";
|
||||
import type { ItemPart, ItemSource } from "./M02_MasterTemplete_ItemSource";
|
||||
import "./M02_MasterTemplete_Structure.css";
|
||||
|
||||
/** 집계표 열 하나에 딸린 문서 셋 — 번호 칸 · 이름 */
|
||||
export type EditKind = ItemPart;
|
||||
export type SheetKind = Exclude<EditKind, "structure">;
|
||||
export const PART_FIELD: Record<EditKind, keyof StructureNumbers> = {
|
||||
structure: "도번",
|
||||
area: "면적번",
|
||||
material: "재료번",
|
||||
};
|
||||
export const PART_WORD: Record<EditKind, string> = {
|
||||
structure: "도면",
|
||||
area: "면적산출",
|
||||
material: "재료산출",
|
||||
};
|
||||
|
||||
const TEXT = {
|
||||
drawingInfo: "도면 정보",
|
||||
edit: "도면 수정",
|
||||
noNumber: "도번 없음",
|
||||
noDrawing: "도면 없음",
|
||||
editSheet: "{value} 수정",
|
||||
noSheetDoc: "{value} 없음",
|
||||
noFieldNumber: "{value} 없음",
|
||||
noSheet: "스프레드시트를 못 띄움 — {value}",
|
||||
readFailed: "구조물 도면을 못 읽음 — {value}",
|
||||
};
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
const fill = (text: string, value: string): string => text.replace("{value}", value);
|
||||
/** 스프레드시트는 면적산출 · 재료산출 카드를 열 때만 받음 */
|
||||
const loadSpreadsheet = () => import("../A00_Common/spreadsheet/spreadsheet");
|
||||
|
||||
/** 카드 틀 — 제목 + 머리 + 본문(좌측 패널과 같은 공용 ui-sidebar 카드) */
|
||||
export const itemCard = (title: string, head: HTMLElement, body: HTMLElement): HTMLElement =>
|
||||
el("section", {
|
||||
className: "m02-struct__card ui-collapsible ui-sidebar-section ui-sidebar-card",
|
||||
children: [el("h3", { className: "ui-collapsible__title", text: title }), head, body],
|
||||
});
|
||||
|
||||
export const itemNote = (text: string): HTMLElement =>
|
||||
el("p", { className: "m02-struct__empty", text });
|
||||
|
||||
export interface ItemCardsOptions {
|
||||
/** 지금 자료 자리 — 열을 읽을 때마다 부름(층이 바뀌어도 따라감) */
|
||||
source: () => ItemSource;
|
||||
/** [수정] 단추를 보일까 — 부를 때마다 다시 봄 */
|
||||
editable: () => boolean;
|
||||
/** [도면 수정] · [면적산출 수정] · [재료산출 수정] */
|
||||
onEdit: (kind: EditKind) => void;
|
||||
/** 문서에 번호가 없을 때 쓸 번호(집계표 번호 줄) */
|
||||
numberOf?: (kind: EditKind, column: string) => string | undefined;
|
||||
/** 도면을 못 읽었을 때 알릴까 — 기본 알림(M02 는 번호가 있는 열만) */
|
||||
warnOnFail?: (column: string) => boolean;
|
||||
/** 세 카드 뒤에 덧붙는 카드(선택 입력 …) */
|
||||
extraCards?: HTMLElement[];
|
||||
}
|
||||
|
||||
export interface ItemCardsHandle {
|
||||
root: HTMLElement;
|
||||
/** 그 열을 읽어 카드를 그림 — `null` 이면 카드를 숨김 */
|
||||
load: (col: { id: string; label: string } | null) => Promise<void>;
|
||||
/** 이름만 새로(CAD 를 다시 안 띄움) */
|
||||
setLabel: (label: string) => void;
|
||||
/** [수정] 단추 글 — 종류마다 · `null` 은 기본 글 */
|
||||
relabel: (labels: Partial<Record<EditKind, string | null>>) => void;
|
||||
/** 지금 보이는 열 id */
|
||||
column: () => string | null;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
let column: string | null = null;
|
||||
let doc: StructureDoc | null = null;
|
||||
let preview: DrawingTemplateHandle | null = null;
|
||||
let seq = 0;
|
||||
|
||||
const numberOf = (kind: EditKind, id: string): string => opts.numberOf?.(kind, id) ?? "";
|
||||
|
||||
const columnName = el("span", { className: "m02-struct__title" });
|
||||
const number = el("span", { className: "m02-struct__no" });
|
||||
const edit = createButton({ label: TEXT.edit, variant: "filled", size: "sm" });
|
||||
const previewHost = el("div", { className: "m02-struct__preview" });
|
||||
const infoCard = itemCard(
|
||||
TEXT.drawingInfo,
|
||||
el("div", { className: "m02-struct__head", children: [number, columnName, edit] }),
|
||||
previewHost,
|
||||
);
|
||||
|
||||
/** 면적산출 · 재료산출 카드 — 번호 · [수정] · 스프레드시트 미리보기 */
|
||||
interface SheetCard {
|
||||
kind: SheetKind;
|
||||
root: HTMLElement;
|
||||
no: HTMLElement;
|
||||
edit: HTMLButtonElement;
|
||||
host: HTMLElement;
|
||||
doc: Workbook | null;
|
||||
number: string;
|
||||
handle: SpreadsheetHandle | null;
|
||||
}
|
||||
const sheetCard = (kind: SheetKind): SheetCard => {
|
||||
const no = el("span", { className: "m02-struct__no" });
|
||||
const editButton = createButton({
|
||||
label: fill(TEXT.editSheet, PART_WORD[kind]),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
});
|
||||
const host = el("div", { className: "m02-struct__sheet" });
|
||||
editButton.addEventListener("click", () => opts.onEdit(kind));
|
||||
return {
|
||||
kind,
|
||||
root: itemCard(
|
||||
PART_WORD[kind],
|
||||
el("div", { className: "m02-struct__subhead", children: [no, editButton] }),
|
||||
host,
|
||||
),
|
||||
no,
|
||||
edit: editButton,
|
||||
host,
|
||||
doc: null,
|
||||
number: "",
|
||||
handle: null,
|
||||
};
|
||||
};
|
||||
const sheets = [sheetCard("area"), sheetCard("material")];
|
||||
edit.addEventListener("click", () => opts.onEdit("structure"));
|
||||
const buttons: Record<EditKind, HTMLButtonElement> = {
|
||||
structure: edit,
|
||||
area: sheets[0].edit,
|
||||
material: sheets[1].edit,
|
||||
};
|
||||
const defaults: Record<EditKind, string> = {
|
||||
structure: TEXT.edit,
|
||||
area: fill(TEXT.editSheet, PART_WORD.area),
|
||||
material: fill(TEXT.editSheet, PART_WORD.material),
|
||||
};
|
||||
|
||||
const root = el("div", {
|
||||
className: "m02-struct ui-sidebar-panel",
|
||||
attrs: { hidden: "" },
|
||||
children: [infoCard, ...sheets.map((c) => c.root), ...(opts.extraCards ?? [])],
|
||||
});
|
||||
attachCollapsible(root);
|
||||
|
||||
const drop = (): void => {
|
||||
preview?.destroy();
|
||||
preview = null;
|
||||
for (const c of sheets) {
|
||||
c.handle?.destroy();
|
||||
c.handle = null;
|
||||
}
|
||||
};
|
||||
|
||||
/** 미리보기 CAD 는 iframe — 안쪽 도구 모음(제목줄 · 리본)을 스타일로 숨김(같은 출처라 접근 가능) */
|
||||
const hideToolbar = (frame: HTMLIFrameElement): void => {
|
||||
const apply = (): void => {
|
||||
const inner = frame.contentDocument;
|
||||
if (!inner?.head || inner.getElementById("m02-struct-hide")) return;
|
||||
const style = inner.createElement("style");
|
||||
style.id = "m02-struct-hide";
|
||||
style.textContent = ".cad-titlebar, .cad-ribbon { display: none !important; }";
|
||||
inner.head.append(style);
|
||||
};
|
||||
frame.addEventListener("load", apply);
|
||||
apply();
|
||||
};
|
||||
|
||||
/** 스프레드시트 미리보기 — 보기만(고침은 [수정] 이 여는 전체 화면) · 부품을 늦게 받음 ·
|
||||
* 그 사이 열이 바뀌면 버림 */
|
||||
const paintSheet = async (c: SheetCard, mine: number): Promise<void> => {
|
||||
if (!c.doc)
|
||||
return void c.host.replaceChildren(itemNote(fill(TEXT.noSheetDoc, PART_WORD[c.kind])));
|
||||
try {
|
||||
const { createSpreadsheet } = await loadSpreadsheet();
|
||||
if (mine !== seq || !c.doc) return;
|
||||
c.handle = createSpreadsheet(c.host, c.doc, { readOnly: true, tabs: false });
|
||||
} catch (error) {
|
||||
if (mine === seq) c.host.replaceChildren(itemNote(fill(TEXT.noSheet, why(error))));
|
||||
}
|
||||
};
|
||||
|
||||
/** 미리보기 · 스프레드시트 — 문서가 없으면 없음 글(서버가 열마다 미리 만들어 둬 보통은 없음) */
|
||||
const paintBody = (mine: number): void => {
|
||||
drop();
|
||||
const at = column ?? "";
|
||||
number.textContent = doc?.도번 || numberOf("structure", at) || TEXT.noNumber;
|
||||
edit.hidden = !opts.editable();
|
||||
for (const c of sheets) {
|
||||
c.no.textContent =
|
||||
c.number || numberOf(c.kind, at) || fill(TEXT.noFieldNumber, PART_FIELD[c.kind]);
|
||||
c.edit.hidden = !opts.editable();
|
||||
c.host.replaceChildren();
|
||||
void paintSheet(c, mine);
|
||||
}
|
||||
if (!doc) {
|
||||
previewHost.replaceChildren(itemNote(TEXT.noDrawing));
|
||||
return;
|
||||
}
|
||||
previewHost.replaceChildren();
|
||||
const source = opts.source();
|
||||
// 미리보기 — 도면 양식과 같은 부품을 읽기만으로. 자동백업 칸은 편집 화면과 갈라 둠.
|
||||
preview = mountDrawingTemplate(previewHost, doc.도면 as unknown as DrawingTemplateDoc, {
|
||||
readOnly: true,
|
||||
name: `preview:${column ?? ""}`,
|
||||
layer: source.layer,
|
||||
projectId: source.projectId,
|
||||
panelsCollapsed: true, // 특성 · 도면층 패널은 접힌 채 — › 를 누르면 펼침
|
||||
preview: true, // 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤
|
||||
originMarker: true, // 좌표 0,0 기준점 십자
|
||||
noArea: true, // 구조물도는 도각이 없음 — 작도 영역 단추 없음
|
||||
});
|
||||
const frame = previewHost.querySelector("iframe");
|
||||
if (frame) hideToolbar(frame);
|
||||
};
|
||||
|
||||
async function load(col: { id: string; label: string } | null): Promise<void> {
|
||||
const mine = ++seq;
|
||||
drop();
|
||||
if (!col) {
|
||||
root.hidden = true;
|
||||
column = null;
|
||||
doc = null;
|
||||
return;
|
||||
}
|
||||
root.hidden = false;
|
||||
column = col.id;
|
||||
columnName.textContent = col.label;
|
||||
number.textContent = numberOf("structure", col.id) || TEXT.noNumber;
|
||||
doc = null;
|
||||
previewHost.replaceChildren();
|
||||
for (const c of sheets) {
|
||||
c.doc = null;
|
||||
c.number = "";
|
||||
c.host.replaceChildren();
|
||||
}
|
||||
const source = opts.source();
|
||||
const [drawing, ...books] = await Promise.allSettled([
|
||||
source.read("structure", col.id),
|
||||
...sheets.map((c) => source.read(c.kind, col.id)),
|
||||
]);
|
||||
if (mine !== seq) return;
|
||||
if (drawing.status === "fulfilled") doc = drawing.value.문서 as StructureDoc;
|
||||
// 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」
|
||||
else if (opts.warnOnFail?.(col.id) ?? true)
|
||||
showToast(fill(TEXT.readFailed, why(drawing.reason)), "error");
|
||||
books.forEach((got, i) => {
|
||||
if (got.status !== "fulfilled") return;
|
||||
const c = sheets[i];
|
||||
const book = got.value.문서 as Workbook & Record<string, unknown>;
|
||||
c.doc = book;
|
||||
c.number = String(book?.[PART_FIELD[c.kind]] ?? "");
|
||||
});
|
||||
paintBody(mine);
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
load,
|
||||
setLabel: (label) => {
|
||||
columnName.textContent = label;
|
||||
},
|
||||
relabel: (labels) => {
|
||||
for (const kind of Object.keys(buttons) as EditKind[]) {
|
||||
const button = buttons[kind];
|
||||
(button.querySelector(".ui-btn__label") ?? button).textContent =
|
||||
labels[kind] ?? defaults[kind];
|
||||
}
|
||||
},
|
||||
column: () => column,
|
||||
destroy: () => {
|
||||
seq += 1;
|
||||
drop();
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_ItemSource.ts
|
||||
* 항목 카드 · 편집기가 문서를 읽는 **자료 자리**(계획서 25-2d) — 층 고정(`getActiveLayer`)이던 자리를
|
||||
* 인자로 뺌. M02 화면은 지금 층(`layerSource`) · 항목 고르기 모달은 프로젝트 복사본 또는 원본 층.
|
||||
* 카드(`M02_MasterTemplete_ItemCards.ts`)는 어디서 읽는지 모르고 이 창구만 부름.
|
||||
* ========================================================================== */
|
||||
|
||||
import { getActiveLayer, readTemplate, type Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
|
||||
/** 집계표 열 하나에 딸린 문서 셋 — 구조물 도면 · 면적산출 · 재료산출 */
|
||||
export type ItemPart = "structure" | "area" | "material";
|
||||
|
||||
export interface ItemSource {
|
||||
/** CAD 자동백업 칸을 가르는 층 — 프로젝트 복사본이면 `project` */
|
||||
layer: Layer;
|
||||
projectId: string | null;
|
||||
/** 열 id 의 문서 하나 — 못 읽으면 던짐(카드가 「없음」 · 알림으로 갈라 보임) */
|
||||
read: (part: ItemPart, column: string) => Promise<{ 문서: unknown }>;
|
||||
}
|
||||
|
||||
/** M02 화면의 자료 자리 — 부를 때의 지금 층(`getActiveLayer`) 그대로 */
|
||||
export const layerSource = (): ItemSource => {
|
||||
const layer = getActiveLayer();
|
||||
return {
|
||||
layer,
|
||||
projectId: null,
|
||||
read: (part, column) => readTemplate(layer, part, column, null),
|
||||
};
|
||||
};
|
||||
@@ -15,6 +15,7 @@ from typing import Any
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Matset as matset
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
@@ -104,7 +105,7 @@ def _fresh_id(want: str, used: set[str]) -> str:
|
||||
|
||||
def copy_item(source: Path, target: Path, key: str, origin: dict[str, Any]) -> dict[str, Any]:
|
||||
"""다른 층 항목(SS `key`) → `target` 층 새 열 하나 — 머리 · 단위 · 일위대가 · 바인딩 그대로 ·
|
||||
도면 · 면적산출 · 재료산출 내용(알약 연결 포함) · 자재 조합 줄(새 UM) 복사 ·
|
||||
도면 · 면적산출 · 재료산출 내용(알약 연결 포함) · 항목 연결 · 자재 조합 줄(새 UM) 복사 ·
|
||||
새 번호 · 새 SS 키 ·
|
||||
열 `본뜬` = `origin`(원래 층 · 주인 · 키). 돌려줌 `{key, 열, 이름}`."""
|
||||
with store.at(source):
|
||||
@@ -153,4 +154,5 @@ def copy_item(source: Path, target: Path, key: str, origin: dict[str, Any]) -> d
|
||||
with store._LOCK:
|
||||
atomic_write_json(store._path(kind, new_id), body)
|
||||
ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id)
|
||||
inputs_store.copy_between(source, target, col_id, new_id) # 항목 연결도 같이(판 1)
|
||||
return {"key": ss["키"], "열": new_id, "이름": " ".join(h for h in store._heads(made) if h)}
|
||||
|
||||
@@ -20,6 +20,7 @@ from pydantic import BaseModel
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Branch as branch
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Matset as matset
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
@@ -38,6 +39,11 @@ Session = dict[str, Any]
|
||||
NO_COMPANY = "회사부터 등록 — 회사 없이는 회사 · 개인 구조물 집계표를 만들 수 없음"
|
||||
|
||||
|
||||
class InputLinksBody(BaseModel):
|
||||
판: int | None = None
|
||||
줄: list[Any] = []
|
||||
|
||||
|
||||
class CopyItemBody(BaseModel):
|
||||
from_layer: Literal["system", "company"]
|
||||
from_owner: str | None = None
|
||||
@@ -145,6 +151,50 @@ def copy_item(body: CopyItemBody, session: Session = Depends(verify_session)) ->
|
||||
raise HTTPException(status_code=e.status, detail=e.detail) from e
|
||||
|
||||
|
||||
# ── 항목 선택 입력(PLAN 25-1b · 25-2a) — 읽기 = 층 읽기 권한(시스템 층 = 로그인 누구나) ──
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/items/{key}/inputs")
|
||||
def get_item_inputs(
|
||||
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
|
||||
) -> dict:
|
||||
"""그 항목 일위대가가 담은 로직마다 입력 전부(합치지 않음) · 자동값 — M01 길은 관리자 전용이라
|
||||
일반 사용자는 이 길로 읽음."""
|
||||
return _call(_root(session, layer, owner), inputs_store.inputs, key)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/items/{key}/input-links")
|
||||
def get_input_links(
|
||||
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
|
||||
) -> dict:
|
||||
return _call(_root(session, layer, owner), inputs_store.read_links, key)
|
||||
|
||||
|
||||
@router.put("/layers/{layer}/items/{key}/input-links")
|
||||
def put_input_links(
|
||||
layer: Layer,
|
||||
key: str,
|
||||
body: InputLinksBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""본문 `{판, 줄}` — 판 다르면 409 · 없는 로직 · 입력 키 422 · 층 쓰기 권한."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
return _call(folder, inputs_store.write_links, key, body.판, body.줄)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/items/{key}/pills")
|
||||
def get_item_pills(
|
||||
layer: Layer,
|
||||
key: str,
|
||||
place: Literal["면적", "재료"],
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""면적산출 · 재료산출 시트 알약 — `{물량: [조합 물량], 입력: [그 시트로 이은 입력]}`."""
|
||||
return _call(_root(session, layer, owner), inputs_store.pills, key, place)
|
||||
|
||||
|
||||
# ── 층 길 — 옛 시스템 길과 같은 모양 ─────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -101,6 +101,16 @@ def _path(kind: str, name: str) -> Path:
|
||||
return root() / kind / f"{name}.json"
|
||||
|
||||
|
||||
# 항목 연결(PLAN 25-1b) — 열 id 마다 `inputs/<열id>.json` · 종류 폴더(KINDS) 밖 · 읽기 · 쓰기는
|
||||
# `M02_MasterTemplete_Inputs` 몫 · 열 지우기 · 분류 더하기 복사는 여기서 같이
|
||||
INPUTS = "inputs"
|
||||
|
||||
|
||||
def inputs_path(column: str) -> Path:
|
||||
"""그 열의 항목 연결 파일 자리 — 이름 규칙은 종류 파일과 같음."""
|
||||
return _path("structure", column).parent.parent / INPUTS / f"{column}.json"
|
||||
|
||||
|
||||
def _info(kind: str, path: Path) -> dict[str, Any]:
|
||||
raw = path.read_bytes()
|
||||
stamp = datetime.fromtimestamp(path.stat().st_mtime).isoformat(timespec="seconds")
|
||||
@@ -163,6 +173,7 @@ def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
|
||||
for column, source in origins.items(): # 빈 파일 채우기 앞 — 원본 내용으로 만듦
|
||||
for part in _KEY:
|
||||
_ensure(part, column, source)
|
||||
_copy_inputs(source, column)
|
||||
backfill_missing_structures() # 지우기 앞 — 같은 저장의 새 열이 지운 번호를 안 받게
|
||||
prune_removed_structures()
|
||||
sync_combos() # 번호가 다 선 뒤 — 조합 표 도번 · 면적번 · 재료번 칸
|
||||
@@ -375,13 +386,26 @@ def copy_from(kind: str, column: str, source: str) -> dict[str, Any]:
|
||||
return _info(kind, _path(kind, column))
|
||||
|
||||
|
||||
def _copy_inputs(source: str, column: str) -> None:
|
||||
"""분류 더하기로 복사한 새 열 — 원본 열의 항목 연결을 판 1 로(이미 있으면 그대로)."""
|
||||
have, got = inputs_path(column), inputs_path(source)
|
||||
if have.is_file() or not got.is_file():
|
||||
return
|
||||
have.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(have, {"판": 1, "줄": json.loads(got.read_bytes()).get("줄") or []})
|
||||
|
||||
|
||||
def prune_removed_structures() -> list[str]:
|
||||
"""집계표에서 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 같이 지움 —
|
||||
"""집계표에서 지운 열(또는 구조물이 아니게 된 열)의 세 파일 · 항목 연결을 같이 지움 —
|
||||
번호는 비워 둠(다른 열 번호를 안 당김)."""
|
||||
keep = set(_column_ids())
|
||||
gone: list[str] = []
|
||||
codes: set[str] = set()
|
||||
with _LOCK:
|
||||
for path in sorted((root() / INPUTS).glob("[!.]*.json")):
|
||||
if path.stem not in keep:
|
||||
path.unlink()
|
||||
gone.append(f"{INPUTS}/{path.stem}")
|
||||
for kind, key, _, _ in PARTS:
|
||||
for path in sorted((root() / kind).glob("[!.]*.json")):
|
||||
if path.stem not in keep:
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
display: grid;
|
||||
flex: 1 1 0; /* 메인 칸(창 높이)에서 표 아래 남은 높이를 다 차지 */
|
||||
grid-auto-rows: minmax(auto, 1fr); /* 카드 셋 같은 높이 */
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
|
||||
padding-top: var(--spacing-12);
|
||||
border-top: 1px solid var(--color-border);
|
||||
min-width: 0;
|
||||
@@ -112,7 +112,7 @@
|
||||
.m02-struct__empty {
|
||||
margin: 0;
|
||||
padding: var(--spacing-16);
|
||||
color: var(--color-text-muted);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 조회 후 선택 창 — 찾기 칸 + 목록(번호 · 이름) */
|
||||
|
||||
@@ -21,9 +21,7 @@ import {
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
import { fetchComboInputs, searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch";
|
||||
import { type ComboSummary, fetchCombos } from "../M01_MasterData/M01_MasterData_UI_Combo_Api";
|
||||
@@ -34,29 +32,25 @@ import {
|
||||
fetchStructureNumbers,
|
||||
getActiveLayer,
|
||||
readTemplate,
|
||||
type StructureDoc,
|
||||
type StructureNumbers,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
mountDrawingTemplate,
|
||||
type DrawingTemplateDoc,
|
||||
type DrawingTemplateHandle,
|
||||
} from "./M02_MasterTemplete_Drawing";
|
||||
createItemCards,
|
||||
PART_FIELD,
|
||||
PART_WORD,
|
||||
type EditKind,
|
||||
type SheetKind,
|
||||
} from "./M02_MasterTemplete_ItemCards";
|
||||
import { layerSource } from "./M02_MasterTemplete_ItemSource";
|
||||
import { createInputsCard, inputPills, type InputsCardHandle } from "./M02_MasterTemplete_Inputs";
|
||||
import "./M02_MasterTemplete_Structure.css";
|
||||
|
||||
export type { EditKind, SheetKind } from "./M02_MasterTemplete_ItemCards";
|
||||
|
||||
/** 구조물 도면이 걸리는 집계표 이름 — 서버 `M02_MasterTemplete_Store.STRUCTURE_TABLE` 과 같은 글 */
|
||||
export const STRUCTURE_TABLE = "구조물집계표";
|
||||
|
||||
const TEXT = {
|
||||
drawingInfo: "도면 정보",
|
||||
edit: "도면 수정",
|
||||
noNumber: "도번 없음",
|
||||
noDrawing: "도면 없음",
|
||||
editSheet: "{value} 수정",
|
||||
noSheetDoc: "{value} 없음",
|
||||
noFieldNumber: "{value} 없음",
|
||||
noSheet: "스프레드시트를 못 띄움 — {value}",
|
||||
readFailed: "구조물 도면을 못 읽음 — {value}",
|
||||
close: "닫기",
|
||||
find: "찾기",
|
||||
clear: "비움",
|
||||
@@ -74,15 +68,10 @@ const TEXT = {
|
||||
};
|
||||
|
||||
/** 집계표 열 하나에 딸린 문서 셋 — 번호 칸 · 이름 · 복사 */
|
||||
export type EditKind = "structure" | "area" | "material";
|
||||
export type SheetKind = Exclude<EditKind, "structure">;
|
||||
const PARTS: Record<
|
||||
EditKind,
|
||||
{ field: keyof StructureNumbers; word: string; copy: (to: string, from: string) => Promise<void> }
|
||||
> = {
|
||||
structure: { field: "도번", word: "도면", copy: copyStructure },
|
||||
area: { field: "면적번", word: "면적산출", copy: copyArea },
|
||||
material: { field: "재료번", word: "재료산출", copy: copyMaterial },
|
||||
const COPY: Record<EditKind, (to: string, from: string) => Promise<void>> = {
|
||||
structure: copyStructure,
|
||||
area: copyArea,
|
||||
material: copyMaterial,
|
||||
};
|
||||
/** 집계표 줄 열쇠 → 문서 */
|
||||
const ROW_KIND: Record<string, EditKind> = {
|
||||
@@ -93,8 +82,6 @@ const ROW_KIND: Record<string, EditKind> = {
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
const fill = (text: string, value: string): string => text.replace("{value}", value);
|
||||
/** 스프레드시트는 면적산출 · 재료산출 카드를 열 때만 받음 */
|
||||
const loadSpreadsheet = () => import("../A00_Common/spreadsheet/spreadsheet");
|
||||
|
||||
/** `{열 id: {도번, 면적번, 재료번}}` — 집계표 번호 줄(없는 열은 키 없음 = 「없음」) */
|
||||
export type ColumnNumbers = Record<string, StructureNumbers>;
|
||||
@@ -207,8 +194,12 @@ export async function pillsFor(
|
||||
kind: SheetKind,
|
||||
column: string,
|
||||
): Promise<{ links: LinkOptions; notice?: string }> {
|
||||
if (kind === "material") return { links: { 항목: [], 모드: "자재", 자재찾기: searchMaterials } };
|
||||
const none = (notice: string) => ({ links: { 항목: [], 모드: "입력" as const }, notice });
|
||||
if (kind === "material") {
|
||||
const own = await inputPills(column, "재료");
|
||||
return { links: { 항목: own, 모드: "자재", 자재찾기: searchMaterials } };
|
||||
}
|
||||
const own = await inputPills(column, "면적"); // 선택 입력 카드가 면적산출로 고른 입력
|
||||
const none = (notice: string) => ({ links: { 항목: own, 모드: "입력" as const }, notice });
|
||||
const table = await readTemplate(getActiveLayer(), "table", STRUCTURE_TABLE, null).catch(
|
||||
() => null,
|
||||
);
|
||||
@@ -217,7 +208,7 @@ export async function pillsFor(
|
||||
if (!combo) return none(TEXT.noPrice);
|
||||
try {
|
||||
const got = await fetchComboInputs(combo);
|
||||
const 항목 = got.물량.map((q) => ({ 키: q.키, 이름: q.이름, 단위: q.단위 }));
|
||||
const 항목 = [...got.물량.map((q) => ({ 키: q.키, 이름: q.이름, 단위: q.단위 })), ...own];
|
||||
return 항목.length ? { links: { 항목, 모드: "입력" } } : none(fill(TEXT.noInputs, combo));
|
||||
} catch (error) {
|
||||
return none(fill(TEXT.inputsFailed, why(error)));
|
||||
@@ -232,6 +223,8 @@ export interface StructurePanelOptions {
|
||||
onEdit: (column: string, label: string, kind: EditKind) => void;
|
||||
/** 집계표에 저장 안 한 고침이 있으면 버릴지 물음 — 취소면 false */
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
/** 「선택 입력」 카드에서 자리를 골랐음 — 저장 안 한 고침 표시 */
|
||||
onDirty?: () => void;
|
||||
}
|
||||
|
||||
export interface StructurePanelHandle {
|
||||
@@ -240,6 +233,8 @@ export interface StructurePanelHandle {
|
||||
numbers: () => ColumnNumbers;
|
||||
/** 번호 목록 다시 받기 */
|
||||
reload: () => Promise<void>;
|
||||
/** 「선택 입력」 카드에서 고른 자리를 서버에 저장 — 집계표 [저장] 뒤에 부름(실패하면 던짐) */
|
||||
saveInputs: () => Promise<void>;
|
||||
/** 고른 열이 바뀜 — 같은 열이면 이름만 새로(CAD 를 다시 안 띄움) */
|
||||
show: (colId: string | null, columns: StructureColumn[]) => void;
|
||||
/** 보던 열을 다시 읽음 — 집계표 [저장] 뒤(서버가 새 열 파일 · 번호를 만듦) */
|
||||
@@ -255,185 +250,38 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
let shown: StructureColumn | null = null;
|
||||
let column: string | null = null;
|
||||
let columnLabel = "";
|
||||
let doc: StructureDoc | null = null;
|
||||
let preview: DrawingTemplateHandle | null = null;
|
||||
let seq = 0;
|
||||
|
||||
const card = (title: string, head: HTMLElement, body: HTMLElement): HTMLElement =>
|
||||
el("section", {
|
||||
className: "m02-struct__card ui-collapsible ui-sidebar-section ui-sidebar-card",
|
||||
children: [el("h3", { className: "ui-collapsible__title", text: title }), head, body],
|
||||
});
|
||||
|
||||
const columnName = el("span", { className: "m02-struct__title" });
|
||||
const number = el("span", { className: "m02-struct__no" });
|
||||
const edit = createButton({ label: TEXT.edit, variant: "filled", size: "sm" });
|
||||
const previewHost = el("div", { className: "m02-struct__preview" });
|
||||
const infoCard = card(
|
||||
TEXT.drawingInfo,
|
||||
el("div", { className: "m02-struct__head", children: [number, columnName, edit] }),
|
||||
previewHost,
|
||||
);
|
||||
|
||||
/** 면적산출 · 재료산출 카드 — 번호 · [수정] · 스프레드시트 미리보기 */
|
||||
interface SheetCard {
|
||||
kind: SheetKind;
|
||||
root: HTMLElement;
|
||||
no: HTMLElement;
|
||||
edit: HTMLButtonElement;
|
||||
host: HTMLElement;
|
||||
doc: Workbook | null;
|
||||
number: string;
|
||||
handle: SpreadsheetHandle | null;
|
||||
}
|
||||
const sheetCard = (kind: SheetKind): SheetCard => {
|
||||
const no = el("span", { className: "m02-struct__no" });
|
||||
const editButton = createButton({
|
||||
label: fill(TEXT.editSheet, PARTS[kind].word),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
});
|
||||
const host = el("div", { className: "m02-struct__sheet" });
|
||||
editButton.addEventListener("click", () => void openEdit(kind));
|
||||
return {
|
||||
kind,
|
||||
root: card(
|
||||
PARTS[kind].word,
|
||||
el("div", { className: "m02-struct__subhead", children: [no, editButton] }),
|
||||
host,
|
||||
),
|
||||
no,
|
||||
edit: editButton,
|
||||
host,
|
||||
doc: null,
|
||||
number: "",
|
||||
handle: null,
|
||||
};
|
||||
};
|
||||
const sheets = [sheetCard("area"), sheetCard("material")];
|
||||
|
||||
const root = el("div", {
|
||||
className: "m02-struct ui-sidebar-panel",
|
||||
attrs: { hidden: "" },
|
||||
children: [infoCard, ...sheets.map((c) => c.root)],
|
||||
const inputs: InputsCardHandle = createInputsCard({
|
||||
isAdmin: opts.isAdmin,
|
||||
onDirty: () => opts.onDirty?.(),
|
||||
});
|
||||
attachCollapsible(root);
|
||||
|
||||
const drop = (): void => {
|
||||
preview?.destroy();
|
||||
preview = null;
|
||||
for (const c of sheets) {
|
||||
c.handle?.destroy();
|
||||
c.handle = null;
|
||||
}
|
||||
};
|
||||
|
||||
const note = (text: string): HTMLElement => el("p", { className: "m02-struct__empty", text });
|
||||
|
||||
/** 미리보기 CAD 는 iframe — 안쪽 도구 모음(제목줄 · 리본)을 스타일로 숨김(같은 출처라 접근 가능) */
|
||||
const hideToolbar = (frame: HTMLIFrameElement): void => {
|
||||
const apply = (): void => {
|
||||
const inner = frame.contentDocument;
|
||||
if (!inner?.head || inner.getElementById("m02-struct-hide")) return;
|
||||
const style = inner.createElement("style");
|
||||
style.id = "m02-struct-hide";
|
||||
style.textContent = ".cad-titlebar, .cad-ribbon { display: none !important; }";
|
||||
inner.head.append(style);
|
||||
};
|
||||
frame.addEventListener("load", apply);
|
||||
apply();
|
||||
};
|
||||
|
||||
/** 스프레드시트 미리보기 — 보기만(고침은 [수정] 이 여는 전체 화면) · 부품을 늦게 받음 ·
|
||||
* 그 사이 열이 바뀌면 버림 */
|
||||
const paintSheet = async (c: SheetCard, mine: number): Promise<void> => {
|
||||
if (!c.doc) return void c.host.replaceChildren(note(fill(TEXT.noSheetDoc, PARTS[c.kind].word)));
|
||||
try {
|
||||
const { createSpreadsheet } = await loadSpreadsheet();
|
||||
if (mine !== seq || !c.doc) return;
|
||||
c.handle = createSpreadsheet(c.host, c.doc, { readOnly: true, tabs: false });
|
||||
} catch (error) {
|
||||
if (mine === seq) c.host.replaceChildren(note(fill(TEXT.noSheet, why(error))));
|
||||
}
|
||||
};
|
||||
|
||||
/** 미리보기 · 스프레드시트 — 문서가 없으면 없음 글(서버가 열마다 미리 만들어 둬 보통은 없음) */
|
||||
const paintBody = (mine: number): void => {
|
||||
drop();
|
||||
number.textContent = doc?.도번 || numbers[column ?? ""]?.도번 || TEXT.noNumber;
|
||||
edit.hidden = !opts.isAdmin;
|
||||
for (const c of sheets) {
|
||||
const field = PARTS[c.kind].field;
|
||||
c.no.textContent =
|
||||
c.number || numbers[column ?? ""]?.[field] || fill(TEXT.noFieldNumber, field);
|
||||
c.edit.hidden = !opts.isAdmin;
|
||||
c.host.replaceChildren();
|
||||
void paintSheet(c, mine);
|
||||
}
|
||||
if (!doc) {
|
||||
previewHost.replaceChildren(note(TEXT.noDrawing));
|
||||
return;
|
||||
}
|
||||
previewHost.replaceChildren();
|
||||
// 미리보기 — 도면 양식과 같은 부품을 읽기만으로. 자동백업 칸은 편집 화면과 갈라 둠.
|
||||
preview = mountDrawingTemplate(previewHost, doc.도면 as unknown as DrawingTemplateDoc, {
|
||||
readOnly: true,
|
||||
name: `preview:${column ?? ""}`,
|
||||
layer: getActiveLayer(),
|
||||
panelsCollapsed: true, // 특성 · 도면층 패널은 접힌 채 — › 를 누르면 펼침
|
||||
preview: true, // 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤
|
||||
originMarker: true, // 좌표 0,0 기준점 십자
|
||||
noArea: true, // 구조물도는 도각이 없음 — 작도 영역 단추 없음
|
||||
});
|
||||
const frame = previewHost.querySelector("iframe");
|
||||
if (frame) hideToolbar(frame);
|
||||
};
|
||||
|
||||
async function load(col: StructureColumn | null): Promise<void> {
|
||||
const mine = ++seq;
|
||||
drop();
|
||||
shown = col;
|
||||
if (!col) {
|
||||
root.hidden = true;
|
||||
column = null;
|
||||
doc = null;
|
||||
return;
|
||||
}
|
||||
root.hidden = false;
|
||||
column = col.id;
|
||||
columnLabel = columnHead(col);
|
||||
columnName.textContent = columnLabel;
|
||||
number.textContent = numbers[col.id]?.도번 || TEXT.noNumber;
|
||||
doc = null;
|
||||
previewHost.replaceChildren();
|
||||
for (const c of sheets) {
|
||||
c.doc = null;
|
||||
c.number = "";
|
||||
c.host.replaceChildren();
|
||||
}
|
||||
const [drawing, ...books] = await Promise.allSettled([
|
||||
readTemplate(getActiveLayer(), "structure", col.id, null),
|
||||
...sheets.map((c) => readTemplate(getActiveLayer(), c.kind, col.id, null)),
|
||||
]);
|
||||
if (mine !== seq) return;
|
||||
if (drawing.status === "fulfilled") doc = drawing.value.문서 as StructureDoc;
|
||||
// 카드 셋은 항목 카드 한 벌(`ItemCards`) — 여기서는 지금 층에서 열 하나를 읽어 넘기기만.
|
||||
const cards = createItemCards({
|
||||
source: layerSource,
|
||||
editable: () => opts.isAdmin,
|
||||
onEdit: (kind) => void openEdit(kind),
|
||||
numberOf: (kind, id) => numbers[id]?.[PART_FIELD[kind]],
|
||||
// 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」
|
||||
else if (numbers[col.id]) showToast(fill(TEXT.readFailed, why(drawing.reason)), "error");
|
||||
books.forEach((got, i) => {
|
||||
if (got.status !== "fulfilled") return;
|
||||
const c = sheets[i];
|
||||
const book = got.value.문서 as Workbook & Record<string, unknown>;
|
||||
c.doc = book;
|
||||
c.number = String(book?.[PARTS[c.kind].field] ?? "");
|
||||
});
|
||||
paintBody(mine);
|
||||
warnOnFail: (id) => !!numbers[id],
|
||||
extraCards: [inputs.root],
|
||||
});
|
||||
const root = cards.root;
|
||||
|
||||
function load(col: StructureColumn | null): Promise<void> {
|
||||
shown = col;
|
||||
column = col?.id ?? null;
|
||||
columnLabel = col ? columnHead(col) : "";
|
||||
if (!col) void inputs.show(null, null);
|
||||
// 집계표에 지금 고른 일위대가(저장 전일 수 있음) — 카드가 「먼저 저장」 안내를 고름
|
||||
else void inputs.show(col.id, (col as { 일위대가?: string | null }).일위대가 ?? null);
|
||||
return cards.load(col ? { id: col.id, label: columnLabel } : null);
|
||||
}
|
||||
|
||||
/** 글 열(NO · 측점 · 비고)은 구조물이 아니라 컨테이너를 닫음 */
|
||||
const show = (colId: string | null, columns: StructureColumn[]): void => {
|
||||
const col = columns.find((c) => c.id === colId && isStructureColumn(c)) ?? null;
|
||||
if ((col?.id ?? null) !== column) return void load(col);
|
||||
if (col) columnName.textContent = columnLabel = columnHead(col); // 머리를 고쳤을 수 있음
|
||||
if (col) cards.setLabel((columnLabel = columnHead(col))); // 머리를 고쳤을 수 있음
|
||||
};
|
||||
|
||||
const pickPrice = async (colId: string, columns: StructureColumn[]): Promise<string | null> => {
|
||||
@@ -462,7 +310,9 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
colId: string,
|
||||
columns: StructureColumn[],
|
||||
): Promise<void> => {
|
||||
const { field, word, copy } = PARTS[kind];
|
||||
const field = PART_FIELD[kind];
|
||||
const word = PART_WORD[kind];
|
||||
const copy = COPY[kind];
|
||||
const to = columns.find((c) => c.id === colId);
|
||||
const toNo = numbers[colId]?.[field];
|
||||
if (!to || !toNo) return void showToast(TEXT.saveFirst, "error");
|
||||
@@ -505,18 +355,18 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
const at = column;
|
||||
if (!at) return;
|
||||
// 저장 전 새 열(분류 더하기) — 문서 · 번호가 아직 없음 · 떠나면 집계표 고침까지 버리게 됨
|
||||
if (!numbers[at]?.[PARTS[kind].field]) return void showToast(TEXT.saveFirst, "info");
|
||||
if (!numbers[at]?.[PART_FIELD[kind]]) return void showToast(TEXT.saveFirst, "info");
|
||||
if (!(await opts.confirmLeave())) return;
|
||||
opts.onEdit(at, columnLabel, kind);
|
||||
}
|
||||
edit.addEventListener("click", () => void openEdit("structure"));
|
||||
|
||||
return {
|
||||
root,
|
||||
numbers: () => numbers,
|
||||
reload: async () => {
|
||||
numbers = await fetchStructureNumbers().catch(() => ({}));
|
||||
inputs.invalidate(); // 새 열 · 바뀐 일위대가를 서버 항목 목록에서 다시 찾게
|
||||
},
|
||||
saveInputs: () => inputs.save(),
|
||||
show,
|
||||
refresh: (columns) => {
|
||||
const at = column;
|
||||
@@ -525,9 +375,8 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
},
|
||||
pick,
|
||||
destroy: () => {
|
||||
seq += 1;
|
||||
drop();
|
||||
root.remove();
|
||||
inputs.destroy();
|
||||
cards.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type StructureColumn,
|
||||
type StructurePanelHandle,
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
import { inputPills } from "./M02_MasterTemplete_Inputs";
|
||||
import { buildComboBar } from "./M02_MasterTemplete_Combo";
|
||||
import { buildComboPanel, type ComboPanel } from "./M02_MasterTemplete_ComboPanel";
|
||||
import {
|
||||
@@ -197,6 +198,7 @@ export function createMain(
|
||||
const made = createStructurePanel({
|
||||
isAdmin,
|
||||
confirmLeave,
|
||||
onDirty: () => (dirty = true), // 「선택 입력」 카드에서 자리를 고름 — 집계표 [저장]으로 함께 저장
|
||||
// 같은 페이지에서 그 열을 CAD·스프레드시트 전체 편집으로 엶
|
||||
onEdit: (column, label, kind) => {
|
||||
void open({ layer, projectId: null, kind, name: column, label });
|
||||
@@ -221,16 +223,17 @@ export function createMain(
|
||||
try {
|
||||
const master = at.layer !== "project" && kind === "material";
|
||||
if (master && !matState) matState = await loadMaterialState(at.name);
|
||||
const [{ createSpreadsheet }, pills] = await Promise.all([
|
||||
const [{ createSpreadsheet }, pills, own] = await Promise.all([
|
||||
import("../A00_Common/spreadsheet/spreadsheet"),
|
||||
master ? null : pillsFor(kind, at.name),
|
||||
master ? inputPills(at.name, "재료") : [], // 「선택 입력」 카드가 재료산출로 고른 입력
|
||||
]);
|
||||
if (mine !== seq) return;
|
||||
if (pills?.notice) showNotice([pills.notice]);
|
||||
const links: LinkOptions =
|
||||
pills?.links ??
|
||||
({
|
||||
항목: toPills(pillItems ?? matState?.combo?.담은자재 ?? []),
|
||||
항목: [...toPills(pillItems ?? matState?.combo?.담은자재 ?? []), ...own],
|
||||
모드: "자재",
|
||||
자재찾기: searchMaterials,
|
||||
// 알약 막대에서 더하기 · 빼기 — 옆 판 표를 같은 목록으로
|
||||
@@ -468,6 +471,7 @@ export function createMain(
|
||||
await panel.reload();
|
||||
sheet.recalc();
|
||||
panel.refresh(sheet.getDoc()?.열 ?? []);
|
||||
await panel.saveInputs(); // 「선택 입력」 카드의 자리 고름 — 같은 [저장]에서
|
||||
}
|
||||
showNotice([]);
|
||||
showToast(L("M02_Saved"), "success");
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""프로젝트 집계 항목 복사본 길 — `/api/projects/{id}/items…` (PLAN 25-1b · 25-2b · 25-2e).
|
||||
|
||||
GET …/items 목록(이름 · 원본 · 가져온날 · 마스터 바뀜)
|
||||
POST …/items {layer, owner, key} 미리 가져오기(구조물에 안 이은 항목) → {폴더, 만듦, 가져온날}
|
||||
GET …/items/{폴더}/{종류} {종류, 판, 내용}
|
||||
PUT …/items/{폴더}/{종류} {판, 내용} — 판이 다르면 409 · 면적 · 재료는 서버 계산값
|
||||
POST …/items/{폴더}/refresh 새로 가져오기(입력값 · 고른자재는 같은 키면 살림)
|
||||
POST …/items/{폴더}/calc 복사본만으로 계산(세 비목 · 계 · 빠진 입력)
|
||||
종류 = 도면 · 면적 · 재료 · 자재조합 · 입력연결 · 입력값 · 고른자재.
|
||||
⚠ 등록은 `main.py`(로그인만) — 프로젝트 권한(같은 회사)은 여기서 `_project` 로 봄(403 · 404).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from common_util import common_util_spreadsheet as spreadsheet
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_summary_item import parse_summary_item
|
||||
from M02_MasterTemplete import M02_Item_Copy as copies
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layer_routes
|
||||
from M02_MasterTemplete.M02_Item_Source import item_name
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["M02 Project Items"])
|
||||
|
||||
Kind = Literal["도면", "면적", "재료", "자재조합", "입력연결", "입력값", "고른자재"]
|
||||
|
||||
|
||||
class WriteBody(BaseModel):
|
||||
판: str | None = None
|
||||
내용: Any
|
||||
|
||||
|
||||
class PrefetchBody(BaseModel):
|
||||
"""`{layer, owner, key}`(이름은 층 SS 줄에서) — 옛 모양 `{summary_item}` 도 받음."""
|
||||
|
||||
layer: str | None = None
|
||||
owner: str | None = None
|
||||
key: str | None = None
|
||||
name: str | None = None
|
||||
summary_item: dict[str, Any] | None = None
|
||||
|
||||
|
||||
async def _root(session: dict[str, Any], project_id: str):
|
||||
return (await layer_routes._project(session, project_id))["root"]
|
||||
|
||||
|
||||
async def _run(fn, *args):
|
||||
try:
|
||||
return await asyncio.to_thread(fn, *args)
|
||||
except copies.Stale as error:
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"message": str(error), "판": error.current}
|
||||
) from error
|
||||
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
|
||||
|
||||
|
||||
@router.get("/{project_id}/items")
|
||||
async def list_items(
|
||||
project_id: str, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> dict[str, Any]:
|
||||
return {"항목": await _run(copies.list_items, await _root(session, project_id))}
|
||||
|
||||
|
||||
@router.post("/{project_id}/items")
|
||||
async def prefetch_item(
|
||||
project_id: str, body: PrefetchBody, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> dict[str, Any]:
|
||||
raw = body.summary_item or {"layer": body.layer, "owner": body.owner, "key": body.key}
|
||||
try: # 모양 먼저(이름은 자리만) — 그다음 층 SS 줄에서 이름
|
||||
item = parse_summary_item({**raw, "name": raw.get("name") or body.name or "-"})
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
if not (raw.get("name") or body.name):
|
||||
try:
|
||||
item["name"] = item_name(item)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=404, detail=str(error)) from error
|
||||
return await _run(copies.prefetch, await _root(session, project_id), item)
|
||||
|
||||
|
||||
@router.get("/{project_id}/items/{folder}/{kind}")
|
||||
async def read_item_file(
|
||||
project_id: str, folder: str, kind: Kind, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> dict[str, Any]:
|
||||
return await _run(copies.read_file, await _root(session, project_id), folder, kind)
|
||||
|
||||
|
||||
def _recalc(document: dict) -> dict:
|
||||
try:
|
||||
return spreadsheet.with_server_values(document)
|
||||
except spreadsheet.RecalcError as error:
|
||||
raise HTTPException(status_code=503, detail=str(error)) from error
|
||||
|
||||
|
||||
@router.put("/{project_id}/items/{folder}/{kind}")
|
||||
async def write_item_file(
|
||||
project_id: str,
|
||||
folder: str,
|
||||
kind: Kind,
|
||||
body: WriteBody,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> dict[str, Any]:
|
||||
root = await _root(session, project_id)
|
||||
return await _run(copies.write_file, root, folder, kind, body.판, body.내용, _recalc)
|
||||
|
||||
|
||||
@router.post("/{project_id}/items/{folder}/refresh")
|
||||
async def refresh_item(
|
||||
project_id: str, folder: str, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> dict[str, Any]:
|
||||
return await _run(copies.refresh, await _root(session, project_id), folder)
|
||||
|
||||
|
||||
@router.post("/{project_id}/items/{folder}/calc")
|
||||
async def calc_item(
|
||||
project_id: str, folder: str, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> dict[str, Any]:
|
||||
return await _run(copies.calc, await _root(session, project_id), folder)
|
||||
@@ -65,6 +65,7 @@ from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_te
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import router as m02_drawing_router
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import router as m02_items_router
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Layers import router as m02_layers_router
|
||||
from M02_MasterTemplete.M02_Project_Items_Router import router as m02_project_items_router
|
||||
from common_util.common_util_audit import note_api_call, record_call_burst
|
||||
from common_util.common_util_auth import (
|
||||
require_company,
|
||||
@@ -641,6 +642,8 @@ app.include_router(m02_master_template_router, dependencies=system_admin_only)
|
||||
app.include_router(m02_layers_router, dependencies=protected)
|
||||
app.include_router(m02_items_router, dependencies=protected)
|
||||
app.include_router(m02_drawing_router, dependencies=protected)
|
||||
# 프로젝트 집계 항목 복사본(PLAN 25-2b) — 로그인만 · 프로젝트 권한은 길 안에서.
|
||||
app.include_router(m02_project_items_router, dependencies=protected)
|
||||
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
|
||||
# 그 위에 서버가 환경까지 한 번 더 본다.
|
||||
app.include_router(dev_unlock_router, dependencies=protected_with_company)
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/* 항목 고르기 모달 자료 창구 시험 헬퍼(계획서 25-2d) — `ui_template_item_picker_project.ts` 와
|
||||
* 선택 입력 카드 셈(`M02_MasterTemplete_Inputs_Model.ts`)을 트랜스파일해 가짜 서버(fetch 대역) 위에서 돌린다.
|
||||
* 서버 모양은 sub6 복사본 길(`M02_Project_Items_Router.py`) · sub4 입력 길 그대로. 결과는 JSON 한 벌.
|
||||
* ① 폴더 이름 · 목록 {항목} · 가져오기 POST · 읽기 {종류, 판, 내용} · 고침 PUT {판, 내용}(판 · 409 · 403)
|
||||
* ② 새로 가져오기 · 계산 한 줄 · 입력 목록(한 번만 받음)
|
||||
* ③ 카드 셈: 범위 · 고르기 원래 모양 · 자리 「선택」 만 묶음
|
||||
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } };
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.endsWith(".css")) return {};
|
||||
if (request.startsWith(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`);
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const reply = (status, body) => ({
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
/** 가짜 서버 — 복사본 폴더마다 부분 파일 · 판(글자) */
|
||||
const state = { readOnly: false, calls: [], folders: {}, inputsHits: 0 };
|
||||
const versionOf = (content) => `v${JSON.stringify(content ?? null).length}`;
|
||||
|
||||
const INPUTS = {
|
||||
일위대가: "UA000004",
|
||||
이름: "기슭막이",
|
||||
로직: [
|
||||
{
|
||||
로직: "GA1",
|
||||
이름: "로직 A",
|
||||
결과단위: "원/m",
|
||||
입력: [
|
||||
{ 이름: "지역", 고르기: ["전국평균", "서울"], 자동값: "전국평균" },
|
||||
{ 이름: "소량감률", 단위: "%", 범위: [0, 50], 자동값: 0 },
|
||||
],
|
||||
},
|
||||
{ 로직: "GA2", 이름: "로직 B", 입력: [{ 이름: "지역", 고르기: [15, 16] }] },
|
||||
],
|
||||
빠진로직: ["GX"],
|
||||
};
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const method = init.method ?? "GET";
|
||||
const body = init.body ? JSON.parse(init.body) : undefined;
|
||||
state.calls.push({ method, url, body });
|
||||
if (/^\/api\/m02\/layers\/\w+\/items\/[^/]+\/inputs$/.test(url)) {
|
||||
state.inputsHits += 1;
|
||||
return reply(200, INPUTS);
|
||||
}
|
||||
const m = /^\/api\/projects\/([^/]+)\/items(?:\/([^/]+)(?:\/([^/]+))?)?$/.exec(url);
|
||||
if (!m) return reply(404, { detail: "Not Found" });
|
||||
const [, , folderEnc, partEnc] = m;
|
||||
if (!folderEnc && method === "GET")
|
||||
return reply(200, { 항목: Object.values(state.folders).map((x) => x.info) });
|
||||
if (!folderEnc && method === "POST") {
|
||||
const folder = `${body.layer}_${(body.owner ?? "none").replace(/\//g, "-")}_${body.key}`;
|
||||
state.folders[folder] = {
|
||||
info: { 폴더: folder, 원본: body, 마스터바뀜: false, 가져온날: "2026-09-30T01:00:00" },
|
||||
parts: { 면적: { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] }, 입력값: null },
|
||||
};
|
||||
return reply(200, { 폴더: folder, 만듦: true, 가져온날: "2026-09-30T01:00:00" });
|
||||
}
|
||||
const item = state.folders[decodeURIComponent(folderEnc)];
|
||||
if (!item) return reply(404, { detail: "복사본이 없음" });
|
||||
const part = partEnc && decodeURIComponent(partEnc);
|
||||
if (part === "refresh") {
|
||||
item.parts.면적 = { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] };
|
||||
return reply(200, { 폴더: item.info.폴더, 가져온날: "2026-09-30T02:00:00", 원본판: "새판" });
|
||||
}
|
||||
if (part === "calc")
|
||||
return reply(200, {
|
||||
줄: [],
|
||||
계: 12345.6,
|
||||
빠진입력: [
|
||||
{ 로직: "GA1", 입력: "지역" },
|
||||
{ 로직: "GA2", 입력: "지역" },
|
||||
],
|
||||
멈춤: ["GA1"],
|
||||
});
|
||||
if (method === "GET") {
|
||||
const content = item.parts[part] ?? null;
|
||||
return reply(200, { 종류: part, 판: versionOf(content), 내용: content });
|
||||
}
|
||||
if (method === "PUT") {
|
||||
if (state.readOnly) return reply(403, { detail: "프로젝트 편집 권한이 없음" });
|
||||
const current = versionOf(item.parts[part] ?? null);
|
||||
if (body.판 !== current)
|
||||
return reply(409, { detail: { message: "다른 곳에서 먼저 고쳤습니다", 판: current } });
|
||||
item.parts[part] = body.내용;
|
||||
return reply(200, { 종류: part, 판: versionOf(body.내용) });
|
||||
}
|
||||
return reply(404, { detail: "Not Found" });
|
||||
};
|
||||
|
||||
const project = loadTs(path.join(ROOT, "ui_template", "ui_template_item_picker_project.ts"));
|
||||
const model = loadTs(path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts"));
|
||||
|
||||
const row = (over = {}) => ({ key: "SS000001", 열: "rv", layer: "system", owner: null, ...over });
|
||||
|
||||
async function main() {
|
||||
const out = {};
|
||||
out.folders = [
|
||||
project.folderOf(row()),
|
||||
project.folderOf(row({ layer: "company", owner: "3" })),
|
||||
project.folderOf(row({ layer: "personal", owner: "3/12", key: "SS000002" })),
|
||||
];
|
||||
|
||||
const items = project.projectItems("P1");
|
||||
out.listEmpty = await items.list();
|
||||
const info = await items.importItem(row({ layer: "personal", owner: "1/3" }));
|
||||
out.info = info;
|
||||
out.importCall = state.calls.find((c) => c.method === "POST");
|
||||
out.list = (await items.list()).map((x) => x.폴더);
|
||||
|
||||
out.read = await items.read(info.폴더, "면적");
|
||||
out.readNullContent = await items.read(info.폴더, "입력값"); // 내용 null = 아직 없음
|
||||
out.readMissing = await items.read("personal_9-9_SS000009", "면적"); // 없는 폴더 404 = null
|
||||
await items.write(info.폴더, "면적", { 시트: [{ id: "s", 칸: { H10: { 값: 12345 } } }] });
|
||||
out.afterWrite = state.folders[info.폴더].parts.면적;
|
||||
// 판: 읽은 판으로 씀 · 쓴 뒤 새 판을 들고 있어 이어서 또 쓸 수 있음
|
||||
await items.write(info.폴더, "면적", { 시트: [{ id: "s", 칸: { H10: { 값: 999 } } }] });
|
||||
out.secondWrite = state.folders[info.폴더].parts.면적.시트[0].칸.H10;
|
||||
// 읽은 적 없는 부분도 쓰기 전에 판을 먼저 앎
|
||||
const getsBefore = state.calls.filter((c) => c.method === "GET").length;
|
||||
await items.write(info.폴더, "고른자재", { GA1: { 0: "MT000001" } });
|
||||
out.writeWithoutReadGets = state.calls.filter((c) => c.method === "GET").length - getsBefore;
|
||||
await items.write(info.폴더, "입력값", { GA1: { 지역: { 값: "서울", 확인: true } } });
|
||||
out.valuesSaved = state.folders[info.폴더].parts.입력값;
|
||||
|
||||
// 다른 곳에서 먼저 고침(판이 달라짐) → 409 까닭(`message`) 그대로
|
||||
state.folders[info.폴더].parts.면적 = { 시트: [], 남이: true };
|
||||
await items.write(info.폴더, "면적", {}).then(
|
||||
() => (out.stale = "성공"),
|
||||
(error) => (out.stale = error.message),
|
||||
);
|
||||
|
||||
state.readOnly = true;
|
||||
await items.write(info.폴더, "입력값", {}).then(
|
||||
() => (out.readOnly = "성공"),
|
||||
(error) => (out.readOnly = error.message),
|
||||
);
|
||||
state.readOnly = false;
|
||||
|
||||
const fresh = await items.refresh(info);
|
||||
out.refresh = fresh;
|
||||
out.refreshCall = state.calls
|
||||
.filter((c) => /refresh$/.test(c.url))
|
||||
.map((c) => `${c.method} ${c.url}`);
|
||||
out.calc = await items.calc(info.폴더);
|
||||
out.calcMissing = await items.calc("없는폴더");
|
||||
|
||||
// 입력 목록 — 같은 항목은 한 번만 받음
|
||||
const first = await project.loadInputs(row());
|
||||
await project.loadInputs(row());
|
||||
out.inputsHits = state.inputsHits;
|
||||
out.inputsLogics = first.로직.map((l) => [l.로직, l.입력.map((i) => i.이름)]);
|
||||
|
||||
// 카드 셈
|
||||
out.model = {
|
||||
range: [
|
||||
model.rangeOf({ 이름: "a", 범위: [0, 50] }),
|
||||
model.rangeOf({ 이름: "b", 범위: { 최소: 1, 최대: 2 } }),
|
||||
model.rangeOf({ 이름: "c" }),
|
||||
],
|
||||
choices: model.choicesOf({ 이름: "d", 고르기: [15, "16", { 값: 18, 이름: "열여덟" }] }),
|
||||
groups: model
|
||||
.pickedGroups(first, {
|
||||
판: "x",
|
||||
줄: [
|
||||
{ 로직: "GA1", 입력: "지역", 자리: "선택" },
|
||||
{ 로직: "GA1", 입력: "소량감률", 자리: "면적" },
|
||||
{ 로직: "GA2", 입력: "지역", 자리: "선택" },
|
||||
],
|
||||
})
|
||||
.map((g) => [g.logic.로직, g.inputs.map((i) => i.이름)]),
|
||||
noLink: model.pickedGroups(first, null).length,
|
||||
pills: model.pillsOf(
|
||||
first,
|
||||
{ 판: "x", 줄: [{ 로직: "GA1", 입력: "소량감률", 자리: "면적" }] },
|
||||
"면적",
|
||||
),
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
||||
main().then(
|
||||
(out) => process.stdout.write(JSON.stringify(out)),
|
||||
(error) => {
|
||||
process.stderr.write(String(error && error.stack ? error.stack : error));
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,56 @@
|
||||
/* M02 「선택 입력」 카드 셈(`M02_MasterTemplete_Inputs_Model.ts`) 단위검증 헬퍼 —
|
||||
* 그 자리에서 트랜스파일해 Node 로 돌리고 값을 JSON 으로 낸다. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const file = path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts");
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, require);
|
||||
const m = box.exports;
|
||||
|
||||
// 같은 이름(높이)의 입력이 두 로직에 있음
|
||||
const list = {
|
||||
일위대가: "UA000001",
|
||||
로직: [
|
||||
{
|
||||
로직: "GC1",
|
||||
이름: "돌쌓기",
|
||||
입력: [
|
||||
{ 이름: "높이", 단위: "m", 범위: [0.5, 5], 자동값: 1.2 },
|
||||
{ 이름: "종류", 고르기: ["막돌", "견치돌"] },
|
||||
],
|
||||
},
|
||||
{ 로직: "GC2", 이름: "타설", 입력: [{ 이름: "높이", 범위: { 최소: 0, 최대: 3 } }, { 이름: "배합" }] },
|
||||
],
|
||||
빠진로직: [],
|
||||
};
|
||||
const seats = m.seatsOf({
|
||||
판: "v1",
|
||||
줄: [
|
||||
{ 로직: "GC1", 입력: "높이", 자리: "면적", 알약: "입력|GC1|높이" },
|
||||
{ 로직: "GC2", 입력: "높이", 자리: "면적" },
|
||||
{ 로직: "GC2", 입력: "배합", 자리: "재료" },
|
||||
{ 로직: "GC1", 입력: "종류", 자리: null },
|
||||
{ 로직: "GC9", 입력: "없는것", 자리: "선택" },
|
||||
],
|
||||
});
|
||||
const links = { 판: "v1", 줄: [...[...seats].map(([k, 자리]) => ({ 로직: k.split("|")[0], 입력: k.split("|")[1], 자리 }))] };
|
||||
const pick = new Map(seats);
|
||||
pick.set(m.inputKey("GC1", "종류"), "선택");
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
keys: [m.inputKey("GC1", "높이"), m.pillKey("GC2", "높이")],
|
||||
seatKeys: [...seats.keys()],
|
||||
count: m.countOf(list),
|
||||
area: m.pillsOf(list, links, "면적").map((p) => [p.키, p.이름, p.단위 ?? null, p.고정]),
|
||||
material: m.pillsOf(list, links, "재료").map((p) => [p.키, p.이름]),
|
||||
lines: m.linesOf(list, pick),
|
||||
none: m.linesOf(list, new Map()),
|
||||
hints: list.로직.flatMap((l) => l.입력).map((i) => [m.valueHint(i), m.autoText(i)]),
|
||||
seatOptions: m.SEAT_OPTIONS.map((o) => o.label),
|
||||
}),
|
||||
);
|
||||
@@ -113,13 +113,13 @@ def test_links_follow_rows_cols(r):
|
||||
assert r["links"] == ["L:B7,W:D5", "L:B7", "L:B7,W:D5", "L:B5,W:D3", "D1"]
|
||||
|
||||
|
||||
def test_links_bar_sits_under_sheet():
|
||||
# 알약 막대 = 시트 아래 · 상태줄 위 · 자재 고르기 창은 막대 위로 엶
|
||||
def test_links_bar_sits_under_statusbar():
|
||||
# 알약 막대 = 시트 · 합계 줄(상태줄) 아래 맨 끝 · 자재 고르기 창은 막대 위로 엶
|
||||
with open(
|
||||
os.path.join(HERE, "../../../A00_Common/spreadsheet/spreadsheet_links.ts"), encoding="utf-8"
|
||||
) as f:
|
||||
src = f.read()
|
||||
assert 'insertBefore(bar, ctx.root.querySelector(":scope > .ss-statusbar"))' in src
|
||||
assert 'ctx.root.querySelector(":scope > .ss-statusbar")?.after(bar)' in src
|
||||
assert "pick.style.bottom = `${o.bottom - bar.getBoundingClientRect().top + 4}px`" in src
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
"""프로젝트 집계 항목 복사본 완전체 (PLAN 25-2b · 25-2e · 임시 저장소 · 임시 마스터 사본).
|
||||
|
||||
- 지정 → [저장] → 폴더에 새 파일(입력연결 · 입력값 · 고른자재 · 단가) · manifest 가져온날 · 원본판
|
||||
- 단가 묶음이 닫혀 있음 — 조합 37개 · 로직 170개를 마스터와 복사본으로 돌려 같은 값
|
||||
- 마스터 단가 한 줄을 바꿔도 프로젝트 계산 그대로 · 목록에 「마스터 바뀜」 · 새로 가져오기 뒤 바뀜
|
||||
(입력값 · 고른자재는 같은 키면 살림)
|
||||
- 쓰기 뒤 다시 읽기 같음 · 판 다르면 409 · 모양 틀리면 400 · 고친 복사본은 안 지움
|
||||
- 옛 복사본은 읽을 때 채움 · 미리 가져오기 · 남의 프로젝트 403
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from config import config_system
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Item_Copy as copies
|
||||
from M02_MasterTemplete import M02_Item_Prices as prices
|
||||
from M02_MasterTemplete import M02_Item_Snapshot as snap
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layer_routes
|
||||
from M02_MasterTemplete import M02_Project_Items_Router as items_router
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
REAL = config_system.PROJECT_ROOT / "resources/master_template"
|
||||
MASTER = config_system.PROJECT_ROOT / "resources/master_data"
|
||||
PIPE = {"layer": "system", "owner": None, "key": "SS000011", "name": "관공 관매설"}
|
||||
FOLDER = "system_none_SS000011"
|
||||
LABOR_PRICE = "LB000002" # 보통인부 — 되메우기 · 파형강관 부설 호표가 씀
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def master_copy(tmp_path_factory) -> Path:
|
||||
target = tmp_path_factory.mktemp("master_data")
|
||||
for path in MASTER.glob("*.json"):
|
||||
shutil.copy2(path, target / path.name)
|
||||
return target
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def world(tmp_path: Path, monkeypatch, master_copy: Path) -> dict[str, Any]:
|
||||
system = tmp_path / "master"
|
||||
system.mkdir()
|
||||
for name in (layers.COMBO_FILE, "자재조합.json"):
|
||||
shutil.copy2(REAL / name, system / name)
|
||||
for kind in ("table", "structure", "area", "material"):
|
||||
shutil.copytree(REAL / kind, system / kind)
|
||||
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
|
||||
monkeypatch.setattr(m01, "FOLDER", master_copy)
|
||||
storage = tmp_path / "storage"
|
||||
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
|
||||
root = storage / "3" / "12" / "proj"
|
||||
(root / "B05_Profile/route").mkdir(parents=True)
|
||||
(root / "B04_PreProcess/drainage/edits").mkdir(parents=True)
|
||||
return {"system": system, "root": root, "master": master_copy}
|
||||
|
||||
|
||||
def _save(root: Path, pipes: list[dict]) -> dict[str, list[str]]:
|
||||
(root / "B05_Profile/route/structures.json").write_text(
|
||||
json.dumps({"revision": 1, "structures": []}), encoding="utf-8"
|
||||
)
|
||||
(root / "B04_PreProcess/drainage/edits/pipe_points.json").write_text(
|
||||
json.dumps({"points": pipes}, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
return snap.sync_snapshots(root)
|
||||
|
||||
|
||||
def _pipe(summary: dict | None = PIPE) -> dict[str, Any]:
|
||||
return {"chainage_m": 40.0, "source": "user", "summary_item": summary}
|
||||
|
||||
|
||||
def _read(path: Path) -> Any:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _auto_values(root: Path) -> dict[str, Any]:
|
||||
"""담은 로직마다 자동값을 확인한 값으로 — 사용자가 다 채운 것과 같게."""
|
||||
labor = _read(root / "templates/items" / FOLDER / "일위대가.json")
|
||||
_, whole = m01.loaded()
|
||||
out: dict[str, Any] = {}
|
||||
for row in labor["로직"]:
|
||||
values = m01.mau.values(whole, whole.logic(row["키"]))
|
||||
out[row["키"]] = {k: {"값": prices.to_json(v), "확인": True} for k, v in values.items()}
|
||||
return out
|
||||
|
||||
|
||||
def _fill_values(root: Path) -> dict[str, Any]:
|
||||
current = copies.read_file(root, FOLDER, "입력값")
|
||||
values = _auto_values(root)
|
||||
copies.write_file(root, FOLDER, "입력값", current["판"], values)
|
||||
return values
|
||||
|
||||
|
||||
# ── 새 파일 ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_지정하면_새_파일까지_복사(world):
|
||||
root = world["root"]
|
||||
assert _save(root, [_pipe()])["만듦"] == [FOLDER]
|
||||
folder = root / "templates/items" / FOLDER
|
||||
assert _read(folder / "입력연결.json") == {"판": None, "줄": []} # 층 연결이 아직 없음
|
||||
assert _read(folder / "입력값.json") == {} and _read(folder / "고른자재.json") == {}
|
||||
price = _read(folder / "단가.json")
|
||||
rows = {k: len(v.get("줄") or v.get("표")) for k, v in price.items()}
|
||||
assert rows["인력.json"] >= 2 and rows["재료_자재품목.json"] > 0 and "_빠진" not in price
|
||||
keys = {row["키"] for data in price.values() for row in data.get("줄") or data.get("표")}
|
||||
assert {"GF000176", "GC000088", "GF000113", "GC000267", LABOR_PRICE} <= keys
|
||||
manifest = _read(folder / "manifest.json")
|
||||
assert manifest["가져온날"] and len(manifest["원본판"]) == 16
|
||||
assert copies.list_items(root) == [
|
||||
{
|
||||
"폴더": FOLDER,
|
||||
"이름": "관공 관매설",
|
||||
"원본": PIPE,
|
||||
"원본판": manifest["원본판"],
|
||||
"가져온날": manifest["가져온날"],
|
||||
"마스터바뀜": False,
|
||||
"원본없음": False,
|
||||
"구조물": ["pipe@40.0"],
|
||||
"고침": False,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_층_입력연결을_복사(world):
|
||||
root, system = world["root"], world["system"]
|
||||
(system / "inputs").mkdir()
|
||||
link = {"판": "a1", "줄": [{"로직": "GF000176", "입력": "관경", "자리": "면적", "알약": "x"}]}
|
||||
column = next(
|
||||
r["열"] for r in _read(system / layers.COMBO_FILE)["줄"] if r["키"] == PIPE["key"]
|
||||
)
|
||||
(system / "inputs" / f"{column}.json").write_text(json.dumps(link, ensure_ascii=False), "utf-8")
|
||||
_save(root, [_pipe()])
|
||||
assert copies.read_file(root, FOLDER, "입력연결")["내용"] == link
|
||||
|
||||
|
||||
def test_단가_묶음은_닫혀_있음(world):
|
||||
"""조합마다 복사본 단가 묶음만으로 돌린 값 = 마스터로 돌린 값(자동값 · 고르기 끝값)."""
|
||||
files, whole = prices.master()
|
||||
combos = [r for d in files.values() if d.get("그룹") == "일위대가조합" for r in d["줄"]]
|
||||
runs = 0
|
||||
for ua in combos:
|
||||
keys = prices.mcb.logic_keys(ua)
|
||||
snapshot = prices.load_prices(prices.to_json(prices.collect(keys)))
|
||||
for key in keys:
|
||||
try:
|
||||
row = whole.logic(key)
|
||||
except prices.mf.FormulaError:
|
||||
continue
|
||||
for pick in (0, -1):
|
||||
values = m01.mau.values(whole, row)
|
||||
for spec in row.get("입력", []):
|
||||
if spec.get("고르기"):
|
||||
values[spec["이름"]] = spec["고르기"][pick]
|
||||
assert _run(whole, key, values) == _run(snapshot, key, values), (ua["키"], key)
|
||||
runs += 1
|
||||
assert len(combos) >= 30 and runs >= 300
|
||||
|
||||
|
||||
def _run(master: Any, key: str, values: dict) -> str:
|
||||
try:
|
||||
got = prices.mf.run(master, key, dict(values))
|
||||
except Exception as error: # 까닭도 같아야 함
|
||||
return f"멈춤 {error}"
|
||||
return str(got.get("계", got.get("결과")))
|
||||
|
||||
|
||||
# ── 계산 · 마스터 바뀜 · 새로 가져오기 ───────────────────
|
||||
|
||||
|
||||
def test_입력이_없으면_빠진_입력_목록(world):
|
||||
root = world["root"]
|
||||
_save(root, [_pipe()])
|
||||
got = copies.calc(root, FOLDER)
|
||||
assert got["계"] == 0 and len(got["멈춤"]) == 3
|
||||
assert {"로직": "GF000176", "입력": "관경"} in got["빠진입력"]
|
||||
|
||||
|
||||
def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바뀜(world):
|
||||
root, master_dir = world["root"], world["master"]
|
||||
_save(root, [_pipe()])
|
||||
values = _fill_values(root)
|
||||
before = copies.calc(root, FOLDER)
|
||||
assert before["빠진입력"] == [] and before["멈춤"] == [] and before["계"] > 0
|
||||
files, whole = m01.loaded()
|
||||
ua = next(
|
||||
r
|
||||
for d in files.values()
|
||||
if d.get("그룹") == "일위대가조합"
|
||||
for r in d["줄"]
|
||||
if r["키"] == "UA000036"
|
||||
)
|
||||
given = {k: {n: v["값"] for n, v in one.items()} for k, one in values.items()}
|
||||
expected = prices.to_json(prices.mcb.run(whole, ua, given))
|
||||
assert before["계"] == pytest.approx(expected["계"]) and before["노무비"] > 0
|
||||
labor_file = master_dir / "인력.json"
|
||||
original = labor_file.read_bytes()
|
||||
try:
|
||||
data = _read(labor_file)
|
||||
row = next(r for r in data["줄"] if r["키"] == LABOR_PRICE)
|
||||
row["값"] = row["값"] * 2
|
||||
labor_file.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
assert copies.calc(root, FOLDER) == before # 복사본만 씀
|
||||
assert copies.list_items(root)[0]["마스터바뀜"] is True
|
||||
copies.refresh(root, FOLDER)
|
||||
after = copies.calc(root, FOLDER)
|
||||
assert after["노무비"] > before["노무비"] and after["재료비"] == before["재료비"]
|
||||
assert copies.read_file(root, FOLDER, "입력값")["내용"] == values # 같은 키 살림
|
||||
assert copies.list_items(root)[0]["마스터바뀜"] is False
|
||||
finally:
|
||||
labor_file.write_bytes(original)
|
||||
|
||||
|
||||
def test_새로_가져오면_없는_키는_버림(world):
|
||||
root = world["root"]
|
||||
_save(root, [_pipe()])
|
||||
folder = root / "templates/items" / FOLDER
|
||||
(folder / "입력값.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"GF000176": {"관경": {"값": 600, "확인": True}, "옛입력": {"값": 1}},
|
||||
"GX999999": {"a": {"값": 1}},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
"utf-8",
|
||||
)
|
||||
(folder / "고른자재.json").write_text(
|
||||
json.dumps({"GF000176": {"0": "MT000001", "99": "MT000002"}}), "utf-8"
|
||||
)
|
||||
copies.refresh(root, FOLDER)
|
||||
assert _read(folder / "입력값.json") == {"GF000176": {"관경": {"값": 600, "확인": True}}}
|
||||
assert _read(folder / "고른자재.json") == {"GF000176": {"0": "MT000001"}}
|
||||
|
||||
|
||||
# ── 쓰기 · 판 · 모양 · 지우기 규칙 ─────────────────────────
|
||||
|
||||
|
||||
def test_쓰기_뒤_다시_읽기_같음_판_다르면_409(world):
|
||||
root = world["root"]
|
||||
_save(root, [_pipe()])
|
||||
first = copies.read_file(root, FOLDER, "고른자재")
|
||||
wrote = copies.write_file(
|
||||
root, FOLDER, "고른자재", first["판"], {"GF000176": {"0": "MT000010"}}
|
||||
)
|
||||
again = copies.read_file(root, FOLDER, "고른자재")
|
||||
assert again["내용"] == {"GF000176": {"0": "MT000010"}} and again["판"] == wrote["판"]
|
||||
with pytest.raises(copies.Stale):
|
||||
copies.write_file(root, FOLDER, "고른자재", first["판"], {})
|
||||
for kind, bad in (
|
||||
("입력값", {"GF000176": {"관경": 600}}),
|
||||
("입력값", {"GF000176": {"관경": {"값": 1, "확인": "네"}}}),
|
||||
("고른자재", {"GF000176": {"첫": "MT000001"}}),
|
||||
("입력연결", {"줄": [{"로직": "GF000176", "입력": "관경", "자리": "어딘가"}]}),
|
||||
("도면", {}),
|
||||
):
|
||||
with pytest.raises(ValueError):
|
||||
copies.write_file(root, FOLDER, kind, again["판"], bad)
|
||||
with pytest.raises(LookupError):
|
||||
copies.read_file(root, "../../etc", "입력값")
|
||||
|
||||
|
||||
def test_고친_복사본은_구조물에서_빠져도_안_지움(world):
|
||||
root = world["root"]
|
||||
_save(root, [_pipe()])
|
||||
assert _save(root, [_pipe(None)])["지움"] == [FOLDER] # 안 고쳤으면 지움(지금 규칙)
|
||||
_save(root, [_pipe()])
|
||||
current = copies.read_file(root, FOLDER, "입력값")
|
||||
copies.write_file(root, FOLDER, "입력값", current["판"], {"GF000176": {"관경": {"값": 600}}})
|
||||
assert _save(root, [_pipe(None)])["지움"] == []
|
||||
assert copies.list_items(root)[0]["구조물"] == [] and copies.list_items(root)[0]["고침"]
|
||||
|
||||
|
||||
def test_미리_가져오기(world):
|
||||
root = world["root"]
|
||||
_save(root, [])
|
||||
made = copies.prefetch(root, PIPE)
|
||||
assert made["폴더"] == FOLDER and made["만듦"] is True
|
||||
assert copies.prefetch(root, PIPE)["만듦"] is False # 있으면 그대로
|
||||
assert copies.list_items(root)[0]["구조물"] == []
|
||||
assert _save(root, [])["지움"] == [FOLDER] # 안 고쳤으면 [저장] 때 지움
|
||||
|
||||
|
||||
def test_옛_복사본은_읽을_때_채움(world):
|
||||
root = world["root"]
|
||||
_save(root, [_pipe()])
|
||||
folder = root / "templates/items" / FOLDER
|
||||
for name in ("입력연결.json", "입력값.json", "고른자재.json", "단가.json"):
|
||||
(folder / name).unlink()
|
||||
manifest = _read(folder / "manifest.json")
|
||||
for key in ("원본판", "가져온날"):
|
||||
manifest.pop(key)
|
||||
(folder / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False), "utf-8")
|
||||
assert copies.read_file(root, FOLDER, "입력값")["내용"] == {}
|
||||
filled = _read(folder / "manifest.json")
|
||||
assert filled["가져온날"] == filled["적용일"] and filled["옮겨받음"]
|
||||
assert (folder / "단가.json").is_file() and copies.calc(root, FOLDER)["멈춤"]
|
||||
assert copies.list_items(root)[0]["마스터바뀜"] is False # 원본이 안 바뀐 옛 복사본
|
||||
|
||||
|
||||
# ── 길 · 권한 ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_길_권한_남의_프로젝트는_403(world, monkeypatch):
|
||||
root = world["root"]
|
||||
_save(root, [_pipe()])
|
||||
|
||||
async def fake_row(project_id: str) -> dict[str, Any]:
|
||||
return {"company_id": 3, "storage_path": str(root)}
|
||||
|
||||
monkeypatch.setattr(layer_routes, "_project_row", fake_row)
|
||||
monkeypatch.setattr(layer_routes, "resolve_stored_project_path", lambda path: path)
|
||||
app = FastAPI()
|
||||
app.include_router(items_router.router)
|
||||
session = {"role": "USER", "company_id": 3}
|
||||
app.dependency_overrides[verify_session] = lambda: session
|
||||
client = TestClient(app)
|
||||
base = "/api/projects/p1/items"
|
||||
assert client.get(base).json()["항목"][0]["폴더"] == FOLDER
|
||||
got = client.get(f"{base}/{FOLDER}/입력값").json()
|
||||
put = client.put(f"{base}/{FOLDER}/입력값", json={"판": got["판"], "내용": {}})
|
||||
assert put.status_code == 200
|
||||
stale = client.put(f"{base}/{FOLDER}/입력값", json={"판": got["판"] + "x", "내용": {}})
|
||||
assert stale.status_code == 409 and stale.json()["detail"]["판"] == put.json()["판"]
|
||||
assert client.get(f"{base}/{FOLDER}/모름").status_code == 422
|
||||
assert client.post(f"{base}/{FOLDER}/calc").json()["멈춤"]
|
||||
assert client.post(base, json={"summary_item": {"layer": "x"}}).status_code == 400
|
||||
made = client.post(base, json={"layer": "system", "owner": None, "key": "SS000022"}).json()
|
||||
assert made["폴더"] == "system_none_SS000022" and made["만듦"] is True
|
||||
names = {i["폴더"]: i["이름"] for i in client.get(base).json()["항목"]}
|
||||
assert names["system_none_SS000022"] == "돌쌓기 (찰·메마다) H=(높이마다)" # 층 SS 줄 이름
|
||||
assert client.post(base, json={"layer": "system", "key": "SS999999"}).status_code == 404
|
||||
session["company_id"] = 4
|
||||
assert client.get(base).status_code == 403
|
||||
assert client.post(f"{base}/{FOLDER}/refresh").status_code == 403
|
||||
@@ -0,0 +1,166 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""항목 고르기 모달 상세(계획서 25-2d) — 복사본 창구 · 선택 입력 카드 셈 · 한 벌 지킴.
|
||||
|
||||
① 프로젝트 복사본 창구(`ui_template_item_picker_project.ts`)를 Node 로 돌려(sub6 길 모양의 가짜 서버 fetch 대역)
|
||||
본다: 목록 `{항목}` · 가져오기 POST · 읽기 `{종류, 판, 내용}` · 고침 PUT `{판, 내용}`(판 · 409 · 403 까닭 그대로) ·
|
||||
새로 가져오기 · 계산 한 줄 · 입력 목록 한 번만 받음 · 카드 셈(범위 · 고르기 원래 모양 · 자리 「선택」).
|
||||
② 모달이 카드 · 편집기를 M02 와 같은 한 벌로 쓰는지, 원본 층에 쓰는 길을 안 부르는지 소스로 지킨다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from common_util.common_util_summary_item import snapshot_folder_name
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = Path(HERE).parents[1]
|
||||
UI = ROOT / "ui_template"
|
||||
M02 = ROOT / "M02_MasterTemplete"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_item_picker_project.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_폴더_이름은_서버_snapshot_folder_name_과_같은_글(out):
|
||||
rows = [
|
||||
{"layer": "system", "owner": None, "key": "SS000001"},
|
||||
{"layer": "company", "owner": "3", "key": "SS000001"},
|
||||
{"layer": "personal", "owner": "3/12", "key": "SS000002"},
|
||||
]
|
||||
assert out["folders"] == [snapshot_folder_name(row) for row in rows]
|
||||
assert out["info"]["폴더"] == snapshot_folder_name(
|
||||
{"layer": "personal", "owner": "1/3", "key": "SS000001"}
|
||||
)
|
||||
|
||||
|
||||
def test_가져오기는_POST_원본_참조_목록은_항목(out):
|
||||
assert out["listEmpty"] == []
|
||||
assert out["importCall"] == {
|
||||
"method": "POST",
|
||||
"url": "/api/projects/P1/items",
|
||||
"body": {"layer": "personal", "owner": "1/3", "key": "SS000001"},
|
||||
}
|
||||
assert out["list"] == [out["info"]["폴더"]]
|
||||
|
||||
|
||||
def test_읽기는_내용만_돌려주고_없으면_null(out):
|
||||
assert out["read"]["시트"][0]["칸"] == {"A1": {"값": 1}}
|
||||
assert out["readNullContent"] is None # 파일이 아직 없음(내용 null)
|
||||
assert out["readMissing"] is None # 없는 폴더 404
|
||||
|
||||
|
||||
def test_고침은_읽은_판으로_쓰고_쓴_뒤_새_판을_들고_있음(out):
|
||||
assert out["afterWrite"]["시트"][0]["칸"] == {"H10": {"값": 12345}}
|
||||
assert out["secondWrite"] == {"값": 999} # 이어서 또 써도 판이 맞음
|
||||
assert out["writeWithoutReadGets"] == 1 # 읽은 적 없는 부분은 쓰기 전에 판을 한 번 읽음
|
||||
assert out["valuesSaved"]["GA1"]["지역"] == {"값": "서울", "확인": True}
|
||||
|
||||
|
||||
def test_판이_달라지면_409_까닭_권한_없으면_서버_까닭_그대로(out):
|
||||
assert out["stale"] == "다른 곳에서 먼저 고쳤습니다"
|
||||
assert out["readOnly"] == "프로젝트 편집 권한이 없음"
|
||||
|
||||
|
||||
def test_새로_가져오기와_계산_한_줄(out):
|
||||
assert out["refreshCall"][0].startswith("POST /api/projects/P1/items/")
|
||||
assert out["refreshCall"][0].endswith("/refresh")
|
||||
assert out["refresh"]["마스터바뀜"] is False and out["refresh"]["원본판"] == "새판"
|
||||
assert out["calc"] == {"total": 12345.6, "gaps": 2, "stuck": 1}
|
||||
assert out["calcMissing"] is None # 없는 복사본 = 계산 줄 없음
|
||||
|
||||
|
||||
def test_입력_목록은_한_번만_받고_로직마다_갈라_둠(out):
|
||||
assert out["inputsHits"] == 1
|
||||
assert out["inputsLogics"] == [["GA1", ["지역", "소량감률"]], ["GA2", ["지역"]]]
|
||||
|
||||
|
||||
def test_선택_입력_카드_셈(out):
|
||||
model = out["model"]
|
||||
assert model["range"] == [{"min": 0, "max": 50}, {"min": 1, "max": 2}, None]
|
||||
assert [c["raw"] for c in model["choices"]] == [15, "16", 18] # 저장은 원래 모양
|
||||
assert model["groups"] == [
|
||||
["GA1", ["지역"]],
|
||||
["GA2", ["지역"]],
|
||||
] # 같은 이름도 로직마다 · 「선택」 만
|
||||
assert model["noLink"] == 0 # 연결이 없으면 아무것도 안 보임
|
||||
assert model["pills"][0]["키"] == "입력|GA1|소량감률" # 25-1b 알약 키
|
||||
|
||||
|
||||
def test_모달은_M02_와_같은_카드_한_벌을_씀():
|
||||
cards = (M02 / "M02_MasterTemplete_ItemCards.ts").read_text(encoding="utf-8")
|
||||
structure = (M02 / "M02_MasterTemplete_Structure.ts").read_text(encoding="utf-8")
|
||||
detail = (UI / "ui_template_item_picker_detail.ts").read_text(encoding="utf-8")
|
||||
assert "createItemCards(" in structure and "mountDrawingTemplate(" not in structure
|
||||
assert 'import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards")' in detail
|
||||
assert "createItemCards(" in detail
|
||||
# 카드를 모달에서 또 그리지 않음 — 미리보기 부품은 카드 파일 한 곳
|
||||
for name in ("ui_template_item_picker_detail.ts", "ui_template_item_picker.ts"):
|
||||
text = (UI / name).read_text(encoding="utf-8")
|
||||
assert "mountDrawingTemplate(" not in text and "createSpreadsheet(" not in text, name
|
||||
assert cards.count("mountDrawingTemplate(") == 1
|
||||
|
||||
|
||||
def test_선택_입력_카드는_sub2_파일_한_벌(out):
|
||||
"""두 벌 금지 — 모달의 값 받기 카드는 `M02_MasterTemplete_Inputs.ts`(M02 카드와 같은 파일)."""
|
||||
assert not (M02 / "M02_MasterTemplete_InputsCard.ts").exists()
|
||||
inputs = (M02 / "M02_MasterTemplete_Inputs.ts").read_text(encoding="utf-8")
|
||||
assert "export function createInputsCard(" in inputs # M02 자리 고르기(link)
|
||||
assert "export function createInputsValueCard(" in inputs # 모달 값 받기(view · value)
|
||||
for name in ("ui_template_item_picker_detail.ts", "ui_template_item_picker_edit.ts"):
|
||||
text = (UI / name).read_text(encoding="utf-8")
|
||||
assert "M02_MasterTemplete_Inputs" in text and 'InputsCard"' not in text, name
|
||||
|
||||
|
||||
def test_모달은_원본_층에_쓰는_길을_안_부름():
|
||||
"""마스터 · 회사 · 개인 원본은 안 바뀜 — 저장 길은 프로젝트 복사본(`items.write`)뿐."""
|
||||
for name in (
|
||||
"ui_template_item_picker_detail.ts",
|
||||
"ui_template_item_picker_edit.ts",
|
||||
"ui_template_item_picker_project.ts",
|
||||
):
|
||||
text = (UI / name).read_text(encoding="utf-8")
|
||||
for forbidden in (
|
||||
"saveTemplate(",
|
||||
"deleteTemplate(",
|
||||
"deleteLayerTemplate(",
|
||||
"copyToPersonal(",
|
||||
"saveInputLinks(",
|
||||
):
|
||||
assert forbidden not in text, f"{name} 이 {forbidden} 를 부름"
|
||||
edit = (UI / "ui_template_item_picker_edit.ts").read_text(encoding="utf-8")
|
||||
assert edit.count("items.write(") >= 3 # 도면 · 시트 · 입력값 모두 복사본으로
|
||||
|
||||
|
||||
def test_B05_구조물_폼이_projectId_를_모달에_넘김():
|
||||
panel = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert re.search(r"projectId: sectionOptions\.projectId \?\? null", panel)
|
||||
shared = (ROOT / "A00_Common" / "design" / "design_shared.ts").read_text(encoding="utf-8")
|
||||
assert "projectId: shared.projectId" in shared
|
||||
|
||||
|
||||
def test_소스가_700줄을_넘지_않음():
|
||||
for path in (
|
||||
*sorted(UI.glob("ui_template_item_picker*.ts")),
|
||||
M02 / "M02_MasterTemplete_ItemCards.ts",
|
||||
M02 / "M02_MasterTemplete_ItemSource.ts",
|
||||
M02 / "M02_MasterTemplete_Inputs.ts",
|
||||
M02 / "M02_MasterTemplete_Inputs_Model.ts",
|
||||
):
|
||||
assert len(path.read_text(encoding="utf-8").splitlines()) <= 700, path.name
|
||||
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""M02 「선택 입력」 카드(PLAN 25-2c) 셈 회귀검증 — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록.
|
||||
|
||||
같은 이름의 입력도 로직마다 따로(키 `{로직}|{이름}`) · 처음은 모두 「안 씀」(저장 줄 없음) ·
|
||||
면적 · 재료로 고른 입력만 그 시트 알약(`입력|{로직}|{이름}` · 「로직 이름 · 입력 이름」).
|
||||
Node 헬퍼(`helper_m02_inputs.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.join(HERE, "..", "..")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def results():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_m02_inputs.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_키_모양(results):
|
||||
assert results["keys"] == ["GC1|높이", "입력|GC2|높이"]
|
||||
|
||||
|
||||
def test_자리는_고른_것만_안_쓰면_없음(results):
|
||||
assert results["seatKeys"] == ["GC1|높이", "GC2|높이", "GC2|배합", "GC9|없는것"]
|
||||
assert results["none"] == [] # 처음은 모두 안 씀 — 저장할 줄 없음
|
||||
|
||||
|
||||
def test_로직_수_입력_수(results):
|
||||
assert results["count"] == {"logics": 2, "inputs": 4}
|
||||
|
||||
|
||||
def test_같은_이름_입력도_로직마다_따로_알약(results):
|
||||
# 높이가 두 로직에 있어도 알약 둘 · 이름은 「로직 이름 · 입력 이름」 · 목록에서 못 뺌(고정)
|
||||
assert results["area"] == [
|
||||
["입력|GC1|높이", "돌쌓기 · 높이", "m", True],
|
||||
["입력|GC2|높이", "타설 · 높이", None, True],
|
||||
]
|
||||
assert results["material"] == [["입력|GC2|배합", "타설 · 배합"]]
|
||||
|
||||
|
||||
def test_저장_줄은_고른_것만_알약_키_함께(results):
|
||||
assert results["lines"] == [
|
||||
{"로직": "GC1", "입력": "높이", "자리": "면적", "알약": "입력|GC1|높이"},
|
||||
{"로직": "GC1", "입력": "종류", "자리": "선택"}, # 선택은 알약 없음
|
||||
{"로직": "GC2", "입력": "높이", "자리": "면적", "알약": "입력|GC2|높이"},
|
||||
{"로직": "GC2", "입력": "배합", "자리": "재료", "알약": "입력|GC2|배합"},
|
||||
]
|
||||
|
||||
|
||||
def test_고르기_범위_자동값_글(results):
|
||||
assert results["hints"] == [
|
||||
["0.5 ~ 5", "1.2"],
|
||||
["막돌 / 견치돌", ""],
|
||||
["0 ~ 3", ""],
|
||||
["", ""],
|
||||
]
|
||||
assert results["seatOptions"] == ["안 씀", "면적산출 알약", "재료산출 알약", "선택"]
|
||||
|
||||
|
||||
def test_카드는_집계표_카드_옆_넷째_그리고_700줄_안():
|
||||
page = os.path.join(ROOT, "M02_MasterTemplete")
|
||||
src = open(os.path.join(page, "M02_MasterTemplete_Structure.ts"), encoding="utf-8").read()
|
||||
assert "inputs.root" in src and "createInputsCard" in src
|
||||
for name in os.listdir(page):
|
||||
if name.endswith((".ts", ".py")):
|
||||
n = len(open(os.path.join(page, name), encoding="utf-8").read().splitlines())
|
||||
assert n <= 700, (name, n)
|
||||
@@ -0,0 +1,238 @@
|
||||
"""M02 항목 선택 입력(PLAN 25-1b · 25-2a) — 입력 목록 · 항목 연결 파일 · 알약 · 권한.
|
||||
|
||||
① 입력 목록은 실제 마스터(시스템 층 · 읽기만)로 — 일위대가 있는 항목 셋에서 로직 수 · 입력 수 ·
|
||||
자동값이 M01 조합 미리 보기가 쓰는 것(로직 줄 `입력` · `master_auto`)과 같은지 · 로직마다 갈라
|
||||
합치지 않는지 · 일위대가 없는 항목은 빈 목록.
|
||||
② 연결 파일 쓰기는 임시 storage(회사 · 개인 층) + 견본 시스템 집계표로만 — 판 409 · 없는 키 422 ·
|
||||
사라짐 표시 · 알약 · 층 권한 · 열 지우기 · 항목 복사 · 분류 더하기 복사가 파일을 같이 다룸.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from config import config_system
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from resources.tester.test_m02_structure import write_sample_table
|
||||
|
||||
TABLE = "table/구조물집계표"
|
||||
UA = "UA000004" # 견본 항목 셋(rv · c4 · c1)이 쓰는 조합 — 담은 로직 여럿
|
||||
OTHER_UA = "UA000007"
|
||||
|
||||
|
||||
def _app(session: dict[str, Any]) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(layers_router.router)
|
||||
app.include_router(items_router.router)
|
||||
app.dependency_overrides[verify_session] = lambda: session
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _m01_view(ua: str) -> list[tuple[str, list[str], dict]]:
|
||||
"""M01 조합 미리 보기가 그리는 것 — 담은 로직마다 (키, 입력 이름들, 자동값)."""
|
||||
row = next(r for r in m01.read(combo_store.FILE)[0]["줄"] if r["키"] == ua)
|
||||
whole = m01.loaded()[1]
|
||||
out = []
|
||||
for one in row["담은로직"]:
|
||||
try:
|
||||
logic = whole.logic(one["로직"])
|
||||
except combo_store.cm.mf.FormulaError:
|
||||
continue # 화면도 못 읽은 로직은 건너뜀
|
||||
auto = inputs_store._num(m01.auto(one["로직"])["값"])
|
||||
out.append((one["로직"], [s["이름"] for s in logic.get("입력") or []], auto))
|
||||
return out
|
||||
|
||||
|
||||
# ── ① 입력 목록 — 실제 시스템 층(읽기만) ─────────────────────
|
||||
|
||||
|
||||
def test_입력_목록은_M01_조합_미리_보기와_같음():
|
||||
client = _app({"user_id": 1, "company_id": 1, "role": "USER", "is_master": False})
|
||||
rows = [r for r in json.loads((store.FOLDER / store.COMBO_FILE).read_bytes())["줄"]]
|
||||
with_ua = [r for r in rows if r.get("일위대가")]
|
||||
picked, seen = [], set()
|
||||
for r in with_ua: # 서로 다른 조합 셋
|
||||
if r["일위대가"] not in seen:
|
||||
picked.append(r)
|
||||
seen.add(r["일위대가"])
|
||||
if len(picked) == 3:
|
||||
break
|
||||
assert len(picked) == 3
|
||||
for r in picked:
|
||||
res = client.get(f"/api/m02/layers/system/items/{r['키']}/inputs")
|
||||
assert res.status_code == 200, res.text # 일반 사용자 읽기
|
||||
got = res.json()
|
||||
want = _m01_view(r["일위대가"])
|
||||
assert got["일위대가"] == r["일위대가"] and got["이름"]
|
||||
assert [lg["로직"] for lg in got["로직"]] == [k for k, _, _ in want]
|
||||
for logic, (_, names, auto) in zip(got["로직"], want):
|
||||
assert [i["이름"] for i in logic["입력"]] == names # 로직마다 · 합치지 않음
|
||||
assert {i["이름"]: i["자동값"] for i in logic["입력"]} == auto
|
||||
assert all({"이름", "설명", "단위", "자동값"} <= set(i) for i in logic["입력"])
|
||||
# 이름이 같은 입력이 두 로직에 있어도 두 번 나옴
|
||||
names = [
|
||||
i["이름"]
|
||||
for r in with_ua
|
||||
for lg in client.get(f"/api/m02/layers/system/items/{r['키']}/inputs").json()["로직"]
|
||||
for i in lg["입력"]
|
||||
]
|
||||
assert len(names) > len(set(names))
|
||||
no_ua = next(r for r in rows if not r.get("일위대가"))
|
||||
empty = client.get(f"/api/m02/layers/system/items/{no_ua['키']}/inputs").json()
|
||||
assert empty == {"일위대가": None, "이름": None, "로직": [], "빠진로직": []}
|
||||
assert client.get("/api/m02/layers/system/items/SS999999/inputs").status_code == 404
|
||||
|
||||
|
||||
# ── ② 연결 파일 — 임시 storage ─────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
||||
system = tmp_path / "system"
|
||||
system.mkdir()
|
||||
write_sample_table(system)
|
||||
monkeypatch.setattr(store, "FOLDER", system)
|
||||
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
|
||||
store.renumber()
|
||||
storage = tmp_path / "storage"
|
||||
storage.mkdir()
|
||||
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
|
||||
session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False}
|
||||
return {"client": _app(session), "session": session, "storage": storage}
|
||||
|
||||
|
||||
def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient:
|
||||
world["session"].update(role=role, user_id=user)
|
||||
return world["client"]
|
||||
|
||||
|
||||
def _put_table(client: TestClient, layer: str, edit) -> None:
|
||||
url = f"/api/m02/layers/{layer}/templates/{TABLE}"
|
||||
got = client.get(url).json()
|
||||
edit(got["문서"]["열"])
|
||||
res = client.put(url, json={"판": got["판"], "문서": got["문서"]})
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
|
||||
def _col(cid: str, ua: str | None = UA) -> dict[str, Any]:
|
||||
return {
|
||||
"id": cid,
|
||||
"머리": ["sub4 돌쌓기", cid, None, None],
|
||||
"단위": "㎡",
|
||||
"꼴": "수",
|
||||
"일위대가": ua,
|
||||
}
|
||||
|
||||
|
||||
def _key(client: TestClient, layer: str, cid: str) -> str:
|
||||
items = client.get(f"/api/m02/items?layer={layer}").json()
|
||||
return next(r["key"] for r in items if r["열"] == cid)
|
||||
|
||||
|
||||
def test_연결_저장_다시_읽기_판_422_사라짐_알약(world):
|
||||
client = _as(world)
|
||||
_put_table(client, "personal", lambda cols: cols.append(_col("sub4_a")))
|
||||
key = _key(client, "personal", "sub4_a")
|
||||
base = f"/api/m02/layers/personal/items/{key}"
|
||||
listing = client.get(f"{base}/inputs").json()
|
||||
first, second = listing["로직"][0], listing["로직"][1]
|
||||
assert client.get(f"{base}/input-links").json() == {"판": None, "줄": []}
|
||||
rows = [
|
||||
{"로직": first["로직"], "입력": first["입력"][0]["이름"], "자리": "면적"},
|
||||
{"로직": second["로직"], "입력": second["입력"][0]["이름"], "자리": "재료"},
|
||||
{"로직": first["로직"], "입력": first["입력"][1]["이름"], "자리": "선택"},
|
||||
]
|
||||
saved = client.put(f"{base}/input-links", json={"판": None, "줄": rows})
|
||||
assert saved.status_code == 200, saved.text
|
||||
assert saved.json()["판"] == 1
|
||||
again = client.get(f"{base}/input-links").json()
|
||||
assert again == saved.json()
|
||||
assert again["줄"][0]["알약"] == f"입력|{first['로직']}|{first['입력'][0]['이름']}"
|
||||
assert "알약" not in again["줄"][2] # 선택은 시트 알약이 아님
|
||||
file = world["storage"] / "7" / "42" / "templates" / "inputs" / "sub4_a.json"
|
||||
assert json.loads(file.read_text("utf-8")) == saved.json()
|
||||
# 판 다르면 409 · 없는 키 · 모르는 자리 · 두 번은 422
|
||||
assert client.put(f"{base}/input-links", json={"판": None, "줄": rows}).status_code == 409
|
||||
for bad in (
|
||||
[{"로직": "GF999999", "입력": "높이", "자리": "면적"}],
|
||||
[{"로직": first["로직"], "입력": "없는입력", "자리": "면적"}],
|
||||
[{"로직": first["로직"], "입력": first["입력"][0]["이름"], "자리": "옆"}],
|
||||
[rows[0], rows[0]],
|
||||
):
|
||||
res = client.put(f"{base}/input-links", json={"판": 1, "줄": bad})
|
||||
assert res.status_code == 422, res.text
|
||||
# 알약 — 면적 = 조합 물량 + 면적으로 이은 입력 · 재료 = 재료로 이은 입력만
|
||||
area = client.get(f"{base}/pills?place=면적").json()
|
||||
assert area["물량"] and [p["키"] for p in area["입력"]] == [again["줄"][0]["알약"]]
|
||||
mat = client.get(f"{base}/pills?place=재료").json()
|
||||
assert mat["물량"] == [] and [p["입력"] for p in mat["입력"]] == [second["입력"][0]["이름"]]
|
||||
assert " · " in mat["입력"][0]["이름"] # 입력 · 로직 이름(합치지 않음)
|
||||
# 일위대가가 바뀌면 옛 로직 연결은 읽을 때 사라짐 · 알약에서 빠짐
|
||||
_put_table(client, "personal", lambda cols: cols[-1].update(일위대가=OTHER_UA))
|
||||
gone = client.get(f"{base}/input-links").json()["줄"]
|
||||
assert all(r.get("사라짐") for r in gone)
|
||||
assert client.get(f"{base}/pills?place=면적").json()["입력"] == []
|
||||
|
||||
|
||||
def test_권한_일반_사용자_읽기_쓰기(world):
|
||||
admin = _as(world, "SYSTEM_ADMIN")
|
||||
_put_table(admin, "system", lambda cols: cols.append(_col("sub4_sys")))
|
||||
key = _key(admin, "system", "sub4_sys")
|
||||
base = f"/api/m02/layers/system/items/{key}"
|
||||
user = _as(world)
|
||||
assert user.get(f"{base}/inputs").status_code == 200
|
||||
assert user.get(f"{base}/input-links").status_code == 200
|
||||
assert user.put(f"{base}/input-links", json={"판": None, "줄": []}).status_code == 403
|
||||
assert _as(world, "SYSTEM_ADMIN").put(f"{base}/input-links", json={"줄": []}).status_code == 200
|
||||
# 회사 층 — 같은 회사 일반 사용자 읽기 · 쓰기 403 · 회사 관리자 쓰기
|
||||
_put_table(_as(world, "ADMIN"), "company", lambda cols: cols.append(_col("sub4_c")))
|
||||
ckey = _key(_as(world), "company", "sub4_c")
|
||||
cbase = f"/api/m02/layers/company/items/{ckey}"
|
||||
assert _as(world).get(f"{cbase}/inputs").status_code == 200
|
||||
assert _as(world).put(f"{cbase}/input-links", json={"줄": []}).status_code == 403
|
||||
assert _as(world, "ADMIN").put(f"{cbase}/input-links", json={"줄": []}).status_code == 200
|
||||
# 남의 개인 층은 못 읽음
|
||||
assert _as(world).get(f"{cbase}/inputs?owner=9").status_code == 403
|
||||
|
||||
|
||||
def test_열_지우기_항목_복사_분류_복사가_연결을_같이(world):
|
||||
admin = _as(world, "ADMIN")
|
||||
_put_table(admin, "company", lambda cols: cols.append(_col("sub4_c")))
|
||||
key = _key(admin, "company", "sub4_c")
|
||||
base = f"/api/m02/layers/company/items/{key}"
|
||||
logic = admin.get(f"{base}/inputs").json()["로직"][0]
|
||||
rows = [{"로직": logic["로직"], "입력": logic["입력"][0]["이름"], "자리": "면적"}]
|
||||
assert admin.put(f"{base}/input-links", json={"판": None, "줄": rows}).status_code == 200
|
||||
# 항목 개인 복사 — 새 열에 연결이 판 1 로
|
||||
user = _as(world)
|
||||
made = user.post(
|
||||
"/api/m02/layers/personal/items/copy", json={"from_layer": "company", "key": key}
|
||||
).json()
|
||||
mine = user.get(f"/api/m02/layers/personal/items/{made['key']}/input-links").json()
|
||||
assert mine["판"] == 1 and [r["입력"] for r in mine["줄"]] == [rows[0]["입력"]]
|
||||
# 분류 더하기(본뜬열) — 새 열이 원본 열 연결을 받음
|
||||
admin = _as(world, "ADMIN")
|
||||
twin = {**_col("sub4_c2", None), "본뜬열": "sub4_c"}
|
||||
_put_table(admin, "company", lambda cols: cols.append(twin))
|
||||
folder = world["storage"] / "7" / "templates"
|
||||
with store.at(folder):
|
||||
assert inputs_store.read_file("sub4_c2")["줄"] == inputs_store.read_file("sub4_c")["줄"]
|
||||
# 열 지우기 — 연결 파일도 같이 지움
|
||||
_put_table(
|
||||
admin, "company", lambda cols: cols.remove(next(c for c in cols if c["id"] == "sub4_c"))
|
||||
)
|
||||
assert not (folder / "inputs" / "sub4_c.json").is_file()
|
||||
assert (folder / "inputs" / "sub4_c2.json").is_file()
|
||||
@@ -21,6 +21,7 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
M02 = ROOT / "M02_MasterTemplete"
|
||||
PANEL = M02 / "M02_MasterTemplete_Structure.ts"
|
||||
CARDS = M02 / "M02_MasterTemplete_ItemCards.ts" # 카드 셋 그리기(25-2d 에서 PANEL 에서 옮김)
|
||||
MAIN = M02 / "M02_MasterTemplete_UI_Main.ts"
|
||||
SIDE = M02 / "M02_MasterTemplete_UI_Side.ts"
|
||||
|
||||
@@ -35,7 +36,7 @@ def test_집계표_이름이_서버와_같음():
|
||||
|
||||
|
||||
def test_소스가_700줄을_넘지_않음():
|
||||
for path in (PANEL, MAIN, M02 / "M02_MasterTemplete_Structure.css"):
|
||||
for path in (PANEL, CARDS, MAIN, M02 / "M02_MasterTemplete_Structure.css"):
|
||||
assert len(_text(path).splitlines()) <= 700, path.name
|
||||
|
||||
|
||||
@@ -53,8 +54,8 @@ def test_소스가_700줄을_넘지_않음():
|
||||
# (`{열 id: {도번, 면적번, 재료번}}`)
|
||||
("M02_MasterTemplete_Api_Fetch.ts", ["fetchStructureNumbers"], SIDE),
|
||||
("M02_MasterTemplete_Structure.ts", ["structureLabel", "isStructureColumn"], SIDE),
|
||||
("M02_MasterTemplete_Drawing.ts", ["mountDrawingTemplate"], PANEL),
|
||||
("../A00_Common/spreadsheet/spreadsheet.ts", ["createSpreadsheet"], PANEL),
|
||||
("M02_MasterTemplete_Drawing.ts", ["mountDrawingTemplate"], CARDS),
|
||||
("../A00_Common/spreadsheet/spreadsheet.ts", ["createSpreadsheet"], CARDS),
|
||||
],
|
||||
)
|
||||
def test_컨테이너가_쓰는_이름이_내보내진_것(source: str, names: list[str], target: Path):
|
||||
@@ -132,7 +133,7 @@ def test_열_머리_글_잇기():
|
||||
|
||||
def test_미리보기_CAD_패널은_숨기지_않고_접힌_채로_엶():
|
||||
"""특성 · 도면층 패널을 숨기지 않음 — 곁값 panelsCollapsed 로 접어 실음(B07 은 안 보냄)."""
|
||||
panel = _text(PANEL)
|
||||
panel = _text(CARDS)
|
||||
assert ".cad-inspector" not in panel and "panelsCollapsed: true" in panel
|
||||
drawing = _text(M02 / "M02_MasterTemplete_Drawing.ts")
|
||||
assert "panelsCollapsed: options.panelsCollapsed" in drawing
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
/* 항목 고르기 모달 — 왼쪽 나무 · 오른쪽 폴더 모양 · 아래 단추 */
|
||||
.ui-item-picker {
|
||||
width: min(900px, 94vw);
|
||||
/* 공용 모달(.ui-modal)의 폭 · 높이보다 앞서게 — 불러오는 차례에 따라 뒤집히지 않도록 두 클래스 */
|
||||
.ui-modal.ui-item-picker {
|
||||
width: min(1320px, 96vw);
|
||||
max-height: 94vh;
|
||||
}
|
||||
|
||||
.ui-item-picker .ui-modal__body {
|
||||
height: min(60vh, 520px);
|
||||
height: min(80vh, 800px);
|
||||
}
|
||||
|
||||
.ui-item-picker__cols {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 5fr) minmax(260px, 6fr);
|
||||
grid-template-columns: minmax(220px, 3fr) minmax(320px, 9fr);
|
||||
gap: var(--spacing-12);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -47,6 +49,133 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ui-item-picker__cols[hidden],
|
||||
.ui-item-picker__actions[hidden],
|
||||
.ui-item-picker__edit-host[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui-item-picker__head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 프로젝트 복사본 상태 줄 — 가져온 날 · 마스터가 바뀜 · [새로 가져오기] */
|
||||
.ui-item-picker__status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.ui-item-picker__changed {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.ui-item-picker__fake {
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* 카드 넷 — M02 와 같은 카드 · 칸이 좁으면 한 줄씩(카드 하나가 300px 안 되면) */
|
||||
.ui-item-picker__cards {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-item-picker__cards .m02-struct {
|
||||
flex: 1 1 0;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 편집 화면 — 뒤로 · 제목 · 저장 한 줄 + 편집 부품이 남는 높이를 채움 */
|
||||
.ui-item-picker__edit-host {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ui-item-picker__edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ui-item-picker__edit-top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ui-item-picker__edit-title {
|
||||
flex: 1 1 12rem;
|
||||
margin: 0;
|
||||
color: var(--color-text-heading);
|
||||
font-size: var(--text-body);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ui-item-picker__edit-note {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.ui-item-picker__edit-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ui-item-picker__edit-sheet,
|
||||
.ui-item-picker__edit-cad {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 도면 부품(.m02-drawing)이 남는 높이를 채움 — M02 메인 칸과 같은 자리 규칙 */
|
||||
.ui-item-picker__edit-cad > .m02-drawing {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.ui-item-picker__edit-sheet > .ss {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-item-picker__edit-inputs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ui-item-picker__edit-inputs > .m02-struct__card {
|
||||
flex: 1 1 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.ui-item-picker__edit-inputs .m02-inputs__wrap {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ui-item-picker__name {
|
||||
margin: 0;
|
||||
font-size: var(--text-body);
|
||||
@@ -104,3 +233,12 @@
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 좁은 폭 — 나무는 위(낮게) · 상세는 아래로 쌓고 카드는 한 줄씩 */
|
||||
@media (max-width: 760px) {
|
||||
.ui-item-picker__cols {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 2fr) minmax(0, 3fr);
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,17 +13,12 @@ import { createButton, createInputField, el, showToast } from "./ui_template_ele
|
||||
import {
|
||||
buildTree,
|
||||
filterRows,
|
||||
folderLines,
|
||||
isCurrent,
|
||||
itemName,
|
||||
layerOf,
|
||||
ownerOf,
|
||||
pathIds,
|
||||
rowId,
|
||||
rowTip,
|
||||
shown,
|
||||
toSummary,
|
||||
type FolderKind,
|
||||
type PickNode,
|
||||
type PickerLayer,
|
||||
type PickerRow,
|
||||
@@ -33,6 +28,8 @@ import {
|
||||
import { t } from "./ui_template_locale";
|
||||
import { openModal } from "./ui_template_modal";
|
||||
import { createTreeRow, renderTree, type TreeNode } from "./ui_template_tree";
|
||||
import { createItemDetail, type EditRequest } from "./ui_template_item_picker_detail";
|
||||
import { createEditView, type EditView } from "./ui_template_item_picker_edit";
|
||||
|
||||
export type { SummaryItem } from "./ui_template_item_picker_data";
|
||||
|
||||
@@ -41,6 +38,8 @@ export interface ItemPickerOptions {
|
||||
current: SummaryItem | null;
|
||||
/** [개인용으로 복사]를 쓸 수 있나 — 개인 층에 쓸 수 있을 때(회사 등록 뒤) */
|
||||
canCopy: boolean;
|
||||
/** 프로젝트 안에서 부르면 그 id — 카드 [수정] 이 프로젝트 복사본을 고침(없으면 원본 보기만 · 25-2d) */
|
||||
projectId?: string | null;
|
||||
}
|
||||
|
||||
export type ItemPickerResult =
|
||||
@@ -48,14 +47,6 @@ export type ItemPickerResult =
|
||||
|
||||
const MANAGE_HASH = "#/m02-master-template?layer=personal";
|
||||
|
||||
const FOLDER_LABEL: Record<FolderKind, () => string> = {
|
||||
drawing: () => t("M02_PickerDrawing"),
|
||||
area: () => t("M02_PickerArea"),
|
||||
material: () => t("M02_PickerMaterial"),
|
||||
combo: () => t("M02_PickerCombo"),
|
||||
unitPrice: () => t("M02_PickerUnitPrice"),
|
||||
};
|
||||
|
||||
const labels = (): TreeLabels => ({
|
||||
layers: {
|
||||
system: t("M02_PickerSystem"),
|
||||
@@ -110,7 +101,22 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
|
||||
|
||||
const search = createInputField({ placeholder: t("M02_PickerSearch"), compact: true });
|
||||
const treeHost = el("div", { className: "ui-item-picker__tree" });
|
||||
const detail = el("div", { className: "ui-item-picker__detail" });
|
||||
const detailView = createItemDetail({
|
||||
projectId: opts.projectId ?? null,
|
||||
layerLabels: labels().layers,
|
||||
hint: t("M02_PickerHint"),
|
||||
unit: t("M02_PickerUnit"),
|
||||
onEdit: (request) => openEdit(request),
|
||||
});
|
||||
const cols = el("div", {
|
||||
className: "ui-item-picker__cols",
|
||||
children: [
|
||||
el("div", { className: "ui-item-picker__left", children: [search.root, treeHost] }),
|
||||
detailView.root,
|
||||
],
|
||||
});
|
||||
const editHost = el("div", { className: "ui-item-picker__edit-host", attrs: { hidden: "" } });
|
||||
let editView: EditView | null = null;
|
||||
const applyButton = createButton({ label: t("M02_PickerApply"), variant: "filled" });
|
||||
const clearButton = createButton({ label: t("M02_PickerClear"), variant: "ghost" });
|
||||
const copyButton = createButton({ label: t("M02_PickerCopy"), variant: "ghost" });
|
||||
@@ -126,47 +132,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
|
||||
el("p", { className: "ui-item-picker__hint", text });
|
||||
|
||||
const paintDetail = (): void => {
|
||||
if (!chosen) {
|
||||
detail.replaceChildren(hint(t("M02_PickerHint")));
|
||||
} else {
|
||||
const where = [labels().layers[layerOf(chosen)], shown(chosen.종류), shown(chosen.분류1)]
|
||||
.filter(Boolean)
|
||||
.join(" › ");
|
||||
const small = (text: string): HTMLElement =>
|
||||
el("p", { className: "ui-item-picker__where", text });
|
||||
detail.replaceChildren(
|
||||
el("h4", {
|
||||
className: "ui-item-picker__name",
|
||||
text: itemName(chosen),
|
||||
attrs: { title: rowTip(chosen, labels()) },
|
||||
}),
|
||||
small(where),
|
||||
...(chosen.단위 ? [small(`${t("M02_PickerUnit")} ${chosen.단위}`)] : []),
|
||||
el("ul", {
|
||||
className: "ui-item-picker__folder",
|
||||
children: folderLines(chosen).map((line) =>
|
||||
el("li", {
|
||||
attrs: line.name && line.key ? { title: line.key } : {},
|
||||
children: [
|
||||
el("span", {
|
||||
className: "ui-item-picker__folder-label",
|
||||
text: FOLDER_LABEL[line.kind](),
|
||||
}),
|
||||
// 이름이 있으면 이름(키는 툴팁) · 없으면 키를 작은 글로 · 둘 다 없으면 「없음」
|
||||
el("span", {
|
||||
className: line.name
|
||||
? ""
|
||||
: line.key
|
||||
? "ui-item-picker__key"
|
||||
: "ui-item-picker__none",
|
||||
text: line.name || line.key || t("M02_PickerNone"),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
detailView.show(chosen);
|
||||
const layer = chosen ? layerOf(chosen) : null;
|
||||
applyButton.disabled = !chosen;
|
||||
clearButton.disabled = !opts.current;
|
||||
@@ -268,29 +234,44 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
|
||||
}
|
||||
});
|
||||
|
||||
const actions = el("div", {
|
||||
className: "ui-item-picker__actions",
|
||||
children: [copyButton, manageButton, clearButton, applyButton],
|
||||
});
|
||||
|
||||
/** 카드 [수정] — 모달 안에서 편집 화면으로 바뀜(나무 · 카드 · 단추는 숨김) */
|
||||
function openEdit(request: EditRequest): void {
|
||||
const view = createEditView(request, closeEdit);
|
||||
editView = view;
|
||||
editHost.replaceChildren(view.root);
|
||||
cols.hidden = true;
|
||||
actions.hidden = true;
|
||||
editHost.hidden = false;
|
||||
view.start();
|
||||
}
|
||||
/** [뒤로] — 선택지로 돌아와 고른 항목을 다시 읽음(고친 값이 카드에 보임) */
|
||||
function closeEdit(): void {
|
||||
editView?.destroy();
|
||||
editView = null;
|
||||
editHost.hidden = true;
|
||||
cols.hidden = false;
|
||||
actions.hidden = false;
|
||||
detailView.reload();
|
||||
}
|
||||
|
||||
close = openModal({
|
||||
title: t("M02_PickerTitle"),
|
||||
closeLabel: t("M02_Close"),
|
||||
dialogClass: "ui-item-picker",
|
||||
// 닫기 단추 · Esc · 바깥 누르기 = 취소
|
||||
beforeClose: () => {
|
||||
// 닫기 단추 · Esc · 바깥 누르기 = 취소 — 편집 중 저장 안 한 고침이 있으면 물음
|
||||
beforeClose: async () => {
|
||||
if (editView && !(await editView.leave())) return false;
|
||||
editView?.destroy();
|
||||
resolve({ action: "cancel" });
|
||||
return true;
|
||||
},
|
||||
mount: (body) => {
|
||||
body.append(
|
||||
el("div", {
|
||||
className: "ui-item-picker__cols",
|
||||
children: [
|
||||
el("div", { className: "ui-item-picker__left", children: [search.root, treeHost] }),
|
||||
detail,
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ui-item-picker__actions",
|
||||
children: [copyButton, manageButton, clearButton, applyButton],
|
||||
}),
|
||||
);
|
||||
body.append(cols, editHost, actions);
|
||||
paintDetail();
|
||||
void load(null);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/* =============================================================================
|
||||
* ui_template_item_picker_detail.ts
|
||||
* 항목 고르기 모달 오른쪽 상세(계획서 25-2d) — 글 목록이던 것을 **카드 넷**(도면 · 면적산출 · 재료산출 ·
|
||||
* 선택 입력)으로. 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `_Inputs`)을 늦게 받아 씀
|
||||
* (B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`).
|
||||
*
|
||||
* 자료 자리 — 프로젝트에 가져온 항목은 **프로젝트 복사본**을 읽고, 아직이면 **원본 층**을 읽음(읽기만).
|
||||
* [수정] 은 복사본만 고침 — 안 가져온 항목은 「프로젝트로 가져와 고침」(미리 가져오기 뒤 편집).
|
||||
* 마스터 · 회사 · 개인 원본은 이 모달이 안 바꿈. 편집 화면은 `ui_template_item_picker_edit.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements";
|
||||
import {
|
||||
readInputLinks,
|
||||
readTemplate,
|
||||
type InputLinks,
|
||||
} from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import type { EditKind, ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards";
|
||||
import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource";
|
||||
import type { InputsValueHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs";
|
||||
import type { ValueDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs_Model";
|
||||
import {
|
||||
folderLines,
|
||||
itemName,
|
||||
layerOf,
|
||||
rowTip,
|
||||
shown,
|
||||
type PickerLayer,
|
||||
type PickerRow,
|
||||
} from "./ui_template_item_picker_data";
|
||||
import {
|
||||
folderOf,
|
||||
loadInputs,
|
||||
PART_OF,
|
||||
projectItems,
|
||||
type ProjectItemInfo,
|
||||
type ProjectItems,
|
||||
} from "./ui_template_item_picker_project";
|
||||
|
||||
/** 편집 화면을 여는 데 필요한 것 — 「선택 입력」 도 한 종류 */
|
||||
export interface EditRequest {
|
||||
kind: EditKind | "inputs";
|
||||
row: PickerRow;
|
||||
info: ProjectItemInfo;
|
||||
items: ProjectItems;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export interface ItemDetailOptions {
|
||||
/** 프로젝트 안에서 열렸으면 그 id — 없으면 원본 보기만(고침 없음) */
|
||||
projectId: string | null;
|
||||
layerLabels: Record<PickerLayer, string>;
|
||||
hint: string;
|
||||
unit: string;
|
||||
onEdit: (request: EditRequest) => void;
|
||||
}
|
||||
|
||||
export interface ItemDetail {
|
||||
root: HTMLElement;
|
||||
show: (row: PickerRow | null) => void;
|
||||
/** 편집을 마치고 돌아옴 — 지금 항목을 다시 읽음 */
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
importEdit: "프로젝트로 가져와 고침",
|
||||
notImported: "프로젝트에 아직 안 가져옴 — 원본을 보고 있음",
|
||||
imported: "프로젝트에 가져옴 {value}",
|
||||
changed: "마스터가 바뀜 — 프로젝트 복사본은 가져온 때 그대로",
|
||||
refresh: "새로 가져오기",
|
||||
refreshAsk: "프로젝트 복사본의 고친 내용이 원본으로 덮임 — 새로 가져올까요?",
|
||||
importFailed: "프로젝트로 가져오지 못함 — {value}",
|
||||
refreshFailed: "새로 가져오지 못함 — {value}",
|
||||
listFailed: "프로젝트 항목을 못 읽음 — {value}",
|
||||
calc: "복사본 계산 — 계 {total}원 · 빠진 입력 {gaps}개",
|
||||
calcStuck: " · 멈춘 로직 {stuck}개",
|
||||
loading: "읽는 중…",
|
||||
noCopy: "복사본에 없음",
|
||||
combo: "자재조합",
|
||||
price: "일위대가",
|
||||
};
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const fill = (text: string, value: string): string => text.replace("{value}", value);
|
||||
const dateOf = (iso?: string | null): string => (iso ? iso.slice(0, 10) : "");
|
||||
|
||||
export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
|
||||
const root = el("div", { className: "ui-item-picker__detail" });
|
||||
const head = el("div", { className: "ui-item-picker__head" });
|
||||
const status = el("div", { className: "ui-item-picker__status", attrs: { hidden: "" } });
|
||||
const cardsHost = el("div", { className: "ui-item-picker__cards" });
|
||||
root.append(head, status, cardsHost);
|
||||
|
||||
let current: PickerRow | null = null;
|
||||
let seq = 0;
|
||||
let infos: ProjectItemInfo[] | null = null;
|
||||
let cards: ItemCardsHandle | null = null;
|
||||
let inputs: InputsValueHandle | null = null;
|
||||
let source: ItemSource = { layer: "system", projectId: null, read: () => Promise.reject() };
|
||||
let info: ProjectItemInfo | null = null;
|
||||
const items: ProjectItems | null = opts.projectId ? projectItems(opts.projectId) : null;
|
||||
|
||||
const small = (text: string): HTMLElement =>
|
||||
el("p", { className: "ui-item-picker__where", text });
|
||||
|
||||
const paintHead = (row: PickerRow): void => {
|
||||
const where = [opts.layerLabels[layerOf(row)], shown(row.종류), shown(row.분류1)]
|
||||
.filter(Boolean)
|
||||
.join(" › ");
|
||||
const pairs = folderLines(row).filter(
|
||||
(line) => line.kind === "combo" || line.kind === "unitPrice",
|
||||
);
|
||||
head.replaceChildren(
|
||||
el("h4", {
|
||||
className: "ui-item-picker__name",
|
||||
text: itemName(row),
|
||||
attrs: { title: rowTip(row, { layers: opts.layerLabels, unclassified: "" }) },
|
||||
}),
|
||||
small(where),
|
||||
...(row.단위 ? [small(`${opts.unit} ${row.단위}`)] : []),
|
||||
...pairs
|
||||
.filter((line) => line.name || line.key)
|
||||
.map((line) =>
|
||||
small(`${line.kind === "combo" ? TEXT.combo : TEXT.price} ${line.name || line.key}`),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/** 프로젝트 복사본 또는 원본 층에서 열 하나의 문서를 읽음 — 없으면 던짐(카드가 「없음」) */
|
||||
const makeSource = (row: PickerRow): ItemSource =>
|
||||
info && opts.projectId
|
||||
? {
|
||||
layer: "project",
|
||||
projectId: opts.projectId,
|
||||
read: async (part) => {
|
||||
const doc = await items?.read(info!.폴더, PART_OF[part]);
|
||||
if (doc == null) throw new Error(TEXT.noCopy);
|
||||
return { 문서: doc };
|
||||
},
|
||||
}
|
||||
: {
|
||||
layer: layerOf(row),
|
||||
projectId: null,
|
||||
read: (part, column) => readTemplate(layerOf(row), part, column, null),
|
||||
};
|
||||
|
||||
const paintStatus = async (row: PickerRow, mine: number): Promise<void> => {
|
||||
if (!items) return void (status.hidden = true);
|
||||
const nodes: (HTMLElement | string)[] = [];
|
||||
if (!info) nodes.push(TEXT.notImported);
|
||||
else {
|
||||
nodes.push(fill(TEXT.imported, dateOf(info.가져온날)));
|
||||
if (info.마스터바뀜) {
|
||||
const button = createButton({ label: TEXT.refresh, variant: "ghost", size: "sm" });
|
||||
button.addEventListener("click", () => void refresh(row));
|
||||
nodes.push(
|
||||
el("strong", { className: "ui-item-picker__changed", text: TEXT.changed }),
|
||||
button,
|
||||
);
|
||||
}
|
||||
}
|
||||
status.replaceChildren(...nodes);
|
||||
status.hidden = false;
|
||||
if (info) void paintCalc(info, mine);
|
||||
};
|
||||
|
||||
/** 복사본만으로 계산한 계 · 빠진 입력을 한 줄로(값을 넣고 돌아오면 달라짐) */
|
||||
async function paintCalc(from: ProjectItemInfo, mine: number): Promise<void> {
|
||||
const got = await items?.calc(from.폴더).catch(() => null);
|
||||
if (!got || mine !== seq) return;
|
||||
const total = Math.round(got.total).toLocaleString();
|
||||
status.append(
|
||||
el("span", {
|
||||
className: "ui-item-picker__calc",
|
||||
text:
|
||||
TEXT.calc.replace("{total}", total).replace("{gaps}", String(got.gaps)) +
|
||||
(got.stuck ? TEXT.calcStuck.replace("{stuck}", String(got.stuck)) : ""),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function refresh(row: PickerRow): Promise<void> {
|
||||
if (!items || !info || !(await showConfirmDialog(TEXT.refreshAsk))) return;
|
||||
try {
|
||||
const fresh = await items.refresh(info);
|
||||
infos = (infos ?? []).map((one) => (one.폴더 === fresh.폴더 ? fresh : one));
|
||||
show(row);
|
||||
} catch (error) {
|
||||
showToast(fill(TEXT.refreshFailed, why(error)), "error");
|
||||
}
|
||||
}
|
||||
|
||||
/** [수정] — 안 가져온 항목이면 먼저 프로젝트로 가져옴 */
|
||||
async function edit(kind: EditKind | "inputs"): Promise<void> {
|
||||
const row = current;
|
||||
if (!row || !items || !opts.projectId) return;
|
||||
if (!info) {
|
||||
try {
|
||||
info = await items.importItem(row);
|
||||
infos = [...(infos ?? []), info];
|
||||
} catch (error) {
|
||||
showToast(fill(TEXT.importFailed, why(error)), "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
opts.onEdit({ kind, row, info, items, projectId: opts.projectId });
|
||||
}
|
||||
|
||||
async function paintInputs(row: PickerRow, mine: number): Promise<void> {
|
||||
if (!inputs) return;
|
||||
try {
|
||||
const list = await loadInputs(row);
|
||||
let link: InputLinks | null;
|
||||
let values: ValueDoc = {};
|
||||
if (info && items) {
|
||||
// 복사본 것(가져온 때 그대로) — 값도 복사본에서
|
||||
link = (await items.read(info.폴더, "입력연결")) as InputLinks | null;
|
||||
values = ((await items.read(info.폴더, "입력값")) as ValueDoc | null) ?? {};
|
||||
} else {
|
||||
link = await readInputLinks(layerOf(row), row.key).catch(() => null);
|
||||
}
|
||||
if (mine === seq) inputs.set({ list, link, values });
|
||||
} catch (error) {
|
||||
if (mine === seq) inputs.set(null, why(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCards(): Promise<void> {
|
||||
if (cards && inputs) return;
|
||||
const [cardModule, inputModule] = await Promise.all([
|
||||
import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"),
|
||||
import("../M02_MasterTemplete/M02_MasterTemplete_Inputs"),
|
||||
]);
|
||||
inputs = inputModule.createInputsValueCard({ mode: "view", onEdit: () => void edit("inputs") });
|
||||
cards = cardModule.createItemCards({
|
||||
source: () => source,
|
||||
editable: () => !!items,
|
||||
onEdit: (kind) => void edit(kind),
|
||||
warnOnFail: () => false,
|
||||
extraCards: [inputs.root],
|
||||
});
|
||||
cardsHost.replaceChildren(cards.root);
|
||||
}
|
||||
|
||||
function show(row: PickerRow | null): void {
|
||||
const mine = ++seq;
|
||||
current = row;
|
||||
if (!row) {
|
||||
head.replaceChildren(el("p", { className: "ui-item-picker__hint", text: opts.hint }));
|
||||
status.hidden = true;
|
||||
cardsHost.replaceChildren();
|
||||
cards = null;
|
||||
inputs = null;
|
||||
return;
|
||||
}
|
||||
paintHead(row);
|
||||
void (async () => {
|
||||
try {
|
||||
if (items) infos ??= await items.list();
|
||||
} catch (error) {
|
||||
if (mine === seq) showToast(fill(TEXT.listFailed, why(error)), "error");
|
||||
infos = [];
|
||||
}
|
||||
if (mine !== seq) return;
|
||||
info = infos?.find((one) => one.폴더 === folderOf(row)) ?? null;
|
||||
source = makeSource(row);
|
||||
await ensureCards();
|
||||
if (mine !== seq || !cards || !inputs) return;
|
||||
const label = info ? null : TEXT.importEdit;
|
||||
cards.relabel({ structure: label, area: label, material: label });
|
||||
inputs.setEditLabel(label ?? "선택 입력 수정");
|
||||
cards.root.hidden = false;
|
||||
void paintStatus(row, mine);
|
||||
void cards.load({ id: row.열, label: itemName(row) });
|
||||
void paintInputs(row, mine);
|
||||
})();
|
||||
}
|
||||
|
||||
return { root, show, reload: () => show(current) };
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/* =============================================================================
|
||||
* ui_template_item_picker_edit.ts
|
||||
* 항목 고르기 모달의 **편집 화면**(계획서 25-2d) — 카드 [수정] 이 모달 안에서 이 화면으로 바뀜.
|
||||
* 도면 = M02 CAD 부품 · 면적산출 · 재료산출 = M02 스프레드시트 · 선택 입력 = 값 받기 카드.
|
||||
* 고침은 **프로젝트 복사본**에만 씀(`ProjectItems.write`) — 마스터 · 회사 · 개인 원본은 안 바뀜.
|
||||
* [뒤로] = 선택지(나무 + 카드)로 · 저장 안 한 고침이 있으면 버릴지 물음.
|
||||
* 편집 부품은 이 화면을 열 때만 `import()`(B05 · B06 번들에 안 섞임).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements";
|
||||
import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
import type { DrawingTemplateHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Drawing";
|
||||
import type { InputsValueHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs";
|
||||
import { pillsOf, type ValueDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs_Model";
|
||||
import type { InputLinks, StructureDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import type { EditRequest } from "./ui_template_item_picker_detail";
|
||||
import { itemName } from "./ui_template_item_picker_data";
|
||||
import { loadInputs, PART_OF } from "./ui_template_item_picker_project";
|
||||
|
||||
export interface EditView {
|
||||
root: HTMLElement;
|
||||
/** 붙인 뒤 부름 — 편집 부품을 받아 세움 */
|
||||
start: () => void;
|
||||
isDirty: () => boolean;
|
||||
/** 저장 안 한 고침을 버릴지 묻고, 괜찮으면 부품을 치움 — false 면 그대로 */
|
||||
leave: () => Promise<boolean>;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
back: "← 뒤로",
|
||||
save: "저장",
|
||||
saved: "프로젝트 복사본에 저장했음",
|
||||
saveFailed: "저장하지 못함 — {value}",
|
||||
loadFailed: "복사본을 못 읽음 — {value}",
|
||||
discard: "저장 안 한 고침이 있음 — 버리고 돌아갈까요?",
|
||||
outOfRange: "범위 밖 값이 있어 저장하지 못함",
|
||||
noAreaPill:
|
||||
"면적산출 알약 없음 — 선택 입력에서 자리를 면적산출로 이으면 여기 뜸(마스터 · 회사 · 개인 층에서)",
|
||||
drawing: "도면",
|
||||
area: "면적산출",
|
||||
material: "재료산출",
|
||||
inputs: "선택 입력",
|
||||
suffix: "{value} 수정",
|
||||
};
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const fill = (text: string, value: string): string => text.replace("{value}", value);
|
||||
|
||||
export function createEditView(req: EditRequest, onBack: () => void): EditView {
|
||||
const { kind, row, info, items } = req;
|
||||
let dirty = false;
|
||||
let saving = false;
|
||||
let doc: unknown = null;
|
||||
let drawing: DrawingTemplateHandle | null = null;
|
||||
let sheet: SpreadsheetHandle | null = null;
|
||||
let card: InputsValueHandle | null = null;
|
||||
|
||||
const word = {
|
||||
structure: TEXT.drawing,
|
||||
area: TEXT.area,
|
||||
material: TEXT.material,
|
||||
inputs: TEXT.inputs,
|
||||
}[kind];
|
||||
const back = createButton({ label: TEXT.back, variant: "ghost", size: "sm" });
|
||||
const save = createButton({ label: TEXT.save, variant: "filled", size: "sm" });
|
||||
save.disabled = true;
|
||||
const note = el("span", { className: "ui-item-picker__edit-note" });
|
||||
const body = el("div", { className: "ui-item-picker__edit-body" });
|
||||
const root = el("div", {
|
||||
className: "ui-item-picker__edit",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "ui-item-picker__edit-top",
|
||||
children: [
|
||||
back,
|
||||
el("h4", {
|
||||
className: "ui-item-picker__edit-title",
|
||||
text: `${itemName(row)} — ${fill(TEXT.suffix, word)}`,
|
||||
}),
|
||||
note,
|
||||
save,
|
||||
],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
});
|
||||
|
||||
const setDirty = (next: boolean): void => {
|
||||
dirty = next;
|
||||
save.disabled = !next || saving;
|
||||
};
|
||||
const fail = (text: string): void => {
|
||||
body.replaceChildren(el("p", { className: "ui-item-picker__hint", text }));
|
||||
};
|
||||
|
||||
/** 복사본의 항목 연결 — 자리가 면적 · 재료인 입력이 그 시트 알약(25-1b · 키 모양은 `pillsOf`) */
|
||||
const linkOf = async (): Promise<InputLinks | null> =>
|
||||
(await items.read(info.폴더, "입력연결")) as InputLinks | null;
|
||||
|
||||
async function sheetLinks(kindOf: "area" | "material"): Promise<LinkOptions> {
|
||||
const [list, link, { searchMaterials }] = await Promise.all([
|
||||
loadInputs(row),
|
||||
linkOf(),
|
||||
import("../M01_MasterData/M01_MasterData_Api_Fetch"),
|
||||
]);
|
||||
const 항목 = pillsOf(list, link, kindOf === "area" ? "면적" : "재료");
|
||||
if (kindOf === "area") {
|
||||
if (!항목.length) note.textContent = TEXT.noAreaPill;
|
||||
return { 항목, 모드: "입력" };
|
||||
}
|
||||
return { 항목, 모드: "자재", 자재찾기: searchMaterials };
|
||||
}
|
||||
|
||||
async function mountSheet(kindOf: "area" | "material"): Promise<void> {
|
||||
const [{ createSpreadsheet }, links] = await Promise.all([
|
||||
import("../A00_Common/spreadsheet/spreadsheet"),
|
||||
sheetLinks(kindOf),
|
||||
]);
|
||||
const host = el("div", { className: "ui-item-picker__edit-sheet" });
|
||||
body.replaceChildren(host);
|
||||
sheet = createSpreadsheet(host, doc as Workbook, {
|
||||
readOnly: false,
|
||||
tabs: false,
|
||||
onChange: () => setDirty(true),
|
||||
links,
|
||||
});
|
||||
}
|
||||
|
||||
async function mountDrawing(): Promise<void> {
|
||||
const { mountDrawingTemplate } =
|
||||
await import("../M02_MasterTemplete/M02_MasterTemplete_Drawing");
|
||||
const host = el("div", { className: "ui-item-picker__edit-cad" });
|
||||
body.replaceChildren(host);
|
||||
drawing = mountDrawingTemplate(host, (doc as StructureDoc).도면 as never, {
|
||||
onSave: () => void doSave(),
|
||||
readOnly: false,
|
||||
name: `item:${info.폴더}`,
|
||||
layer: "project",
|
||||
projectId: req.projectId,
|
||||
onChanged: (changed) => setDirty(changed),
|
||||
originMarker: true,
|
||||
noArea: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function mountInputs(): Promise<void> {
|
||||
const [{ createInputsValueCard }, list, link] = await Promise.all([
|
||||
import("../M02_MasterTemplete/M02_MasterTemplete_Inputs"),
|
||||
loadInputs(row),
|
||||
linkOf(),
|
||||
]);
|
||||
const values = ((await items.read(info.폴더, "입력값")) as ValueDoc | null) ?? {};
|
||||
card = createInputsValueCard({ mode: "value", onChange: () => setDirty(true) });
|
||||
card.set({ list, link, values });
|
||||
body.replaceChildren(
|
||||
el("div", { className: "ui-item-picker__edit-inputs", children: [card.root] }),
|
||||
);
|
||||
}
|
||||
|
||||
async function start(): Promise<void> {
|
||||
try {
|
||||
if (kind !== "inputs") {
|
||||
doc = await items.read(info.폴더, PART_OF[kind]);
|
||||
if (doc == null) return fail(fill(TEXT.loadFailed, "복사본에 없음"));
|
||||
}
|
||||
if (kind === "structure") await mountDrawing();
|
||||
else if (kind === "inputs") await mountInputs();
|
||||
else await mountSheet(kind);
|
||||
} catch (error) {
|
||||
fail(fill(TEXT.loadFailed, why(error)));
|
||||
}
|
||||
}
|
||||
|
||||
async function doSave(): Promise<void> {
|
||||
if (saving) return;
|
||||
saving = true;
|
||||
save.disabled = true;
|
||||
try {
|
||||
if (kind === "inputs") {
|
||||
if (!card?.isValid()) return void showToast(TEXT.outOfRange, "error");
|
||||
await items.write(info.폴더, "입력값", card.getValues());
|
||||
} else if (kind === "structure") {
|
||||
const edited = await drawing?.getDoc();
|
||||
await items.write(info.폴더, "도면", { ...(doc as StructureDoc), 도면: edited });
|
||||
} else if (sheet) {
|
||||
// 번호 칸은 스프레드시트가 모름 — 읽은 문서 위에 편집본을 얹음(M02 저장과 같은 규칙)
|
||||
const merged = { ...(doc as Record<string, unknown>), ...sheet.getDoc() };
|
||||
await items.write(info.폴더, PART_OF[kind], merged);
|
||||
doc = merged;
|
||||
}
|
||||
setDirty(false);
|
||||
showToast(TEXT.saved, "success");
|
||||
} catch (error) {
|
||||
showToast(fill(TEXT.saveFailed, why(error)), "error");
|
||||
} finally {
|
||||
saving = false;
|
||||
save.disabled = !dirty;
|
||||
}
|
||||
}
|
||||
|
||||
const destroy = (): void => {
|
||||
drawing?.destroy();
|
||||
sheet?.destroy();
|
||||
drawing = null;
|
||||
sheet = null;
|
||||
root.remove();
|
||||
};
|
||||
const leave = async (): Promise<boolean> => !dirty || showConfirmDialog(TEXT.discard);
|
||||
|
||||
save.addEventListener("click", () => void doSave());
|
||||
back.addEventListener("click", async () => {
|
||||
if (await leave()) onBack();
|
||||
});
|
||||
|
||||
return { root, start: () => void start(), isDirty: () => dirty, leave, destroy };
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/* =============================================================================
|
||||
* ui_template_item_picker_project.ts
|
||||
* 항목 고르기 모달의 **자료 창구**(계획서 25-2d) — 프로젝트 복사본 · 입력 목록 · 계산을 한 곳에서 부름.
|
||||
*
|
||||
* 프로젝트 복사본(sub6 · 25-2b)
|
||||
* GET /api/projects/{id}/items → {항목: [{폴더, 이름, 원본, 원본판, 가져온날, 마스터바뀜, …}]}
|
||||
* POST /api/projects/{id}/items {layer, owner, key} 미리 가져오기 → {폴더, 만듦, 가져온날}
|
||||
* GET|PUT /api/projects/{id}/items/{폴더}/{부분} → {종류, 판, 내용} · 고침은 {판, 내용}(판이 다르면 409)
|
||||
* POST …/{폴더}/refresh 새로 가져오기(입력값은 같은 키면 살림)
|
||||
* POST …/{폴더}/calc 복사본만으로 계산(계 · 빠진 입력)
|
||||
* 입력 목록 · 항목 연결(sub4 · 25-2a) — `M02_MasterTemplete_Api_Fetch` 의 `fetchItemInputs` · `readInputLinks`
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import {
|
||||
fetchItemInputs,
|
||||
type ItemInputs,
|
||||
} from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import { layerOf, ownerOf, type PickerRow } from "./ui_template_item_picker_data";
|
||||
|
||||
/** 복사본 폴더 안 부분 — 25-1b 약속 그대로 */
|
||||
export type ProjectPart =
|
||||
"도면" | "면적" | "재료" | "자재조합" | "입력연결" | "입력값" | "고른자재";
|
||||
|
||||
/** 원본 종류 → 복사본 부분 */
|
||||
export const PART_OF: Record<"structure" | "area" | "material", ProjectPart> = {
|
||||
structure: "도면",
|
||||
area: "면적",
|
||||
material: "재료",
|
||||
};
|
||||
|
||||
export interface ProjectItemInfo {
|
||||
폴더: string;
|
||||
이름?: string | null;
|
||||
원본: { layer: string; owner: string | null; key: string; name?: string };
|
||||
원본판?: string | null;
|
||||
가져온날?: string | null;
|
||||
/** 원본(마스터 · 회사 · 개인)이 가져온 뒤 바뀜 */
|
||||
마스터바뀜?: boolean;
|
||||
원본없음?: boolean;
|
||||
구조물?: string[];
|
||||
고침?: boolean;
|
||||
}
|
||||
|
||||
/** `{층}_{주인 슬러그}_{키}` — 서버 `snapshot_folder_name` 과 같은 글(owner 없음 = none · `/` = `-`) */
|
||||
export const folderOf = (row: PickerRow): string =>
|
||||
`${layerOf(row)}_${(ownerOf(row) ?? "none").replace(/\//g, "-")}_${row.key}`;
|
||||
|
||||
/** 복사본 계산 결과에서 모달이 보이는 것 — 계 · 빠진 입력 수 · 멈춘 로직 수 */
|
||||
export interface ProjectCalc {
|
||||
total: number;
|
||||
gaps: number;
|
||||
stuck: number;
|
||||
}
|
||||
|
||||
interface Reply {
|
||||
status: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
|
||||
/** 서버가 막은 까닭 그대로 — 문장 · `{message}`(409) · 입력 검사 목록(`[{msg}]`) */
|
||||
function reasonOf(reply: Reply): string {
|
||||
const detail = (reply.body as { detail?: unknown } | null)?.detail;
|
||||
if (typeof detail === "string" && detail) return detail;
|
||||
const message = (detail as { message?: unknown } | null)?.message;
|
||||
if (typeof message === "string" && message) return message;
|
||||
if (Array.isArray(detail)) {
|
||||
const lines = detail
|
||||
.map((one) => String((one as { msg?: unknown })?.msg ?? ""))
|
||||
.filter(Boolean);
|
||||
if (lines.length) return lines.join(" · ");
|
||||
}
|
||||
return `HTTP ${reply.status}`;
|
||||
}
|
||||
|
||||
async function call(path: string, init: RequestInit = {}): Promise<Reply> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
credentials: "include",
|
||||
...init,
|
||||
headers: init.body ? { "Content-Type": "application/json" } : undefined,
|
||||
});
|
||||
return { status: response.status, body: await response.json().catch(() => null) };
|
||||
}
|
||||
|
||||
const post = (body: unknown): RequestInit => ({ method: "POST", body: JSON.stringify(body) });
|
||||
|
||||
export interface ProjectItems {
|
||||
/** 프로젝트에 가져온 항목 목록 — 서버가 항목당 0.5초쯤 걸려 모달이 열 때 한 번만 부르고 들고 있음 */
|
||||
list: () => Promise<ProjectItemInfo[]>;
|
||||
/** 복사본 부분 하나(`내용`) — 없으면 null · 판은 고칠 때를 위해 들고 있음 */
|
||||
read: (folder: string, part: ProjectPart) => Promise<unknown | null>;
|
||||
/** 복사본 고침 — 못 하면 서버 까닭 그대로 던짐(권한 · 판이 다름 …) */
|
||||
write: (folder: string, part: ProjectPart, doc: unknown) => Promise<void>;
|
||||
/** 프로젝트로 미리 가져옴 */
|
||||
importItem: (row: PickerRow) => Promise<ProjectItemInfo>;
|
||||
/** 새로 가져옴 — 복사본을 원본으로 덮음(넣은 값은 같은 키면 서버가 살림) */
|
||||
refresh: (info: ProjectItemInfo) => Promise<ProjectItemInfo>;
|
||||
/** 복사본만으로 계산 — 계산할 것이 없거나 못 하면 null */
|
||||
calc: (folder: string) => Promise<ProjectCalc | null>;
|
||||
}
|
||||
|
||||
const clients = new Map<string, ProjectItems>();
|
||||
|
||||
/** 프로젝트 하나의 창구 — 같은 프로젝트는 한 벌(고칠 때 쓸 판을 들고 있음) */
|
||||
export function projectItems(projectId: string): ProjectItems {
|
||||
const kept = clients.get(projectId);
|
||||
if (kept) return kept;
|
||||
const base = `/projects/${enc(projectId)}/items`;
|
||||
const versions = new Map<string, string | null>();
|
||||
const at = (folder: string, part: string): string => `${base}/${enc(folder)}/${enc(part)}`;
|
||||
|
||||
const client: ProjectItems = {
|
||||
list: async () => {
|
||||
const reply = await call(base);
|
||||
const rows = (reply.body as { 항목?: ProjectItemInfo[] } | null)?.항목;
|
||||
if (reply.status !== 200 || !Array.isArray(rows)) throw new Error(reasonOf(reply));
|
||||
return rows;
|
||||
},
|
||||
read: async (folder, part) => {
|
||||
const reply = await call(at(folder, part));
|
||||
if (reply.status === 404) return null;
|
||||
if (reply.status !== 200) throw new Error(reasonOf(reply));
|
||||
const got = reply.body as { 판?: string | null; 내용?: unknown };
|
||||
versions.set(`${folder}/${part}`, got.판 ?? null);
|
||||
return got.내용 ?? null;
|
||||
},
|
||||
write: async (folder, part, doc) => {
|
||||
const key = `${folder}/${part}`;
|
||||
if (!versions.has(key)) await client.read(folder, part); // 고칠 판을 먼저 앎
|
||||
const reply = await call(at(folder, part), {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ 판: versions.get(key) ?? null, 내용: doc }),
|
||||
});
|
||||
if (reply.status >= 400) throw new Error(reasonOf(reply));
|
||||
versions.set(key, (reply.body as { 판?: string } | null)?.판 ?? null);
|
||||
},
|
||||
importItem: async (row) => {
|
||||
const ref = { layer: layerOf(row), owner: ownerOf(row), key: row.key };
|
||||
const reply = await call(base, post(ref));
|
||||
if (reply.status >= 300) throw new Error(reasonOf(reply));
|
||||
const got = reply.body as { 폴더: string; 가져온날?: string | null };
|
||||
return { 폴더: got.폴더, 원본: ref, 가져온날: got.가져온날, 마스터바뀜: false };
|
||||
},
|
||||
refresh: async (info) => {
|
||||
const reply = await call(`${base}/${enc(info.폴더)}/refresh`, post({}));
|
||||
if (reply.status >= 300) throw new Error(reasonOf(reply));
|
||||
for (const key of [...versions.keys()])
|
||||
if (key.startsWith(`${info.폴더}/`)) versions.delete(key);
|
||||
inputsCache.clear(); // 원본이 바뀌었을 수 있어 입력 목록도 다시 받음
|
||||
const got = reply.body as { 가져온날?: string | null; 원본판?: string | null };
|
||||
return {
|
||||
...info,
|
||||
가져온날: got.가져온날,
|
||||
원본판: got.원본판,
|
||||
마스터바뀜: false,
|
||||
고침: false,
|
||||
};
|
||||
},
|
||||
calc: async (folder) => {
|
||||
const reply = await call(`${base}/${enc(folder)}/calc`, post({}));
|
||||
if (reply.status !== 200) return null;
|
||||
const got = reply.body as { 계?: number; 빠진입력?: unknown[]; 멈춤?: unknown[] };
|
||||
return {
|
||||
total: Number(got.계 ?? 0),
|
||||
gaps: got.빠진입력?.length ?? 0,
|
||||
stuck: got.멈춤?.length ?? 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
clients.set(projectId, client);
|
||||
return client;
|
||||
}
|
||||
|
||||
/* ── 입력 목록(원본 층 기준) — 같은 항목은 한 번만 받음 ───────────────────────── */
|
||||
const inputsCache = new Map<string, Promise<ItemInputs>>();
|
||||
|
||||
/** 원본 항목의 입력 목록(로직마다 갈라 · 합치지 않음) — 못 받으면 다음에 다시 */
|
||||
export function loadInputs(row: PickerRow): Promise<ItemInputs> {
|
||||
const id = `${layerOf(row)}|${ownerOf(row) ?? ""}|${row.key}`;
|
||||
const kept = inputsCache.get(id);
|
||||
if (kept) return kept;
|
||||
const made = fetchItemInputs(layerOf(row), row.key);
|
||||
inputsCache.set(id, made);
|
||||
made.catch(() => inputsCache.delete(id));
|
||||
return made;
|
||||
}
|
||||
Reference in New Issue
Block a user