merge(M02): origin/sub_laptop_6 받음 — UI_Main 충돌 풂(면적 일위대가 패널 살림 · 재료산출 · 조합 패널 길 지움)

- sub3 apanel · 고른 칸 · refresh 살림 · sub6 의 matPanel · matState 지움 · 면적만 남아 죽은 조합 패널(cpanel) · remount 지움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
This commit is contained in:
2026-10-04 20:39:09 +09:00
co-authored by Claude Opus 5.5
94 changed files with 393 additions and 4977 deletions
+1 -1
View File
@@ -227,7 +227,7 @@ def combo_edit(key: str, version: str, combo_: dict, owner: str = "") -> dict:
def users_of(field: str, key: str) -> list[str]:
"""집계표 · SS 장부에서 열 칸 `field`(「일위대가」 · 「자재조합」)가 `key` 인 열 이름들."""
"""집계표 · SS 장부에서 열 칸 `field`(「일위대가」)가 `key` 인 열 이름들."""
names: dict[str, str] = {}
for path in USERS:
if not path.is_file():
+5 -23
View File
@@ -39,7 +39,6 @@ from M02_MasterTemplete.M02_Item_Snapshot import (
)
from M02_MasterTemplete.M02_Item_Source import (
LABOR_FILE,
LINK_FILE,
PRICE_FILE,
build_source,
read_json,
@@ -50,16 +49,12 @@ from M02_MasterTemplete.M02_Item_Source import (
KINDS = {
"도면": "도면.json",
"면적": "면적.json",
"재료": "재료.json",
"자재조합": "자재조합.json",
"입력연결": LINK_FILE,
"입력값": VALUES_FILE,
"고른자재": PICKS_FILE,
"일위대가": LABOR_FILE, # 읽기만 — 일위대가는 원본 층에서 고침(39-1c)
}
#: 뼈대 검사에 쓰는 층 양식 종류(도면 · 면적산출 · 재료산출)
SKELETON = {"도면": "structure", "면적": "area", "재료": "material"}
SEATS = ("면적", "재료", "선택", None)
#: 뼈대 검사에 쓰는 층 양식 종류(도면 · 면적산출)
SKELETON = {"도면": "structure", "면적": "area"}
_MT = re.compile(r"^MT\d{6}$")
@@ -160,17 +155,6 @@ def _check_picks(content: Any) -> None:
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 == "일위대가":
raise ValueError("일위대가는 원본(마스터 · 회사 · 개인) 층에서 고침 — 프로젝트는 넣을 값만")
@@ -178,8 +162,6 @@ def check(kind: str, content: Any) -> None:
_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):
@@ -194,13 +176,13 @@ def write_file(
content: Any,
recalc: Callable[[dict], dict] | None = None,
) -> dict[str, Any]:
"""판이 같을 때만 씀 — 면적 · 재료는 `recalc`(서버 계산값)를 거친 것을 씀."""
"""판이 같을 때만 씀 — 면적은 `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 ("면적", "재료"):
if recalc is not None and kind == "면적":
content = recalc(content)
atomic_write_json(target / KINDS[kind], content)
manifest = read_json(target / MANIFEST_NAME) or {}
@@ -248,7 +230,7 @@ def _kept_picks(picks: Any, labor: dict[str, Any]) -> dict[str, Any]:
def reapply(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)
+1 -1
View File
@@ -2,7 +2,7 @@
붙일 폴더(`dest`) 아래 새 항목 — 대상 층 새 키(SC · SP) · 열 `본뜬` = 원본(층 · 주인 · 키) ·
같은 이름은 「이름 (2)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더 (2)」).
파일(도면 · 면적 · 재료 · 입력 연결 · 자재 조합)은 `Items.copy_item` 이 옮김. 권한은 부르는 쪽 몫.
파일(도면 · 면적 · 입력 연결)은 `Items.copy_item` 이 옮김. 권한은 부르는 쪽 몫.
"""
from __future__ import annotations
+6 -24
View File
@@ -1,9 +1,9 @@
"""프로젝트에서 개인 층으로 — [개인에 저장](save-as) · 프로젝트에서 새 항목(new) (PLAN 26-2b).
개인 층 새 항목 = 구조물집계표 새 열 하나 + 빈 도면 · 면적산출 · 재료산출(번호 · SS 키는 저장소가
줌). [개인에 저장]은 구조물 복사본의 열 정의 · 도면 · 면적 · 재료 · 자재조합 · 입력연결을 담음 —
개인 층 새 항목 = 구조물집계표 새 열 하나 + 빈 도면 · 면적산출(번호 · SS 키는 저장소가
줌). [개인에 저장]은 구조물 복사본의 열 정의 · 도면 · 면적을 담음 —
단가 묶음 · 입력값 · 고른자재는 프로젝트 몫이라 안 가져감. 쓰기는 지금 층 저장소 함수(`store.at` ·
`store.write` · `matset.create` · 항목 연결 `write_file`) — 나무 고침(`M02_MasterTemplete_Tree`)과
`store.write`) — 나무 고침(`M02_MasterTemplete_Tree`)과
같은 집계표 저장 한 줄기(경로 · 머리 줄 수 맞춤 · 번호 · SS 장부)를 탐(PLAN 26-2a).
권한(본인 개인 층 · 회사 없음 409)은 부르는 쪽 몫.
"""
@@ -15,16 +15,14 @@ from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
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
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
from M02_MasterTemplete import M02_Template_Layers as layers
from M02_MasterTemplete.M02_Item_Source import LINK_FILE, UM_FILE, read_json
from M02_MasterTemplete.M02_Item_Source import read_json
#: 복사본 파일 → 층 양식 종류
PART_KINDS = (("도면.json", "structure"), ("면적.json", "area"), ("재료.json", "material"))
PART_KINDS = (("도면.json", "structure"), ("면적.json", "area"))
def heads_of(path: list[str], name: str | None) -> list[str]:
@@ -45,17 +43,6 @@ def _free_path(doc: dict[str, Any], full: list[str]) -> list[str]:
return out
def _new_matset(row: dict[str, Any], fallback: str) -> str:
"""자재 조합 줄을 이 층에 새 키로 — 이름이 겹치면 「이름 (2)」."""
taken = {str(r.get("이름") or "") for r in matset.listing()["줄"]}
name = base = str(row.get("이름") or fallback)
n = 2
while name in taken:
name, n = f"{base} ({n})", n + 1
got = {k: row.get(k) for k in ("담은자재", "소유", "비고")}
return matset.create({**got, "이름": name, "본뜬키": None})["키"]
def create_item(
folder: Path,
path: list[str],
@@ -77,8 +64,6 @@ def create_item(
doc = table["문서"]
used = {c.get("id") for c in doc.get("열", []) if isinstance(c, dict)}
new_id = items_store._fresh_id(str(base_col.get("id") or "item").split("@")[0], used)
um_row = read_json(copy / UM_FILE) if copy else None
um_key = _new_matset(um_row, heads[0]) if isinstance(um_row, dict) else None
heads = _free_path(doc, heads)
column = {
**json.loads(json.dumps(base_col)),
@@ -88,9 +73,9 @@ def create_item(
"꼴": "수",
"단위": unit or base_col.get("단위") or base_row.get("단위"),
"일위대가": base_col.get("일위대가") or base_row.get("일위대가"),
"자재조합": um_key,
}
column.pop("본뜬열", None)
column.pop("자재조합", None) # 옛 자재 조합 칸은 안 옮김
if origin:
column["본뜬"] = origin
tree._place(doc, column) # 같은 앞마디 형제 곁(제목행 병합이 이어지게)
@@ -107,9 +92,6 @@ def create_item(
body = store._server_values(body)
with store._LOCK:
atomic_write_json(store._path(kind, new_id), body)
links = read_json(copy / LINK_FILE) if copy else None
if isinstance(links, dict) and links.get("줄"):
inputs_store.write_file(new_id, {"판": 1, "줄": links["줄"]})
ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id)
return {"key": ss["키"], "열": new_id, "이름": tree.name_of(heads)}
+7 -12
View File
@@ -1,7 +1,7 @@
"""구조물 집계 항목 복사본 — 프로젝트 `templates/structures/{구조물 id}/` (PLAN 25-2b · 26-2b).
복사본은 **구조물마다 하나** — 같은 항목을 고른 구조물 둘도 각자 도면 · 면적 · 재료 · 자재조합 ·
일위대가 · 입력연결 · 입력값 · 고른자재 · 단가를 가짐(길이 · 소재 · 높이 · 옵션이 구조물마다 다름).
복사본은 **구조물마다 하나** — 같은 항목을 고른 구조물 둘도 각자 도면 · 면적 ·
일위대가 · 입력값 · 고른자재 · 단가를 가짐(길이 · 소재 · 높이 · 옵션이 구조물마다 다름).
폴더 이름 = 구조물 id(`structure_id`) · 관 지점은 `pipe@측점`.
[저장](`PUT /route/structures` · `PUT /drainage/pipe-points`) 뒤 서버가 맞춤:
@@ -32,7 +32,6 @@ 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 (
LABOR_FILE,
LINK_FILE,
PRICE_FILE,
build_source,
item_path,
@@ -236,28 +235,24 @@ def safe_sync(project_root: str | Path) -> None:
def complete_old(folder: Path) -> bool:
"""옛 복사본(21-3 · 새 파일 없음)을 채움 — 입력연결 · 단가는 지금 원본에서 · 값은 빈 것.
"""옛 복사본(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()
name for name in (PRICE_FILE, VALUES_FILE, PICKS_FILE) if not (folder / name).is_file()
]
if not lacking and "원본판" in manifest:
return False
need_source = LINK_FILE in lacking or PRICE_FILE in lacking
need_source = PRICE_FILE in lacking
try:
source = build_source(manifest["원본"]) if need_source else {}
except LookupError:
source = {} # 원본이 지워짐 — 빈 연결 · 빈 단가로(계산은 멈춤으로 알림)
source = {} # 원본이 지워짐 — 빈 단가로(계산은 멈춤으로 알림)
for name in lacking:
atomic_write_json(
folder / name, source.get(name, {"판": None, "줄": []} if name == LINK_FILE else {})
)
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"))
+3 -26
View File
@@ -1,11 +1,9 @@
"""집계 항목 원본 — 층(시스템 · 회사 · 개인)에서 항목 내용을 모음 (PLAN 21-1 · 25-1b · 25-2b).
모으는 것 = `{파일 이름: 내용}`:
`집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA · `재료.json` SM ·
`자재조합.json` UM 줄 ·
`집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA ·
`일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄)
+ 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) ·
`입력연결.json` 층의 `inputs/<열id>.json`(없으면 빈 연결) ·
`단가.json` 계산에 닿는 마스터 줄 전부(담은 로직 + 일위대가 자재 MT)
`원본판` = 모은 내용의 해시 — 지금 층 · 마스터에서 다시 모은 판과 대어 「마스터가 바뀜」 을 앎.
프로젝트가 고치는 값(`입력값.json` · `고른자재.json`)은 원본이 아니라 여기서 안 모음.
@@ -22,11 +20,9 @@ from typing import Any
from M02_MasterTemplete import M02_Template_Layers as layers
TABLE_NAME = "구조물집계표"
UM_FILE = "자재조합.json"
#: 열 id → 스냅샷 파일(구조물 도면 · 면적산출 · 재료산출)
PART_FILES = (("structure", "도면.json"), ("area", "면적.json"), ("material", "재료.json"))
#: 열 id → 스냅샷 파일(구조물 도면 · 면적산출)
PART_FILES = (("structure", "도면.json"), ("area", "면적.json"))
LABOR_KIND = "labor" # 열 id → 일위대가 문서(39-1c) — 스냅샷은 `LABOR_FILE` 에 감싸서
LINK_FILE = "입력연결.json"
PRICE_FILE = "단가.json"
LABOR_FILE = "일위대가.json"
@@ -123,20 +119,6 @@ def logic_rows(row: dict[str, Any]) -> dict[str, Any]:
}
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
@@ -153,10 +135,6 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]:
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
doc = read_json(root / LABOR_KIND / f"{column_id}.json") if column_id else None
if isinstance(doc, dict):
labor = labor_doc_rows(doc)
@@ -164,7 +142,6 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]:
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("로직", [])]
materials = [str(m.get("코드")) for m in ((labor or {}).get("줄") or {}).get("자재") or []]
wanted = logic_keys + [code for code in materials if code]
+3 -28
View File
@@ -1,9 +1,9 @@
"""M02 일위대가 입력 목록(39-3 · 옛 선택 입력 `M02_MasterTemplete_Inputs` 에서 옮김).
"""M02 일위대가 입력 목록(39-3).
담은 단가산출 로직마다 입력 전부 · 로직마다 갈라 보이고 이름이 같아도 합치지 않음 · 입력 목록은
그 로직 줄의 `입력` 그대로(하위 로직으로 넘겨지는 입력은 맨 위 로직 입력으로만) · 자동값 =
`master_auto.values`(보기용 · 저장 안 함). 일위대가 입력 모달(`GET /api/m02/logic-lib/inputs`)과
옛 선택 입력 길(`inputs`)이 같이 씀. M01 은 읽기만 · 권한은 부르는 쪽 몫.
`master_auto.values`(보기용 · 저장 안 함). 일위대가 입력 모달(`GET /api/m02/logic-lib/inputs`)이
씀. M01 은 읽기만 · 권한은 부르는 쪽 몫.
"""
from __future__ import annotations
@@ -13,7 +13,6 @@ from typing import Any
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_Combo_Store as combos
_SPEC = ("이름", "설명", "단위", "고르기", "범위")
@@ -41,17 +40,6 @@ def _logics(codes: list[str]) -> tuple[list[dict], list[str]]:
return found, missing
def combo_logics(ua: str | None) -> tuple[dict[str, Any] | None, list[dict], list[str]]:
"""(조합 줄, [로직 줄 …], 빠진 로직 키) — 조합이 없거나 못 찾으면 (None, [], [])."""
if not ua:
return None, [], []
row = combos.find(str(ua)) # 마스터 UA · 회사 UC · 개인 UP(27-2c)
if row is None:
return None, [], []
found, missing = _logics(combo_store.mcb.logic_keys(row))
return row, found, missing
def _listed(logics: list[dict]) -> list[dict[str, Any]]:
"""로직 줄마다 `{로직, 이름, 결과단위, 입력: [{이름, 설명, 단위, 고르기?, 범위?, 자동값}]}`."""
whole = m01.loaded()[1]
@@ -79,16 +67,3 @@ def logic_inputs(codes: list[str]) -> dict[str, Any]:
"""일위대가 입력 모달 — 로직 키들의 입력 목록 `{로직: [...], 빠진로직}`(키 차례 · 겹침 한 번)"""
found, missing = _logics(list(dict.fromkeys(c for c in codes if c)))
return _num({"로직": _listed(found), "빠진로직": missing})
def inputs_of(ua: str | None) -> dict[str, Any]:
"""옛 선택 입력 목록 — `{일위대가, 이름, 로직: [...], 빠진로직}` · 일위대가 없으면 로직 없음."""
row, logics, missing = combo_logics(ua)
return _num(
{
"일위대가": ua or None,
"이름": row.get("이름") if row else None,
"로직": _listed(logics),
"빠진로직": missing,
}
)
-1
View File
@@ -19,7 +19,6 @@
min-width: 0;
}
.m02-inputval__places,
.m02-inputval__missing {
margin: 0;
color: var(--color-text-secondary);
+26 -25
View File
@@ -1,16 +1,22 @@
/* =============================================================================
* M02_Labor_Values.ts
* 넣을 값 칸(39-3 · 옛 선택 입력 `M02_MasterTemplete_Inputs.ts` 에서 옮김) — 값 칸 규칙 한 벌:
* 넣을 값 칸(39-3) — 값 칸 규칙 한 벌:
* 고르기 = 목록 · 범위 = 숫자(밖이면 붉은 글) · 빈 칸 = 자동값(흐리게 · placeholder).
* · `createValueControl` — 칸 하나(일위대가 입력 모달 · 프로젝트 값 카드가 같이 씀)
* · `createInputsValueCard` — 프로젝트 고르기 모달 값 카드(옛 자리 `M02_MasterTemplete_Inputs.ts` 가 다시 내보냄)
* · `createInputsValueCard` — 프로젝트 고르기 모달 값 카드(담은 로직 입력 전부)
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import type { ItemInput, ItemInputs, ItemLogic, InputLinks } from "./M02_MasterTemplete_Api_Fetch";
import type { ItemInput, ItemInputs, ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
import { cardNote, cardShell } from "./M02_MasterTemplete_CardShell";
import { inputKey, pickedGroups, seatsOf } from "./M02_MasterTemplete_Inputs_Model";
import { choicesOf, isOutside, rangeOf, storedOf, type ValueDoc } from "./M02_Labor_Values_Model";
import {
choicesOf,
inputKey,
isOutside,
rangeOf,
storedOf,
type ValueDoc,
} from "./M02_Labor_Values_Model";
import "./M02_Labor_Values.css";
const fill = (text: string, values: Record<string, string | number>): string =>
@@ -82,20 +88,19 @@ export function createValueControl(
}
/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 고르기 모달 카드 ─────────────────────────────
* 같은 카드 틀 · 같은 입력 목록인데 여기서는 자리 고르기 대신 **값**을 받음: 자리가 「선택」 인 입력만
* 같은 카드 틀 · 같은 입력 목록에서 **값**을 받음: 담은 로직 입력 전부를
* 로직마다 묶어, 고르기 = 목록 · 범위 = 숫자 · 자동값은 흐리게 · 「확인함」 표시. 자동값은 저장값이 아님
* (확인 안 한 입력은 계산 때 「빠진 입력」). 값은 로직마다 따로 — 이름이 같아도 합치지 않음.
* mode "view" 보기만 + [선택 입력 수정] · mode "value" 값 받기(범위 밖은 저장 막음) */
* mode "view" 보기만 + [넣을 값 수정] · mode "value" 값 받기(범위 밖은 저장 막음) */
export interface InputsValueData {
list: ItemInputs;
link: InputLinks | null;
values: ValueDoc;
}
export interface InputsValueOptions {
mode: "view" | "value";
/** mode "view" — [선택 입력 수정] */
/** mode "view" — [넣을 값 수정] */
onEdit?: () => void;
/** mode "value" — 값이 바뀔 때마다 */
onChange?: (values: ValueDoc) => void;
@@ -111,21 +116,20 @@ export interface InputsValueHandle {
getValues: () => ValueDoc;
/** 범위 밖 값이 없나 */
isValid: () => boolean;
/** [선택 입력 수정] 글 — 아직 안 가져온 항목이면 「프로젝트로 가져와 고침」 */
/** [넣을 값 수정] 글 — 아직 안 가져온 항목이면 「프로젝트로 가져와 고침」 */
setEditLabel: (label: string) => void;
}
const VALUE_TEXT = {
title: "선택 입력",
title: "넣을 값",
missing: "빠진 로직 {value}",
edit: "선택 입력 수정",
none: "선택으로 이은 입력이 없음",
edit: "넣을 값 수정",
none: "넣을 값이 없음 — 담은 로직에 입력이 없음",
auto: "자동값",
unconfirmed: "확인 안 함",
confirmed: "✓ 확인함",
confirmBox: "확인",
summary: "선택 {value}",
places: "면적산출 알약 {area} · 재료산출 알약 {material}",
summary: "넣을 값 {value}",
noList: "입력 목록을 못 읽음 — {value}",
};
@@ -140,20 +144,22 @@ export function createInputsValueCard(opts: InputsValueOptions): InputsValueHand
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" });
// 공용 카드 틀(31-7) — 부제 = 확인 n/m · 제목 줄 오른쪽 = [선택 입력 수정] · 상태 글은 목록 칸에
// 공용 카드 틀(31-7) — 부제 = 확인 n/m · 제목 줄 오른쪽 = [넣을 값 수정] · 상태 글은 목록 칸에
const shell = cardShell({
title: VALUE_TEXT.title,
sub: [summary],
body: el("div", { className: "m02-inputval__wrap", children: [places, body] }),
body: el("div", { className: "m02-inputval__wrap", children: [body] }),
stateHost: body,
});
shell.tools.append(edit);
const root = shell.root;
const groups = (): ReturnType<typeof pickedGroups> =>
data ? pickedGroups(data.list, data.link) : [];
/** 로직마다 묶은 입력 — 입력이 없는 로직은 뺌 */
const groups = (): { logic: ItemLogic; inputs: ItemInput[] }[] =>
(data?.list.로직 ?? [])
.map((logic) => ({ logic, inputs: logic.입력 }))
.filter((group) => group.inputs.length > 0);
const one = (logic: string, name: string): ValueEntry =>
values[logic]?.[name] ?? { 값: null, 확인: false };
const isEmpty = (value: string | number | null): boolean => value === null || value === "";
@@ -239,14 +245,9 @@ export function createInputsValueCard(opts: InputsValueOptions): InputsValueHand
invalid = 0;
paintSummary();
if (!data) {
places.textContent = "";
if (note && retry) return shell.fail(fill(VALUE_TEXT.noList, { value: note }), retry);
return shell.note(note ? fill(VALUE_TEXT.noList, { value: note }) : VALUE_TEXT.none);
}
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
? [
+4 -1
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* M02_Labor_Values_Model.ts
* 넣을 값 셈(화면 없음 · 39-3) — 옛 선택 입력 `M02_MasterTemplete_Inputs_Model.ts` 의 값 함수를 옮김.
* 넣을 값 셈(화면 없음 · 39-3).
* 일위대가 입력 모달 · 프로젝트 고르기 모달 값 카드가 같은 값 칸 규칙(고르기 = 목록 · 범위 = 숫자 ·
* 빈 값 = 자동값)을 씀. 일위대가 입력값 = `{입력 이름: 값}`(빈 값은 안 둠 = 자동값 · 모르는 이름 버림).
* ========================================================================== */
@@ -32,6 +32,9 @@ export function valueHint(input: ItemInput): string {
export const autoText = (input: ItemInput): string => text(input.자동값);
/** `입력값.json` — `{로직키: {입력이름: {값, 확인}}}` (서버 검사 모양 · 값은 수 · 글 · 빈 값) */
/** 입력 키 `{로직키}|{입력이름}` — 같은 이름의 입력도 로직마다 따로 */
export const inputKey = (logic: string, name: string): string => `${logic}|${name}`;
export type ValueDoc = Record<
string,
Record<string, { 값: string | number | null; 확인: boolean }>
@@ -8,9 +8,9 @@ import { API_BASE_URL } from "@config/config_frontend";
/** 층 이름 — 서버와 같은 글 */
export type Layer = "system" | "company" | "personal" | "project";
/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure ·
* 「면적산출」 = area · 「재료산출」 = material(둘 다 구조물집계표 열마다 한 통합문서) ·
* 「면적산출」 = area(구조물집계표 열마다 한 통합문서) ·
* 「일위대가」 = labor(열마다 문서 · 39장) */
export type Kind = "table" | "drawing" | "structure" | "area" | "material" | "labor";
export type Kind = "table" | "drawing" | "structure" | "area" | "labor";
export interface TemplateInfo {
종류: Kind;
@@ -130,7 +130,7 @@ export const deleteLayerTemplate = (
/* --- 구조물 도면(종류 structure · 이름 = 구조물집계표 열 id) ---
* 읽기 · 저장 · 지우기는 위 readTemplate · saveTemplate · deleteTemplate 에 "structure" */
/** 구조물 도면 문서 — `도면` = 도면 양식과 같은 CAD 문서 · 면적산출 · 재료산출은 따로(같은 열 id) */
/** 구조물 도면 문서 — `도면` = 도면 양식과 같은 CAD 문서 · 면적산출은 따로(같은 열 id) */
export interface StructureDoc {
양식: "구조물도면";
종류: "structure";
@@ -144,21 +144,20 @@ export interface StructureDoc {
export const listStructures = async (): Promise<TemplateInfo[]> =>
(await listTemplates("system", null)).filter((t) => t.종류 === "structure");
/** 새로 — 도번 자동 · 빈 도면 (+ 없으면 빈 면적산출 · 재료산출 통합문서) · 이미 있으면 StaleError */
/** 새로 — 도번 자동 · 빈 도면 (+ 없으면 빈 면적산출 통합문서) · 이미 있으면 StaleError */
export const createStructure = (column: string): Promise<TemplateDoc> =>
scoped(`/structures/${enc(column)}`, { method: "POST" });
/** 한 열의 번호 — 도번 `SD` · 면적번 `SA` · 재료번 `SM` + 같은 8자리
/** 한 열의 번호 — 도번 `SD` · 면적번 `SA` + 같은 8자리
* [종류][분류1][분류2][분류3](없는 쪽은 빈 글) */
export interface StructureNumbers {
도번: string;
면적번: string;
재료번: string;
/** 일위대가 `SU-######`(39장) */
일위대가번: string;
}
/** `{열 id: {도번, 면적번, 재료번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */
/** `{열 id: {도번, 면적번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */
export const fetchStructureNumbers = (): Promise<Record<string, StructureNumbers>> =>
scoped("/structures/numbers");
@@ -172,8 +171,6 @@ export interface StructureCombo extends StructureNumbers {
분류3: string | null;
단위: string | null;
일위대가: string | null;
/** 자재 조합 키 `UM` */
자재조합: string | null;
바인딩: unknown;
펼침: unknown;
}
@@ -210,72 +207,13 @@ export const branchCombo = (
): Promise<BranchedCombo> =>
scoped(`/structures/${enc(column)}/combo`, { method: "POST", body: JSON.stringify(body) });
/** 있는 조합을 이 열에 씀 — 집계표 그 열 일위대가 · 자재조합 키만 바꿈(조합은 그대로 · `새로만듦` false) */
/** 있는 조합을 이 열에 씀 — 집계표 그 열 일위대가 키만 바꿈(조합은 그대로 · `새로만듦` false) */
export const setColumnCombo = (
column: string,
body: { 종류: "일위대가" | "자재조합"; 키: string; 판?: string },
body: { 종류: "일위대가"; 키: string; 판?: string },
): Promise<BranchedCombo> =>
scoped(`/structures/${enc(column)}/use-combo`, { method: "POST", body: JSON.stringify(body) });
/** 자재 조합(키 `UM`) 담은 자재 한 줄 */
export interface MaterialItem {
코드: string;
이름: string | null;
규격: string | null;
단위: string | null;
}
/** 자재 조합 한 줄 — 본뜬키 = 갈라 낸 원래 키(없으면 null) */
export interface MaterialCombo {
키: string;
이름: string;
담은자재: MaterialItem[];
본뜬키: string | null;
소유: string;
비고: string | null;
}
export const fetchMaterialCombo = (key: string): Promise<MaterialCombo> =>
scoped(`/material-combos/${enc(key)}`);
/** 이 열의 자재 조합을 고친 담은 자재로 갈라 냄 — 규칙은 `branchCombo` 와 같음 ·
* `본뜬키` 빈 글 = 이 열의 첫 자재 조합 */
export const branchMaterialCombo = (
column: string,
body: { 본뜬키?: string; 담은자재: MaterialItem[]; 이름: string; 판?: string },
): Promise<BranchedCombo> =>
scoped(`/structures/${enc(column)}/material-combo`, {
method: "POST",
body: JSON.stringify(body),
});
/* --- 자재 조합 표(마스터 템플릿 `자재조합.json`) — 좌측 「자재 조합」 컨테이너 --- */
/** 표 통째 — 판(파일 판본) · 줄(담은 자재 포함) · 쓰는곳(키 → 쓰는 구조물 열 이름들) */
export const listMaterialCombos = (): Promise<{
판: string;
줄: MaterialCombo[];
쓰는곳: Record<string, string[]>;
}> => scoped("/material-combos");
/** 새로 · 본떠 만들기(`본뜬키`) — 키는 서버가 표 「다음」 번호로 줌 */
export const createMaterialCombo = (
판: string,
줄: Omit<MaterialCombo, "키">,
): Promise<{ 키: string; 판: string; 줄: MaterialCombo }> =>
scoped("/material-combos", { method: "POST", body: JSON.stringify({ 판, 줄 }) });
/** 고치기 — 이름 · 비고 · 담은자재(본뜬키 · 소유는 서버가 있던 것으로) */
export const editMaterialCombo = (
키: string,
판: string,
줄: MaterialCombo,
): Promise<{ 키: string; 판: string; 줄: MaterialCombo }> =>
scoped(`/material-combos/${enc(키)}`, { method: "PUT", body: JSON.stringify({ 판, 줄 }) });
/** 지우기 — 구조물 열이 쓰면 서버가 막고 그 열 이름들을 까닭으로 줌 */
export const deleteMaterialCombo = (키: string, 판: string): Promise<unknown> =>
scoped(`/material-combos/${enc(키)}?판=${enc(판)}`, { method: "DELETE" });
/* --- 구조물 옵션 보이기(마스터 템플릿 `구조물옵션표시.json`) — 좌측 「구조물 옵션 보이기」 줄 --- */
export interface OptionKind {
type_id: string;
@@ -323,12 +261,7 @@ export const copyArea = async (column: string, from: string): Promise<void> => {
await scoped(`/area/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
};
/** 다른 열(`from`) 재료산출 내용(알약 연결 포함)을 받는 열(`column`)로 가져옴 — 재료번 · 열은 그대로 */
export const copyMaterial = async (column: string, from: string): Promise<void> => {
await scoped(`/material/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
};
/** 회사 · 개인 층의 집계표 항목(열 하나 + 도면 · 면적 · 재료 파일 + 자재조합 줄)을 내 개인 층으로 복사 —
/** 회사 · 개인 층의 집계표 항목(열 하나 + 도면 · 면적 파일)을 내 개인 층으로 복사 —
* 새 SS 키로 만들고 `본뜬` 표시 · `fromOwner` = 회사 층 회사 · 개인 층 「회사/사용자」 */
export const copyItemToPersonal = (body: {
fromLayer: Layer;
@@ -365,15 +298,8 @@ 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;
}
/* --- 넣을 값 입력 목록(39-3) — 일위대가 담은 로직마다 입력(고르기 · 범위 · 자동값) ---
* 길은 `M02_MasterTemplete_Labor_Api` 의 `fetchLogicInputs` · 모양만 여기 */
/** 로직의 입력 하나 — 고르기(선택지) · 범위 · 자동값은 보이기만 */
export interface ItemInput {
@@ -399,42 +325,6 @@ export interface ItemInputs {
빠진로직: 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 =>
owned(layer, `/layers/${layer}/items/${enc(key)}/${tail}`);
export const fetchItemRows = (layer: Layer): Promise<ItemRow[]> =>
call<ItemRow[]>(owned(layer, `/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({ 판, 줄 }) });
/* --- 구조물 라이브러리(PLAN 26-1 · 26-3a) — 폴더 경로 깊이 자유 · 고침은 층 쓰기 권한 --- */
export interface TreeItem {
key: string;
@@ -7,8 +7,8 @@
개인에 저장 · 프로젝트에서 새 항목은 그 층의 새 키(본뜬 곳은 열 `본뜬`). 회사 · 개인 층에 남은
옛 `SS` 키는 맞출 때 새 모양으로 옮기고(숫자 여섯 그대로 · 겹치면 다음 번호) 옛 키는 줄 `옛키` 에
남김 — 프로젝트 복사본이 들고 있는 옛 키로도 찾음(`find`).
줄 = `{키, 열, 경로, 이름, 종류, 분류1~3(경로 앞 넷 — 옛 코드가 읽음), 단위, 일위대가, 자재조합,
도번, 면적번, 재료번, 바인딩, 펼침, 옛키?}`. `M02_MasterTemplete_Store.py` 가 커서 떼어 냄 —
줄 = `{키, 열, 경로, 이름, 종류, 분류1~3(경로 앞 넷 — 옛 코드가 읽음), 단위, 일위대가,
도번, 면적번, 바인딩, 펼침, 옛키?}`. `M02_MasterTemplete_Store.py` 가 커서 떼어 냄 —
저장소는 `store.sync_combos` · `store.read_combos` · `store.find_combo` 이름으로 이 파일을 부름.
"""
@@ -75,7 +75,6 @@ def _row(
**tree.old_fields(path),
"단위": col.get("단위"),
"일위대가": col.get("일위대가"),
"자재조합": col.get("자재조합"),
**{field: numbers.get(field, "") for _, field, _, _ in store.PARTS},
"바인딩": col.get("바인딩"),
"펼침": col.get("펼침"),
@@ -2,7 +2,6 @@
가리키게 함(PLAN 15장 · 검토 `14_조합편집검토.md` 1장).
· 일위대가 조합(UA · UC · UP) — 새 줄은 지금 층 조합 저장소가 만듦(`Combo_Store` · PLAN 27-2c:
마스터 층 = UA · 회사 층 = UC · 개인 층 = UP · 고르기는 세 층 중 이 층이 쓸 수 있는 것)
· 자재 조합(UM · 마스터 템플릿 `자재조합.json`) — M02 자재 조합 저장소가 만듦(표 「다음」 번호)
조합은 여러 열 · 모든 프로젝트가 같이 씀 — 제자리 고치지 않음. 담은 것(차례 · 메모까지)이
이미 있는 조합과 같으면 새로 안 만들고 그 키(원래 키 먼저) · 새 줄의 본뜬키 = 원래 키 · 소유 현장.
@@ -13,9 +12,7 @@ from __future__ import annotations
from typing import Any, Callable
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
from M02_MasterTemplete import M02_MasterTemplete_Matset as matset
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
_OWNER = "현장"
@@ -41,7 +38,7 @@ def _branch(
name: str,
) -> dict[str, Any]:
"""집계표 판 검사 → 같은 내용 찾기 · 없으면 `make(이름, 줄들)` 로 새 줄 → 집계표 그 열 `field`.
`slot` = 담은 칸(담은로직 · 담은자재) · `ident` = 담은 줄에서 견주는 칸(로직 · 메모 / 코드)."""
`slot` = 담은 칸(담은로직) · `ident` = 담은 줄에서 견주는 칸(로직 · 메모)."""
table = _table(column, version)
if not isinstance(items, list) or not items:
raise store.StoreError(400, "담은 것이 비었음")
@@ -84,11 +81,9 @@ def _point(table: dict, column: str, field: str, key: str) -> str:
def use_combo(column: str, field: str, key: str, version: str = "") -> dict[str, Any]:
"""있는 조합을 이 열에 씀 — `field` 일위대가(UA) · 자재조합(UM) · 조합은 그대로(고르기만)."""
"""있는 조합을 이 열에 씀 — `field` 일위대가(UA) · 조합은 그대로(고르기만)."""
if field == "일위대가":
name = _combo(key).get("이름")
elif field == "자재조합":
name = material_combo(key).get("이름")
else:
raise store.StoreError(400, f"모르는 조합 종류 「{field}」")
판 = _point(_table(column, version), column, field, key)
@@ -133,31 +128,3 @@ def branch_combo(
return _branch(
column, version, "일위대가", rows, "담은로직", ("로직", "메모"), items, source, make, name
)
def material_combo(key: str) -> dict[str, Any]:
return matset.one(key)
def branch_material_combo(
column: str, source: str, items: list[dict], name: str, version: str = ""
) -> dict[str, Any]:
"""자재 조합 갈라 내기 — `source` 가 빈 글이면 이 열의 첫 자재 조합(본뜬키 null)."""
was = material_combo(source) if source else {}
items = [
{k: x.get(k) for k in ("코드", "이름", "규격", "단위")}
for x in items or []
if isinstance(x, dict)
]
def make(made_name: str, _rows) -> str:
row = {"이름": made_name, "담은자재": items, "본뜬키": source or None, "소유": _OWNER}
return matset.create(row)["키"]
heads = next((c for c in store._structure_columns() if c["id"] == column), {})
default = tree.name_of(tree.path_of(heads)) if heads else column # 경로 이음(26-1)
name = name.strip() or str(was.get("이름") or default or column)
rows = matset.listing()["줄"]
return _branch(
column, version, "자재조합", rows, "담은자재", ("코드",), items, source, make, name
)
@@ -1,6 +1,6 @@
/* =============================================================================
* M02_MasterTemplete_CardShell.ts
* 항목 카드 공용 틀(PLAN 31-7) — 도면 정보 · 면적산출 · 재료산출 · 선택 입력(관리 화면 · 고르기 모달)이
* 항목 카드 공용 틀(PLAN 31-7) — 도면 정보 · 면적산출 · 넣을 값(관리 화면 · 고르기 모달)이
* 내용만 넣음. 모양 · 높이 · 스크롤은 `M02_MasterTemplete_Structure.css`.
* 머리 = 제목 줄(h3 · 제목 · 저장 안 한 점 ● · 오른쪽 단추 자리) + 한 줄 부제(번호 · 이름 · 넘치면 말줄임)
* 본문 = 카드 높이를 채움 · 넘치면 카드 안 스크롤 · 상태 = 불러오는 중 · 실패 + [다시 불러오기] · 비었음
@@ -73,45 +73,3 @@
margin: 0;
line-height: 1.5;
}
/* 재료산출 — 오른쪽 조합 패널 「이 열의 조합」 칸: 담은 자재 표 */
.m02-mat {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
font-size: var(--text-body-sm);
}
/* 패널 폭이 좁아도 줄이 넘치지 않게 — 이름 · 규격 칸이 줄어듦 */
.m02-mat .m02-mat__row {
grid-template-columns: 5.5rem minmax(0, 1.4fr) minmax(0, 1fr) 2.5rem 3rem auto;
}
.m02-mat__title {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4) var(--spacing-12);
color: var(--color-text);
}
.m02-mat__row {
display: grid;
grid-template-columns: 5.5rem minmax(6rem, 1.4fr) minmax(4rem, 1fr) 3rem 4rem auto;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-4) 0;
border-bottom: 1px solid var(--color-border);
overflow-wrap: anywhere;
}
.m02-mat__row--head {
color: var(--color-text-secondary);
font-size: var(--text-caption);
font-weight: 600;
}
.m02-mat__foot {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
@@ -3,7 +3,6 @@
* 면적산출 편집 화면 — 오른쪽 조합 패널 「이 열의 조합」 칸의 조합 줄 + [조합 고치기](시스템 관리자만).
* 일위대가 조합(UA)은 여러 열 · 모든 프로젝트가 같이 씀 → 제자리 고치지 않고 고친 내용으로 새 조합을 만들어
* 이 열만 가리킴(서버 `branchCombo`) · 원래 조합은 그대로. 담은 로직 표는 M01 조합 화면과 같은 줄 모양.
* 갈라 내기 모달(`askBranch`)은 재료산출 자재 목록(`M02_MasterTemplete_Materials.ts`)도 씀.
* ========================================================================== */
import {
@@ -1,11 +1,11 @@
/* =============================================================================
* M02_MasterTemplete_ComboPanel.ts
* 면적산출 · 재료산출 편집 화면 오른쪽 안쪽 패널 — 좌측 패널과 같은 공용 틀(사이드바 카드 · 접기) ·
* 면적산출 편집 화면 오른쪽 안쪽 패널 — 좌측 패널과 같은 공용 틀(사이드바 카드 · 접기) ·
* 공용 폭 손잡이(`ui_template_resizer` · 폭 기억 · 최소 폭) · 공용 접기 손잡이(`ui_template_overlay`).
* · 「이 열의 조합」 — 면적 = 일위대가 조합 줄 + [조합 고치기](`M02_MasterTemplete_Combo.ts`) ·
* 재료 = 담은 자재 표(`M02_MasterTemplete_Materials.ts`) — 넣는 쪽이 `setMine` 으로 갈아 끼움
* · 조합 목록 — 면적 = 일위대가 조합(이 층이 쓸 수 있는 마스터 UA · 회사 UC · 개인 UP · 27-2c) ·
* 재료 = 자재 조합(UM) · 찾기 · 이 열 조합 강조 ·
* · 「이 열의 조합」 — 일위대가 조합 줄 + [조합 고치기](`M02_MasterTemplete_Combo.ts`) ·
* 넣는 쪽이 `setMine` 으로 갈아 끼움
* · 조합 목록 — 일위대가 조합(이 층이 쓸 수 있는 마스터 UA · 회사 UC · 개인 UP · 27-2c) ·
* 찾기 · 이 열 조합 강조 ·
* 하나 골라 [이 열에 쓰기] = 집계표 그 열 키만 바꿈(서버 `use-combo` · 조합은 그대로)
* ========================================================================== */
@@ -21,7 +21,7 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createPanelResizer } from "@ui/ui_template_resizer";
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
import { fetchUsableCombos } from "./M02_MasterTemplete_Labor_Api";
import { getActiveLayer, listMaterialCombos, setColumnCombo } from "./M02_MasterTemplete_Api_Fetch";
import { getActiveLayer, setColumnCombo } from "./M02_MasterTemplete_Api_Fetch";
import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api";
import "./M02_MasterTemplete_ComboPanel.css";
@@ -30,10 +30,8 @@ const TEXT = {
mine: "이 열의 조합",
noneArea: "이 열에 일위대가 조합 없음",
listArea: "일위대가 조합 목록",
listMaterial: "자재 조합 목록",
search: "키 · 이름으로 찾기",
logics: "로직 {n}",
items: "자재 {n}",
origin: "본뜬 {value}",
current: "이 열",
empty: "맞는 조합 없음",
@@ -61,7 +59,7 @@ interface Row {
}
export interface ComboPanelOptions {
kind: "area" | "material";
kind: "area";
/** 집계표 열 id */
column: string;
isAdmin: boolean;
@@ -71,7 +69,7 @@ export interface ComboPanelOptions {
export interface ComboPanel {
root: HTMLElement;
/** 「이 열의 조합」 부분을 갈아 끼움(면적 = 조합 줄 · 재료 = 담은 자재 표) */
/** 「이 열의 조합」 부분을 갈아 끼움(조합 줄) */
setMine: (node: HTMLElement | null) => void;
/** 목록 · 이 열 조합 키를 다시 받음(갈라 낸 뒤 등) */
refresh: () => Promise<void>;
@@ -79,7 +77,6 @@ export interface ComboPanel {
}
export function buildComboPanel(opts: ComboPanelOptions): ComboPanel {
const area = opts.kind === "area";
let rows: Row[] = [];
let current: string | null = null;
let picked: string | null = null;
@@ -97,10 +94,7 @@ export function buildComboPanel(opts: ComboPanelOptions): ComboPanel {
const use = createButton({ label: TEXT.use, variant: "filled", size: "sm" });
const body = el("div", {
className: "m02-cpanel__body ui-sidebar-panel",
children: [
card(TEXT.mine, mine),
card(area ? TEXT.listArea : TEXT.listMaterial, find.root, list),
],
children: [card(TEXT.mine, mine), card(TEXT.listArea, find.root, list)],
});
attachCollapsible(body);
@@ -138,7 +132,7 @@ export function buildComboPanel(opts: ComboPanelOptions): ComboPanel {
...(shown.length
? shown.map((r) => {
const meta = [
fill(area ? TEXT.logics : TEXT.items, r.수),
fill(TEXT.logics, r.수),
r.본뜬키 ? fill(TEXT.origin, r.본뜬키) : "",
r.키 === current ? TEXT.current : "",
];
@@ -177,26 +171,17 @@ export function buildComboPanel(opts: ComboPanelOptions): ComboPanel {
try {
const [base, got] = await Promise.all([
readColumnBase(opts.column),
area
? fetchUsableCombos(getActiveLayer()).then((cs) =>
cs.map((c) => ({
키: c.키,
이름: c.이름,
수: c.count, // 목록 요약엔 담은로직이 없음 — 수만 옴
본뜬키: c.본뜬키 ?? null,
})),
)
: listMaterialCombos().then((d) =>
d.줄.map((m) => ({
키: m.키,
이름: m.이름,
수: m.담은자재.length,
본뜬키: m.본뜬키,
})),
),
fetchUsableCombos(getActiveLayer()).then((cs) =>
cs.map((c) => ({
키: c.키,
이름: c.이름,
수: c.count, // 목록 요약엔 담은로직이 없음 — 수만 옴
본뜬키: c.본뜬키 ?? null,
})),
),
]);
if (mineSeq !== seq) return;
current = (area ? base.일위대가 : base.자재조합) ?? null;
current = base.일위대가 ?? null;
rows = got;
} catch (error) {
if (mineSeq !== seq) return;
@@ -213,7 +198,7 @@ export function buildComboPanel(opts: ComboPanelOptions): ComboPanel {
use.disabled = true;
try {
const got = await setColumnCombo(opts.column, {
종류: area ? "일위대가" : "자재조합",
종류: "일위대가",
키: row.키,
});
showToast(fill(TEXT.used, `${got.키} ${got.이름}`), "success");
@@ -1,7 +1,7 @@
/* =============================================================================
* M02_MasterTemplete_Combo_Api.ts
* 조합 고치기 · 자재 목록 화면이 함께 쓰는 읽기 — 집계표 열이 가리키는 조합 키 · 같이 쓰는 다른 열 수 ·
* 서버 까닭 글. 갈라 내기 길(`branchCombo` · `branchMaterialCombo` · `fetchMaterialCombo`)은 `M02_MasterTemplete_Api_Fetch.ts`.
* 조합 고치기 화면이 쓰는 읽기 — 집계표 열이 가리키는 조합 키 · 같이 쓰는 다른 열 수 ·
* 서버 까닭 글. 갈라 내기 길(`branchCombo`)은 `M02_MasterTemplete_Api_Fetch.ts`.
* ========================================================================== */
import { getActiveLayer, readTemplate } from "./M02_MasterTemplete_Api_Fetch";
@@ -12,17 +12,15 @@ const STRUCTURE_TABLE = "구조물집계표";
/** 집계표 열이 가리키는 조합 키 + 같은 조합을 쓰는 다른 열 수 */
export interface ColumnBase {
일위대가: string | null;
자재조합: string | null;
다른열일위대가: number;
다른열자재조합: number;
}
/** 서버가 준 까닭 글 */
export const reasonOf = (error: unknown): string => (error instanceof Error ? error.message : "");
/** 집계표(시스템 층)에서 이 열의 일위대가 · 자재조합 키와 같이 쓰는 다른 열 수 */
/** 집계표(시스템 층)에서 이 열의 일위대가 키와 같이 쓰는 다른 열 수 */
export async function readColumnBase(column: string): Promise<ColumnBase> {
type Col = { id: string; 일위대가?: string | null; 자재조합?: string | null };
type Col = { id: string; 일위대가?: string | null };
const table = await readTemplate(getActiveLayer(), "table", STRUCTURE_TABLE, null).catch(
() => null,
);
@@ -32,8 +30,6 @@ export async function readColumnBase(column: string): Promise<ColumnBase> {
key ? cols.filter((c) => c.id !== column && pick(c) === key).length : 0;
return {
일위대가: mine?.일위대가 || null,
자재조합: mine?.자재조합 || null,
다른열일위대가: same(mine?.일위대가, (c) => c.일위대가),
다른열자재조합: same(mine?.자재조합, (c) => c.자재조합),
};
}
@@ -1,105 +0,0 @@
/* 「선택 입력」 카드 — 로직마다 묶은 입력 줄 + 줄마다 자리 고르기. 색은 테마 토큰. */
.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;
}
@@ -1,172 +0,0 @@
"""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 pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.M02_Labor_Inputs import _num, combo_logics
PLACES = ("면적", "재료", "선택", None)
PILL_HEAD = "입력"
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 = store.find_combo(key) # 옛 키(회사 · 개인 층 SS)로도
if row is None:
raise store.StoreError(404, f"없는 항목 「{key}」")
return row
def _combo_row(ua: str) -> dict[str, Any] | None:
return combos.find(ua) # 마스터 UA · 회사 UC · 개인 UP(27-2c)
def inputs(key: str) -> dict[str, Any]:
"""25-1b 입력 목록 — `{일위대가, 이름, 로직: [{로직, 이름, 결과단위, 입력: [...]}], 빠진로직}`.
일위대가 없는 항목은 로직 빈 목록 · 몸은 `M02_Labor_Inputs`(일위대가 입력 모달과 한 벌)."""
return labor_inputs.inputs_of(_item(key).get("일위대가"))
# ── 항목 연결 파일 ─────────────────────────────────────
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_pills` · 권한 무관 읽기) + 자리를
그 시트로 이은 입력 알약 · 재료면 이은 입력 알약만. 입력 알약 = `{키, 이름, 단위, 로직, 입력}` —
이름은 로직마다 갈라 「입력 · 로직 이름」(같은 이름 입력이 여러 로직에 있어도 안 합침)."""
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 := _combo_row(str(ua))):
quantity = combo_store.combo_pills(combo)["물량"]
_, 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
@@ -1,290 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Inputs.ts
* 구조물집계표 아래 넷째 카드 「선택 입력」(PLAN 25-2c) — 고른 열(항목) 일위대가의 로직마다 묶어
* 입력 목록을 보이고, 입력마다 자리(면적산출 알약 · 재료산출 알약 · 선택 · 안 씀)를 사용자가 고름.
*
* · 처음은 모두 「안 씀」 — 프로그램이 추측해 나누지 않음 · 이름이 같은 입력도 로직마다 따로.
* · 마스터 층에서는 값을 받지 않음 — 자리 연결만(값은 프로젝트 복사본 몫 · 25-2b).
* · 저장은 집계표 [저장]과 같은 단추 흐름 — 여기는 고른 것을 초안으로 들고 있다가 `save()` 가 서버로 보냄
* (자동저장 없음). 고름이 바뀌면 `onDirty` 로 메인에 알림(떠날 때 물음).
* · 면적 · 재료로 고른 입력은 그 시트 편집 화면 알약 줄에 뜸(`inputPills` → `pillsFor`).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links";
import {
fetchItemInputs,
fetchItemRows,
getActiveLayer,
readInputLinks,
saveInputLinks,
type InputSeat,
type ItemInputs,
type ItemRow,
type Layer,
} from "./M02_MasterTemplete_Api_Fetch";
import {
autoText,
countOf,
inputKey,
linesOf,
pillsOf,
seatsOf,
SEAT_OPTIONS,
valueHint,
type SeatMap,
} from "./M02_MasterTemplete_Inputs_Model";
import { cardNote, cardShell } from "./M02_MasterTemplete_CardShell";
import "./M02_MasterTemplete_Inputs.css";
const TEXT = {
title: "선택 입력",
count: "로직 {logics} · 입력 {inputs}",
chosen: "면적산출 {area} · 재료산출 {material} · 선택 {pick}",
auto: "자동값",
noItem: "구조물 조합을 먼저 저장하면 입력 목록이 나옴",
noCombo: "일위대가를 먼저 고름 — 구조물 조합 일위대가 줄에서 고름",
unsavedCombo: "고른 일위대가는 구조물 조합을 저장하면 입력 목록이 나옴",
noLogic: "이 일위대가에 담긴 로직이 없음",
missing: "빠진 로직 {value}",
failed: "입력 목록을 못 받음 — {value}",
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" });
// 공용 카드 틀(31-7) — 부제 = 요약 · [저장]은 없음(집계표 [저장]과 함께) · 점(●) = 고른 것 저장 전
const shell = cardShell({ title: TEXT.title, className: "m02-inputs", sub: [summary], body });
const root = shell.root;
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);
shell.setDirty(true);
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 : [cardNote(TEXT.noLogic)]),
...(missing.length ? [cardNote(fill(TEXT.missing, { value: missing.join(", ") }))] : []),
);
}
async function show(colId: string | null, combo: string | null): Promise<void> {
const mine = ++seq;
clear();
shell.setDirty(!!colId && !!drafts.get(colId)?.dirty);
if (!colId) return;
const kept = drafts.get(colId);
if (kept) return paint(kept);
const stop = shell.loading(); // 0.3초 넘으면 「불러오는 중」(31-7 · 네 카드 같게)
try {
const layer: Layer = getActiveLayer();
const row = (await itemRows()).find((r) => r.열 === colId);
if (mine !== seq) return;
stop();
if (!row) return shell.note(combo ? TEXT.unsavedCombo : TEXT.noItem);
if (!row.일위대가) return shell.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 shell.note(TEXT.noCombo);
const draft: Draft = {
key: row.key,
판: links.판,
list,
seats: seatsOf(links),
dirty: false,
};
drafts.set(colId, draft);
paint(draft);
} catch (error) {
// 실패 = 카드 안 까닭 + [다시 불러오기](31-7)
if (mine === seq)
shell.fail(fill(TEXT.failed, { value: why(error) }), () => void show(colId, combo));
} finally {
stop();
}
}
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;
}
shell.setDirty(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) — 39-3 에 `M02_Labor_Values.ts` 로 옮김 · 옛 이름 그대로 내보냄 ── */
export {
createInputsValueCard,
type InputsValueData,
type InputsValueHandle,
type InputsValueOptions,
} from "./M02_Labor_Values";
@@ -1,101 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Inputs_Model.ts
* 「선택 입력」 카드의 셈(화면 없음) — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록.
* 값 글 · 값 칸 규칙은 `M02_Labor_Values_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;
}
/* ── 값 함수 — 일위대가 입력 모달과 한 벌(39-3 · `M02_Labor_Values_Model.ts` 로 옮김 · 옛 이름 그대로 내보냄) ── */
export { autoText, choicesOf, rangeOf, valueHint, type ValueDoc } from "./M02_Labor_Values_Model";
/* ── 프로젝트에서 값 받기(PLAN 25-2d) — 자리 「선택」 인 입력만 로직마다 묶어 값 · 확인 표시 ── */
/** 자리가 「선택」 인 입력만 로직 차례로 묶음 — 값 받기 카드가 보이는 입력 */
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);
}
@@ -1,13 +1,13 @@
/* =============================================================================
* M02_MasterTemplete_ItemCards.ts
* 항목 카드 — 도면 정보 · 면적산출 · 재료산출(보기만 + [수정]) + 덧붙는 카드(선택 입력 …).
* 항목 카드 — 도면 정보 · 면적산출(보기만 + [수정]) + 덧붙는 카드(넣을 값 …).
* M02 구조물 설계 컨테이너(`M02_MasterTemplete_Structure.ts`)에서 떼어 냄(계획서 25-2d) — 항목 고르기
* 모달(`ui_template_item_picker_detail.ts`)이 **같은 카드**를 쓰려고. 문서를 어디서 읽는지는
* 자료 자리(`ItemSource`)가 정함 · 카드는 몰라도 됨.
* 카드 겉 틀 · 제목 줄 · 부제 · [저장] · ? · 점(●) · 상태 글은 공용 틀(`M02_MasterTemplete_CardShell` · 31-7).
* 면적산출 · 재료산출 = 스프레드시트 · 부품은 카드를 열 때만 `import()`(다른 페이지 번들에 안 섞임).
* 덧붙는 카드는 `extraCards` 로 끼움 — 선택 입력 카드(`M02_MasterTemplete_Inputs.ts`)가 그 자리.
* `sheetEdit` 가 있으면 면적산출 · 재료산출 카드를 미리보기에서 고침(31-3) — 스프레드시트 `preview`(메뉴 없이
* 면적산출 = 스프레드시트 · 부품은 카드를 열 때만 `import()`(다른 페이지 번들에 안 섞임).
* 덧붙는 카드는 `extraCards` 로 끼움 — 고르기 모달 넣을 값 카드(`M02_Labor_Values.ts`)가 그 자리.
* `sheetEdit` 가 있으면 면적산출 카드를 미리보기에서 고침(31-3) — 스프레드시트 `preview`(메뉴 없이
* 수식 입력 줄 · 칸 고침 · 단축키) + 아래 알약 줄 + 카드 [저장] · 없으면 보기만(고르기 모달).
* ========================================================================== */
@@ -43,12 +43,10 @@ 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 = {
@@ -92,7 +90,7 @@ const emptyBook = (book: Workbook): boolean =>
(s) => !s.연결?.length && Object.values(s.칸 ?? {}).every((c) => (c.값 ?? "") === "" && !c.식),
);
const fill = (text: string, value: string): string => text.replace("{value}", value);
/** 스프레드시트는 면적산출 · 재료산출 카드를 열 때만 받음 */
/** 스프레드시트는 면적산출 카드를 열 때만 받음 */
const loadSpreadsheet = () => import("../A00_Common/spreadsheet/spreadsheet");
export interface ItemCardsOptions {
@@ -100,23 +98,23 @@ 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[];
/** 카드 가운데 하나라도 저장 안 한 고침이 생김 · 없어짐(PLAN 31-4 · 31-7) */
onDirty?: (dirty: boolean) => void;
/** 면적산출 · 재료산출 카드를 미리보기에서 고침(31-3) — 없으면 보기만 */
/** 면적산출 카드를 미리보기에서 고침(31-3) — 없으면 보기만 */
sheetEdit?: SheetEditOptions;
/** 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 다른 항목의 이 칸을 골라 덮어씀 · 없으면 단추 없음 */
onImport?: (kind: EditKind) => void;
}
/** 카드 스프레드시트를 다루는 손잡이 — 알약 · 자재 조합 판이 씀 */
/** 카드 스프레드시트를 다루는 손잡이 — 알약이 씀 */
export interface SheetCardControl {
column: string;
label: string;
@@ -131,7 +129,7 @@ export interface SheetCardControl {
export interface SheetEditOptions {
/** 지금 고칠 수 있나 — 부를 때마다 다시 봄 */
can: () => boolean;
/** 아래 알약 줄(입력 연결) · 카드 아래 덧붙는 판(재료산출 = 자재 조합) — 전체 편집기와 같은 부품 */
/** 아래 알약 줄(입력 연결) · 카드 아래 덧붙는 판 — 전체 편집기와 같은 부품 */
links: (
kind: SheetKind,
control: SheetCardControl,
@@ -150,7 +148,7 @@ export interface ItemCardsHandle {
relabel: (labels: Partial<Record<EditKind, string | null>>) => void;
/** 지금 보이는 열 id */
column: () => string | null;
/** 카드(도면 · 면적산출 · 재료산출)에 저장 안 한 고침이 있나 — 페이지가 떠날 때 물음 */
/** 카드(도면 · 면적산출)에 저장 안 한 고침이 있나 — 페이지가 떠날 때 물음 */
dirty: () => boolean;
/** 고친 카드를 모두 저장(도면 · 엑셀) — 실패하면 던짐 */
saveAll: () => Promise<void>;
@@ -253,7 +251,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
preview ? preview.write() : Promise.resolve(),
);
/** 면적산출 · 재료산출 카드 — 스프레드시트 미리보기 · 카드 고침(31-3) */
/** 면적산출 카드 — 스프레드시트 미리보기 · 카드 고침(31-3) */
interface SheetCard extends Card {
kind: SheetKind;
host: HTMLElement;
@@ -275,7 +273,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
} as SheetCard;
return card;
};
const sheets = [sheetCard("area"), sheetCard("material")];
const sheets = [sheetCard("area")];
// 면적산출 카드 [알약] — 아래 알약 줄 보임 · 숨김(기본 숨김 · 계정 취향 · 상세는 늘 보임)
const pills = createButton({ label: TEXT.pills, variant: "ghost", size: "sm" });
pills.title = TEXT.pillsTip;
@@ -294,7 +292,6 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
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", {
@@ -335,7 +332,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
c.doc = next as Workbook;
}
/** 미리보기 수정 — 메뉴 없이 수식 입력 줄 · 칸 고침 · 아래 알약 줄 · 덧붙는 판(재료산출 = 자재 조합) */
/** 미리보기 수정 — 메뉴 없이 수식 입력 줄 · 칸 고침 · 아래 알약 줄 · 덧붙는 판 */
const paintEditable = async (
c: SheetCard,
mine: number,
@@ -12,8 +12,8 @@ import {
type Layer,
} from "./M02_MasterTemplete_Api_Fetch";
/** 집계표 열 하나에 딸린 문서 셋 — 구조물 도면 · 면적산출 · 재료산출 */
export type ItemPart = "structure" | "area" | "material";
/** 집계표 열 하나에 딸린 문서 — 구조물 도면 · 면적산출 */
export type ItemPart = "structure" | "area";
export interface ItemSource {
/** CAD 자동백업 칸을 가르는 층 — 프로젝트 복사본이면 `project` */
+10 -39
View File
@@ -1,7 +1,7 @@
"""M02 층 집계표 항목 — 회사 · 개인 층 빈 뼈대 · 항목 목록 · 항목 복사(PLAN 21-1 · 21-2).
항목 = 구조물집계표조합(SS) 한 줄 — 그 열의 일위대가(UA) · 자재조합(UM) ·
도번 SD · 면적번 SA · 재료번 SM.
항목 = 구조물집계표조합(SS) 한 줄 — 그 열의 일위대가(UA) ·
도번 SD · 면적번 SA.
층 폴더(시스템 · 회사 · 개인)는 모양이 같아 저장소 함수를 `store.at(층 폴더)` 안에서 그대로 부름.
권한은 부르는 쪽(`M02_MasterTemplete_Router_Items.py`) 몫 — 여기는 폴더와 파일만.
"""
@@ -15,8 +15,6 @@ from typing import Any
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
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
# 21-1 목록 칸 — SS 줄에서 그대로(키는 `key`)
@@ -30,16 +28,14 @@ ITEM_FIELDS = (
"분류3",
"단위",
"일위대가",
"자재조합",
"도번",
"면적번",
"재료번",
)
def ensure(folder: Path) -> bool:
"""회사 · 개인 층 첫 진입 — 빈 집계표(열 0 · 층 이름 · 줄 머리는 시스템 것) ·
조합 장부(`다음` 1) · 자재 조합 빈 표 · 종류 폴더.
조합 장부(`다음` 1) · 종류 폴더.
이미 집계표가 있으면 아무것도 안 함(시스템 층은 늘 있음)."""
table = Path(folder) / "table" / f"{store.STRUCTURE_TABLE}.json"
if table.is_file():
@@ -60,31 +56,24 @@ def ensure(folder: Path) -> bool:
return False
atomic_write_json(table, doc)
store.sync_combos()
mat = Path(folder) / matset.FILE
if not mat.is_file():
atomic_write_json(mat, matset._load()[0])
return True
def items(folder: Path) -> list[dict[str, Any]]:
"""그 층 항목 — SS 줄마다 `{key, 열, 경로, 이름, 종류, 분류1~3, 단위, 일위대가, 자재조합,
도번, 면적번, 재료번, names}` · 경로 = 폴더 이름 배열 · 이름 = 경로 이음(26-1 · 종류 ·
"""그 층 항목 — SS 줄마다 `{key, 열, 경로, 이름, 종류, 분류1~3, 단위, 일위대가,
도번, 면적번, names}` · 경로 = 폴더 이름 배열 · 이름 = 경로 이음(26-1 · 종류 ·
분류1~3 은 경로 앞 넷) · `names` = 화면 이름(키 대신 · 없으면 null) —
일위대가 = 조합 이름 · 자재조합 = 그 층 자재 조합 이름 ·
도번 · 면적번 · 재료번 = 항목(열) 이름."""
일위대가 = 조합 이름 ·
도번 · 면적번 = 항목(열) 이름."""
with store.at(folder):
ua = combos.names() # 마스터 · 이 층이 쓸 수 있는 회사 · 개인 조합(27-2c)
rows = store.read_combos().get("줄", [])
um = {str(r.get("키")): str(r.get("이름") or "") for r in matset.listing()["줄"]}
out = []
for r in rows:
head = r.get("이름")
names = {
"일위대가": ua.get(str(r.get("일위대가"))) if r.get("일위대가") else None,
"자재조합": um.get(str(r.get("자재조합"))) if r.get("자재조합") else None,
**{
k: (head or r.get("열")) if r.get(k) else None for k in ("도번", "면적번", "재료번")
},
**{k: (head or r.get("열")) if r.get(k) else None for k in ("도번", "면적번")},
}
out.append(
{
@@ -142,8 +131,7 @@ def copy_item(
path: list[str] | None = None,
) -> dict[str, Any]:
"""다른 층 항목(SS `key`) → `target` 층 새 열 하나 — 머리 · 단위 · 바인딩 그대로 ·
도면 · 일위대가(새 SU · 조합 키는 안 받음 · 39-1b) · 면적산출 · 재료산출 내용(알약 연결 포함) ·
항목 연결 · 자재 조합 줄(새 UM) 복사 ·
도면 · 일위대가(새 SU · 조합 키는 안 받음 · 39-1b) · 면적산출 내용(알약 연결 포함) 복사 ·
새 번호 · 새 SS 키 ·
열 `본뜬` = `origin`(원래 층 · 주인 · 키) · `path` = 새 자리(폴더 + 이름 · 없으면 원래 경로 ·
붙여넣기 PLAN 30-2). 돌려줌 `{key, 열, 이름}`."""
@@ -159,24 +147,8 @@ def copy_item(
docs[kind] = store.read(kind, col_id)["문서"]
except store.StoreError:
pass # 없는 파일은 새 층에서 빈 문서로 섬
um = None
if col.get("자재조합"):
try:
um = matset.one(col["자재조합"])
except store.StoreError:
um = None # 가리키던 줄이 없으면 비움
ensure(target)
with store.at(target):
new_um = None
if um:
names = matset.listing()["줄"]
taken = {str(r.get("이름") or "") for r in names}
name, n = str(um.get("이름") or key), 2
base = name
while name in taken:
name, n = f"{base} ({n})", n + 1
got = {k: um.get(k) for k in ("담은자재", "소유", "비고")}
new_um = matset.create({**got, "이름": name, "본뜬키": None})["키"]
table = store.read("table", store.STRUCTURE_TABLE)
doc = table["문서"]
new_id = _fresh_id(col_id, {c.get("id") for c in doc.get("열", []) if isinstance(c, dict)})
@@ -184,9 +156,9 @@ def copy_item(
**json.loads(json.dumps(col)),
"id": new_id,
"일위대가": None, # 정본 = 일위대가 문서(아래 복사)
"자재조합": new_um,
"본뜬": origin,
}
made.pop("자재조합", None) # 옛 자재 조합 칸은 안 옮김
if path:
made.update({"경로": list(path), "머리": list(path)}) # 머리 줄 수는 저장 맞춤이 채움
doc["열"] = [*doc.get("열", []), made]
@@ -201,5 +173,4 @@ def copy_item(
atomic_write_json(store._path(kind, new_id), body)
store._refresh_labor(new_id) # 면적이 일위대가 뒤에 서므로 칸 값으로 다시
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, "이름": ss["이름"]}
@@ -1,253 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Materials.ts
* 재료산출 편집 화면 — 엑셀 옆 접히는 판: 그 열의 자재 조합(UM) 담은 자재 표 + [자재 더하기] · [빼기] · [적용].
* 자재 조합도 여러 열이 같이 쓸 수 있어 [적용] 때 일위대가처럼 모달 후 새 조합으로 갈라 냄
* (서버 `branchMaterialCombo`) · 원래 조합은 그대로. 빼는 자재가 칸에 이어져 있으면 확인 후 연결도 풂.
* ========================================================================== */
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links";
import type { CellLink } from "../A00_Common/spreadsheet/spreadsheet_types";
import {
branchMaterialCombo,
fetchMaterialCombo,
type MaterialCombo,
type MaterialItem,
} from "./M02_MasterTemplete_Api_Fetch";
import { pickMaterial } from "./M02_Material_Pick";
import { askBranch, branchName, branchToast } from "./M02_MasterTemplete_Combo";
import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api";
const TEXT = {
combo: "자재 조합",
branched: "본뜬 조합 {value}",
none: "자재 조합 없음 — 자재를 더하면 새로 만듦",
code: "코드",
name: "이름",
spec: "규격",
unit: "단위",
cell: "이은 칸",
remove: "빼기",
add: "자재 추가",
apply: "적용",
undo: "되돌리기",
empty: "담은 자재가 없음",
noItems: "담은 자재가 없어 적용 안 됨 — 자재를 하나 이상 담음",
already: "이미 담은 자재 — {value}",
unlinkAsk: "{value} 자재는 {cell} 칸에 이어져 있음 — 빼면 연결도 풂",
unlinkOk: "빼고 연결 풂",
loadFailed: "자재 조합을 못 읽음 — {value}",
askBody:
"이 자재 조합은 열 {n} 개와 모든 프로젝트가 같이 씀 — 고친 내용으로 새 자재 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용이면 있는 조합을 씀 · 원래 조합은 그대로",
askFirst:
"이 열에 새 자재 조합을 만듦 — 담은 자재 목록으로 새 조합을 만들고 이 열이 가리킴 · 같은 내용의 자재 조합이 있으면 그것을 씀",
askAlone:
"이 자재 조합은 모든 프로젝트가 같이 씀 — 고친 내용으로 새 자재 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용이면 있는 조합을 씀",
failed: "자재 조합을 못 고침 — {value}",
};
const fill = (text: string, values: Record<string, string | number>): string =>
Object.entries(values).reduce((s, [k, v]) => s.replace(`{${k}}`, String(v)), text);
/** 이 열의 자재 조합 — 키(없으면 빈 글) · 같이 쓰는 다른 열 수 · 담은 자재 */
export interface MaterialState {
key: string;
others: number;
combo: MaterialCombo | null;
}
export async function loadMaterialState(column: string): Promise<MaterialState> {
const base = await readColumnBase(column);
const key = base.자재조합 ?? "";
let combo: MaterialCombo | null = null;
if (key) {
try {
combo = await fetchMaterialCombo(key);
} catch (error) {
showToast(fill(TEXT.loadFailed, { value: reasonOf(error) }), "error");
}
}
return { key, others: base.다른열자재조합, combo };
}
/** 알약 막대 자재 항목 ↔ 담은 자재 — 알약 키 = 자재 코드 */
export const toPills = (items: MaterialItem[]): LinkItem[] =>
items.map((m) => {
const 자재 = {
코드: m.코드,
이름: m.이름 ?? "",
규격: m.규격 ?? "",
단위: m.단위 ?? "",
};
return { 키: m.코드, 이름: 자재.이름, 단위: 자재.단위, 자재 };
});
export interface MaterialPanel {
root: HTMLElement;
/** 알약 막대에서 목록이 바뀜 — 표만 다시 그림(알약은 이미 맞음) */
setItems: (items: MaterialItem[]) => void;
/** 연결이 바뀜(이음 · 풂 · 되돌리기) — 이은 칸을 다시 그림 */
refresh: () => void;
}
export interface MaterialPanelOptions {
/** 집계표 열 id */
column: string;
state: MaterialState;
/** 표에서 더하기 · 빼기 · 되돌리기 — 알약 막대를 이 목록으로 맞춤 */
onChange: (items: MaterialItem[]) => void;
/** 이은 칸이 있는 줄을 누름 — 알약을 눌러 그 칸으로 감(깜빡임) */
goTo: (code: string) => void;
/** 구조물 이름 — 새 이름 자동 미리 채움 */
label: string;
isAdmin: boolean;
/** 지금 시트 문서의 연결 전부 */
links: () => CellLink[];
/** 자재 코드에 이어진 칸 연결을 풂(시트 문서를 고치고 고침 표시) */
unlink: (codes: string[]) => void;
/** [적용]으로 이 열이 새 · 다른 자재 조합을 가리킴 — 조합 목록 다시 받기 */
onApplied?: () => void;
}
/** 옆 판 — 이 열에 자재 조합이 없어도 판은 뜸(더하면 새로 만듦) */
export function buildMaterialPanel(opts: MaterialPanelOptions): MaterialPanel {
let key = opts.state.key;
let others = opts.state.others;
let combo = opts.state.combo;
let saved: MaterialItem[] = combo?.담은자재 ?? [];
let items: MaterialItem[] = [...saved];
const sig = (list: MaterialItem[]): string => JSON.stringify(list.map((m) => m.코드));
const title = el("div", { className: "m02-mat__title" });
const table = el("div", { className: "m02-mat__table" });
const foot = el("div", { className: "m02-mat__foot" });
// 오른쪽 조합 패널의 「이 열의 조합」 칸에 들어감(접기 · 폭은 패널이 함)
const root = el("div", { className: "m02-mat", children: [title, table, foot] });
const cellsOf = (code: string): string[] =>
opts
.links()
.filter((l) => (l.자재?.코드 ?? l.키) === code)
.map((l) => l.칸);
const paint = (): void => {
title.replaceChildren(
el("span", { text: key ? `${TEXT.combo} ${key} ${combo?.이름 ?? ""}`.trim() : TEXT.none }),
...(opts.isAdmin && combo?.본뜬키
? [
el("span", {
className: "m02-combo__origin",
text: fill(TEXT.branched, { value: combo.본뜬키 }),
}),
]
: []),
);
const head = el("div", {
className: "m02-mat__row m02-mat__row--head",
children: [TEXT.code, TEXT.name, TEXT.spec, TEXT.unit, TEXT.cell, ""].map((t) =>
el("span", { text: t }),
),
});
const rows = items.map((m, i) => {
const cells = cellsOf(m.코드);
const acts = el("span", { className: "m02-mat__acts" });
if (opts.isAdmin) {
const del = createButton({ label: TEXT.remove, variant: "ghost", size: "sm" });
del.addEventListener("click", async () => {
if (
cells.length &&
!(await showConfirmDialog(
fill(TEXT.unlinkAsk, { value: m.이름 ?? m.코드, cell: cells.join(", ") }),
TEXT.unlinkOk,
))
)
return;
items.splice(i, 1);
if (cells.length) opts.unlink([m.코드]); // 확인 글대로 바로 풂(알약 막대 「목록에서 빼기」와 같음)
paint();
opts.onChange(items);
});
acts.append(del);
}
const row = el("div", {
className: `m02-mat__row${cells.length ? " is-linked" : ""}`,
attrs: { "data-material": m.코드 },
children: [m.코드, m.이름 ?? "", m.규격 ?? "", m.단위 ?? "", cells.join(", ")]
.map((t) => el("span", { text: t }))
.concat(acts),
});
if (cells.length) row.addEventListener("click", () => opts.goTo(m.코드));
return row;
});
table.replaceChildren(
head,
...(rows.length ? rows : [el("p", { className: "m02-struct__empty", text: TEXT.empty })]),
);
paintFoot();
};
const paintFoot = (): void => {
if (!opts.isAdmin) return foot.replaceChildren();
const add = createButton({ label: TEXT.add, variant: "ghost", size: "sm" });
add.addEventListener("click", () =>
pickMaterial((m) => {
if (items.some((x) => x.코드 === m.코드))
return showToast(fill(TEXT.already, { value: m.코드 }), "info");
items.push(m);
paint();
opts.onChange(items);
}),
);
const buttons = [add];
if (sig(items) !== sig(saved)) {
const apply = createButton({ label: TEXT.apply, variant: "filled", size: "sm" });
apply.addEventListener("click", async () => {
if (!items.length) return showToast(TEXT.noItems, "error");
const message = !key
? TEXT.askFirst
: others
? fill(TEXT.askBody, { n: others + 1 })
: TEXT.askAlone;
const name = await askBranch(message, branchName(combo?.이름 ?? "", opts.label));
if (name === null) return;
apply.disabled = true;
try {
const result = await branchMaterialCombo(opts.column, {
본뜬키: key,
담은자재: items,
이름: name,
});
branchToast(result, TEXT.combo);
key = result.키;
others = 0;
combo = await fetchMaterialCombo(key).catch(() => null);
saved = combo?.담은자재 ?? [...items];
items = [...saved];
paint();
opts.onApplied?.();
} catch (error) {
apply.disabled = false;
showToast(fill(TEXT.failed, { value: reasonOf(error) }), "error");
}
});
const undo = createButton({ label: TEXT.undo, variant: "ghost", size: "sm" });
undo.addEventListener("click", () => {
items = [...saved];
paint();
opts.onChange(items);
});
buttons.push(apply, undo);
}
foot.replaceChildren(...buttons);
};
paint();
return {
root,
setItems: (list) => {
items = [...list];
paint();
},
refresh: paint,
};
}
@@ -1,150 +0,0 @@
"""M02 자재 조합 저장소 — 마스터 템플릿 표 `자재조합.json`(구조물집계표조합.json 과 같은 자리).
줄 = 키 UM · 이름 · 담은자재(코드 · 이름 · 규격 · 단위) · 본뜬키 · 소유 · 비고 — 수량 · 단가는 안 둠
(재료산출 몫). 키는 표 안 「다음」 번호(지운 키는 다시 안 씀) · 집계표 열 `자재조합` 칸이 가리킴.
틀 검사 = `master_material_combo.check_form`(check_master 와 같은 것) · 판 = 파일 sha256 앞 16자.
"""
from __future__ import annotations
import json
from typing import Any
from common_util.common_util_json import atomic_write_json
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
mmc = m01.cm.mmc # 자재 조합 틀(`resources/master_data/scripts/master_material_combo.py`)
FILE = mmc.FILE
OWNERS = mmc.OWNERS
_HEAD = "UM"
def _load() -> tuple[dict[str, Any], str]:
"""(표, 판) — 파일이 없으면 빈 표 · 판 빈 글."""
path = store.root() / FILE
if not path.is_file():
return {"그룹": mmc.GROUP, "판": "2026", "비고": [], "다음": 1, "줄": []}, ""
raw = path.read_bytes()
return json.loads(raw), store.version_of(raw)
def _find(doc: dict, key: str) -> dict[str, Any]:
row = next((r for r in doc["줄"] if r.get("키") == key), None)
if row is None:
raise store.StoreError(404, f"없는 자재 조합 「{key}」")
return row
def listing() -> dict[str, Any]:
"""표 통째 — 판 · 줄(담은 자재 포함 · 작아서 한 번에) · 쓰는곳(키 → 쓰는 구조물 열 이름들)."""
doc, version = _load()
used: dict[str, list[str]] = {}
for col in store._structure_columns():
if key := col.get("자재조합"):
used.setdefault(key, []).append(_name(col))
return {"판": version, "줄": doc["줄"], "쓰는곳": used}
def _name(col: dict) -> str:
return tree.name_of(tree.path_of(col)) or col["id"] # 경로 이음(26-1)
def one(key: str) -> dict[str, Any]:
return _find(_load()[0], key)
def users_of(key: str) -> list[str]:
"""구조물집계표에서 `자재조합` 칸이 이 키인 열 — 머리 글(없으면 열 id)."""
out = []
for col in store._structure_columns():
if col.get("자재조합") == key:
out.append(_name(col))
return out
def _clean(row: Any, key: str, keep: dict | None = None) -> dict[str, Any]:
"""서버가 잡는 칸 — 키 · 열 한 벌 · 자재 줄 네 칸 · 본뜬키는 만들 때만(고칠 때는 있던 것)."""
if not isinstance(row, dict):
raise store.StoreError(400, "자재 조합 줄이 묶음 아님")
name = row.get("이름")
if not isinstance(name, str) or not name.strip():
raise store.StoreError(400, "자재 조합 줄에 「이름」 이 없거나 글자 아님")
got = row.get("담은자재")
got = [
{c: m.get(c) for c in mmc.ITEM_COLS} if isinstance(m, dict) else m
for m in (got if isinstance(got, list) else [])
]
known = {str(r.get("키")) for d in m01.loaded()[0].values() for r in d.get("줄") or []}
lost = [str(m.get("코드")) for m in got if isinstance(m, dict) and m.get("코드") not in known]
if lost:
raise store.StoreError(422, {"errors": [f"마스터에 없는 자재 {' · '.join(lost)}"]})
base = keep or row
owner = base.get("소유") or OWNERS[0]
return {
"키": key,
"이름": name.strip(),
"담은자재": got,
"본뜬키": base.get("본뜬키") or None,
"소유": owner,
"비고": row.get("비고") or None,
}
def _short(name: str, at: int = 30) -> str:
return name if len(name) <= at else name[:at] + "…"
def _check_name(rows: list, key: str, name: str) -> None:
"""이름 겹침 · 긴 이름 — 틀 검사(파일명 · 키가 섞인 글) 앞에서 쉬운 말 한 줄로 막음."""
if len(name) > mmc.MAX_이름:
raise store.StoreError(422, {"errors": [f"이름이 너무 김 — {mmc.MAX_이름}자까지"]})
if any(r.get("키") != key and str(r.get("이름", "")).strip() == name for r in rows):
raise store.StoreError(
422, {"errors": [f"「{_short(name)}」 이름의 자재 조합이 이미 있음 — 다른 이름으로"]}
)
def _save(doc: dict, version: str) -> str:
"""틀 검사 → 판이 같을 때만 씀 → 새 판."""
if errors := mmc.check_form(FILE, doc):
raise store.StoreError(422, {"errors": errors})
path = store.root() / FILE
with store._LOCK:
have = store.version_of(path.read_bytes()) if path.is_file() else ""
if have != (version or ""):
raise store.StoreError(409, {"stale": [FILE], "판": have})
atomic_write_json(path, doc)
return store.version_of(path.read_bytes())
def create(row: Any, version: str | None = None) -> dict[str, Any]:
"""새 줄 — 키 = 「다음」 번호 · 본뜬키는 있는 다른 키만. `version` None = 지금 판(갈라 내기)."""
doc, have = _load()
key = f"{_HEAD}{int(doc.get('다음') or 1):06d}"
fixed = _clean(row, key)
if fixed["본뜬키"] and fixed["본뜬키"] not in {r.get("키") for r in doc["줄"]}:
raise store.StoreError(400, f"본뜬키 「{fixed['본뜬키']}」 — 있는 다른 자재 조합 키여야 함")
_check_name(doc["줄"], key, fixed["이름"])
doc = {**doc, "다음": int(doc.get("다음") or 1) + 1, "줄": [*doc["줄"], fixed]}
return {"키": key, "판": _save(doc, have if version is None else version), "줄": fixed}
def edit(key: str, row: Any, version: str) -> dict[str, Any]:
doc, _ = _load()
fixed = _clean(row, key, keep=_find(doc, key))
_check_name(doc["줄"], key, fixed["이름"])
doc = {**doc, "줄": [fixed if r.get("키") == key else r for r in doc["줄"]]}
return {"키": key, "판": _save(doc, version), "줄": fixed}
def delete(key: str, version: str) -> dict[str, Any]:
"""지우기 — 구조물 열이 쓰면 409 + 열 이름들(지우지 않음)."""
doc, _ = _load()
_find(doc, key)
if users := users_of(key):
msg = f"구조물 {len(users)} 곳이 쓰는 자재 조합이라 못 지움 — {' · '.join(users)}"
raise store.StoreError(409, {"errors": [msg], "users": users})
doc = {**doc, "줄": [r for r in doc["줄"] if r.get("키") != key]}
return {"판": _save(doc, version)}
@@ -1,267 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Matset.ts
* 좌측 「자재 조합」 컨테이너 — 찾기 · 목록(키 맨 앞) · 새로 · 본떠 만들기 · 지우기(컨테이너 안) ·
* 줄을 누르면 메인 칸에 상세: 이름 · 비고 · 본뜬키 · 담은 자재 표(빼기) · [자재 더하기] · [저장].
* 표 = 마스터 템플릿 `자재조합.json`(키 UM · 서버 `M02_MasterTemplete_Matset.py`) · 지우기는 쓰는 열이
* 있으면 서버가 막음. 재료산출 옆 판이 갈라 낸 조합도 같은 표라 여기 보임(펼칠 때 새로 받음).
* ========================================================================== */
import {
createButton,
createInputField,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import {
createMaterialCombo,
deleteMaterialCombo,
editMaterialCombo,
listMaterialCombos,
StaleError,
type MaterialCombo,
} from "./M02_MasterTemplete_Api_Fetch";
import { pickMaterial } from "./M02_Material_Pick";
import { tm } from "./M02_MasterTemplete_Matset_Text";
export interface MatsetSectionOptions {
isAdmin: boolean;
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
confirmLeave: () => Promise<boolean>;
/** 메인 칸에 상세를 띄움 — 고침 표시 손잡이를 돌려받음 */
show: (label: string, node: HTMLElement) => (dirty: boolean) => void;
/** 메인 칸을 비움(지운 뒤) */
close: () => void;
/** 상세를 열었음 — 좌측 다른 줄 고름 표시를 지움 */
onOpen: () => void;
}
export interface MatsetSection {
title: string;
body: HTMLElement;
/** 새로 · 본떠 만들기 · 지우기 줄(관리자만 · 아니면 빈 목록) */
actions: HTMLElement[];
/** 표를 새로 받아 목록을 다시 그림 */
refresh: () => Promise<void>;
/** 다른 양식을 열었음 — 고름 표시만 지움 */
clear: () => void;
}
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
export function buildMatsetSection(opt: MatsetSectionOptions): MatsetSection {
let all: MaterialCombo[] = [];
let version = "";
let used: Record<string, string[]> = {};
let q = "";
/** 열린 상세의 키 — 새(저장 전)는 빈 글 · 안 열렸으면 null */
let openKey: string | null = null;
const find = createInputField({ type: "search", placeholder: tm("Search") });
const list = el("div", { className: "m02-side__items ui-sidebar-list" });
const body = el("div", { className: "m02-side__items", children: [find.root, list] });
find.input.addEventListener("input", () => {
q = find.input.value.trim().toLowerCase();
paintList();
});
const btnNew = createButton({ label: L("M02_New"), variant: "filled", size: "sm" });
const btnCopy = createButton({ label: L("M02_Copy"), variant: "ghost", size: "sm" });
const btnDel = createButton({ label: L("M02_Delete"), variant: "danger", size: "sm" });
const actions = opt.isAdmin
? [el("div", { className: "m02-side__row", children: [btnNew, btnCopy, btnDel] })]
: [];
const paintButtons = (): void => {
btnCopy.disabled = btnDel.disabled = !openKey;
};
function paintList(): void {
const shown = all.filter((x) => !q || `${x.키} ${x.이름}`.toLowerCase().includes(q));
list.replaceChildren(
...(shown.length
? shown.map((x) => {
const b = el("button", {
className: `m02-side__item ui-sidebar-row${x.키 === openKey ? " is-active" : ""}`,
text: `${x.키} ${x.이름}`,
attrs: { type: "button", "data-matset-key": x.키 },
});
b.addEventListener("click", () => void select(x.키));
return b;
})
: [el("p", { className: "m02-side__empty", text: tm("Empty") })]),
);
}
async function refresh(): Promise<void> {
try {
({ 판: version, 줄: all, 쓰는곳: used } = await listMaterialCombos());
} catch (error) {
showToast(tm("Load_Failed", why(error)), "error");
}
paintList();
paintButtons();
}
async function select(key: string): Promise<void> {
if (key === openKey || !(await opt.confirmLeave())) return;
const row = all.find((x) => x.키 === key);
if (row) draw(structuredClone(row), false);
}
/** 메인 칸 상세 — `row` 는 고칠 사본(저장 전까지 표에 안 들어감) */
function draw(row: MaterialCombo, isNew: boolean): void {
openKey = isNew ? "" : row.키;
const edit = opt.isAdmin;
const field = (label: string, name: "이름" | "비고") => {
const f = createInputField({ type: "text", label });
f.input.value = row[name] ?? "";
f.input.disabled = !edit;
f.input.setAttribute("data-matset-field", name);
f.input.addEventListener("input", () => {
if (name === "이름") row.이름 = f.input.value;
else row.비고 = f.input.value || null;
setDirty(true);
});
return f.root;
};
const table = el("div", { className: "m02-matset__table" });
const paintItems = (): void => {
const head = el("div", {
className: "m02-mat__row m02-mat__row--head",
children: [tm("Col_Code"), tm("Col_Name"), tm("Col_Spec"), tm("Col_Unit"), "", ""].map(
(t) => el("span", { text: t }),
),
});
const rows = row.담은자재.map((m, i) => {
const acts = el("span");
if (edit) {
const del = createButton({ label: tm("Remove"), variant: "ghost", size: "sm" });
del.addEventListener("click", () => {
row.담은자재.splice(i, 1);
setDirty(true);
paintItems();
});
acts.append(del);
}
return el("div", {
className: "m02-mat__row",
attrs: { "data-matset-item": m.코드 },
children: [m.코드, m.이름 ?? "", m.규격 ?? "", m.단위 ?? "", ""]
.map((t) => el("span", { text: t }))
.concat(acts),
});
});
table.replaceChildren(
head,
...(rows.length ? rows : [el("p", { className: "m02-side__empty", text: tm("NoItem") })]),
);
};
paintItems();
const add = createButton({ label: tm("AddItem"), variant: "ghost" });
add.addEventListener("click", () =>
pickMaterial((m) => {
if (row.담은자재.some((x) => x.코드 === m.코드)) return;
row.담은자재.push({ 코드: m.코드, 이름: m.이름, 규격: m.규격, 단위: m.단위 });
setDirty(true);
paintItems();
}),
);
const save = createButton({ label: tm("Save"), variant: "filled" });
save.addEventListener("click", async () => {
if (!row.이름.trim()) return showToast(tm("NeedName"), "error");
if (!row.담은자재.length) return showToast(tm("NeedItem"), "error");
save.disabled = true; // 연달아 눌러도 한 번만
try {
const { 키, ...rest } = row;
const done = isNew
? await createMaterialCombo(version, rest)
: await editMaterialCombo(키, version, row);
setDirty(false);
showToast(tm("Saved"), "success");
await refresh();
draw(structuredClone(done.줄), false);
} catch (error) {
if (error instanceof StaleError) {
showToast(tm("Stale"), "error");
await refresh();
} else showToast(tm("Failed", why(error)), "error");
} finally {
save.disabled = false;
}
});
const node = el("div", {
className: "m02-matset",
children: [
el("div", {
className: "m02-matset__fields",
children: [
field(tm("Head_Name"), "이름"),
field(tm("Head_Note"), "비고"),
el("p", {
className: "m02-side__empty",
text: `${tm("Head_Origin")}: ${row.본뜬키 ?? tm("Origin_Self")}`,
attrs: { "data-matset-origin": row.본뜬키 ?? "" },
}),
],
}),
el("h3", { className: "m02-matset__head", text: tm("Items_Title") }),
table,
...(edit ? [el("div", { className: "m02-side__row", children: [add, save] })] : []),
],
});
const setDirty = opt.show(isNew ? tm("New") : `${row.키} ${row.이름}`, node);
if (isNew) {
setDirty(true);
node.querySelector<HTMLInputElement>('[data-matset-field="이름"]')?.focus(); // 바로 이름 적기
}
opt.onOpen();
paintList();
paintButtons();
}
btnNew.addEventListener("click", async () => {
if (!(await opt.confirmLeave())) return;
draw({ 키: "", 이름: "", 담은자재: [], 본뜬키: null, 소유: "현장", 비고: null }, true);
});
btnCopy.addEventListener("click", async () => {
const from = all.find((x) => x.키 === openKey);
if (!from || !(await opt.confirmLeave())) return;
const copy = structuredClone(from);
draw({ ...copy, 키: "", 이름: "", 본뜬키: from.키, 소유: "현장" }, true); // 이름은 새로 적음
});
btnDel.addEventListener("click", async () => {
const key = openKey;
if (!key) return;
await refresh(); // 최신 판 · 쓰는 열로
// 쓰는 열이 있으면 확인 창 없이 바로 알림(서버도 막음)
const users = used[key] ?? [];
if (users.length) return showToast(tm("InUse", users.join(" · ")), "error");
if (!(await showConfirmDialog(tm("DeleteAsk", key)))) return;
try {
await deleteMaterialCombo(key, version);
showToast(tm("Deleted"), "success");
openKey = null;
opt.close();
} catch (error) {
showToast(tm("Failed", why(error)), "error");
}
await refresh();
});
paintButtons();
return {
title: tm("Title"),
body,
actions,
refresh,
clear: () => {
if (openKey === null) return;
openKey = null;
paintList();
paintButtons();
},
};
}
@@ -1,48 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Matset_Text.ts
* 좌측 「자재 조합」 컨테이너 · 상세 화면 글자 — [한국어, 영어]
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Title: ["자재 조합", "Material sets"],
Search: ["키 · 이름으로 찾기", "Search by key or name"],
Empty: ["자재 조합 없음", "No material sets"],
New: ["새 자재 조합(저장 전)", "New material set (unsaved)"],
Save: ["저장", "Save"],
InUse: [
"{value} 열이 쓰는 자재 조합이라 못 지움",
"Used by the column(s) {value} — cannot delete",
],
DeleteAsk: ["자재 조합 {value} 을 지울까요?", "Delete material set {value}?"],
Saved: ["저장함", "Saved"],
Deleted: ["지움", "Deleted"],
Stale: [
"그 사이 자재 조합 표가 바뀜 — 목록을 새로 받았으니 다시 고쳐 주세요",
"The table changed meanwhile — reloaded, please edit again",
],
NeedName: ["이름을 적어야 저장됨", "A name is required"],
NeedItem: ["담은 자재가 하나는 있어야 저장됨", "Add at least one material"],
Load_Failed: ["자재 조합을 못 받음 — {value}", "Could not load material sets — {value}"],
Failed: ["자재 조합을 못 고침 — {value}", "Could not change the material set — {value}"],
Head_Name: ["이름", "Name"],
Head_Note: ["비고", "Note"],
Head_Origin: ["본뜬키", "Copied from"],
Origin_Self: ["원본", "Original"],
Items_Title: ["담은 자재", "Materials in it"],
Col_Code: ["코드", "Code"],
Col_Name: ["이름", "Name"],
Col_Spec: ["규격", "Spec"],
Col_Unit: ["단위", "Unit"],
Remove: ["빼기", "Remove"],
AddItem: ["자재 추가", "Add material"],
NoItem: ["담은 자재 없음", "No materials yet"],
} as const satisfies Record<string, readonly [string, string]>;
export type MatsetTextKey = keyof typeof TEXT;
export function tm(key: MatsetTextKey, value = ""): string {
const entry = TEXT[key];
return (entry[currentLanguageIndex as 0 | 1] ?? entry[0]).replace("{value}", value);
}
@@ -120,7 +120,7 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection
}
const kind = current;
optionList.replaceChildren(
el("h3", { className: "m02-matset__head", text: kind.name }),
el("h3", { className: "m02-options__head", text: kind.name }),
...kind.options.map((o) => {
const box = el("input", {
attrs: { type: "checkbox", "data-options-key": o.key },
@@ -11,7 +11,6 @@ from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from M02_MasterTemplete import M02_MasterTemplete_Branch as branch
from M02_MasterTemplete import M02_MasterTemplete_Matset as matset
from M02_MasterTemplete import M02_MasterTemplete_Options as options
from M02_MasterTemplete import M02_MasterTemplete_Store as store
@@ -30,18 +29,6 @@ class BranchBody(BaseModel):
판: str = "" # 집계표 판 — 주면 어긋날 때 409
class MaterialBranchBody(BaseModel):
본뜬키: str = "" # 빈 글 = 이 열의 첫 자재 조합
담은자재: list[dict[str, Any]]
이름: str = ""
판: str = ""
class MatsetBody(BaseModel):
판: str = "" # 자재 조합 표 판 — 어긋나면 409
줄: dict[str, Any]
class OptionsBody(BaseModel):
판: str = "" # 옵션 표시 표 판 — 어긋나면 409
표시: dict[str, Any] # {종류: {옵션 키: 참 · 거짓}} — 거짓만 남김
@@ -72,7 +59,7 @@ def get_structure_combos() -> dict:
@router.post("/structures/{column}")
def post_structure(column: str) -> dict:
"""구조물 도면 새로 — 열 id 하나 · 도번 자동 · 빈 면적산출 · 재료산출도 · 이미 있으면 409."""
"""구조물 도면 새로 — 열 id 하나 · 도번 자동 · 빈 면적산출도 · 이미 있으면 409."""
return _call(store.create_structure, column)
@@ -82,58 +69,18 @@ def post_structure_combo(column: str, body: BranchBody) -> dict:
return _call(branch.branch_combo, column, body.본뜬키, body.담은로직, body.이름, body.판)
@router.post("/structures/{column}/material-combo")
def post_structure_material_combo(column: str, body: MaterialBranchBody) -> dict:
"""자재 조합 갈라 내기 — 같은 규칙(같은 자재 집합이면 있는 키 · 아니면 새 UM)."""
return _call(
branch.branch_material_combo, column, body.본뜬키, body.담은자재, body.이름, body.판
)
class UseComboBody(BaseModel):
종류: str # 일위대가 · 자재조합
종류: str # 일위대가
키: str
판: str = "" # 집계표 판 — 주면 어긋날 때 409
@router.post("/structures/{column}/use-combo")
def post_structure_use_combo(column: str, body: UseComboBody) -> dict:
"""있는 조합을 이 열에 씀 — 집계표 그 열 일위대가 · 자재조합 키만 바꿈(조합은 그대로)."""
"""있는 조합을 이 열에 씀 — 집계표 그 열 일위대가 키만 바꿈(조합은 그대로)."""
return _call(branch.use_combo, column, body.종류, body.키, body.판)
# 자재 조합 표(마스터 템플릿 `자재조합.json` · 키 UM) — 좌측 「자재 조합」 컨테이너
@router.get("/material-combos")
def get_material_combos() -> dict:
"""표 통째 — `{판, 줄}`(줄마다 담은 자재 포함)."""
return _call(matset.listing)
@router.get("/material-combos/{key}")
def get_material_combo(key: str) -> dict:
"""자재 조합 한 줄(키 UM) — 키 · 이름 · 담은자재 · 본뜬키 · 소유 · 비고."""
return _call(branch.material_combo, key)
@router.post("/material-combos")
def post_material_combo(body: MatsetBody) -> dict:
"""새로 · 본떠 만들기(`줄.본뜬키`) — 키는 표 「다음」 번호 · `{키, 판, 줄}`."""
return _call(matset.create, body.줄, body.판)
@router.put("/material-combos/{key}")
def put_material_combo(key: str, body: MatsetBody) -> dict:
"""고치기 — 이름 · 비고 · 담은자재(본뜬키 · 소유는 있던 것)."""
return _call(matset.edit, key, body.줄, body.판)
@router.delete("/material-combos/{key}")
def delete_material_combo(key: str, 판: str = "") -> dict:
"""지우기 — 구조물 열이 쓰면 409 `{errors, users}`."""
return _call(matset.delete, key, 판)
# 구조물 옵션 보이기 — B05/B06 폼에 보일 옵션(마스터 템플릿 `구조물옵션표시.json`)
@router.get("/structure-options")
def get_structure_options() -> dict:
"""`{판, 표시, 종류}` — 표시 = 숨긴 것만 · 종류 = 옵션 있는 종류와 그 옵션."""
@@ -157,11 +104,6 @@ def post_area_copy(column: str, source: str = Query(alias="from")) -> dict:
return _call(store.copy_from, "area", column, source)
@router.post("/material/{column}/copy")
def post_material_copy(column: str, source: str = Query(alias="from")) -> dict:
return _call(store.copy_from, "material", column, source)
@router.get("/templates/{kind}/{name}")
def get_template(kind: str, name: str) -> dict:
return _call(store.read, kind, name)
@@ -27,15 +27,11 @@ from config.config_db import get_db_pool
from M02_MasterTemplete import M02_Item_Paste as paste_store
from M02_MasterTemplete import M02_Layer_Access as access
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
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
from M02_MasterTemplete.M02_MasterTemplete_Router import (
BranchBody,
MaterialBranchBody,
MatsetBody,
UseComboBody,
)
@@ -72,11 +68,6 @@ Session = dict[str, Any]
NO_COMPANY = access.NO_COMPANY
class InputLinksBody(BaseModel):
판: int | None = None
줄: list[Any] = []
class TreeBody(BaseModel):
"""26-1 나무 고침 — `{op, 경로, 이름?, 새경로?, 키?, 판}` (+ 항목 더하기 때 `단위?`)."""
@@ -197,8 +188,7 @@ def get_items(
all: bool = False, # noqa: A002 — 21-1 약속 이름 `?all=1`
session: Session = Depends(verify_session),
):
"""21-1 — 한 층이면 `[{key, 열, 종류, 분류1~3, 단위, 일위대가, 자재조합, 도번, 면적번,
재료번}]` ·
"""21-1 — 한 층이면 `[{key, 열, 종류, 분류1~3, 단위, 일위대가, 도번, 면적번}]` ·
`?all=1` 이면 세 층 `[{layer, owner, items}]`(회사 · 개인은 세션 것 · 회사 없으면 빈 목록)."""
if all:
return [_listing(session, one, None) for one in ("system", "company", "personal")]
@@ -207,7 +197,7 @@ def get_items(
@router.post("/layers/personal/items/copy")
def copy_item(body: CopyItemBody, session: Session = Depends(verify_session)) -> dict:
"""회사 · 마스터 항목 → 내 개인 층 새 항목(열 · 세 파일 · 자재 조합 줄 · 새 SS 키 · `본뜬`)."""
"""회사 · 마스터 항목 → 내 개인 층 새 항목(열 · 세 파일 · 새 SS 키 · `본뜬`)."""
target = _root(session, "personal", None, write=True)
source = _root(session, body.from_layer, body.from_owner)
owner = access.grant(session, body.from_layer, body.from_owner).owner
@@ -218,50 +208,6 @@ 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)
# ── 폴더 구조(PLAN 26-1 · 26-2a) — 읽기 = 층 읽기 권한 · 고침 = 층 쓰기 권한 ──
@@ -375,19 +321,6 @@ def post_combo(
)
@router.post("/layers/{layer}/structures/{column}/material-combo")
def post_material_combo_branch(
layer: Layer,
column: str,
body: MaterialBranchBody,
owner: str | None = None,
session: Session = Depends(verify_session),
):
folder = _root(session, layer, owner, write=True)
args = (column, body.본뜬키, body.담은자재, body.이름, body.판)
return _call(folder, branch.branch_material_combo, *args)
@router.post("/layers/{layer}/structures/{column}/use-combo")
def post_use_combo(
layer: Layer,
@@ -412,7 +345,7 @@ def _copy_practice(column: str, key: str) -> dict[str, Any]:
@router.post("/layers/{layer}/{kind}/{column}/copy")
def post_copy(
layer: Layer,
kind: Literal["structures", "labor", "area", "material"],
kind: Literal["structures", "labor", "area"],
column: str,
source: str = Query(alias="from"),
owner: str | None = None,
@@ -430,47 +363,3 @@ def post_copy(
return _call(folder, _copy_practice, column, source)
origin = _root(session, from_layer, from_owner) if from_layer else None
return _call(folder, store.copy_from, part, column, source, origin)
@router.get("/layers/{layer}/material-combos")
def get_matsets(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)):
return _call(_root(session, layer, owner), matset.listing)
@router.get("/layers/{layer}/material-combos/{key}")
def get_matset(
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
):
return _call(_root(session, layer, owner), matset.one, key)
@router.post("/layers/{layer}/material-combos")
def post_matset(
layer: Layer,
body: MatsetBody,
owner: str | None = None,
session: Session = Depends(verify_session),
):
return _call(_root(session, layer, owner, write=True), matset.create, body.줄, body.판)
@router.put("/layers/{layer}/material-combos/{key}")
def put_matset(
layer: Layer,
key: str,
body: MatsetBody,
owner: str | None = None,
session: Session = Depends(verify_session),
):
return _call(_root(session, layer, owner, write=True), matset.edit, key, body.줄, body.판)
@router.delete("/layers/{layer}/material-combos/{key}")
def delete_matset(
layer: Layer,
key: str,
판: str = "",
owner: str | None = None,
session: Session = Depends(verify_session),
):
return _call(_root(session, layer, owner, write=True), matset.delete, key, 판)
@@ -336,8 +336,8 @@ async def save_layer_template(
return await asyncio.to_thread(
_store_save, folder, kind, name, body.판 or "", document, layer
)
if kind in ("area", "material"):
# 면적산출 · 재료산출 계산값은 서버가 같은 TS 엔진을 Node 로 돌려 새로 적음
if kind == "area":
# 면적산출 계산값은 서버가 같은 TS 엔진을 Node 로 돌려 새로 적음
# (브라우저 값 버림 · 5장 ②)
try:
document = await asyncio.to_thread(spreadsheet.with_server_values, document)
+20 -48
View File
@@ -26,7 +26,7 @@ FOLDER: Path = Path(__file__).resolve().parent.parent / "resources" / "master_te
KINDS = layers.KINDS # 시험은 FOLDER 를 사본으로 바꿈
# 지금 다루는 층 폴더 — 층 라우터가 `at(층 폴더)` 로 바꿈 · 안 바꾸면 시스템 층(`FOLDER`).
# 회사 · 개인 층도 시스템과 같은 모양(`table/구조물집계표.json` · 조합 장부 ·
# structure/area/material · 자재조합.json — PLAN 21-1)이라 아래 함수는 그대로 씀
# structure/area — PLAN 21-1)이라 아래 함수는 그대로 씀
_ROOT: ContextVar[Path | None] = ContextVar("m02_layer_root", default=None)
@@ -45,9 +45,8 @@ def at(folder: str | Path) -> Iterator[Path]:
# 구조물집계표 구조물 열(꼴=수) 마다 세 파일(파일 이름 = 열 id) —
# 구조물 도면 `structure/` (도각 없는 전용 화면) · 면적산출 `area/` · 재료산출 `material/`
# (둘 다 스프레드시트 통합문서)
# 번호 = 도번 SD · 면적번 SA · 재료번 SM + 뒷자리 — 한 열의 세 번호는 같은 뒷자리 · 한 번 준 번호는
# 구조물 도면 `structure/` (도각 없는 전용 화면) · 면적산출 `area/`(스프레드시트 통합문서)
# 번호 = 도번 SD · 면적번 SA + 뒷자리 — 한 열의 번호는 같은 뒷자리 · 한 번 준 번호는
# 파일에 남아 안 바뀜(PLAN 13장). 뒷자리 두 모양:
# 옛 8자리 `SD01020300` = [종류][분류1][분류2][분류3] 두 자리씩(그대로 유효 · 더는 새로 안 줌)
# 새 일련 `SD-000001` = 붙임표 + 6자리 · 자리 뜻 없음(PLAN 26-1 · 26-4a) — 옛 8자리 ·
@@ -57,7 +56,7 @@ def at(folder: str | Path) -> Iterator[Path]:
STRUCTURE_TABLE = "구조물집계표"
# 프로그램이 이름으로 부르는 양식 — 지우면 그 기능이 죽음(B07 이름은 `B07_DesignDetail_Engine_*`)
IN_USE = {
("table", STRUCTURE_TABLE): "구조물 도면 · 면적산출 · 재료산출 · 조합 장부가 씀",
("table", STRUCTURE_TABLE): "구조물 도면 · 면적산출 · 조합 장부가 씀",
("drawing", "00_template_A1"): "B07 도면 도각",
("drawing", "00_template_compass"): "B07 방위표",
("drawing", "00_template_cover"): "B07 표지",
@@ -67,11 +66,10 @@ PARTS = (
("structure", "도번", "SD", None),
("labor", "일위대가번", "SU", None), # 일위대가 — `M02_Labor_Doc`(39장)
("area", "면적번", "SA", "면적산출"),
("material", "재료번", "SM", "재료산출"),
)
_KEY = {kind: key for kind, key, _, _ in PARTS}
SHEETS = ("area", "material") # 통합문서 종류 — [저장] 때 서버가 계산값을 새로 적음
_CODE = re.compile(r"^S[DAMU](\d{8}|-\d{6})$")
SHEETS = ("area",) # 통합문서 종류 — [저장] 때 서버가 계산값을 새로 적음
_CODE = re.compile(r"^S[DAU](\d{8}|-\d{6})$")
_OLD_CODE = re.compile(r"^S[DCA](\d{6})$") # PLAN 12장 6자리 — 끝에 00 을 붙여 읽음
_BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.')
# 도각 없는 빈 CAD 문서의 기본 도면층 — 없으면 B07 CAD 가 못 엶("Cannot read properties
@@ -106,16 +104,6 @@ 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")
@@ -153,14 +141,14 @@ def _check_skeleton(kind: str, doc: Any) -> None:
def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> dict[str, Any]:
"""`version` 이 빈 글이면 새로 만듦(이미 있으면 409) · 아니면 그 판일 때만 덮어씀.
구조물집계표를 저장하면 — 새 구조물 열의 빈 구조물 도면 · 면적산출 · 재료산출을 번호와 같이
구조물집계표를 저장하면 — 새 구조물 열의 빈 구조물 도면 · 면적산출을 번호와 같이
만들고 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 지움(잠금 밖에서 · 안에서 부르면 죽음).
통합문서(area · material)는 서버가 같은 TS 엔진을 Node 로 돌려 `계산값` 을 새로 적음
통합문서(area)는 서버가 같은 TS 엔진을 Node 로 돌려 `계산값` 을 새로 적음
(브라우저 값 버림 · 5장 ②) · 알약 `연결` 은 문서와 같이 그대로 적힘 · 일위대가(labor)는
`M02_Labor_Doc.server` 가 정리 · `계산값` 을 새로 적음(면적산출 저장 때 그 열 일위대가도).
번호 칸(도번 · 면적번 · 재료번)은 서버 것 — 있던 파일 번호를 그대로 둠(화면 값을 안 받음).
번호 칸(도번 · 일위대가번 · 면적번)은 서버 것 — 있던 파일 번호를 그대로 둠(화면 값을 안 받음).
표 열의 `본뜬열`(분류 더하기로 복사한 새 열)은 적지 않음 — 집계표면 아직 파일이 없는 새 열이
원본 열의 일위대가 · 자재조합 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로).
원본 열의 일위대가 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로).
`screen` = 화면 표 [저장](PLAN 26-7e) — 있던 항목의 경로 · 층 이름 · 빈 폴더는 저장본 그대로
(정본 = 나무 · 머리는 경로에서 다시 만듦 · `tree.keep_paths`)."""
path = _path(kind, name)
@@ -187,10 +175,9 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
for part in _KEY:
_ensure(part, column, source)
_refresh_labor(column) # 면적이 일위대가 뒤에 서므로 칸 값으로 다시
_copy_inputs(source, column)
backfill_missing_structures() # 지우기 앞 — 같은 저장의 새 열이 지운 번호를 안 받게
prune_removed_structures()
sync_combos() # 번호가 다 선 뒤 — 조합 표 도번 · 면적번 · 재료번 칸
sync_combos() # 번호가 다 선 뒤 — 조합 표 도번 · 면적번 칸
return info
@@ -206,7 +193,7 @@ def delete(kind: str, name: str, version: str | None = None) -> None:
path.unlink()
# ── 구조물 도면 · 면적산출 · 재료산출 ─────────────────────
# ── 구조물 도면 · 면적산출 ─────────────────────
def _docs(kind: str) -> list[tuple[str, Any]]:
@@ -225,7 +212,7 @@ def _code(doc: Any, key: str) -> str | None:
def _codes() -> dict[str, str]:
"""`{열 id: 뒷자리}` — 구조물 도면 도번 먼저 · 없으면 면적번 · 재료번."""
"""`{열 id: 뒷자리}` — 구조물 도면 도번 먼저 · 없으면 면적번."""
codes: dict[str, str] = {}
for kind, key, _, _ in reversed(PARTS):
codes.update({name: c for name, doc in _docs(kind) if (c := _code(doc, key))})
@@ -233,7 +220,7 @@ def _codes() -> dict[str, str]:
def structure_numbers() -> dict[str, dict[str, str]]:
"""`{열 id: {도번, 면적번, 재료번}}` — 집계표 줄 · 좌측 줄이 한 번에 받음(없는 쪽은 빈 글)."""
"""`{열 id: {도번, 면적번}}` — 집계표 줄 · 좌측 줄이 한 번에 받음(없는 쪽은 빈 글)."""
out: dict[str, dict[str, str]] = {}
for kind, key, _, _ in PARTS:
for name, doc in _docs(kind):
@@ -285,7 +272,7 @@ def _retired() -> list[str]:
def _blank(kind: str, column: str) -> dict[str, Any]:
"""빈 문서(번호는 부르는 쪽이 붙임) — 구조물 도면 = 도각 없음 · 기본 도면층 하나 /
면적산출 · 재료산출 = 진짜 빈 시트 하나(식 없어 `계산값` 없음 · 첫 [저장] 때 서버가 적음)."""
면적산출 = 진짜 빈 시트 하나(식 없어 `계산값` 없음 · 첫 [저장] 때 서버가 적음)."""
if kind == "labor":
return {
"종류": "일위대가",
@@ -336,8 +323,7 @@ def _ensure(kind: str, column: str, source: str | None = None) -> bool:
def _take_origins(doc: Any) -> dict[str, str]:
"""표 열의 `본뜬열` 을 떼어 냄 → `{새 열: 원본 열}` (구조물 파일이 아직 없는 열만) ·
그 열에 자재조합 키가 비었으면 원본 열 것을 받음 · 일위대가는 원본 열 문서를 새 번호로 복사
(`_ensure` · 39-1b — 조합 키는 안 받음)."""
일위대가는 원본 열 문서를 새 번호로 복사(`_ensure` · 39-1b — 조합 키는 안 받음)."""
cols = (
[c for c in (doc.get("열") or []) if isinstance(c, dict)] if isinstance(doc, dict) else []
)
@@ -350,13 +336,12 @@ def _take_origins(doc: Any) -> dict[str, str]:
if (root() / "structure" / f"{col['id']}.json").is_file():
continue
out[col["id"]] = source
col["자재조합"] = col.get("자재조합") or by_id[source].get("자재조합")
return out
def create_structure(column: str) -> dict[str, Any]:
"""구조물 열 하나의 구조물 도면 새로 — 도번 SD + 새 일련(`-` + 6자리) · 이미 있으면 409.
일위대가 · 면적산출 · 재료산출 파일이 없으면 빈 문서도 같이 만듦(같은 뒷자리)."""
일위대가 · 면적산출 파일이 없으면 빈 문서도 같이 만듦(같은 뒷자리)."""
if column not in _column_ids():
raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 구조물 열 「{column}」")
if not _ensure("structure", column):
@@ -394,26 +379,13 @@ def copy_from(
return info
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]:
"""집계표에서 지운 열(또는 구조물이 아니게 된 열)의 세 파일 · 항목 연결을 같이 지움 —
번호는 비워 둠(다른 열 번호를 안 당김)."""
"""집계표에서 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 같이 지움 —
번호는 비워 둠(다른 열 번호를 안 당김) · 옛 `inputs/` 는 안 건드림."""
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:
@@ -456,7 +428,7 @@ def _move_basis() -> list[str]:
def renumber() -> dict[str, list[str]]:
"""한 번 — 옛 산출근거를 면적산출로 옮김 · 옛 번호(6자리 · 구-NN · 표-NN · 없음)를 8자리로
(6자리는 끝에 00 · 번호 없는 열은 열 차례로 일련번호) ·
구조물 아닌 열 파일 지움 · 빠진 파일(재료산출 등) 만듦. 내용은 그대로 · 두 번 돌려도 같음."""
구조물 아닌 열 파일 지움 · 빠진 파일(면적산출 등) 만듦. 내용은 그대로 · 두 번 돌려도 같음."""
moved = _move_basis()
gone = prune_removed_structures()
changed: list[str] = []
@@ -1,15 +1,15 @@
/* =============================================================================
* M02_MasterTemplete_Structure.ts
* 구조물집계표 아래 설계 컨테이너 — 고른 열의 도면 정보 · 면적산출 · 재료산출 카드(보기만 + [수정]).
* 구조물집계표 아래 설계 컨테이너 — 고른 열의 도면 정보 · 면적산출 카드(보기만 + [수정]).
* 카드 셋은 좌측 패널과 같은 공용 ui-sidebar 틀 — 고침은 [수정] 이 여는 M02 메인 칸 전체 편집 화면
* (`M02_MasterTemplete_UI_Main.ts`)이 맡음.
* 면적산출 · 재료산출 = 스프레드시트(`A00_Common/spreadsheet/`) · 열마다 따로 파일(`area|material/<열 id>.json`) ·
* 면적산출 = 스프레드시트(`A00_Common/spreadsheet/`) · 열마다 따로 파일(`area/<열 id>.json`) ·
* 부품은 카드를 열 때만 `import()` 로 받음(다른 페이지 번들에 안 섞임).
*
* 구조물 도면은 집계표 열마다 하나(`resources/master_template/structure/<열 id>.json`) ·
* 서버가 열마다 미리 만들어 둠(번호 자동 · 새 열도 집계표 저장 때 서버가 같이 만듦 — `M02_MasterTemplete_Store.py`).
* 이 칸은 표 부품 host 밖 형제 자리에 붙음 — 집계표는 그대로 두고 여기만 열마다 다시 그림.
* 집계표 일위대가 · 도면 · 면적산출 · 재료산출 줄의 조회 후 선택(`pick`)도 여기 — 일위대가 = M01 일위대가
* 집계표 일위대가 · 도면 · 면적산출 줄의 조회 후 선택(`pick`)도 여기 — 일위대가 = M01 일위대가
* 목록에서 고름 · 나머지 = 다른 열 것을 골라 내용만 복사해 가져옴(번호 · 열은 그대로 · 서버가 복사).
* 글자는 이 파일에 둠(도면 부품 `M02_MasterTemplete_Drawing.ts` 와 같은 방식).
* ========================================================================== */
@@ -23,11 +23,9 @@ import {
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch";
import { fetchUsableCombos } from "./M02_MasterTemplete_Labor_Api";
import {
copyArea,
copyMaterial,
copyStructure,
fetchLaborPills,
fetchStructureNumbers,
@@ -46,10 +44,8 @@ import {
type SheetEditOptions,
type SheetKind,
} from "./M02_MasterTemplete_ItemCards";
import { buildMaterialPanel, loadMaterialState, toPills } from "./M02_MasterTemplete_Materials";
import { layerSource } from "./M02_MasterTemplete_ItemSource";
import { pickHere, pickedName, type From } from "./M02_MasterTemplete_ItemPick";
import { createInputsCard, inputPills, type InputsCardHandle } from "./M02_MasterTemplete_Inputs";
import "./M02_MasterTemplete_Structure.css";
export type { EditKind, SheetKind } from "./M02_MasterTemplete_ItemCards";
@@ -72,26 +68,24 @@ const TEXT = {
copyFailed: "복사 실패 — {value}",
noPills: "이 열 일위대가에 담은 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음",
pillsFailed: "일위대가 알약을 못 받음 — {value}",
discardCards: "카드(도면 · 면적산출 · 재료산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?",
discardCards: "카드(도면 · 면적산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?",
};
/** 집계표 열 하나에 딸린 문서 셋 — 번호 칸 · 이름 · 복사 */
const COPY: Record<EditKind, (to: string, from: string) => Promise<void>> = {
structure: copyStructure,
area: copyArea,
material: copyMaterial,
};
/** 집계표 줄 열쇠 → 문서 */
const ROW_KIND: Record<string, EditKind> = {
"s:drawing": "structure",
"s:area": "area",
"s:material": "material",
};
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
const fill = (text: string, value: string): string => text.replace("{value}", value);
/** `{열 id: {도번, 면적번, 재료번}}` — 집계표 번호 줄(없는 열은 키 없음 = 「없음」) */
/** `{열 id: {도번, 면적번}}` — 집계표 번호 줄(없는 열은 키 없음 = 「없음」) */
export type ColumnNumbers = Record<string, StructureNumbers>;
/** 일위대가 목록 — 이 층이 쓸 수 있는 세 층 조합(27-2c) · 고를 때마다 받음(층을 바꿔 다시 들어와도 맞게) */
@@ -209,7 +203,7 @@ export interface StructureColumn {
꼴?: string | null;
}
/** 구조물 열 = 꼴=수 — NO · 측점 · 비고 같은 글 열은 도면 · 면적산출 · 재료산출 · 번호가 없음(서버와 같은 규칙) */
/** 구조물 열 = 꼴=수 — NO · 측점 · 비고 같은 글 열은 도면 · 면적산출 · 번호가 없음(서버와 같은 규칙) */
export const isStructureColumn = (col: StructureColumn): boolean => col.꼴 === "수";
/** 관리 화면 「구조물 조합」에서 숨기는 것(PLAN 26-7b · 30-7) — NO · 측점 · 비고 열 · 전구간 줄(본문 줄) ·
@@ -277,17 +271,13 @@ export const columnHead = (col: StructureColumn): string =>
export const structureLabel = (number: string, col: StructureColumn): string =>
[number, columnHead(col)].filter(Boolean).join(" ");
/** 면적산출 · 재료산출 편집 화면 아래 알약 막대 — 면적 = 그 열 일위대가의 로직 물량 「이름|단위」 + 자재 「자재|MT키」
* (서버 pills 길 · 39-4 · 다 `고정` — 더하기 · 빼기는 일위대가 쪽) · 재료 = 자재DB 찾기.
/** 면적산출 편집 화면 아래 알약 막대 — 그 열 일위대가의 로직 물량 「이름|단위」 + 자재 「자재|MT키」
* (서버 pills 길 · 39-4 · 다 `고정` — 더하기 · 빼기는 일위대가 쪽).
* 알약이 없으면 까닭 글(`notice`)을 같이 돌려줌 */
export async function pillsFor(
kind: SheetKind,
_kind: SheetKind, // 종류는 면적산출 하나 — 부르는 쪽 모양 그대로
column: string,
): Promise<{ links: LinkOptions; notice?: string }> {
if (kind === "material") {
const own = await inputPills(column, "재료");
return { links: { 항목: own, 모드: "자재", 자재찾기: searchMaterials } };
}
const none = (notice: string) => ({ links: { 항목: [], 모드: "입력" as const }, notice });
try {
const { 알약 } = await fetchLaborPills(column);
@@ -304,86 +294,33 @@ export async function pillsFor(
}
}
/** 카드 미리보기 수정의 알약 · 자재 조합 판(31-3) — 전체 편집기(`UI_Main` `mountSheet`)와 같은 부품 ·
* 면적 = 그 열 일위대가 입력값(`pillsFor`) · 재료 = 자재 조합 담은 자재 + 선택 입력 · 아래 자재 조합 판 */
/** 카드 미리보기 수정의 알약(31-3) — 전체 편집기(`UI_Main` `mountSheet`)와 같은 부품(`pillsFor`) */
async function cardLinks(
kind: SheetKind,
ctl: SheetCardControl,
isAdmin: boolean,
): ReturnType<SheetEditOptions["links"]> {
if (kind === "area") return { links: (await pillsFor("area", ctl.column)).links };
const [state, own] = await Promise.all([
loadMaterialState(ctl.column),
inputPills(ctl.column, "재료"), // 「선택 입력」 카드가 재료산출로 고른 입력
]);
let items = state.combo?.담은자재 ?? [];
const links = (): LinkOptions => ({
항목: [...toPills(items), ...own],
모드: "자재",
자재찾기: searchMaterials,
// 알약 막대에서 추가 · 빼기 — 아래 판 표를 같은 목록으로
onItemsChange: (list) => {
items = list.flatMap((i) => (i.자재 ? [{ ...i.자재 }] : []));
panel.setItems(items);
},
});
const panel = buildMaterialPanel({
column: ctl.column,
label: ctl.label,
isAdmin,
state,
links: () =>
ctl
.handle()
?.getDoc()
.시트.flatMap((s) => s.연결 ?? []) ?? [],
// 판 표에서 고침 — 알약 막대만 이 목록으로 다시 만듦(저장 안 한 시트 고침은 그대로)
onChange: (list) => {
items = list;
ctl.remount(links());
},
goTo: (code) =>
ctl.host
.querySelector<HTMLElement>(`.ss-link.is-linked[data-key="${CSS.escape(code)}"]`)
?.click(),
unlink: (codes) => {
const book = ctl.handle()?.getDoc();
if (!book) return;
for (const sheet of book.시트) {
const rest = (sheet.연결 ?? []).filter((l) => !codes.includes(l.자재?.코드 ?? l.키));
if (rest.length) sheet.연결 = rest;
else delete sheet.연결;
}
ctl.handle()?.setDoc(book);
ctl.touched();
},
});
return { links: links(), below: panel.root, refresh: panel.refresh };
return { links: (await pillsFor(kind, ctl.column)).links };
}
export interface StructurePanelOptions {
/** 시스템 관리자만 저장함(구조물 도면은 서버가 미리 만들어 둠) */
isAdmin: boolean;
/** [도면 수정] · [면적산출 수정] · [재료산출 수정] — 그 열을 CAD · 스프레드시트 전체 편집으로 엶
/** [도면 수정] · [면적산출 수정] — 그 열을 CAD · 스프레드시트 전체 편집으로 엶
* (페이지의 `open`) · label 은 화면 제목에 씀 */
onEdit: (column: string, label: string, kind: EditKind) => void;
/** 집계표에 저장 안 한 고침이 있으면 버릴지 물음 — 취소면 false */
confirmLeave: () => Promise<boolean>;
/** 「선택 입력」 카드에서 자리를 골랐음 — 저장 안 한 고침 표시 */
onDirty?: () => void;
/** 카드 고침을 버리지 않고 머묾 — 표의 고른 열을 되돌림(31-3) */
keepColumn?: (colId: string) => void;
}
export interface StructurePanelHandle {
root: HTMLElement;
/** `{열 id: {도번, 면적번, 재료번}}` — 집계표 `drawingLabel` · `areaLabel` · `materialLabel` 이 씀 */
/** `{열 id: {도번, 면적번}}` — 집계표 `drawingLabel` · `areaLabel` 이 씀 */
numbers: () => ColumnNumbers;
/** 번호 목록 다시 받기 */
reload: () => Promise<void>;
/** 「선택 입력」 카드에서 고른 자리를 서버에 저장 — 집계표 [저장] 뒤에 부름(실패하면 던짐) */
saveInputs: () => Promise<void>;
/** 카드에 저장 안 한 고침(도면 31-4 · 면적산출 · 재료산출 31-3) — 페이지가 떠날 때 함께 물음 */
/** 카드에 저장 안 한 고침(도면 31-4 · 면적산출 31-3) — 페이지가 떠날 때 함께 물음 */
dirty: () => boolean;
/** 고친 카드 저장(도면 · 엑셀) — 집계표 [저장] 이 카드를 다시 읽기 전에 부름(실패하면 던짐) */
saveCards: () => Promise<void>;
@@ -392,7 +329,7 @@ export interface StructurePanelHandle {
/** 보던 열을 다시 읽음 — 집계표 [저장] 뒤(서버가 새 열 파일 · 번호를 만듦) */
refresh: (columns: StructureColumn[]) => void;
/** 집계표 조회 후 선택(`onPick`) — 일위대가 = 고른 키(「비움」 = 빈 글 · 닫으면 null) ·
* 도면 · 면적산출 · 재료산출 = 다른 열 것을 복사해 가져옴(돌려주는 글 없음) */
* 도면 · 면적산출 = 다른 열 것을 복사해 가져옴(돌려주는 글 없음) */
pick: (row: string, colId: string | null, columns: StructureColumn[]) => Promise<string | null>;
destroy: () => void;
}
@@ -403,10 +340,6 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
let column: string | null = null;
let columnLabel = "";
const inputs: InputsCardHandle = createInputsCard({
isAdmin: opts.isAdmin,
onDirty: () => opts.onDirty?.(),
});
// 카드 셋은 항목 카드 한 벌(`ItemCards`) — 여기서는 지금 층에서 열 하나를 읽어 넘기기만.
const cards = createItemCards({
source: layerSource,
@@ -415,13 +348,12 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
numberOf: (kind, id) => numbers[id]?.[PART_FIELD[kind]],
// 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」
warnOnFail: (id) => !!numbers[id],
extraCards: [inputs.root],
// 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 집계표 줄 「조회 후 선택」 과 같은 길
onImport: (kind) => void pickCopy(kind, shown ?? undefined),
// 면적산출 · 재료산출 카드 미리보기 수정(31-3) — 전체 편집기와 같은 알약 · 저장 길
// 면적산출 카드 미리보기 수정(31-3) — 전체 편집기와 같은 알약 · 저장 길
sheetEdit: {
can: () => opts.isAdmin,
links: (kind, ctl) => cardLinks(kind, ctl, opts.isAdmin),
links: (kind, ctl) => cardLinks(kind, ctl),
save: async (kind, at, doc, version) =>
(await saveTemplate(getActiveLayer(), kind, at, null, version, doc)).판,
},
@@ -432,9 +364,6 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
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);
}
@@ -472,7 +401,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
const same = (from: From): boolean => from.layer === here.layer && from.owner === here.owner;
const picked = await pickHere({
title: `${fill(TEXT.pickCopy, word)} — ${target}`,
kind: kind === "material" ? "item" : kind,
kind,
skip: (from, row) => same(from) && row.열 === to.id,
ask: (p) =>
TEXT.overwrite
@@ -519,9 +448,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
numbers: () => numbers,
reload: async () => {
numbers = await fetchStructureNumbers().catch(() => ({}));
inputs.invalidate(); // 새 열 · 바뀐 일위대가를 서버 항목 목록에서 다시 찾게
},
saveInputs: () => inputs.save(),
dirty: () => cards.dirty(),
saveCards: () => cards.saveAll(),
show,
@@ -532,7 +459,6 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
},
pick,
destroy: () => {
inputs.destroy();
cards.destroy();
},
};
@@ -247,7 +247,7 @@ def apply(
_taken_item(out, made)
col: dict[str, Any] = {"id": _fresh_id(out), "단위": body.get("단위"), "꼴": "수"}
# 항목 추가 창에서 고른 일위대가(PLAN 31-2) — 없으면 비움 · 단위는 안 받아도 됨
col.update({"일위대가": body.get("일위대가") or None, "자재조합": None})
col["일위대가"] = body.get("일위대가") or None
_set_path(col, made)
_place(out, col)
note.update({"경로": made, "열": col["id"]})
+18 -116
View File
@@ -26,21 +26,10 @@ import {
type StructureColumn,
type StructurePanelHandle,
} from "./M02_MasterTemplete_Structure";
import { inputPills } from "./M02_MasterTemplete_Inputs";
import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs";
import { buildAreaPanel, type AreaPanel } from "./M02_MasterTemplete_AreaPanel";
import { buildComboPanel, type ComboPanel } from "./M02_MasterTemplete_ComboPanel";
import {
buildMaterialPanel,
loadMaterialState,
toPills,
type MaterialPanel,
type MaterialState,
} from "./M02_MasterTemplete_Materials";
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch";
import type { MaterialItem } from "./M02_MasterTemplete_Api_Fetch";
export interface Selection {
layer: Layer;
projectId: string | null;
@@ -67,7 +56,6 @@ type Mods = Record<string, () => Promise<unknown>>;
const NUMBER_FIELD: Partial<Record<Kind, string>> = {
structure: "도번",
area: "면적번",
material: "재료번",
labor: "일위대가번",
};
// 표 부품(sub3) · 도면 부품(sub2) — 내보낸 이름으로 찾음
@@ -141,7 +129,7 @@ export function createMain(
const title = el("h2", { className: "m02-main__title", text: L("M02_PickTemplate") });
const reload = createButton({ label: L("M02_Reload"), variant: "ghost" });
const save = createButton({ label: L("M02_Save"), variant: "filled" });
// 구조물 도면 · 면적산출 · 재료산출 상세 화면 → 구조물집계표(그 열 고른 채)
// 구조물 도면 · 면적산출 상세 화면 → 구조물집계표(그 열 고른 채)
const back = createButton({ label: L("M02_Back"), variant: "ghost", size: "sm" });
const slot = el("div", { className: "m02-main__slot" }); // 도면 양식의 작도 영역 칸이 들어옴
const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } });
@@ -173,7 +161,7 @@ export function createMain(
title.classList.toggle("m02-main__title--fold", combo);
title.title = combo ? L("M02_TableFold") : "";
if (!combo) {
host.hidden = false; // 다른 상세(도면 · 면적 · 재료)는 늘 펼침
host.hidden = false; // 다른 상세(도면 · 면적)는 늘 펼침
title.classList.remove("is-folded");
}
};
@@ -191,14 +179,11 @@ export function createMain(
let sheet: SheetEditorHandle | null = null;
let panel: StructurePanelHandle | null = null;
let sheetHandle: SpreadsheetHandle | null = null;
let matPanel: MaterialPanel | null = null;
let matState: MaterialState | null = null;
let cpanel: ComboPanel | null = null; // 재료산출 오른쪽 조합 패널(프로젝트 층 아님)
let apanel: AreaPanel | null = null; // 면적산출 오른쪽 일위대가 패널(프로젝트 층 아님 · 39-4)
let picked = "A1"; // 면적산출 시트에서 지금 고른 칸 — 칸 지정 모달 [지금 고른 칸으로]
let seq = 0;
let dirty = false;
/** 저장 안 한 고침 — 이 화면 + 카드(도면 31-4 · 면적산출 · 재료산출 31-3) */
/** 저장 안 한 고침 — 이 화면 + 카드(도면 31-4 · 면적산출 31-3) */
const busy = (): boolean => dirty || !!panel?.dirty();
const guard = (event: BeforeUnloadEvent): void => {
@@ -240,10 +225,6 @@ export function createMain(
editor = null;
sheet = null;
sheetHandle = null;
matPanel = null;
matState = null;
cpanel?.destroy();
cpanel = null;
apanel?.destroy();
apanel = null;
panel?.destroy();
@@ -252,12 +233,11 @@ export function createMain(
slot.replaceChildren();
};
/** 구조물집계표 아래 설계 컨테이너 — 고른 열의 도면 정보 · 면적산출 · 재료산출 카드 */
/** 구조물집계표 아래 설계 컨테이너 — 고른 열의 도면 정보 · 면적산출 카드 */
function buildPanel(layer: Layer): StructurePanelHandle {
const made = createStructurePanel({
isAdmin,
confirmLeave,
onDirty: () => (dirty = true), // 「선택 입력」 카드에서 자리를 고름 — 집계표 [저장]으로 함께 저장
keepColumn: (colId) => sheet?.selectColumn(colId), // 카드 고침을 안 버림 — 고른 열 되돌림
// 같은 페이지에서 그 열을 CAD·스프레드시트 전체 편집으로 엶
onEdit: (column, label, kind) => {
@@ -268,38 +248,23 @@ export function createMain(
return made;
}
/** 면적산출 · 재료산출 스프레드시트 — 아래 알약 막대 · 면적 = 그 열 일위대가 알약(`pillsFor`) ·
* 재료 = 자재DB 찾기 · 프로젝트 층 아니면 면적 = 오른쪽 일위대가 패널(담은 로직 · 자재목록 · 칸 지정) ·
* 재료 = 그 열 자재 조합 담은 자재(알약 항목 = 옆 판 표 = 같은 목록) + 옆 판
* (`pillItems` = 옆 판에서 고친 목록으로 알약 막대만 다시 만들 때) */
/** 면적산출 스프레드시트 — 아래 알약 막대 = 그 열 일위대가 알약(`pillsFor`) ·
* 프로젝트 층 아니면 오른쪽 일위대가 패널(담은 로직 · 자재목록 · 칸 지정) */
async function mountSheet(
doc: unknown,
kind: "area" | "material",
kind: "area",
at: Selection,
readOnly: boolean,
pillItems?: MaterialItem[],
): Promise<void> {
const mine = seq;
try {
const master = at.layer !== "project" && kind === "material";
if (master && !matState) matState = await loadMaterialState(at.name);
const [{ createSpreadsheet }, pills, own] = await Promise.all([
const [{ createSpreadsheet }, pills] = await Promise.all([
import("../A00_Common/spreadsheet/spreadsheet"),
master ? null : pillsFor(kind, at.name),
master ? inputPills(at.name, "재료") : [], // 「선택 입력」 카드가 재료산출로 고른 입력
pillsFor(kind, at.name),
]);
if (mine !== seq) return;
if (pills?.notice) showNotice([pills.notice]);
const links: LinkOptions =
pills?.links ??
({
항목: [...toPills(pillItems ?? matState?.combo?.담은자재 ?? []), ...own],
모드: "자재",
자재찾기: searchMaterials,
// 알약 막대에서 더하기 · 빼기 — 옆 판 표를 같은 목록으로
onItemsChange: (list) =>
matPanel?.setItems(list.flatMap((i) => (i.자재 ? [{ ...i.자재 }] : []))),
} satisfies LinkOptions);
if (pills.notice) showNotice([pills.notice]);
const links: LinkOptions = pills.links;
const handle: SpreadsheetHandle = createSpreadsheet(
host,
doc as Parameters<typeof createSpreadsheet>[1],
@@ -308,7 +273,6 @@ export function createMain(
tabs: false, // 시트 하나 · 탭 줄 없음
onChange: () => {
dirty = !readOnly;
matPanel?.refresh(); // 알약을 이음 · 풂 — 옆 판 「이은 칸」 바로 갱신
apanel?.refresh(); // 칸 값 · 연결이 바뀜 — 반영 금액을 바로 다시 곱함
},
onSelect: (s) => (picked = s.칸),
@@ -332,71 +296,11 @@ export function createMain(
host.append(apanel.root);
return;
}
// 오른쪽 조합 패널 — 한 번 만들고 시트를 다시 붙여도 그대로(늘 시트 뒤)
cpanel ??= buildComboPanel({
kind,
column: at.name,
isAdmin,
onUsed: () => remount(kind, at, readOnly, true),
});
host.append(cpanel.root);
if (matPanel || !matState) return;
matPanel = buildMaterialPanel({
column: at.name,
label: at.label ?? at.name,
isAdmin,
state: matState,
onApplied: () => void cpanel?.refresh(),
links: () => sheetHandle?.getDoc().시트.flatMap((s) => s.연결 ?? []) ?? [],
// 옆 판 표에서 고침 — 알약 막대만 이 목록으로 다시 만듦(저장 안 한 시트 고침은 그대로)
onChange: (items) => {
const kept = sheetHandle?.getDoc();
if (!kept) return;
editor?.destroy();
editor = null;
void mountSheet(kept, kind, at, readOnly, items);
},
goTo: (code) =>
host
.querySelector<HTMLElement>(`.ss-link.is-linked[data-key="${CSS.escape(code)}"]`)
?.click(),
unlink: (codes) => {
const book = sheetHandle?.getDoc();
if (!book) return;
for (const s of book.시트) {
const rest = (s.연결 ?? []).filter((l) => !codes.includes(l.자재?.코드 ?? l.키));
if (rest.length) s.연결 = rest;
else delete s.연결;
}
sheetHandle?.setDoc(book);
dirty = !readOnly;
},
});
cpanel.setMine(matPanel.root);
} catch {
empty(L("M02_NoSpreadsheet"));
}
}
/** 시트만 다시 붙임(알약을 새로 받음) — 저장 안 한 시트 고침은 그대로 옮김 ·
* `fresh` = 이 열 조합이 바뀜(재료는 자재 조합 · 담은 자재 표를 새로 받음) */
async function remount(
kind: "area" | "material",
at: Selection,
readOnly: boolean,
fresh: boolean,
): Promise<void> {
const kept = sheetHandle?.getDoc();
if (sel !== at || !kept) return;
editor?.destroy();
editor = null;
if (fresh && kind === "material") {
matPanel = null;
matState = null;
}
await mountSheet(kept, kind, at, readOnly);
}
async function mount(doc: unknown, at: Selection): Promise<void> {
const readOnly = !canEdit(at.layer, isAdmin);
host.replaceChildren();
@@ -406,8 +310,8 @@ export function createMain(
"createSheet",
);
if (!create) return empty(L("M02_NoSheet"));
// 구조물집계표(프로젝트 층 아님)만 — 도면 · 면적산출 · 재료산출 줄에 번호 · 칸을 고르면 아래 컨테이너 ·
// 고칠 수 있으면 일위대가 · 도면 · 면적산출 · 재료산출 칸은 조회 후 선택
// 구조물집계표(프로젝트 층 아님)만 — 도면 · 면적산출 줄에 번호 · 칸을 고르면 아래 컨테이너 ·
// 고칠 수 있으면 일위대가 · 도면 · 면적산출 칸은 조회 후 선택
const struct =
at.layer !== "project" && at.name === STRUCTURE_TABLE ? buildPanel(at.layer) : null;
if (struct) await struct.reload();
@@ -427,7 +331,6 @@ export function createMain(
? {
drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "",
areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "",
materialLabel: (colId: string) => struct.numbers()[colId]?.재료번 ?? "",
combo: {
...(readOnly || !extras.onHeadRename
? {}
@@ -472,8 +375,8 @@ export function createMain(
});
return;
}
if (at.kind === "area" || at.kind === "material") {
// 면적산출 · 재료산출 = 구조물집계표 열마다 한 스프레드시트 — 페이지 번들에 안 섞이게 이 화면을
if (at.kind === "area") {
// 면적산출 = 구조물집계표 열마다 한 스프레드시트 — 페이지 번들에 안 섞이게 이 화면을
// 열 때만 `import()` 로 받음(계약 `spreadsheet_types.ts` 머리)
return mountSheet(doc, at.kind, at, readOnly);
}
@@ -529,7 +432,7 @@ export function createMain(
)
: got.문서;
if (mine !== seq) return;
// 구조물 도면 · 면적산출 · 재료산출은 열 id 로 열리니 머리 맨 앞에 번호(「SD01010100 돌기슭막이 …」)
// 구조물 도면 · 면적산출은 열 id 로 열리니 머리 맨 앞에 번호(「SD01010100 돌기슭막이 …」)
const field = NUMBER_FIELD[next.kind];
const number = field && (got.문서 as Record<string, unknown> | null)?.[field];
if (number) title.textContent = `${String(number)} ${next.label ?? next.name}`;
@@ -551,11 +454,11 @@ export function createMain(
try {
const edited = editor ? await editor.getDoc() : loaded;
// 구조물 도면 저장은 `도면` 칸만 갈아 끼움 — 도번은 읽은 그대로.
// 면적산출 · 재료산출은 스프레드시트 문서로 통째 갈아 끼우되 번호는 계약 밖(엔진이 모름)이라 옮겨 붙임.
// 면적산출은 스프레드시트 문서로 통째 갈아 끼우되 번호는 계약 밖(엔진이 모름)이라 옮겨 붙임.
const doc =
at.kind === "structure" && loaded
? { ...(loaded as StructureDoc), 도면: edited as StructureDoc["도면"] }
: (at.kind === "area" || at.kind === "material") && loaded
: at.kind === "area" && loaded
? { ...(loaded as Record<string, unknown>), ...(edited as Record<string, unknown>) }
: edited;
const info = await saveTemplate(at.layer, at.kind, at.name, at.projectId, version, doc);
@@ -569,7 +472,6 @@ export function createMain(
await panel.reload();
sheet.recalc();
panel.refresh(sheet.getDoc()?.열 ?? []);
await panel.saveInputs(); // 「선택 입력」 카드의 자리 고름 — 같은 [저장]에서
}
showNotice([]);
showToast(L("M02_Saved"), "success");
@@ -1,11 +1,11 @@
/* =============================================================================
* M02_MasterTemplete_UI_Side.ts
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무 → 수평선 → 하위 구조물 도면 · 일위대가 ·
* 면적산출 · 재료산출 · 자재 조합 · 하위마다 맨 위 「전체 항목」) · 「도면 양식」(새로 · 본떠 · 지우기).
* 면적산출 · 하위마다 맨 위 「전체 항목」) · 「도면 양식」(새로 · 본떠 · 지우기).
* 서버 길은 계약 `6_계약.md`.
* 구조물 도면 · 면적산출 · 재료산출 줄은 집계표 구조물 열(꼴=수)마다 하나씩 —
* 서버가 미리 만들어 둠(도번 SD · 면적번 SA · 재료번 SM + 같은 8자리) ·
* 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」). 「자재 조합」 컨테이너는 `M02_MasterTemplete_Matset.ts`.
* 구조물 도면 · 일위대가 · 면적산출 줄은 집계표 구조물 열(꼴=수)마다 하나씩 —
* 서버가 미리 만들어 둠(도번 SD · 일위대가번 SU · 면적번 SA + 같은 뒷자리) ·
* 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」).
* 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로.
* 구조물 라이브러리는 좌측이 아니라 「구조물 조합」 상세 화면 왼쪽(PLAN 26-7c · 페이지가 붙임).
* ========================================================================== */
@@ -39,7 +39,6 @@ import {
structureLabel,
type StructureColumn,
} from "./M02_MasterTemplete_Structure";
import { buildMatsetSection } from "./M02_MasterTemplete_Matset";
import { buildOptionsSection } from "./M02_MasterTemplete_Options";
import { buildPracticeSection } from "./M02_MasterTemplete_Practice";
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
@@ -50,7 +49,7 @@ const KINDS: { kind: Kind; label: () => string }[] = [
];
/** 구조물 열마다 한 줄씩인 하위 목록 — 종류(편집 칸이 여는 `Kind`) · 번호 칸 · 제목 ·
* `all` = 맨 위 「전체 항목」 줄(PLAN 39-2) · 차례 = 구조물 도면 → 일위대가(SU) → 면적산출 */
type ColumnKind = "structure" | "labor" | "area" | "material";
type ColumnKind = "structure" | "labor" | "area";
const COLUMN_KINDS: {
kind: ColumnKind;
key: keyof StructureNumbers;
@@ -60,7 +59,6 @@ const COLUMN_KINDS: {
{ kind: "structure", key: "도번", label: () => L("M02_KindStructure"), all: true },
{ kind: "labor", key: "일위대가번", label: () => L("M02_KindLabor"), all: true },
{ kind: "area", key: "면적번", label: () => L("M02_KindArea"), all: true },
{ kind: "material", key: "재료번", label: () => L("M02_KindMaterial"), all: false },
];
/** 「전체 항목」 줄이 있는 하위 — 누르면 H 목록 화면(`onAll`) */
export type AllKind = "structure" | "labor" | "area";
@@ -143,24 +141,12 @@ export function buildSide(opt: SideOptions): SideHandle {
del.addEventListener("click", () => void deleteCurrent());
return [el("div", { className: "m02-side__row", children: [btnNew, copy, del] })];
};
const matset = buildMatsetSection({
isAdmin: canEdit(layer, opt.isAdmin),
confirmLeave: opt.confirmLeave,
show: opt.showView,
close: () => opt.onOpen(null),
onOpen: () => (options.clear(), practice?.clear(), (allPicked = null), paint()),
});
const matsetSection = section(matset.title, matset.body, ...matset.actions);
// 펼칠 때마다 새로 받음 — 재료산출 옆 판이 갈라 낸 조합도 바로 보이게
matsetSection.querySelector(".ui-collapsible__title")?.addEventListener("click", () => {
if (matsetSection.classList.contains("is-collapsed")) void matset.refresh();
});
// 구조물 옵션 보이기 — 구조물 집계 양식 아래 별도 줄 하나(누르면 메인 칸에 종류별 옵션 체크)
const options = buildOptionsSection({
isAdmin: canEdit(layer, opt.isAdmin),
confirmLeave: opt.confirmLeave,
show: opt.showView,
onOpen: () => (matset.clear(), practice?.clear(), (allPicked = null), paint()),
onOpen: () => (practice?.clear(), (allPicked = null), paint()),
});
/** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */
const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => {
@@ -177,7 +163,6 @@ export function buildSide(opt: SideOptions): SideHandle {
el("div", { className: "m02-side__items ui-sidebar-list", children: opt.tree ?? [] }),
el("hr", { className: "m02-side__rule" }),
...columnSections,
matsetSection,
);
summary.classList.remove("is-collapsed");
summary.classList.add("m02-side__summary");
@@ -198,7 +183,7 @@ export function buildSide(opt: SideOptions): SideHandle {
? buildPracticeSection({
confirmLeave: opt.confirmLeave,
show: opt.showView,
onOpen: () => (matset.clear(), options.clear(), (allPicked = null), paint()),
onOpen: () => (options.clear(), (allPicked = null), paint()),
})
: null;
const listHost = el("div", {
@@ -288,21 +273,19 @@ export function buildSide(opt: SideOptions): SideHandle {
for (const [kind, b] of editButtons) b.copy.disabled = b.del.disabled = cur?.종류 !== kind;
}
/** 고름 표시는 여기서만 — 목록 · 열 줄 · 자재 조합 줄을 함께 그려야 두 줄이 동시에 안 남음
* (양식이 열려 있으면 자재 조합 고름은 지움 · 자재 조합 상세는 열린 양식이 없음) */
/** 고름 표시는 여기서만 — 목록 · 열 줄 · 옵션 줄을 함께 그려야 두 줄이 동시에 안 남음
* (양식이 열려 있으면 옵션 · 일위대가 조합 고름은 지움) */
function paint(): void {
if (opt.getOpen()) allPicked = null;
paintList();
paintColumns();
paintButtons();
if (opt.getOpen()) matset.clear();
if (opt.getOpen()) options.clear();
if (opt.getOpen()) practice?.clear();
}
async function selectAll(kind: AllKind): Promise<void> {
if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return;
matset.clear();
options.clear();
practice?.clear();
allPicked = kind;
@@ -351,7 +334,7 @@ export function buildSide(opt: SideOptions): SideHandle {
if (mine !== seq) return;
paintList();
paintButtons();
await Promise.all([refreshStructure(), matset.refresh()]);
await refreshStructure();
}
/* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */
@@ -155,23 +155,8 @@
gap: var(--spacing-4);
}
/* 좌측 「자재 조합」 상세(메인 칸) — 이름 · 비고 · 본뜬키 + 담은 자재 표(줄 모양은 재료산출 옆 판 `m02-mat__row`) */
.m02-matset {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
max-width: 56rem;
font-size: var(--text-body-sm);
}
.m02-matset__fields {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-12);
}
.m02-matset__head {
/* 구조물 옵션 보이기 — 종류 제목 */
.m02-options__head {
margin: 0;
color: var(--color-text);
font-size: var(--text-body);
@@ -5,12 +5,12 @@
POST …/items {structure, layer, owner, key} 미리 가져오기 → {폴더, 만듦, 가져온날}
POST …/items/new {structure, 경로, 이름, 단위} 새 항목(개인 층에도) · 회사 없음 409
GET …/items/{폴더}/{종류} {종류, 판, 내용}
PUT …/items/{폴더}/{종류} {판, 내용} — 판이 다르면 409 · 면적 · 재료는 서버 계산값
PUT …/items/{폴더}/{종류} {판, 내용} — 판이 다르면 409 · 면적은 서버 계산값
POST …/items/{폴더}/reapply 재적용(입력값 · 고른자재는 같은 키면 살림 · `덮임` 알림)
POST …/items/{폴더}/save-as {to, 경로, 이름} 개인 층에 새 항목으로(단가 묶음은 안 가져감)
POST …/items/{폴더}/calc 복사본만으로 계산(세 비목 · 계 · 빠진 입력)
일위대가 문서면 줄마다 수량 · 반영 + 자재 · 기준합 · 반영합
종류 = 도면 · 면적 · 재료 · 자재조합 · 입력연결 · 입력값 · 고른자재 · 일위대가(읽기만 · 쓰면 400) ·
종류 = 도면 · 면적 · 입력값 · 고른자재 · 일위대가(읽기만 · 쓰면 400) ·
`refresh` = 옛 이름(재적용).
⚠ 등록은 `main.py`(로그인만) — 프로젝트 권한(같은 회사)은 여기서 `_project` 로 봄(403 · 404).
"""
@@ -33,7 +33,7 @@ from M02_MasterTemplete.M02_Item_Source import item_name
router = APIRouter(prefix="/api/projects", tags=["M02 Project Items"])
Kind = Literal["도면", "면적", "재료", "자재조합", "입력연결", "입력값", "고른자재", "일위대가"]
Kind = Literal["도면", "면적", "입력값", "고른자재", "일위대가"]
class WriteBody(BaseModel):
+5 -6
View File
@@ -1,9 +1,9 @@
"""M02 양식 층 — 네 층의 자리 · 읽기 · 쓰기 · 복사 · manifest.
층 넷 (PLAN 10-5):
system `resources/master_template/{table,drawing,structure,area,material}/<이름>.json` (git)
company `storage/{회사}/templates/{table,drawing,structure,area,material}/`
personal `storage/{회사}/{사용자}/templates/{table,drawing,structure,area,material}/`
system `resources/master_template/{table,drawing,structure,area}/<이름>.json` (git)
company `storage/{회사}/templates/{table,drawing,structure,area}/`
personal `storage/{회사}/{사용자}/templates/{table,drawing,structure,area}/`
project `storage/{회사}/{사용자}/{프로젝트}/templates/{…같은 다섯}/`
+ 초기 사본 `templates/_initial/`
@@ -26,9 +26,9 @@ from common_util.common_util_json import atomic_write_json
from config import config_system
LAYERS = ("system", "company", "personal", "project")
# area = 면적산출 · material = 재료산출 — 통합문서(구조물집계표 열마다 · `A00_Common/spreadsheet/`)
# area = 면적산출 — 통합문서(구조물집계표 열마다 · `A00_Common/spreadsheet/`)
# labor = 일위대가(구조물 열마다 · `M02_Labor_Doc`)
KINDS = ("table", "drawing", "structure", "labor", "area", "material")
KINDS = ("table", "drawing", "structure", "labor", "area")
TEMPLATES_DIRNAME = "templates"
INITIAL_DIRNAME = "_initial"
MANIFEST_NAME = "manifest.json"
@@ -171,7 +171,6 @@ _SKELETON = {
"structure": ("도면.entities",),
"labor": ("담은로직",),
"area": ("시트",),
"material": ("시트",),
}
-10
View File
@@ -298,13 +298,3 @@
- `이름` = 글자 · 빈 이름 안 됨 · 60자까지(`master_combo.MAX_이름`) · 같은 파일 안에서 안 겹침.
- 검사(`check_master.py 조합` · 엔진 `scripts/master_combo.py`) — 없는 로직 키 · 단가산출 로직이 아닌 키 · 같은 로직 두 번 · 빈 조합 · **조합이 조합을 담음**(1단만) · 메모가 글자·`null` 아님 · 이름이 글자 아니거나 없음·너무 김·겹침.
- 「이 로직을 쓰는 조합」 은 로직 줄에 칸으로 박지 않고 물을 때 훑어 찾음(`master_combo.using`) — 조합은 관리자가 그때그때 만드는 것이라 박아 두면 낡음. 소요량 「용도」 와 결은 같되 자리가 다름.
## 11. 자재 조합 파일
마스터 템플릿 `resources/master_template/자재조합.json` 하나(이 폴더 밖 · `구조물집계표조합.json` 과 같은 자리) — 구조물 열 재료산출이 쓰는 **자재 목록 묶음**. 머리 `그룹` · `판` · `비고` · `다음` · `줄`(원문 없음). 검사는 `check_master.py 틀` 이 같이 봄(`master_material_combo.check_file`).
- 열 한 벌 — `키` · `이름` · `담은자재` · `본뜬키` · `소유` · `비고`(차례까지 이대로 · 엔진 `scripts/master_material_combo.py`).
- `키` = `UM` + 6자리 — 표 머리 `다음` 번호(`_키대장` 밖) · 지운 키는 다시 안 씀 · 줄에 `원문번호` 를 두지 않음.
- `담은자재` 줄 = `{코드, 이름, 규격, 단위}` 뿐 · 빈 목록 안 됨 · 같은 코드 두 번 안 됨 · 수량 · 계산은 두지 않음(재료산출 몫).
- `본뜬키` = 갈라 낸 원래 자재 조합 키 · 없으면 `null` · `소유` = `현장` · `공용`.
- 새 줄은 M02 갈라 내기(`POST /api/m02/structures/{열}/material-combo`)가 만듦 — 같은 자재 집합이 있으면 그 키.
-4
View File
@@ -196,10 +196,6 @@
- 못 도는 줄은 통째로 멈추지 않고 그 줄만 `ok` false · `까닭` · `멈춤` 에 한 줄 — 나머지 줄의 합계는 그대로 냄.
- 답 — 200 · 400(틀린 몸 · 이름 없음 · 모르는 소유) · 404(없는 키) · 409(낡은 판본) · 422 `{detail: {errors: […]}}`(검사 걸림 — 없는 로직 키 · 같은 로직 두 번 · 빈 조합 · 조합을 담음).
### 자재 조합
M01 에 없음 — 마스터 템플릿 표(`resources/master_template/자재조합.json` · 키 `UM` · `_틀.md` 11장) · 길과 화면은 M02(`/api/m02/material-combos…` · 좌측 「자재 조합」 컨테이너).
## 10. 재료 · 기계 고르기
재료 줄은 키 대신 기본 검색어로 품목을 찾음 — 파일 모양 · 찾기 규칙 · 자동값 차례 · 단위 환산은 `_틀.md` 7장.
@@ -30,7 +30,6 @@ sys.path.insert(0, str(ROOT / "resources/knowledge/original/_pipeline"))
import master_combo as mcb # noqa: E402
import master_formula as mf # noqa: E402
import master_keys as mk # noqa: E402
import master_material_combo as mmc # noqa: E402
# (2) 본문은 따로 모듈 — 부르는 쪽은 `cm.check_body` · `cm.DIVISIONS` 그대로
from check_master_body import DIVISIONS, check_body # noqa: E402, F401
@@ -549,7 +548,6 @@ def main(argv: list[str]) -> int:
report = {}
if mode in ("틀", "전부"):
report["틀"] = [x for name, data in files.items() for x in check_form(name, data)]
report["틀"] += mmc.check_file() # 자재 조합은 마스터 템플릿 표
if mode in ("본문", "전부"):
report["본문"] = check_body(files)
if mode in ("로직", "전부"):
@@ -1,84 +0,0 @@
# -*- coding: utf-8 -*-
"""자재 조합 — 구조물 열 재료산출이 쓰는 자재 목록 묶음(키 UM · `_틀.md` 11장).
자리는 마스터 템플릿 `resources/master_template/자재조합.json`(구조물집계표조합.json 과 같은 결) ·
키는 표 안 「다음」 번호(`_키대장` 밖). 담은 자재 목록만 지님(수량 · 계산은 재료산출 몫) ·
갈라 내기(M02) 때 새 줄 · 본뜬키 = 원래 키. 저장 · 화면은 M02(`M02_MasterTemplete_Matset.py`).
"""
from __future__ import annotations
import json
from pathlib import Path
import master_combo as mcb
import master_keys as mk
GROUP = "자재조합"
FILE = "자재조합.json"
TID = "UM"
OWNERS = mcb.OWNERS
MAX_이름 = mcb.MAX_이름
PATH = Path(__file__).resolve().parents[2] / "master_template" / FILE
COLS = ("키", "이름", "담은자재", "본뜬키", "소유", "비고")
ITEM_COLS = ("코드", "이름", "규격", "단위")
def items(row: dict) -> list[dict]:
got = row.get("담은자재")
return got if isinstance(got, list) else []
def check_form(name: str, data: dict) -> list[str]:
"""파일 틀 + 줄 — 키 모양 · 열 한 벌 · 이름 · 소유 · 담은 자재(빈 목록 · 모양 · 같은 코드 두 번)."""
out = [] if data.get("그룹") == GROUP else [f"{name} · 머리 그룹이 「{GROUP}」 아님"]
rows = data.get("줄")
if not isinstance(rows, list):
return out + [f"{name} · 머리 「줄」 목록 없음"]
after = data.get("다음")
if not isinstance(after, int) or after < 1:
out.append(f"{name} · 머리 「다음」 이 1 이상 정수 아님")
after = None
seen_names: dict[str, str] = {}
for row in rows:
key = str(row.get("키"))
where = f"{name} · {key} {row.get('이름') or ''}".strip()
if not mk.KEY.fullmatch(key) or key[:2] != TID:
out.append(f"{where} · 키 모양이 「{TID}」 + 6자리 아님")
elif after and int(key[2:]) >= after:
out.append(f"{where} · 키 번호가 머리 「다음」({after}) 보다 작지 않음")
if tuple(row) != COLS:
out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(COLS)}」")
plain = row.get("이름").strip() if isinstance(row.get("이름"), str) else ""
if not plain or len(plain) > MAX_이름:
out.append(f"{where} · 「이름」 이 없거나 {MAX_이름}자 넘음")
elif plain in seen_names:
out.append(f"{where} · 「이름」 이 같은 조합과 겹침 — {seen_names[plain]}")
seen_names.setdefault(plain, key)
if row.get("소유") not in OWNERS:
out.append(f"{where} · 소유 「{row.get('소유')}」 — {' · '.join(OWNERS)} 아님")
origin = row.get("본뜬키")
if origin is not None and not (mk.KEY.fullmatch(str(origin)) and origin[:2] == TID):
out.append(f"{where} · 본뜬키 모양 「{origin}」")
got = items(row)
if not got:
out.append(f"{where} · 빈 조합 — 담은 자재가 없음")
codes: set[str] = set()
for at, one in enumerate(got, start=1):
if not isinstance(one, dict) or set(one) - set(ITEM_COLS):
out.append(f"{where} 줄{at} · 담은 자재 줄 모양 「{one}」")
continue
code = str(one.get("코드") or "")
if not mk.KEY.fullmatch(code):
out.append(f"{where} 줄{at} · 자재 코드 모양 아님 「{code}」")
elif code in codes:
out.append(f"{where} 줄{at} · 같은 자재를 두 번 담음 「{code}」")
codes.add(code)
return out
def check_file(path: Path = PATH) -> list[str]:
"""마스터 템플릿 자재 조합 표 — 없으면 한 줄."""
if not path.is_file():
return [f"{path.name} · 마스터 템플릿에 없음"]
return check_form(path.name, json.loads(path.read_text(encoding="utf-8")))
@@ -1,95 +0,0 @@
{
"판": 3,
"줄": [
{
"로직": "GC000297",
"입력": "형식",
"자리": "선택"
},
{
"로직": "GC000297",
"입력": "두께",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "구분",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "믹서",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "지역",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "재료배합",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "골재최대치수",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "수중",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "모래상태",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "자갈상태",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "모래단위중량",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "자갈단위중량",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "자재지역",
"자리": "선택"
},
{
"로직": "GF000162",
"입력": "포장두께",
"자리": "선택"
},
{
"로직": "GF000162",
"입력": "운반",
"자리": "선택"
},
{
"로직": "GF000162",
"입력": "운반감률",
"자리": "선택"
},
{
"로직": "GF000162",
"입력": "공급감률",
"자리": "선택"
},
{
"로직": "GF000162",
"입력": "종단경사",
"자리": "선택"
}
]
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "item1",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "재료산출",
"칸": {}
}
],
"활성": "s1",
"재료번": "SM-000001"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "item2",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "재료산출",
"칸": {}
}
],
"활성": "s1",
"재료번": "SM-000002"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "item3",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "재료산출",
"칸": {}
}
],
"활성": "s1",
"재료번": "SM-000003"
}
@@ -1,7 +0,0 @@
{
"그룹": "자재조합",
"판": "2026",
"비고": ["자재 조합 = 구조물 열 재료산출이 쓰는 자재 목록 묶음 · 수량·계산은 두지 않음(재료산출 몫) · 키 UM 은 「다음」 번호로 매김 · 지운 키는 다시 안 씀"],
"다음": 1,
"줄": []
}
@@ -1,36 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "c4",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "재료산출",
"칸": {
"I5": {
"값": 6
}
},
"연결": [
{
"키": "MT003810",
"이름": "모 래, 서울, 도착도 강모래",
"칸": "I5",
"단위": "㎥",
"자재": {
"코드": "MT003810",
"이름": "모 래, 서울, 도착도",
"규격": "강모래",
"단위": "㎥"
}
}
]
}
],
"활성": "s1",
"재료번": "SM01010101",
"계산값": {}
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "ms",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "재료산출",
"칸": {}
}
],
"활성": "s1",
"재료번": "SM10010100"
}
@@ -1,17 +0,0 @@
{
"종류": "통합문서",
"판": 1,
"열": "pp_len",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "재료산출",
"칸": {}
}
],
"활성": "s1",
"재료번": "SM03010100"
}
File diff suppressed because it is too large Load Diff
@@ -1,7 +0,0 @@
{
"그룹": "자재조합",
"판": "2026",
"비고": ["자재 조합 = 구조물 열 재료산출이 쓰는 자재 목록 묶음 · 수량·계산은 두지 않음(재료산출 몫) · 키 UM 은 「다음」 번호로 매김 · 지운 키는 다시 안 씀"],
"다음": 1,
"줄": []
}
@@ -1,9 +1,9 @@
/* 항목 고르기 모달 자료 창구 시험 헬퍼(계획서 25-2d) — `ui_template_item_picker_project.ts` 와
* 선택 입력 카드 셈(`M02_MasterTemplete_Inputs_Model.ts`)을 트랜스파일해 가짜 서버(fetch 대역) 위에서 돌린다.
* 넣을 값 셈(`M02_Labor_Values_Model.ts`)을 트랜스파일해 가짜 서버(fetch 대역) 위에서 돌린다.
* 서버 모양은 sub6 복사본 길(`M02_Project_Items_Router.py`) · sub4 입력 길 그대로. 결과는 JSON 한 벌.
* ① 폴더 이름 · 목록 {항목} · 가져오기 POST · 읽기 {종류, 판, 내용} · 고침 PUT {판, 내용}(판 · 409 · 403)
* ② 새로 가져오기 · 계산 한 줄 · 입력 목록(한 번만 받음)
* ③ 카드 셈: 범위 · 고르기 원래 모양 · 자리 「선택」 만 묶음
* ② 새로 가져오기 · 계산 한 줄
* ③ 값 셈: 범위 · 고르기 원래 모양
* ④ 일위대가(39-1c): 복사본 요약 · 원본 층 계산 요약 · 넣을 값 = 담은 로직 입력 전부
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다.
*/
@@ -42,7 +42,7 @@ const reply = (status, body) => ({
});
/** 가짜 서버 — 복사본 폴더마다 부분 파일 · 판(글자) */
const state = { readOnly: false, calls: [], folders: {}, inputsHits: 0 };
const state = { readOnly: false, calls: [], folders: {} };
const versionOf = (content) => `v${JSON.stringify(content ?? null).length}`;
const INPUTS = {
@@ -82,10 +82,6 @@ globalThis.fetch = async (url, init = {}) => {
반영합: { 계: 1500 },
});
}
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;
@@ -173,7 +169,7 @@ globalThis.fetch = async (url, init = {}) => {
};
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 model = loadTs(path.join(ROOT, "M02_MasterTemplete", "M02_Labor_Values_Model.ts"));
const row = (over = {}) => ({ key: "SS000001", 열: "rv", layer: "system", owner: null, ...over });
@@ -257,18 +253,12 @@ async function main() {
const valueData = await project.laborValueData(["GA1", "GA2"], { GA1: {} });
out.laborValues = {
keys: state.logicKeys,
link: valueData.link.줄.map((l) => `${l.로직}|${l.입력}|${l.자리}`),
shown: model.pickedGroups(valueData.list, valueData.link).length,
logics: valueData.list.로직.map((l) => [l.로직, l.입력.map((i) => i.이름)]),
hasLink: "link" in valueData,
values: valueData.values,
};
// 입력 목록 — 같은 항목은 한 번만 받음
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] }),
@@ -276,22 +266,7 @@ async function main() {
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", 입력: "소량감률", 자리: "면적" }] },
"면적",
),
key: model.inputKey("GA1", "지역"),
};
return out;
}
-61
View File
@@ -1,61 +0,0 @@
/* 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"));
// 상대 경로 TS(값 함수 = `M02_Labor_Values_Model.ts` 다시 내보냄)는 이어 트랜스파일
function loadTs(file) {
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const local = (request) =>
request.startsWith(".") ? loadTs(`${path.resolve(path.dirname(file), request)}.ts`) : require(request);
new Function("exports", "module", "require", js)(box.exports, box, local);
return box.exports;
}
const m = loadTs(path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts"));
// 같은 이름(높이)의 입력이 두 로직에 있음
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),
}),
);
+1 -1
View File
@@ -1,7 +1,7 @@
"""시험 안 마스터 견본(PLAN 26-5a) — 초기화 전 마스터 층을 줄여 얼린 것
(`fixtures/m02_master_sample`).
집계표(바인딩 열 54) · SS 장부(51 줄) · 자재조합 · 세 파일은 rv · c4 · pp_len · ms 넷(도면 줄임) —
집계표(바인딩 열 54) · SS 장부(51 줄) · 도면 · 면적 파일은 rv · c4 · pp_len · ms 넷(도면 줄임) —
시험은 실제 `resources/master_template` 을 안 읽고 이 견본을 임시 폴더에 복사해 씀.
"""
@@ -1,4 +1,4 @@
"""저장 뒤 식 · 값 유지 — M02 면적산출 · 재료산출 저장본을 서버 엔진으로 다시 풀면 저장된 `계산값` 과 같음.
"""저장 뒤 식 · 값 유지 — M02 면적산출 저장본을 서버 엔진으로 다시 풀면 저장된 `계산값` 과 같음.
시험 안 견본 저장본(`fixtures/m02_master_sample` · PLAN 26-5a)을 읽기만 함.
저장 때 서버가 적은 값과 지금 엔진 풀이가 어긋나면 화면을 다시 열 때
@@ -18,7 +18,7 @@ pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가
def _books() -> list[tuple[str, dict]]:
out = []
for kind in ("area", "material"):
for kind in ("area",):
for path in sorted((FOLDER / kind).glob("[!.]*.json")):
book = json.loads(path.read_text(encoding="utf-8"))
if isinstance(book, dict) and book.get("시트"):
@@ -1,4 +1,4 @@
"""F — 면적산출(area) · 재료산출(material) 저장 · 옛 산출근거(basis) 옮김 · [저장] 때 서버 계산값 ·
"""F — 면적산출(area) 저장 · 옛 산출근거(basis) 옮김 · [저장] 때 서버 계산값 ·
알약 연결(`연결`)은 저장 · 복사 때 문서와 같이.
서버 Node 풀이는 `test_spreadsheet_node.py` 가 따로 봄 — 여기는 `with_server_values` 를 바꿔 끼워
@@ -42,7 +42,7 @@ def _write(folder, kind, column, doc):
def test_옛_산출근거를_면적산출로_옮기고_번호_8자리(client, tmp_path):
"""12장 파일(SD · SC 6자리 · basis/) → SA 로 옮김 · 끝에 00 · 재료산출 새로 · 내용 그대로."""
"""12장 파일(SD · SC 6자리 · basis/) → SA 로 옮김 · 끝에 00 · 재료산출은 안 만듦 · 내용 그대로."""
structure = {"양식": "구조물도면", "종류": "structure", "판": 1, "열": "rv", "도번": "SD010101"}
_write(tmp_path, "structure", "rv", {**structure, "도면": {"entities": [{"type": "line"}]}})
old = {**store._blank("area", "rv"), "표번": "SC010101", "연결": LINKS}
@@ -56,30 +56,30 @@ def test_옛_산출근거를_면적산출로_옮기고_번호_8자리(client, tm
"A2": {"값": "터파기"}
}
assert store.read("structure", "rv")["문서"]["도번"] == "SD01010100"
material = store.read("material", "rv")["문서"]
assert (material["재료번"], material["시트"][0]["이름"]) == ("SM01010100", "재료산출")
assert material["시트"][0]["칸"] == {} and material["서식"] == [{}] # 진짜 빈 시트
assert not (tmp_path / "material").exists() # 재료산출은 없앰
before = {p: p.read_bytes() for p in tmp_path.rglob("*.json")}
assert store.renumber() == {"옮김": [], "지움": [], "바꿈": [], "새로": []}
assert before == {p: p.read_bytes() for p in tmp_path.rglob("*.json")} # 두 번 돌려도 같음
def test_새_구조물_도면은_면적_재료_진짜_빈_시트_같은_8자리(client):
def test_새_구조물_도면은_면적_진짜_빈_시트_같은_8자리(client):
made1 = client.post("/api/m02/structures/rv").json()["문서"]
made2 = client.post("/api/m02/structures/pv_b").json()["문서"]
# 새 항목 번호 = 자리 뜻 없는 일련번호(PLAN 26-1)
assert made1["도번"] == "SD-000001" and made2["도번"] == "SD-000002"
for kind, key, head in (("area", "면적번", "SA"), ("material", "재료번", "SM")):
for kind, key, head in (("area", "면적번", "SA"),):
doc = client.get(f"/api/m02/templates/{kind}/pv_b").json()["문서"]
assert doc[key] == f"{head}-000002"
assert doc["시트"][0]["칸"] == {} and doc["서식"] == [{}] # 머리 줄 없음
numbers = client.get("/api/m02/structures/numbers").json()
assert numbers["pv_b"] == {"도번": "SD-000002", "면적번": "SA-000002", "재료번": "SM-000002"}
assert numbers["pv_b"] == {
"도번": "SD-000002",
"일위대가번": "SU-000002",
"면적번": "SA-000002",
}
@pytest.mark.parametrize(
("kind", "key", "head"), [("area", "면적번", "SA"), ("material", "재료번", "SM")]
)
@pytest.mark.parametrize(("kind", "key", "head"), [("area", "면적번", "SA")])
def test_다른_열_내용을_복사해_가져옴_연결도_같이(client, kind, key, head):
store.renumber()
got = client.get(f"/api/m02/templates/{kind}/rv").json()
@@ -117,21 +117,21 @@ def test_저장은_서버_계산값만_적음_연결_두고_번호는_서버_것
def test_못_풀면_저장을_막음(client, monkeypatch):
store._ensure("material", "rv")
got = client.get("/api/m02/templates/material/rv").json()
store._ensure("area", "rv")
got = client.get("/api/m02/templates/area/rv").json()
def broken(_book):
raise spreadsheet.RecalcError("스프레드시트 계산 실패 — 시험")
monkeypatch.setattr(spreadsheet, "with_server_values", broken)
r = client.put("/api/m02/templates/material/rv", json={"판": got["판"], "문서": got["문서"]})
r = client.put("/api/m02/templates/area/rv", json={"판": got["판"], "문서": got["문서"]})
assert r.status_code == 503
assert client.get("/api/m02/templates/material/rv").json()["판"] == got["판"]
assert client.get("/api/m02/templates/area/rv").json()["판"] == got["판"]
def test_층_규칙과_프로젝트_복사(client, tmp_path, monkeypatch):
client.post("/api/m02/structures/rv")
for kind in ("area", "material"):
for kind in ("area",):
doc = store.read(kind, "rv")["문서"]
layers.check_skeleton(kind, doc)
for bad in ({}, {**doc, "시트": {}}):
@@ -140,5 +140,6 @@ def test_층_규칙과_프로젝트_복사(client, tmp_path, monkeypatch):
layers.check_skeleton("structure", {"도면": {"entities": []}}) # 산출근거 없어도 됨
monkeypatch.setattr(layers, "SYSTEM_ROOT", tmp_path)
copied = layers.seed_project(tmp_path / "project")
for kind in ("area", "material"):
assert not any(k.startswith("material/") for k in copied["작업본"]) # 재료산출 없앰
for kind in ("area",):
assert f"{kind}/rv" in copied["작업본"] and f"{kind}/rv" in copied["초기"]
+6 -22
View File
@@ -2,7 +2,7 @@
복사본은 구조물마다(`templates/structures/{구조물 id}/` · 관은 `pipe@측점`).
- 지정 → [저장] → 폴더에 새 파일(입력연결 · 입력값 · 고른자재 · 단가) · manifest 가져온날 · 원본판
- 지정 → [저장] → 폴더에 새 파일(입력값 · 고른자재 · 단가) · manifest 가져온날 · 원본판
- 단가 묶음이 닫혀 있음 — 조합 37개 · 로직 170개를 마스터와 복사본으로 돌려 같은 값
- 마스터 단가 한 줄을 바꿔도 프로젝트 계산 그대로 · 목록에 「마스터 바뀜」 · 새로 가져오기 뒤 바뀜
(입력값 · 고른자재는 같은 키면 살림)
@@ -52,9 +52,9 @@ def master_copy(tmp_path_factory) -> Path:
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"):
for name in (layers.COMBO_FILE,):
shutil.copy2(REAL / name, system / name)
for kind in ("table", "structure", "area", "material"):
for kind in ("table", "structure", "area"):
shutil.copytree(REAL / kind, system / kind)
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
monkeypatch.setattr(m01, "FOLDER", master_copy)
@@ -109,7 +109,7 @@ def test_지정하면_새_파일까지_복사(world):
root = world["root"]
assert _save(root, [_pipe()])["만듦"] == [FOLDER]
folder = root / "templates/structures" / FOLDER
assert _read(folder / "입력연결.json") == {"판": None, "줄": []} # 층 연결이 아직 없음
assert not (folder / "입력연결.json").exists() # 선택 입력 연결은 없앰
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()}
@@ -134,18 +134,6 @@ def test_지정하면_새_파일까지_복사(world):
]
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()
@@ -263,7 +251,6 @@ def test_쓰기_뒤_다시_읽기_같음_판_다르면_409(world):
("입력값", {"GF000176": {"관경": 600}}),
("입력값", {"GF000176": {"관경": {"값": 1, "확인": "네"}}}),
("고른자재", {"GF000176": {"첫": "MT000001"}}),
("입력연결", {"줄": [{"로직": "GF000176", "입력": "관경", "자리": "어딘가"}]}),
("도면", {}),
):
with pytest.raises(ValueError):
@@ -303,7 +290,7 @@ def test_옛_복사본은_읽을_때_채움(world):
root = world["root"]
_save(root, [_pipe()])
folder = root / "templates/structures" / FOLDER
for name in ("입력연결.json", "입력값.json", "고른자재.json", "단가.json"):
for name in ("입력값.json", "고른자재.json", "단가.json"):
(folder / name).unlink()
manifest = _read(folder / "manifest.json")
for key in ("원본판", "가져온날"):
@@ -437,9 +424,6 @@ def test_재적용은_시트_고침을_덮고_알림_입력값은_살림(world):
def test_개인에_저장하면_개인_층에_항목(world):
root = world["root"]
_save(root, [_pipe()])
rows = [{"로직": "GF000176", "입력": "관경", "자리": "선택"}]
current = copies.read_file(root, FOLDER, "입력연결")
copies.write_file(root, FOLDER, "입력연결", current["판"], {"판": 3, "줄": rows})
made = copies.save_as(root, FOLDER, 3, 12, ["관공", "우리 현장"], "파형강관 매설")
assert made["summary_item"] == {
"layer": "personal",
@@ -454,7 +438,7 @@ def test_개인에_저장하면_개인_층에_항목(world):
copy = root / "templates/structures" / FOLDER
drawing = _read(folder / "structure" / f"{made['열']}.json")
assert drawing["도면"] == _read(copy / "도면.json")["도면"]
assert _read(folder / "inputs" / f"{made['열']}.json") == {"판": 1, "줄": rows}
assert not (folder / "inputs").exists() # 선택 입력 연결은 안 옮김
assert not list(folder.rglob("단가.json")) # 단가 묶음은 안 가져감
# 개인 층 항목을 다른 구조물에 지정하면 그 내용으로 복사본이 섬
assert _save_all(root, [_wall("b1", made["summary_item"])], [])["만듦"] == ["b1"]
+12 -24
View File
@@ -1,9 +1,9 @@
# -*- coding: utf-8 -*-
"""항목 고르기 모달 상세(계획서 25-2d) — 복사본 창구 · 선택 입력 카드 셈 · 한 벌 지킴.
"""항목 고르기 모달 상세(계획서 25-2d) — 복사본 창구 · 넣을 값 셈 · 한 벌 지킴.
① 프로젝트 복사본 창구(`ui_template_item_picker_project.ts`)를 Node 로 돌려(sub6 길 모양의 가짜 서버 fetch 대역)
본다: 목록 `{항목}` · 가져오기 POST · 읽기 `{종류, 판, 내용}` · 고침 PUT `{판, 내용}`(판 · 409 · 403 까닭 그대로) ·
새로 가져오기 · 계산 한 줄 · 입력 목록 한 번만 받음 · 카드 셈(범위 · 고르기 원래 모양 · 자리 「선택」).
새로 가져오기 · 계산 한 줄 · 값 셈(범위 · 고르기 원래 모양).
② 모달이 카드 · 편집기를 M02 와 같은 한 벌로 쓰는지, 원본 층에 쓰는 길을 안 부르는지 소스로 지킨다.
"""
@@ -103,21 +103,11 @@ def test_회사_없음_409_는_서버_까닭_그대로(out):
assert out[key].startswith("회사부터 등록"), key
def test_입력_목록은_한_번만_받고_로직마다_갈라_둠(out):
assert out["inputsHits"] == 1
assert out["inputsLogics"] == [["GA1", ["지역", "소량감률"]], ["GA2", ["지역"]]]
def test_선택_입력_카드_셈(out):
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 알약 키
assert model["key"] == "GA1|지역" # 같은 이름도 로직마다 따로
def test_모달은_M02_와_같은_카드_한_벌을_씀():
@@ -136,15 +126,15 @@ def test_모달은_M02_와_같은_카드_한_벌을_씀():
assert drawing_card.count("mountDrawingTemplate(") == 1
def test_선택_입력_카드는_sub2_파일_한_벌(out):
def test_넣을_값_카드는_sub2_파일_한_벌(out):
"""두 벌 금지 — 모달의 값 받기 카드는 `M02_Labor_Values.ts`(39-3 · 일위대가 입력 모달과 같은
값 칸) · 옛 자리 `M02_MasterTemplete_Inputs.ts` 가 다시 내보냄."""
assert not (M02 / "M02_MasterTemplete_InputsCard.ts").exists()
inputs = (M02 / "M02_MasterTemplete_Inputs.ts").read_text(encoding="utf-8")
값 칸) · 옛 선택 입력(자리 고르기)은 없앰(39-7b)."""
for gone in ("M02_MasterTemplete_InputsCard.ts", "M02_MasterTemplete_Inputs.ts",
"M02_MasterTemplete_Inputs_Model.ts", "M02_MasterTemplete_Inputs.py"): # fmt: skip
assert not (M02 / gone).exists(), gone
values = (M02 / "M02_Labor_Values.ts").read_text(encoding="utf-8")
assert "export function createInputsCard(" in inputs # M02 자리 고르기(link)
assert "export function createInputsValueCard(" in values # 모달 값 받기(view · value)
assert "createInputsValueCard," in inputs and '} from "./M02_Labor_Values";' in inputs
assert "seatsOf" not in values and "pickedGroups" not in values
for name in ("ui_template_item_picker_detail.ts", "ui_template_item_picker_edit.ts"):
text = (UI / name).read_text(encoding="utf-8") # 모달은 값 카드를 바로 받음(39-1c)
assert "M02_Labor_Values" in text and 'InputsCard"' not in text, name
@@ -174,8 +164,8 @@ def test_일위대가_요약_복사본_원본_넣을_값_전부(out):
assert out["laborNone"] is None # 일위대가 없는 열(층 calc 404)
values = out["laborValues"]
assert values["keys"] == ["GA1", "GA2"]
assert values["link"] == ["GA1|지역|선택", "GA1|소량감률|선택", "GA2|지역|선택"]
assert values["shown"] == 2 and values["values"] == {"GA1": {}}
assert values["logics"] == [["GA1", ["지역", "소량감률"]], ["GA2", ["지역"]]]
assert values["hasLink"] is False and values["values"] == {"GA1": {}}
def test_모달_카드_차례_도면_일위대가_면적():
@@ -218,8 +208,6 @@ def test_소스가_700줄을_넘지_않음():
*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",
M02 / "M02_Labor_Values.ts",
M02 / "M02_Labor_Values_Model.ts",
):
+6 -7
View File
@@ -3,7 +3,7 @@
· 스프레드시트 `preview` = 도구 모음 · 오른쪽 클릭 메뉴 없음 · 수식 입력 줄 · 칸 고침
(다른 화면 그대로)
· 카드 = `sheetEdit` 가 있을 때만 고침(M02 구조물 조합) · 고르기 모달은 보기만 그대로
· 알약 · 자재 조합 판 = 전체 편집기와 같은 부품 · 카드 [저장] = 같은 저장 길 · 저장 안 한 고침 물음
· 알약 = 전체 편집기와 같은 부품 · 카드 [저장] = 같은 저장 길 · 저장 안 한 고침 물음
"""
from __future__ import annotations
@@ -55,13 +55,11 @@ def test_카드_저장은_읽은_문서에_시트를_덮음():
assert "saveTemplate(getActiveLayer(), kind, at, null, version, doc)" in struct
def test_알약_자재_조합_판은_전체_편집기와_같은_부품():
def test_알약은_전체_편집기와_같은_부품():
struct = _text(M02 / "M02_MasterTemplete_Structure.ts")
links = struct.split("async function cardLinks")[1].split("export interface")[0]
assert 'pillsFor("area", ctl.column)' in links
assert "loadMaterialState(ctl.column)" in links and 'inputPills(ctl.column, "재료")' in links
assert "toPills(items)" in links and "자재찾기: searchMaterials" in links
assert "buildMaterialPanel({" in links and "ctl.remount(links())" in links
assert "pillsFor(kind, ctl.column)" in links
assert "buildMaterialPanel" not in struct # 자재 조합 판은 없앰
assert "can: () => opts.isAdmin" in struct
@@ -95,7 +93,8 @@ def test_면적산출_알약은_일위대가_pills_길():
"""39-4 D7 — 로직 물량 「이름|단위」 + 자재 「자재|MT키」 · 다 고정 · 선택 입력 알약 빠짐."""
struct = _text(M02 / "M02_MasterTemplete_Structure.ts")
pills = struct.split("export async function pillsFor")[1].split("async function cardLinks")[0]
area = pills.split('if (kind === "material")')[1].split("\n }\n", 1)[1]
area = pills # 재료산출 갈래는 없앰 — 몸통이 면적산출 알약
assert 'kind === "material"' not in pills
assert "await fetchLaborPills(column)" in area
assert "고정: true" in area and "...(p.자재 ? { 자재: p.자재 } : {})" in area
assert "inputPills" not in area and "fetchComboInputs" not in struct
+2 -2
View File
@@ -44,8 +44,8 @@ def test_네_카드가_틀을_씀_수정_단추_한_모양():
assert 'variant: "filled"' not in cards # [수정] 은 모두 빈 단추(ghost)
assert "c.saver.button.hidden = c.help.hidden = !can;" in cards # [저장] · ? 늘 보임
assert "DRAWING_KEYS" in cards and "SHEET_KEYS" in cards
inputs = _text("M02_MasterTemplete_Inputs.ts") + _text("M02_Labor_Values.ts")
assert inputs.count("cardShell({") == 2 # 선택 입력 · 값 카드(39-3 에 `M02_Labor_Values.ts` 로)
inputs = _text("M02_Labor_Values.ts")
assert inputs.count("cardShell({") == 1 # 넣을 값 카드(선택 입력 카드는 없앰 · 39-7b)
assert "shell.fail(" in inputs and "shell.loading()" in inputs
assert "m02-struct__card ui-collapsible" not in inputs # 손으로 만든 겉 틀 없음
drawing = _text("M02_MasterTemplete_DrawingCard.ts")
+5 -48
View File
@@ -1,11 +1,10 @@
"""조합 갈라 내기 · 분류 더하기 복사 (PLAN 15장).
M02 견본 표(`write_sample_table`) + M01 마스터 사본(조합 줄은 비워 시작)으로만 봄 · 자재 조합 표는
마스터 템플릿 사본(없이 시작).
M02 견본 표(`write_sample_table`) + M01 마스터 사본(조합 줄은 비워 시작)으로만 봄.
· 일위대가(UA) — 새로 만듦(본뜬키 = 원래 키) · 이 열만 새 키 · 다른 열은 옛 키 · 원래 조합 그대로 ·
SS 장부 맞춤 · 담은 로직(차례 · 메모까지)이 같으면 있는 키(되돌리면 원래 키) · 차례 · 메모만
바꿔도 새로 · 또 고치면 또 새로 · 같은 이름 (2)
· 자재 조합(UM) — 같은 규칙 · 처음 만드는 열(본뜬키 없음) · 읽기
· 자재 조합(UM)은 없앰 — 키 적기 종류 「자재조합」 = 400
· 집계표 판 어긋남 = 409 · 조합 안 만듦
· 분류 더하기로 복사한 열(`본뜬열`) — [저장] 때 원본 키 · 세 파일 내용을 새 번호로 받음
"""
@@ -25,10 +24,6 @@ from resources.tester.test_m02_structure import write_sample_table
REAL = m01.FOLDER
A, B, C = "GF000219", "GF000220", "GF000211" # 돌쌓기 메쌓기(인력 · 장비) · 채집(인력)
M1, M2 = (
{"코드": "MT000008", "이름": "시멘트", "규격": "40kg", "단위": "포"},
{"코드": "MT000009", "이름": "모래", "규격": None, "단위": "㎥"},
)
TABLE = "/api/m02/templates/table/구조물집계표"
@@ -156,36 +151,6 @@ def test_집계표_판_어긋나면_409_조합_안_만듦(client):
_branch(client, "rv", "UA999999", [A, C], want=404) # 없는 원래 조합
def _material(client, column, source, items, name="", want=200):
body = {"본뜬키": source, "담은자재": items, "이름": name}
res = client.post(f"/api/m02/structures/{column}/material-combo", json=body)
assert res.status_code == want, res.text
return res.json()
def test_자재_조합_처음_만듦_같은_내용이면_있는_키_고치면_새로(client):
first = _material(client, "rv", "", [M1, M2])
assert first["새로만듦"] is True and first["키"].startswith("UM")
assert first["이름"] == "돌기슭막이 (형태마다) H=(높이마다)" # 이름 없으면 열 머리
row = client.get(f"/api/m02/material-combos/{first['키']}").json()
assert tuple(row) == ("키", "이름", "담은자재", "본뜬키", "소유", "비고")
assert (row["본뜬키"], row["소유"], row["담은자재"]) == (None, "현장", [M1, M2])
same = _material(client, "c1", "", [M1, M2]) # 같은 차례 = 있는 키
assert (same["키"], same["새로만듦"]) == (first["키"], False)
order = _material(client, "c1", first["키"], [M2, M1], "모래 먼저") # 차례만 다름 = 새로
assert order["새로만듦"] is True and order["키"] != first["키"]
fork = _material(client, "c1", first["키"], [M1], "시멘트만")
assert fork["새로만듦"] is True and fork["키"] != first["키"]
assert client.get(f"/api/m02/material-combos/{fork['키']}").json()["본뜬키"] == first["키"]
assert (_cols(client, "자재조합")["rv"], _cols(client, "자재조합")["c1"]) == (
first["키"],
fork["키"],
)
assert _ss(client, "자재조합")["c1"] == fork["키"]
assert client.get("/api/m02/material-combos/UM999999").status_code == 404
_material(client, "rv", "", [{"코드": "없는모양"}], want=422) # 자재 조합 저장 검사
def _use(client, column, kind, key, 판="", want=200):
body = {"종류": kind, "키": key, "판": 판}
res = client.post(f"/api/m02/structures/{column}/use-combo", json=body)
@@ -208,19 +173,11 @@ def test_있는_조합을_이_열에_씀_조합은_그대로(client):
_use(client, "c1", "일위대가", "UA999999", want=404)
_use(client, "no", "일위대가", ua, want=404) # 글 열
_use(client, "c1", "모름", ua, want=400)
um = _material(client, "rv", "", [M1, M2])["키"]
_use(client, "c1", "자재조합", um)
assert _cols(client, "자재조합")["c1"] == um and _ss(client, "자재조합")["c1"] == um
_use(client, "c1", "자재조합", "UM999999", want=404)
_use(client, "c1", "자재조합", "UM000001", want=400) # 자재 조합은 없앰
def test_분류_더하기로_복사한_열은_원본_키_세_파일_내용을_받음(client):
ua = _source(client)
table = client.get(TABLE).json()
doc = table["문서"]
rv = next(c for c in doc["열"] if c["id"] == "rv")
rv["자재조합"] = "UM000001"
assert client.put(TABLE, json={"판": table["판"], "문서": doc}).status_code == 200
drawing = client.get("/api/m02/templates/structure/rv").json()
marked = {**drawing["문서"], "도면": {"format": 6, "entities": [{"type": "line"}]}}
put = client.put("/api/m02/templates/structure/rv", json={"판": drawing["판"], "문서": marked})
@@ -233,13 +190,13 @@ def test_분류_더하기로_복사한_열은_원본_키_세_파일_내용을_
assert client.put(TABLE, json={"판": table["판"], "문서": doc}).status_code == 200
saved = next(c for c in client.get(TABLE).json()["문서"]["열"] if c["id"] == "c9")
assert "본뜬열" not in saved # 뗌
assert (saved["일위대가"], saved["자재조합"]) == (ua, "UM000001")
assert not saved.get("일위대가") # 열 키는 안 받음 — 일위대가는 labor 문서로 복사(39-1b)
copy = client.get("/api/m02/templates/structure/c9").json()["문서"]
assert copy["도면"]["entities"] == [{"type": "line"}] and copy["열"] == "c9"
numbers = client.get("/api/m02/structures/numbers").json()
assert numbers["c9"]["도번"] != numbers["rv"]["도번"] # 새 번호
assert numbers["c9"]["면적번"][2:] == numbers["c9"]["도번"][2:]
for kind in ("area", "material"):
for kind in ("area",):
assert client.get(f"/api/m02/templates/{kind}/c9").json()["문서"]["열"] == "c9"
# 그 뒤로는 열마다 따로 — 원본을 고쳐도 복사본은 그대로
drawing = client.get("/api/m02/templates/structure/rv").json()
+1 -13
View File
@@ -227,19 +227,7 @@ def test_면적_알약이_개인_조합을_읽음(world):
got = me.get(f"{URL.format('personal')}/{up}/inputs").json()
assert [p["키"] for p in got["물량"]] == ["돌쌓기 찰쌓기|㎡"]
assert me.get(f"/api/m02/combos/key/{up}/inputs").json() == got
tree = me.post(
"/api/m02/layers/personal/tree",
json={"op": "add-item", "경로": [], "이름": "찰쌓기 옹벽", "단위": "m", "판": _판(me)},
).json()
column = tree["알림"]["열"]
me.post(
f"/api/m02/layers/personal/structures/{column}/use-combo",
json={"종류": "일위대가", "키": up},
)
key = next(i["key"] for i in tree["items"] if i["열"] == column)
pills = me.get(f"/api/m02/layers/personal/items/{key}/pills", params={"place": "면적"})
assert pills.status_code == 200, pills.text
assert [p["키"] for p in pills.json()["물량"]] == ["돌쌓기 찰쌓기|㎡"]
# 항목 알약 길(`/items/{key}/pills`)은 선택 입력과 같이 없앰(39-7b)
def test_담을_로직_읽기는_로그인이면_됨(world):
+1 -2
View File
@@ -80,9 +80,8 @@ def test_지운_뒤_항목_0_틀은_남음(empty):
combos = client.get("/api/m02/structure-combos").json()
assert combos["줄"] == [] and combos["다음"] == 52
assert len(combos["지운번호"]) == 4 # 견본에 세 파일이 있던 넷(rv · c4 · pp_len · ms)
for kind in ("structure", "area", "material", "inputs"):
for kind in ("structure", "area"):
assert not list((system / kind).glob("*.json")), kind
assert (system / "자재조합.json").is_file()
def test_빈_마스터에서_첫_화면_나무_새_항목_지우기(empty):
-80
View File
@@ -1,80 +0,0 @@
# -*- 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)
-240
View File
@@ -1,240 +0,0 @@
"""M02 항목 선택 입력(PLAN 25-1b · 25-2a) — 입력 목록 · 항목 연결 파일 · 알약 · 권한.
① 입력 목록은 시험 안 마스터 견본(`fixtures/m02_master_sample` 사본 · PLAN 26-5a)으로 —
일위대가 있는 항목 셋에서 로직 수 · 입력 수 · 자동값이 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.helper_m02_master_sample import copy_master_sample
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_조합_미리_보기와_같음(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(store, "FOLDER", copy_master_sample(tmp_path / "master"))
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()
+3 -4
View File
@@ -109,8 +109,7 @@ def test_옛_SS_키는_새_모양으로_옮기고_옛_키로도_찾음(world):
migrated = json.loads((folder / store.COMBO_FILE).read_bytes())
assert [r["옛키"] for r in migrated["줄"]] == [["SS000001"], ["SS000003"]]
assert migrated["다음"] == 5
# 옛 키로도 찾음 — 입력 목록 · 나무 고침 · 복사본 원본 찾기(M02_Item_Source)
assert client.get("/api/m02/layers/personal/items/SS000003/inputs").status_code == 200
# 옛 키로도 찾음 — 나무 고침 · 복사본 원본 찾기(M02_Item_Source)
tree_ = Tree(client, "personal")
tree_.do(op="rename", 키="SS000001", 이름="a2")
assert tree_.item(["a2"])["key"] == "SP42-000001"
@@ -164,7 +163,7 @@ def test_새_번호는_옛_번호와_모양이_달라_안_겹침(world):
doc = json.loads(path.read_bytes())
path.write_text(json.dumps({**doc, key: f"{head}{code}"}), encoding="utf-8")
old = {cid: _numbers(system, cid) for cid in store._column_ids()}
assert old["rv"] == {"SD01010100", "SU01010100", "SA01010100", "SM01010100"}
assert old["rv"] == {"SD01010100", "SU01010100", "SA01010100"}
first = max(int(n[3:]) for nums in old.values() for n in nums if NEW.match(n)) + 1
with store.at(system):
got = store.read("table", store.STRUCTURE_TABLE)
@@ -191,7 +190,7 @@ def test_새_번호는_옛_번호와_모양이_달라_안_겹침(world):
got["문서"]["열"].append({"id": "y", "머리": ["y"], "꼴": "수"})
store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"])
last = f"-{first + 120:06d}" # 지운 x119 번호(first + 119) 다음
assert _numbers(system, "y") == {f"SD{last}", f"SU{last}", f"SA{last}", f"SM{last}"}
assert _numbers(system, "y") == {f"SD{last}", f"SU{last}", f"SA{last}"}
def test_층마다_번호는_따로_파일은_열_id_로(world):
+2 -2
View File
@@ -65,9 +65,9 @@ def _first_input(code: str) -> tuple[str, Any]:
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"):
for name in (layers.COMBO_FILE,):
shutil.copy2(SAMPLE / name, system / name)
for kind in ("table", "structure", "area", "material"):
for kind in ("table", "structure", "area"):
shutil.copytree(SAMPLE / kind, system / kind)
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
monkeypatch.setattr(m01, "FOLDER", master_copy)
+2 -2
View File
@@ -2,7 +2,7 @@
못박는 것
- 입력 목록 = 키 차례 · 겹친 키 한 번 · 입력마다 자동값 · 없는 키는 「빠진로직」.
- 옛 선택 입력 길(`M02_MasterTemplete_Inputs.inputs`)과 몸 한 벌(`M02_Labor_Inputs`).
- 입력 목록 몸 한 벌(`M02_Labor_Inputs`) · 옛 선택 입력은 없앰(39-7b).
- 값 셈 = 칸 글 → 저장 값(빈 글 = 자동값 · 범위 = 수 · 고르기 = 원래 모양) · 범위 밖 ·
모르는 이름 버림(사라진 값 알림) · 줄 아래 작은 글 · 빈 값 수(자동값도 없는 입력만).
화면 TS 는 그 자리에서 트랜스파일해 node 로 돌림 · 정본은 읽기만.
@@ -44,7 +44,7 @@ def test_입력_목록은_키_차례_겹침_한_번_자동값_빠진_로직(clie
one = got["로직"][1]
assert one["이름"] and one["결과단위"]
assert one["입력"] and all("자동값" in i and "이름" in i for i in one["입력"])
# 옛 선택 입력 목록과 같은 몸
# 입력 목록 몸 한 벌(M02_Labor_Inputs)
assert got["로직"] == labor_inputs.logic_inputs([B, A])["로직"]
+2 -2
View File
@@ -207,7 +207,7 @@ def test_층_단추_셋_남의_회사_사람_나무():
def test_층_길_접두어와_고정_풀림():
api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts")
assert 'activeLayer === "system" ? "" : `/layers/${activeLayer}`' in api
for path in ("/structures/numbers", "/structure-combos", "/material-combos"):
for path in ("/structures/numbers", "/structure-combos"):
assert re.search(rf'scoped\(\s*[`"]{re.escape(path)}', api), path
assert "/layers/personal/items/copy" in api
for name in ("Structure", "Combo_Api"):
@@ -245,7 +245,7 @@ def test_열_0_첫_열_만들기():
assert fresh and first, "freshId · addFirstColumn 을 못 찾음"
script = (
"interface SheetColumn { id: string; 머리: (string | null)[]; 단위?: string | null;"
" 꼴?: string; 일위대가?: string | null; 자재조합?: string | null }\n"
" 꼴?: string; 일위대가?: string | null }\n"
"interface SheetDoc { 열: SheetColumn[] }\n"
+ fresh.group(0)
+ "\n"
+6 -24
View File
@@ -3,7 +3,6 @@
임시 storage(회사 · 개인 층) + 견본 시스템 집계표(`write_sample_table`)로만 봄 —
실제 양식은 안 읽음(시스템 층 두 자리 `store.FOLDER` · `layers.SYSTEM_ROOT` 모두 사본).
자재 코드는 실제 M01 마스터에 있는 MT 키를 씀(자재 조합 저장이 마스터 대조를 함).
"""
from __future__ import annotations
@@ -18,7 +17,6 @@ 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_MasterTemplete_Combo_Store as combos
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router
@@ -29,14 +27,6 @@ from resources.tester.test_m02_structure import write_sample_table
TABLE = "table/구조물집계표"
def _material() -> dict[str, Any]:
for data in m01.loaded()[0].values():
for row in data.get("줄") or []:
if str(row.get("키", "")).startswith("MT"):
return {"코드": row["키"], "이름": row.get("이름"), "규격": None, "단위": None}
raise AssertionError("마스터에 자재 키가 없음")
@pytest.fixture()
def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
system = tmp_path / "system"
@@ -84,7 +74,7 @@ def test_시스템_층은_그대로_항목_목록은_SS_줄(world):
"labor",
*items_router.items_store.ITEM_FIELDS,
}
assert set(rows[0]["names"]) == {"일위대가", "자재조합", "도번", "면적번", "재료번"}
assert set(rows[0]["names"]) == {"일위대가", "도번", "면적번"}
assert client.get("/api/m02/layers/system/structure-combos").json() == store.read_combos()
# 시스템 층 쓰기는 SYSTEM_ADMIN 만
assert client.post("/api/m02/layers/system/structures/rv").status_code == 403
@@ -108,7 +98,7 @@ def test_회사_층_첫_진입_뼈대와_쓰기_권한(world):
== json.loads((world["system"] / "table/구조물집계표.json").read_text("utf-8"))["층"]
)
assert json.loads((root / "구조물집계표조합.json").read_text("utf-8"))["다음"] == 1
assert json.loads((root / "자재조합.json").read_text("utf-8"))["다음"] == 1
assert not (root / "자재조합.json").exists() # 자재 조합은 없앰
url = f"/api/m02/layers/company/templates/{TABLE}"
got = client.get(url).json()
got["문서"]["열"].append(COL)
@@ -140,7 +130,8 @@ def test_개인_층은_본인만_회사_없으면_409(world):
assert [(g["layer"], g["items"]) for g in groups][1:] == [("company", []), ("personal", [])]
assert len(groups[0]["items"]) == len(store.read_combos()["줄"]) # 마스터는 보임
made = lonely.post(
"/api/m02/layers/personal/material-combos", json={"줄": {"이름": "a", "담은자재": []}}
"/api/m02/layers/personal/structures/p1/use-combo",
json={"종류": "일위대가", "키": "UA000001"},
)
assert made.status_code == 409 and "회사부터" in made.json()["detail"]
@@ -152,20 +143,14 @@ def test_회사_항목을_개인으로_복사(world):
drawing = admin.get(f"{base}/templates/structure/cw").json()
doc = {**drawing["문서"], "도면": {**drawing["문서"]["도면"], "entities": [{"type": "line"}]}}
admin.put(f"{base}/templates/structure/cw", json={"판": drawing["판"], "문서": doc})
판 = admin.get(f"{base}/material-combos").json()["판"]
row = {"이름": "회사 자재", "담은자재": [_material()]}
um = admin.post(f"{base}/material-combos", json={"판": 판, "줄": row}).json()["키"]
admin.post(f"{base}/structures/cw/use-combo", json={"종류": "자재조합", "키": um})
admin.post(f"{base}/structures/cw/use-combo", json={"종류": "일위대가", "키": "UA000004"})
row = admin.get("/api/m02/items?layer=company").json()[0]
ss = row["key"]
ua_name = combos.find("UA000004")["이름"]
assert row["names"] == { # 키 대신 화면 이름
"일위대가": ua_name,
"자재조합": "회사 자재",
"도번": "돌기슭막이 찰쌓기 회사형",
"면적번": "돌기슭막이 찰쌓기 회사형",
"재료번": "돌기슭막이 찰쌓기 회사형",
}
user = _as(world)
@@ -182,13 +167,10 @@ def test_회사_항목을_개인으로_복사(world):
assert copy["도면"]["entities"] == [{"type": "line"}] and copy["열"] == "cw"
col = next(c for c in user.get(f"{mine}/templates/{TABLE}").json()["문서"]["열"])
assert col["본뜬"] == {"layer": "company", "owner": "7", "key": ss}
mat = user.get(f"{mine}/material-combos/{col['자재조합']}").json()
assert mat["이름"] == "회사 자재" and mat["담은자재"][0]["코드"] == _material()["코드"]
# 또 복사하면 새 열 id · 새 SS 키 · 자재 조합 이름 (2)
assert "자재조합" not in col # 옛 자재 조합 칸은 안 옮김
# 또 복사하면 새 열 id · 새 SS 키
again = user.post("/api/m02/layers/personal/items/copy", json=body).json()
assert (again["열"], again["key"]) == ("cw_2", "SP42-000002")
names = [r["이름"] for r in user.get(f"{mine}/material-combos").json()["줄"]]
assert names == ["회사 자재", "회사 자재 (2)"]
# 마스터 항목도 개인으로 · 없는 키 404
master = {"from_layer": "system", "key": "SS000001"}
assert user.post("/api/m02/layers/personal/items/copy", json=master).status_code == 200
-140
View File
@@ -1,140 +0,0 @@
"""M02 자재 조합(`/api/m02/material-combos…` · 마스터 템플릿 `자재조합.json` · 키 UM).
못박는 것
- 정본 표는 마스터 템플릿 자리 · 틀대로(머리 「다음」) · 키 대장에 UM 없음 · M01 길 없음.
- 새로 만들면 키 = 표 「다음」 번호 · 열 한 벌 · 담은 자재는 네 칸 · 지운 키는 다시 안 씀.
- 없는 자재 코드 · 같은 자재 두 번 · 이름 없음 · 이름 겹침 · 없는 본뜬키는 막음.
- 고치기 · 지우기는 낡은 판이면 409 · 본뜬키는 고쳐도 그대로 · 쓰는 열이 있으면 못 지움.
- 정본은 읽기만 함(사본 폴더 · 견본 집계표).
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from M01_MasterData import M01_MasterData_Router as m01_router
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_MasterTemplete_Matset as matset
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.M02_MasterTemplete_Router import router
from resources.tester.test_m02_structure import write_sample_table
ROOT = Path(__file__).resolve().parents[2]
M1, M2 = (
{"코드": "MT000008", "이름": "시멘트", "규격": "40kg", "단위": "포"},
{"코드": "MT000009", "이름": "모래", "규격": "", "단위": "㎥"},
)
URL = "/api/m02/material-combos"
TABLE = "/api/m02/templates/table/구조물집계표"
@pytest.fixture
def client(tmp_path: Path, monkeypatch) -> TestClient:
write_sample_table(tmp_path)
monkeypatch.setattr(store, "FOLDER", tmp_path)
app = FastAPI()
app.include_router(router)
return TestClient(app)
def _new(client: TestClient, name: str, mats: list[dict], 판: str | None = None, **more):
if 판 is None:
판 = client.get(URL).json()["판"]
return client.post(URL, json={"판": 판, "줄": {"이름": name, "담은자재": mats, **more}})
def test_정본_표는_마스터_템플릿_자리_틀대로_UM_은_대장_밖() -> None:
path = ROOT / "resources/master_template/자재조합.json"
data = json.loads(path.read_text(encoding="utf-8"))
assert data["그룹"] == "자재조합" and isinstance(data["다음"], int)
assert matset.mmc.check_file(path) == []
assert not (ROOT / "resources/master_data/자재조합.json").exists()
book = (ROOT / "resources/master_data/_키대장.json").read_text(encoding="utf-8")
assert '"UM"' not in book.splitlines()[1] # 대장 머리 「다음」
assert not any("matset" in getattr(r, "path", "") for r in m01_router.router.routes)
def test_새로_만들면_다음_번호_열_한_벌_지운_키는_다시_안_씀(client) -> None:
res = _new(client, "합판 자재", [{**M1, "덤": 1}])
assert res.status_code == 200, res.text
made = res.json()
assert made["키"] == "UM000001" and tuple(made["줄"]) == matset.mmc.COLS
assert made["줄"]["담은자재"] == [M1] and made["줄"]["본뜬키"] is None
listed = client.get(URL).json()
assert listed["판"] == made["판"] and [r["키"] for r in listed["줄"]] == ["UM000001"]
assert client.get(f"{URL}/UM000001").json()["이름"] == "합판 자재"
assert client.delete(f"{URL}/UM000001", params={"판": made["판"]}).status_code == 200
again = _new(client, "다음 것", [M2]).json()
assert again["키"] == "UM000002" # 지운 번호는 다시 안 씀
assert json.loads((store.FOLDER / matset.FILE).read_text(encoding="utf-8"))["다음"] == 3
def test_없는_자재_두_번_이름_본뜬키는_막는다(client) -> None:
assert _new(client, "없는 코드", [{**M1, "코드": "MT999999"}]).status_code == 422
assert _new(client, "두 번", [M1, M1]).status_code == 422
assert _new(client, " ", [M1]).status_code == 400
assert _new(client, "빈 것", []).status_code == 422
assert _new(client, "없는 원본", [M1], 본뜬키="UM999999").status_code == 400
ok = _new(client, "원본", [M1]).json()["키"]
copy = _new(client, "본뜬 것", [M1, M2], 본뜬키=ok).json()
assert copy["줄"]["본뜬키"] == ok and copy["키"] != ok
dup = _new(client, "원본", [M2]) # 이름 겹침 — 파일명 · 키 없이 쉬운 말 한 줄
assert dup.status_code == 422 and dup.json()["detail"]["errors"] == [
"「원본」 이름의 자재 조합이 이미 있음 — 다른 이름으로"
]
long = "긴" * 40
assert _new(client, long, [M1]).status_code == 200
cut = _new(client, long, [M2]).json()["detail"]["errors"][0]
assert cut.startswith("「" + "긴" * 30 + "…」") and matset.FILE not in cut and "UM0" not in cut
assert _new(client, "긴" * 61, [M1]).json()["detail"]["errors"] == ["이름이 너무 김 — 60자까지"]
assert _new(client, "낡은 판", [M2], 판="0000").status_code == 409
def test_고치기는_낡은_판이면_409_본뜬키는_그대로(client) -> None:
src = _new(client, "원본", [M1]).json()["키"]
made = _new(client, "고칠 것", [M1], 본뜬키=src).json()
row = {**made["줄"], "이름": "고침", "담은자재": [M1, M2], "본뜬키": None}
res = client.put(f"{URL}/{made['키']}", json={"판": made["판"], "줄": row})
assert res.status_code == 200, res.text
assert res.json()["줄"]["이름"] == "고침" and res.json()["줄"]["본뜬키"] == src
assert client.put(f"{URL}/{made['키']}", json={"판": made["판"], "줄": row}).status_code == 409
assert client.put(f"{URL}/UM999999", json={"판": "", "줄": row}).status_code == 404
def test_쓰는_열이_있으면_못_지우고_없으면_지워진다(client) -> None:
made = _new(client, "쓰는 것", [M1]).json()
table = client.get(TABLE).json()
for col in table["문서"]["열"]:
if col["id"] == "rv":
col["자재조합"] = made["키"]
assert client.put(TABLE, json={"판": table["판"], "문서": table["문서"]}).status_code == 200
res = client.delete(f"{URL}/{made['키']}", params={"판": made["판"]})
assert client.get(URL).json()["쓰는곳"] == {made["키"]: ["돌기슭막이 (형태마다) H=(높이마다)"]}
assert res.status_code == 409 and res.json()["detail"]["users"] == [
"돌기슭막이 (형태마다) H=(높이마다)"
]
rows = client.get("/api/m02/structure-combos").json()["줄"]
assert next(r for r in rows if r["열"] == "rv")["자재조합"] == made["키"] # SS 장부 칸
table = client.get(TABLE).json()
for col in table["문서"]["열"]:
col.pop("자재조합", None)
assert client.put(TABLE, json={"판": table["판"], "문서": table["문서"]}).status_code == 200
assert client.delete(f"{URL}/{made['키']}", params={"판": "낡음"}).status_code == 409
assert client.delete(f"{URL}/{made['키']}", params={"판": made["판"]}).status_code == 200
assert client.get(f"{URL}/{made['키']}").status_code == 404
def test_M01_마스터는_UM_을_모른다() -> None:
assert "UM" not in m01.cm.mk.IDS and "자재조합" not in m01.cm.mk.ROW_ORDER
def test_새로_본떠_만들기는_열자마자_이름_칸에_초점() -> None:
ui = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Matset.ts").read_text(encoding="utf-8")
assert "'[data-matset-field=\"이름\"]')?.focus()" in ui.split("if (isNew) {")[1]
copy = ui.split("btnCopy.addEventListener")[1].split("});")[0]
assert '이름: ""' in copy # 본떠 만들기는 이름 칸 비움
+6 -7
View File
@@ -1,7 +1,7 @@
"""M02 구조물 도면(structure) — 새로 · 도번 SD+8자리 · 409 · 저장 · 지운 뒤 번호 ·
번호 매김(새 항목 = 자리 뜻 없는 일련번호 · PLAN 26-1) · 집계표 저장 때 만들기/지우기 ·
복사 가져오기 · 프로젝트 복사.
면적산출(area) · 재료산출(material)은 따로 파일 —
면적산출(area)은 따로 파일(재료산출은 없앰) —
`resources/tester/spreadsheet/test_spreadsheet_store.py`."""
import json
@@ -69,7 +69,6 @@ def _num(code: str) -> dict:
"도번": f"SD{code}",
"일위대가번": f"SU{code}",
"면적번": f"SA{code}",
"재료번": f"SM{code}",
}
@@ -87,10 +86,10 @@ def test_새로_도번_자동_409_없는_열(client):
assert doc["도면"]["format"] == 6
assert doc["도면"]["entities"] == []
assert len(doc["도면"]["layers"]) == 1
# 면적산출 · 재료산출은 구조물 문서 밖 — 같은 열 id 로 따로 한 통합문서씩
# 면적산출은 구조물 문서 밖 — 같은 열 id 로 따로 한 통합문서 · 재료산출은 안 만듦
assert "산출근거" not in doc
for kind in ("area", "material"):
assert client.get(f"/api/m02/templates/{kind}/pp_len").json()["문서"]["종류"] == "통합문서"
assert client.get("/api/m02/templates/area/pp_len").json()["문서"]["종류"] == "통합문서"
assert client.get("/api/m02/templates/material/pp_len").status_code == 404
assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "SD-000002"
assert client.post("/api/m02/structures/pp_len").status_code == 409
@@ -132,7 +131,7 @@ def test_지워도_번호를_당기지_않음_짝은_같은_번호(client):
client.post(f"/api/m02/structures/{column}")
assert client.delete("/api/m02/templates/structure/rv").status_code == 200
numbers = client.get("/api/m02/structures/numbers").json()
assert numbers["rv"] == {**_num("-000002"), "도번": ""} # 면적 · 재료는 남아 번호를 쥠
assert numbers["rv"] == {**_num("-000002"), "도번": ""} # 면적은 남아 번호를 쥠
assert numbers["ms"] == _num("-000003") # 다른 열 번호는 그대로
# 새 열 = 다음 일련번호(자리 뜻 없음) · 다시 만든 도면은 짝(면적산출) 번호를 이어 받음
assert client.post("/api/m02/structures/pp_cp").json()["문서"]["도번"] == "SD-000004"
@@ -186,7 +185,7 @@ def test_집계표_저장_때_새_열은_만들고_지운_열은_같이_지움(c
assert saved.status_code == 200
numbers = client.get("/api/m02/structures/numbers").json()
assert "pv_a" not in numbers and "note2" not in numbers
for kind_ in ("structure", "area", "material"):
for kind_ in ("structure", "area"):
assert client.get(f"/api/m02/templates/{kind_}/pv_a").status_code == 404
# 새 열 = 열 차례로 다음 일련번호 · 지운 pv_a(5) 는 비워 둠(다른 번호를 안 당김)
assert numbers["pv_x"] == _num("-000014")
+2 -4
View File
@@ -84,11 +84,9 @@ def test_집계표_저장_때_새_열_지운_열_고친_열을_맞춤_키_유지
assert (rv["분류1"], rv["분류3"]) == (old_rv["분류1"], old_rv["분류3"])
assert (rv["단위"], rv["일위대가"]) == ("m", "UA000004")
assert (rv["바인딩"], rv["펼침"]) == ({"종류": "revetment"}, True)
# 번호는 세 파일과 같음 — 새 열도 [저장] 한 번에 SD · SA · SM 이 섬
# 번호는 파일과 같음 — 새 열도 [저장] 한 번에 SD · SA 가 섬
numbers = client.get("/api/m02/structures/numbers").json()
assert {k: rows["new1"][k] for k in ("도번", "일위대가번", "면적번", "재료번")} == numbers[
"new1"
]
assert {k: rows["new1"][k] for k in ("도번", "일위대가번", "면적번")} == numbers["new1"]
assert rows["new1"]["도번"].startswith("SD")
# 또 저장해도(바뀐 것 없음) 키 · 다음 그대로
@@ -125,9 +125,10 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에
assert (
order.index("summary") < order.index("extras.옵션, extras.도면") < order.index("practice")
)
# 자재 조합을 열면 이 줄 · 실무문서 · 「전체 항목」 고름 지움 / 이 줄을 열면 자재 조합 · … 지움
assert "onOpen: () => (options.clear(), practice?.clear(), (allPicked = null), paint())" in side
assert "onOpen: () => (matset.clear(), practice?.clear(), (allPicked = null), paint())" in side
# 실무문서를 열면 이 줄 · 「전체 항목」 고름 지움 / 이 줄을 열면 실무문서 · … 지움(자재 조합 없앰)
assert "onOpen: () => (options.clear(), (allPicked = null), paint())" in side
assert "onOpen: () => (practice?.clear(), (allPicked = null), paint())" in side
assert "matset" not in side
paint = side.split("function paint(): void {")[1].split("\n }\n")[0]
assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움
api = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts").read_text("utf-8")
+13 -12
View File
@@ -51,8 +51,8 @@ def test_소스가_700줄을_넘지_않음():
),
# 산출근거 저장은 컨테이너가 안 함 — [산출근거 수정] 이 여는 메인 칸 전체 편집이 함
("M02_MasterTemplete_Api_Fetch.ts", ["saveTemplate"], MAIN),
# 좌측 구조물 도면 · 면적산출 · 재료산출 줄 번호는 한 번에 받음
# (`{열 id: {도번, 면적번, 재료번}}`)
# 좌측 구조물 도면 · 면적산출 줄 번호는 한 번에 받음
# (`{열 id: {도번, 면적번}}`)
("M02_MasterTemplete_Api_Fetch.ts", ["fetchStructureNumbers"], SIDE),
("M02_MasterTemplete_Structure.ts", ["structureLabel", "isStructureColumn"], SIDE),
# 도면 카드(31-4)는 카드 파일에서 뗀 `DrawingCard` 가 부름
@@ -72,15 +72,16 @@ def test_컨테이너가_쓰는_이름이_내보내진_것(source: str, names: l
def test_sub2_가_부르는_이름과_모양():
"""브레인 확정 이름 · 모양 — sub2 가 이대로 부름(바꾸면 브레인에게)."""
body = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts")
for name in ("copyStructure", "copyArea", "copyMaterial"):
for name in ("copyStructure", "copyArea"):
assert re.search(
rf"export const {name} = async \(column: string, from: string\): Promise<void>", body
), name
assert "fetchStructureNumbers = (): Promise<Record<string, StructureNumbers>>" in body
assert re.search(
r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*재료번: string;", body
r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*/\*\* 일위대가[^*]*\*/\s*일위대가번: string;\s*\}",
body,
)
assert re.search(r'export type Kind = [^;]*"area" \| "material"', body)
assert re.search(r'export type Kind = [^;]*"area" \| "labor";', body) # 재료산출 없앰
def test_집계표만_컨테이너를_붙임():
@@ -154,7 +155,7 @@ def test_좌측_구조물_집계_양식_아래_하위_넷_새로_본떠_지우
assert "createSelectField" not in side # 새로 = 종류 고르기 없이 이름만
assert "cur?.종류 !== kind" in side # 본떠 · 지우기 = 그 컨테이너에서 고른 것이 있을 때만
assert "i.이름 === STRUCTURE_TABLE" in side # 표는 구조물집계표 한 줄만
# 구조물 도면 · 일위대가 · 면적산출 · 재료산출 · 자재 조합 = 구조물 집계 양식 컨테이너 안(같이 접힘) ·
# 구조물 도면 · 일위대가 · 면적산출 = 구조물 집계 양식 컨테이너 안(같이 접힘) · 자재 조합 없앰 ·
# 층 단추 · 나무와 하위 사이 수평선(39-2)
summary = side.split("const summary = section(")[1].split(");")[0]
assert (
@@ -162,7 +163,7 @@ def test_좌측_구조물_집계_양식_아래_하위_넷_새로_본떠_지우
< summary.index("m02-side__rule")
< summary.index("...columnSections")
)
assert "matsetSection" in summary
assert "matset" not in side
assert "const columnSections = COLUMN_KINDS.map(" in side
assert 'M02_KindTable: ["구조물 조합"' in _text( # 화면 이름(26-7a)
ROOT / "ui_template" / "ui_template_locale_m2.ts"
@@ -177,8 +178,8 @@ def test_면적_재료_오른쪽_조합_패널은_공용_틀_손잡이():
assert "ui-sidebar-card" in cpanel and "setColumnCombo" in cpanel
main = _text(MAIN)
assert "comboSlot" not in main # 조합 줄은 패널 「이 열의 조합」 칸으로
assert "cpanel.setMine(matPanel.root)" in main # 담은 자재 표도 패널 안
assert '"aside"' not in _text(M02 / "M02_MasterTemplete_Materials.ts")
assert "matPanel" not in main # 자재 조합 판은 없앰
assert not (M02 / "M02_MasterTemplete_Materials.ts").exists()
def test_첫_화면과_닫은_뒤는_구조물집계표():
@@ -200,9 +201,9 @@ def test_좌측_고름_표시는_열린_것_한_줄뿐():
body = side.split(fn)[1].split("\n }\n")[0]
assert "paint" not in body, fn # 여기서 한쪽만 그리면 두 줄이 남음
assert re.search(r"sel = next;\s*onOpened\?\.\(\);", _text(MAIN))
# 자재 조합 상세도 같은 한 곳 — 열면 양식 · 열 줄 고름 지움 · 양식을 열면 자재 조합 고름 지움
# 옵션 상세도 같은 한 곳 — 열면 양식 · 열 줄 고름 지움 · 양식을 열면 옵션 고름 지움
assert re.search(r"sel = null;\s*onOpened\?\.\(\);", _text(MAIN))
assert "if (opt.getOpen()) matset.clear();" in side.split("function paint(): void {")[1]
assert "if (opt.getOpen()) options.clear();" in side.split("function paint(): void {")[1]
# 편집 칸이 열 때마다 좌측 `paint`(일위대가 화면 해시 맞춤은 39-2 에서 지움)
assert "() => side.paint()," in _text(M02 / "M02_MasterTemplete_UI_Page.ts")
@@ -225,7 +226,7 @@ def test_표_도구_막대에_줄_더하기_줄_지우기_손_입력_열_없음(
def test_상세_화면_돌아가기는_집계표로_그_열_고른_채():
main = _text(MAIN)
assert "children: [back, title, slot, reload, save, " in main # M01 꼴 — 제목 앞(39-5)
assert "!(sel.kind in NUMBER_FIELD)" in main # 구조물 도면 · 면적산출 · 재료산출만
assert "!(sel.kind in NUMBER_FIELD)" in main # 구조물 도면 · 일위대가 · 면적산출만
body = main.split('back.addEventListener("click"')[1]
assert "confirmLeave()" in body and "STRUCTURE_TABLE" in body
assert "sheet?.selectColumn(column)" in body
+2 -4
View File
@@ -182,13 +182,11 @@ def test_빈_층에서_폴더_항목_깊이_1_2_5_이름_옮기기_지우기(wor
t.do(op="move", 경로=["A", "b"], 새경로=["돌쌓기"])
assert t.paths() == [["돌쌓기", "메쌓기"], ["돌쌓기", "소단"], ["돌쌓기", "b", "c", "d", "e"]]
t.do(422, op="move", 경로=["돌쌓기"], 새경로=["돌쌓기", "b"])
# 지우기 — 항목의 세 파일 · 입력 연결 파일도 같이 · 마지막 항목까지
# 지우기 — 항목의 파일 · 마지막 항목까지
gone = t.item(["돌쌓기", "소단"])
(root / "inputs").mkdir(exist_ok=True)
(root / "inputs" / f"{gone['열']}.json").write_text('{"판": 1, "줄": []}', encoding="utf-8")
note = t.do(op="delete", 키=gone["key"])["알림"]
assert note["지운항목"] == [gone["열"]]
for part in ("structure", "area", "material", "inputs"):
for part in ("structure", "area"):
assert not (root / part / f"{gone['열']}.json").is_file()
t.do(op="add-item", 경로=["돌쌓기"], 이름="새것") # 지운 번호는 다시 안 줌
assert _numbers(root, t.item(["돌쌓기", "새것"])["열"]) == {"-000004"}
+1 -1
View File
@@ -50,7 +50,7 @@ def test_회사_항목_폴더를_개인에_붙임_새_키_파일_이름2(world):
table = json.loads((root / "table/구조물집계표.json").read_bytes())
made = next(c for c in table["열"] if c.get("경로") == ["옹벽"])
assert made["본뜬"] == {"layer": "company", "owner": "7", "key": wall}
for kind in ("structure", "area", "material"):
for kind in ("structure", "area"):
assert (root / kind / f"{made['id']}.json").is_file()
# 다시 붙이면 같은 이름 「(2)」 · 붙일 폴더 안으로
again = client.post(PASTE.format("personal"), json={**body, "경로": []}).json()
@@ -1,6 +1,6 @@
"""표 도면 줄 · 칸 고르기 신호 — `ui_template_sheet_ops.ts` 를 Node 로 바로 돌려 봄.
마스터 줄 = 일위대가 · 도면 `s:drawing` · 면적산출 `s:area` · 재료산출 `s:material`(식 · 들어갈 것 줄 없음) ·
마스터 줄 = 일위대가 · 도면 `s:drawing` · 면적산출 `s:area`(식 · 들어갈 것 줄 없음) ·
master 만 · 단위 줄 바로 아래 차례(키보드) · project 에는 없음.
`selectionOf` = 격자가 `onSelect` 로 넘기는 모양({row, col, colId} · 모르는 열은 col -1 · colId null).
"""
@@ -26,7 +26,7 @@ console.log(JSON.stringify({
master: navRows(doc, "master"),
project: navRows(doc, "project"),
pick: [selectionOf(doc, "s:drawing", "c2"), selectionOf(doc, "d:r1", "c1"), selectionOf(doc, "s:unit", "zz")],
blank: ["s:price", "s:drawing", "s:area", "s:material", "s:unit", "d:r1"].map((k) => [isBlankPick(k, { id: "t", 머리: [], 꼴: "글" }), isBlankPick(k, { id: "n", 머리: [], 꼴: "수" })]),
blank: ["s:price", "s:drawing", "s:area", "s:unit", "d:r1"].map((k) => [isBlankPick(k, { id: "t", 머리: [], 꼴: "글" }), isBlankPick(k, { id: "n", 머리: [], 꼴: "수" })]),
}));
"""
@@ -53,7 +53,7 @@ def _run() -> dict:
def test_drawing_row_under_price_master_only():
got = _run()
assert got["master"] == [
"s:unit", "s:price", "s:drawing", "s:area", "s:material", "d:r1", "t:t1",
"s:unit", "s:price", "s:drawing", "s:area", "d:r1", "t:t1",
] # fmt: skip
assert got["project"] == ["s:unit", "d:r1", "t:t1"]
@@ -68,5 +68,5 @@ def test_selection_shape():
def test_text_column_pick_rows_blank():
assert _run()["blank"] == [
[True, False], [True, False], [True, False], [True, False], [False, False], [False, False],
[True, False], [True, False], [True, False], [False, False], [False, False],
] # fmt: skip
@@ -34,9 +34,9 @@ PIPE = {"layer": "system", "owner": None, "key": "SS000011", "name": "관공 관
def _copy_layer(target: Path) -> None:
target.mkdir(parents=True)
for name in (layers.COMBO_FILE, "자재조합.json"):
for name in (layers.COMBO_FILE,):
shutil.copy2(REAL / name, target / name)
for kind in ("table", "structure", "area", "material"):
for kind in ("table", "structure", "area"):
shutil.copytree(REAL / kind, target / kind)
@@ -131,7 +131,12 @@ def test_지정하면_폴더가_생기고_내용이_원본과_같음(world):
assert result == {"만듦": ["pipe@40.0", "st0"], "지움": [], "못만듦": [], "옮겨받음": []}
folder = _items(root) / "st0"
got = {path.name for path in folder.iterdir()}
assert {"집계.json", "도면.json", "면적.json", "재료.json", "manifest.json"} <= got
assert {
"집계.json",
"도면.json",
"면적.json",
"manifest.json",
} <= got and "재료.json" not in got
total = json.loads((folder / "집계.json").read_text(encoding="utf-8"))
assert total["줄"]["키"] == "SS000022" and total["열"]["id"] == "ms"
drawing = json.loads((world["system"] / "structure/ms.json").read_text(encoding="utf-8"))
+8 -8
View File
@@ -41,10 +41,8 @@ const row = (
분류3: 분류[2],
단위: "m",
일위대가: "UA000004",
자재조합: "UM000001",
도번: "SD01010100",
면적번: "SA01010100",
재료번: "SM01010100",
layer,
owner: layer === "company" ? "샘플회사" : layer === "personal" ? "샘플회사/사용자" : null,
...extra,
@@ -54,10 +52,8 @@ const rows: PickerRow[] = [
row("SS000001", "rv", "돌기슭막이", ["메쌓기", "1.2m", "기초유"], "system", {
names: {
일위대가: "돌기슭막이 메쌓기 일위대가",
자재조합: null,
도번: "돌기슭막이 도면",
면적번: "돌기슭막이 면적산출",
재료번: "돌기슭막이 재료산출",
},
}),
row("SS000002", "rv2", "돌기슭막이", ["메쌓기", "1.5m", "기초유"], "system"),
@@ -221,16 +217,20 @@ assert.deepEqual(pathIds(rows[0], labels), [
"system|돌기슭막이|메쌓기|1.2m",
]);
// 폴더 모양 — 다섯 줄 · 이름은 서버 names · 없으면 키만(작은 글) · 둘 다 없으면 빈 키
// 폴더 모양 — 세 줄 · 이름은 서버 names · 없으면 키만(작은 글) · 둘 다 없으면 빈 키
const lines = folderLines(rows[0]);
assert.deepEqual(
lines.map((l) => l.kind),
["drawing", "area", "material", "combo", "unitPrice"],
["drawing", "area", "unitPrice"],
);
assert.deepEqual(lines[0], { kind: "drawing", name: "돌기슭막이 도면", key: "SD01010100" });
assert.deepEqual(lines[3], { kind: "combo", name: null, key: "UM000001" }); // names 에 없음
assert.deepEqual(lines[2], {
kind: "unitPrice",
name: "돌기슭막이 메쌓기 일위대가",
key: "UA000004",
});
assert.deepEqual(folderLines(rows[1])[0], { kind: "drawing", name: null, key: "SD01010100" }); // names 자체가 없음
assert.deepEqual(folderLines({ key: "SS1", 열: "x" })[4], {
assert.deepEqual(folderLines({ key: "SS1", 열: "x" })[2], {
kind: "unitPrice",
name: null,
key: "",
+5 -7
View File
@@ -1,12 +1,12 @@
/* =============================================================================
* ui_template_sheet.ts
* 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange, drawingLabel, areaLabel, materialLabel, onSelect, onPick})`
* 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange, drawingLabel, areaLabel, onSelect, onPick})`
* → `{getDoc, setDoc, recalc, selectColumn, destroy}`.
*
* master = 시스템 관리자 양식 고치기 — 머리 · 층 이름 · 단위 · 일위대가 ·
* 층마다 열 더하기(「분류1 더하기」 … 층 이름 + 더하기) · 열 지우기.
* 도면 · 면적산출 · 재료산출 줄 = `drawingLabel` · `areaLabel` · `materialLabel` 글 ·
* 칸을 고르면 `onSelect` · `onPick` 을 주면 일위대가 · 도면 · 면적산출 · 재료산출 칸은
* 도면 · 면적산출 줄 = `drawingLabel` · `areaLabel` 글 ·
* 칸을 고르면 `onSelect` · `onPick` 을 주면 일위대가 · 도면 · 면적산출 칸은
* 치지 않고 조회 후 선택.
* `readOnly` = 볼 수만 있음 — 단추 줄 · 안내 글 숨김 · 칸 고치기 · 머리 고치기 · 열 너비 끌기 막음 ·
* 열이 하나도 없으면 [열 더하기] 가 켜져 첫 열을 만듦(머리 글 바로 고치기).
@@ -56,12 +56,11 @@ export interface SheetOptions {
onChange?: (doc: SheetDoc) => void;
/** 도면 줄 칸 글(마스터 첫 쪽 · 열 id) — 없거나 빈 글이면 「없음」 · 글이 바뀌면 `recalc()` 로 다시 그림. */
drawingLabel?: (colId: string) => string;
/** 면적산출 · 재료산출 줄 칸 글(번호) — `drawingLabel` 과 같음. */
/** 면적산출 줄 칸 글(번호) — `drawingLabel` 과 같음. */
areaLabel?: (colId: string) => string;
materialLabel?: (colId: string) => string;
/** 칸을 고름 — 누르기 · 방향키 · Tab. */
onSelect?: (sel: SheetSelection) => void;
/** 조회 후 선택 — master 일위대가 · 도면 · 면적산출 · 재료산출 칸을 두 번 누르기 · Enter · F2 로 부름.
/** 조회 후 선택 — master 일위대가 · 도면 · 면적산출 칸을 두 번 누르기 · Enter · F2 로 부름.
* 글을 돌려주면 일위대가 칸에 넣음 · 번호 글 줄은 부른 쪽이 가져온 뒤 `recalc()`.
* 없으면 일위대가는 글로 고침 · 번호 글 줄은 읽기만. */
onPick?: (sel: SheetSelection) => Promise<string | null | undefined> | void;
@@ -90,7 +89,6 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
sel: null,
drawingLabel: opts.drawingLabel,
areaLabel: opts.areaLabel,
materialLabel: opts.materialLabel,
pick: !!opts.onPick,
headLocked: !!opts.lockHead,
combo: !!opts.combo,
+7 -10
View File
@@ -22,16 +22,15 @@ export const KEY_UNIT = "s:unit";
export const KEY_PRICE = "s:price";
export const KEY_DRAWING = "s:drawing";
export const KEY_AREA = "s:area";
export const KEY_MATERIAL = "s:material";
/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 도면 · 면적산출 · 재료산출 줄은 손으로 못 침(번호 글). */
export const MASTER_ROWS = [KEY_PRICE, KEY_DRAWING, KEY_AREA, KEY_MATERIAL] as const;
/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 도면 · 면적산출 줄은 손으로 못 침(번호 글). */
export const MASTER_ROWS = [KEY_PRICE, KEY_DRAWING, KEY_AREA] as const;
export type MasterRowKey = (typeof MASTER_ROWS)[number];
/** 번호 글 줄 — 부른 쪽이 준 글(`refLabel`)만 보임. */
export const REF_ROWS: readonly string[] = [KEY_DRAWING, KEY_AREA, KEY_MATERIAL];
export const REF_ROWS: readonly string[] = [KEY_DRAWING, KEY_AREA];
/** 조회 후 선택 줄 — `onPick` 을 주면 이 줄 칸은 치지 않고 고름. */
export const PICK_ROWS: readonly string[] = MASTER_ROWS;
/** 글 열(NO · 측점 · 비고 같은 꼴=글)의 일위대가 · 도면 · 면적산출 · 재료산출 칸 — 빈칸 · 잠김. */
/** 글 열(NO · 측점 · 비고 같은 꼴=글)의 일위대가 · 도면 · 면적산출 칸 — 빈칸 · 잠김. */
export const isBlankPick = (key: string, col: SheetColumn): boolean =>
PICK_ROWS.includes(key) && col.꼴 === "글";
@@ -120,15 +119,13 @@ export function addLevel(
"c",
doc.열.map((c) => c.id),
);
// 원본(`after`) 열을 따라감 — 일위대가 · 자재조합 같은 키 · 구조물 파일은 저장 때 서버가 복사
// 원본(`after`) 열을 따라감 — 일위대가 같은 키 · 구조물 파일은 저장 때 서버가 복사
doc.열.splice(at + 1, 0, {
id,
머리: head,
단위: base.단위 ?? null,
꼴: "수",
...(base.꼴 !== "글"
? { 본뜬열: base.id, 일위대가: base.일위대가 ?? null, 자재조합: base.자재조합 ?? null }
: {}),
...(base.꼴 !== "글" ? { 본뜬열: base.id, 일위대가: base.일위대가 ?? null } : {}),
});
return id;
}
@@ -141,7 +138,7 @@ export function addFirstColumn(doc: SheetDoc, depth: number, label: string): str
);
const head: (string | null)[] = [label];
while (head.length < depth) head.push("");
doc.열.push({ id, 머리: head, 단위: null, 꼴: "수", 일위대가: null, 자재조합: null });
doc.열.push({ id, 머리: head, 단위: null, 꼴: "수", 일위대가: null });
return id;
}
+7 -15
View File
@@ -3,8 +3,8 @@
* 표 그리기 — 문서 + 풀이 결과 → 쪽마다 `<table>`(머리 층 · 단위 줄 · 본문 · 합계 줄).
*
* 칸마다 `data-r`(줄 열쇠) · `data-c`(열 id) — 고치기 · 키보드는 격자(`ui_template_sheet.ts`)가 위임으로 받음.
* 줄 열쇠 — 본문 `d:<줄id>` · 합계 `t:<합계id>` · 마스터 줄 `s:unit|price|drawing|area|material`.
* master = 본문 위에 열마다 일위대가 · 도면 · 면적산출 · 재료산출 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽).
* 줄 열쇠 — 본문 `d:<줄id>` · 합계 `t:<합계id>` · 마스터 줄 `s:unit|price|drawing|area`.
* master = 본문 위에 열마다 일위대가 · 도면 · 면적산출 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽).
* 머리 층 줄 머리 칸은 `data-layer`(층 차례) — master 에서 층 이름을 고침.
* ========================================================================== */
@@ -17,7 +17,6 @@ import {
isBlankPick,
isCalcColumn,
KEY_AREA,
KEY_MATERIAL,
KEY_UNIT,
MASTER_ROWS,
type MasterRowKey,
@@ -39,10 +38,9 @@ export interface RenderState {
mode: SheetMode;
result: SheetResult;
sel: { r: string; c: string } | null;
/** 도면 · 면적산출 · 재료산출 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */
/** 도면 · 면적산출 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */
drawingLabel?: (colId: string) => string;
areaLabel?: (colId: string) => string;
materialLabel?: (colId: string) => string;
/** 조회 후 선택 줄(`PICK_ROWS`)을 고름으로 받나 — 칸 겉모양만. */
pick?: boolean;
/** 머리 · 층 이름을 못 고침(`lockHead`) — 칸 겉모양만. */
@@ -53,13 +51,9 @@ export interface RenderState {
headEditable?: boolean;
}
/** 도면 · 면적산출 · 재료산출 줄 칸 글. */
/** 도면 · 면적산출 줄 칸 글. */
export const refText = (state: RenderState, key: string, colId: string): string =>
(key === KEY_AREA
? state.areaLabel
: key === KEY_MATERIAL
? state.materialLabel
: state.drawingLabel)?.(colId)?.trim() || st("Drawing_None");
(key === KEY_AREA ? state.areaLabel : state.drawingLabel)?.(colId)?.trim() || st("Drawing_None");
/** 열 폭 — 저장 폭(없으면 기본) · 머리 글이 안 잘리는 최소 폭보다 좁지 않게. */
const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map<string, number>): number =>
@@ -68,7 +62,7 @@ const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map<string, number>)
mins.get(col.id) ?? 0,
);
/** 구조물 조합 — 일위대가 · 도면 · 면적산출 · 재료산출 줄 글(키 · 번호)이 줄바꿈 없이 들게 최소 폭을 넓힘
/** 구조물 조합 — 일위대가 · 도면 · 면적산출 줄 글(키 · 번호)이 줄바꿈 없이 들게 최소 폭을 넓힘
* (`.ui-sheet__meta td` 글꼴과 같게 잼). */
function fitMeta(state: RenderState, cols: SheetColumn[], mins: Map<string, number>): void {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
@@ -115,7 +109,7 @@ export function renderSheet(state: RenderState): HTMLElement {
const mins = headMinWidths(cols, depth, font);
if (state.combo) fitMeta(state, cols, mins);
const widthDoc = state.combo ? { ...doc, 보기: { ...doc.보기, 열너비: {} } } : doc;
// 줄 머리 칸 폭 — 층 이름 · 단위 · 일위대가 · 도면 · 면적산출 · 재료산출 · 합계 이름이 안 잘리게
// 줄 머리 칸 폭 — 층 이름 · 단위 · 일위대가 · 도면 · 면적산출 · 합계 이름이 안 잘리게
const GUTTER = Math.min(
GUTTER_MAX,
Math.max(
@@ -126,7 +120,6 @@ export function renderSheet(state: RenderState): HTMLElement {
st("Row_Unit_Price"),
st("Row_Drawing"),
st("Row_Area"),
st("Row_Material"),
...(doc.합계줄 ?? []).map((t) => t.이름),
].map((label) => Math.ceil(textWidth(label, font)) + 16),
),
@@ -193,7 +186,6 @@ export function renderSheet(state: RenderState): HTMLElement {
"s:price": "Row_Unit_Price",
"s:drawing": "Row_Drawing",
"s:area": "Row_Area",
"s:material": "Row_Material",
} as const
)[key];
const tr = el("tr", { className: "ui-sheet__meta", children: [gutter("th", st(label))] });
@@ -13,7 +13,6 @@ const TEXT = {
Row_Unit_Price: ["일위대가", "Unit price"],
Row_Drawing: ["도면", "Drawing"],
Row_Area: ["면적산출", "Area"],
Row_Material: ["재료산출", "Material"],
Add_Suffix: [" 추가", " +"],
New_Prefix: ["새 ", "New "],
Drawing_None: ["없음", "None"],
+1 -3
View File
@@ -35,8 +35,6 @@ export interface SheetColumn {
바인딩?: SheetBinding | null;
/** 일위대가 조합 키. */
일위대가?: string | null;
/** 자재 조합 키(`UM`). */
자재조합?: string | null;
/** 분류 더하기로 복사한 원본 열 id — 저장 때 서버가 원본 열 내용을 새 열로 복사하고 뗌. */
본뜬열?: string | null;
/** 마스터 한 칸 → 프로젝트에서 설계 값마다 열. */
@@ -82,7 +80,7 @@ export interface SheetDoc {
보기?: SheetView;
}
/** 고른 칸 — `row` = 줄 열쇠(`d:<줄id>` · `t:<합계id>` · `s:unit|price|drawing|area|material`) ·
/** 고른 칸 — `row` = 줄 열쇠(`d:<줄id>` · `t:<합계id>` · `s:unit|price|drawing|area`) ·
* `col` = 열 차례(0부터 · 없으면 -1) · `colId` = 열 id(없으면 null). */
export interface SheetSelection {
row: string;
+4 -14
View File
@@ -15,7 +15,7 @@ export interface SummaryItem {
}
/** 항목 줄이 가리키는 키 칸 — 서버가 `names` 로 이름을 함께 주면 이름을 보임 */
export type NameField = "일위대가" | "자재조합" | "도번" | "면적번" | "재료번";
export type NameField = "일위대가" | "도번" | "면적번";
/** 목록 한 줄(21-1) — 서버는 `?all=1` 을 `[{layer, owner, items}]` 로 주고, 줄마다 `layer` · `owner` 를 붙여 폄.
* `열` 은 집계표 열 id(rv …) — 보이는 이름은 종류 · 분류를 이은 `itemName`. */
@@ -28,10 +28,8 @@ export interface PickerRow {
분류3?: string;
단위?: string;
일위대가?: string;
자재조합?: string;
도번?: string;
면적번?: string;
재료번?: string;
/** 폴더 이름 배열(깊이 자유 · 26-1) — 없으면 종류 · 분류1~3 중 있는 것으로 이음 */
경로?: string[] | null;
/** 위 키 칸의 이름(21-9 서버가 더함) — 없으면 키를 작은 글로 보임 */
@@ -102,13 +100,7 @@ export function filterRows(rows: PickerRow[], text: string): PickerRow[] {
const words = text.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (!words.length) return rows;
return rows.filter((row) => {
const hay = [
itemName(row),
row.key,
row.일위대가,
row.자재조합,
...Object.values(row.names ?? {}),
]
const hay = [itemName(row), row.key, row.일위대가, ...Object.values(row.names ?? {})]
.join(" ")
.toLowerCase();
return words.every((word) => hay.includes(word));
@@ -174,7 +166,7 @@ const ancestorIds = (layer: PickerLayer, path: string[]): string[] => [
export const pathIds = (row: PickerRow, _labels?: TreeLabels): string[] =>
ancestorIds(layerOf(row), pathOf(row).slice(0, -1));
export type FolderKind = "drawing" | "area" | "material" | "combo" | "unitPrice";
export type FolderKind = "drawing" | "area" | "unitPrice";
export interface FolderLine {
kind: FolderKind;
@@ -187,12 +179,10 @@ export interface FolderLine {
const FOLDER_FIELDS: readonly [FolderKind, NameField][] = [
["drawing", "도번"],
["area", "면적번"],
["material", "재료번"],
["combo", "자재조합"],
["unitPrice", "일위대가"],
];
/** 오른쪽 폴더 모양 — 다섯 줄 모두 서버의 키 칸 + `names` 의 이름 */
/** 오른쪽 폴더 모양 — 세 줄 모두 서버의 키 칸 + `names` 의 이름 */
export function folderLines(row: PickerRow): FolderLine[] {
return FOLDER_FIELDS.map(([kind, field]) => ({
kind,
+8 -17
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* ui_template_item_picker_detail.ts
* 항목 고르기 모달 오른쪽 상세(계획서 25-2d · 26-3b · 39-1c) — 카드 = 도면 · 일위대가 · 면적산출(· 재료산출) ·
* 항목 고르기 모달 오른쪽 상세(계획서 25-2d · 26-3b · 39-1c) — 카드 = 도면 · 일위대가 · 면적산출 ·
* 넣을 값. 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `M02_Labor_Values`) + 일위대가 카드
* (`ui_template_item_picker_labor`)를 늦게 받아 씀(B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`).
* 넣을 값 = 일위대가 담은 로직 입력 전부(복사본이면 복사본 입력값).
@@ -41,7 +41,7 @@ import {
type ProjectItems,
} from "./ui_template_item_picker_project";
/** 편집 화면을 여는 데 필요한 것 — 「선택 입력」 도 한 종류 */
/** 편집 화면을 여는 데 필요한 것 — 「넣을 값」 도 한 종류 */
export interface EditRequest {
kind: EditKind | "inputs";
row: PickerRow;
@@ -85,10 +85,9 @@ const TEXT = {
changed: "마스터가 바뀜 — 복사본은 가져온 때 그대로",
gone: "원본이 없어짐 — 복사본은 그대로 씀",
reapply: "재적용",
reapplyTip:
"원본을 다시 가져옴 — 이 구조물 복사본에서 고친 것(도면 · 시트 · 선택 입력 자리)이 원본 것으로 덮임",
reapplyTip: "원본을 다시 가져옴 — 이 구조물 복사본에서 고친 것(도면 · 시트)이 원본 것으로 덮임",
reapplyAsk:
"원본을 다시 가져옴 — 이 구조물 복사본에서 고친 도면 · 면적산출 · 재료산출 · 선택 입력 자리가 원본 것으로 덮임(넣은 값은 같은 키면 남음). 재적용할까요?",
"원본을 다시 가져옴 — 이 구조물 복사본에서 고친 도면 · 면적산출이 원본 것으로 덮임(넣은 값은 같은 키면 남음). 재적용할까요?",
reapplied: "재적용했음 — 덮인 것: {value}",
reappliedNone: "재적용했음 — 덮인 것 없음(원본과 같았음)",
reapplyFailed: "재적용하지 못함 — {value}",
@@ -109,14 +108,10 @@ const TEXT = {
calc: "복사본 계산 — 계 {total}원 · 빠진 입력 {gaps}개",
calcStuck: " · 멈춘 로직 {stuck}개",
noCopy: "복사본에 없음",
combo: "자재조합",
price: "일위대가",
parts: {
도면: "도면",
면적: "면적산출",
재료: "재료산출",
자재조합: "자재조합",
입력연결: "선택 입력 자리",
일위대가: "일위대가",
} as Record<string, string>,
};
@@ -154,9 +149,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
const where = [opts.layerLabels[layerOf(row)], ...pathOf(row).slice(0, -1)]
.filter(Boolean)
.join(" › ");
const pairs = folderLines(row).filter(
(line) => line.kind === "combo" || line.kind === "unitPrice",
);
const pairs = folderLines(row).filter((line) => line.kind === "unitPrice");
head.replaceChildren(
el("h4", {
className: "ui-item-picker__name",
@@ -167,9 +160,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
...(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}`),
),
.map((line) => small(`${TEXT.price} ${line.name || line.key}`)),
);
};
@@ -373,8 +364,8 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
await ensureCards();
if (mine !== seq || !cards || !inputs || !labor) return;
const label = !info && canCopy ? TEXT.importEdit : null;
cards.relabel({ structure: label, area: label, material: label });
inputs.setEditLabel(label ?? "선택 입력 수정");
cards.relabel({ structure: label, area: label });
inputs.setEditLabel(label ?? "넣을 값 수정");
cards.root.hidden = false;
paintStatus(row, mine);
void cards.load({ id: row.열, label: itemName(row) });
+21 -28
View File
@@ -1,7 +1,7 @@
/* =============================================================================
* ui_template_item_picker_edit.ts
* 항목 고르기 모달의 **편집 화면**(계획서 25-2d) — 카드 [수정] 이 모달 안에서 이 화면으로 바뀜.
* 도면 = M02 CAD 부품 · 면적산출 · 재료산출 = M02 스프레드시트 · 선택 입력 = 값 받기 카드.
* 도면 = M02 CAD 부품 · 면적산출 = M02 스프레드시트 · 넣을 값 = 값 받기 카드.
* 고침은 **프로젝트 복사본**에만 씀(`ProjectItems.write`) — 마스터 · 회사 · 개인 원본은 안 바뀜.
* [뒤로] = 선택지(나무 + 카드)로 · 저장 안 한 고침이 있으면 버릴지 물음.
* 편집 부품은 이 화면을 열 때만 `import()`(B05 · B06 번들에 안 섞임).
@@ -12,11 +12,11 @@ import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spre
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_Labor_Values";
import { pillsOf, type ValueDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs_Model";
import type { InputLinks, StructureDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import type { ValueDoc } from "../M02_MasterTemplete/M02_Labor_Values_Model";
import type { LaborPill, 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 { laborValueData, loadInputs, PART_OF } from "./ui_template_item_picker_project";
import { laborValueData, PART_OF } from "./ui_template_item_picker_project";
export interface EditView {
root: HTMLElement;
@@ -36,12 +36,10 @@ const TEXT = {
loadFailed: "복사본을 못 읽음 — {value}",
discard: "저장 안 한 고침이 있음 — 버리고 돌아갈까요?",
outOfRange: "범위 밖 값이 있어 저장하지 못함",
noAreaPill:
"면적산출 알약 없음 — 선택 입력에서 자리를 면적산출로 이으면 여기 뜸(마스터 · 회사 · 개인 층에서)",
noAreaPill: "면적산출 알약 없음 — 이 복사본 일위대가에 담은 로직 · 자재가 없음",
drawing: "도면",
area: "면적산출",
material: "재료산출",
inputs: "선택 입력",
inputs: "넣을 값",
suffix: "{value} 수정",
};
@@ -61,7 +59,6 @@ export function createEditView(req: EditRequest, onBack: (saved: boolean) => voi
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" });
@@ -96,28 +93,24 @@ export function createEditView(req: EditRequest, onBack: (saved: boolean) => voi
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 };
/** 복사본 일위대가의 알약(가져온 때 그대로) — 로직 물량 「이름|단위」 + 자재 「자재|MT키」 · 다 고정 */
async function sheetLinks(): Promise<LinkOptions> {
const labor = (await items.read(info.폴더, "일위대가")) as { 알약?: LaborPill[] } | null;
const 항목 = (labor?.알약 ?? []).map((p) => ({
키: p.키,
이름: p.이름,
단위: p.단위,
...(p.자재 ? { 자재: p.자재 } : {}),
고정: true,
}));
if (!항목.length) note.textContent = TEXT.noAreaPill;
return { 항목, 모드: "입력" };
}
async function mountSheet(kindOf: "area" | "material"): Promise<void> {
async function mountSheet(): Promise<void> {
const [{ createSpreadsheet }, links] = await Promise.all([
import("../A00_Common/spreadsheet/spreadsheet"),
sheetLinks(kindOf),
sheetLinks(),
]);
const host = el("div", { className: "ui-item-picker__edit-sheet" });
body.replaceChildren(host);
@@ -171,7 +164,7 @@ export function createEditView(req: EditRequest, onBack: (saved: boolean) => voi
}
if (kind === "structure") await mountDrawing();
else if (kind === "inputs") await mountInputs();
else await mountSheet(kind);
else await mountSheet();
} catch (error) {
fail(fill(TEXT.loadFailed, why(error)));
}
+6 -31
View File
@@ -10,29 +10,22 @@
* POST …/{폴더}/reapply 재적용 → {폴더, 가져온날, 원본판, 덮임: [부분]}
* POST …/{폴더}/save-as {to, 경로, 이름} 개인에 저장 → {key, 열, 이름, summary_item}
* POST …/{폴더}/calc 복사본만으로 계산(계 · 빠진 입력 · 기준합 · 반영합)
* 입력 목록 · 항목 연결(sub4 · 25-2a) — `M02_MasterTemplete_Api_Fetch` 의 `fetchItemInputs` · `readInputLinks`
* 일위대가(39-1c) — 복사본 `일위대가` 부분 · 원본 층은 `calcLabor` · 넣을 값 목록은 `fetchLogicInputs`
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import {
fetchItemInputs,
type InputLinks,
type ItemInputs,
} from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import type { ItemInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { calcLabor, fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
import type { ValueDoc } from "../M02_MasterTemplete/M02_Labor_Values_Model";
import { layerOf, ownerOf, type PickerRow } from "./ui_template_item_picker_data";
/** 복사본 폴더 안 부분 — 25-1b 약속 그대로 */
export type ProjectPart =
"도면" | "면적" | "재료" | "자재조합" | "입력연결" | "입력값" | "고른자재" | "일위대가";
export type ProjectPart = "도면" | "면적" | "입력값" | "고른자재" | "일위대가";
/** 원본 종류 → 복사본 부분 */
export const PART_OF: Record<"structure" | "area" | "material", ProjectPart> = {
export const PART_OF: Record<"structure" | "area", ProjectPart> = {
structure: "도면",
area: "면적",
material: "재료",
};
export interface ProjectItemInfo {
@@ -187,7 +180,6 @@ export function projectItems(projectId: string): ProjectItems {
const reply = await call(`${base}/${enc(info.폴더)}/reapply`, post({}));
if (reply.status >= 300) throw new Error(reasonOf(reply));
forget(info.폴더);
inputsCache.clear(); // 원본이 바뀌었을 수 있어 입력 목록도 다시 받음
const got = reply.body as {
가져온날?: string | null;
원본판?: string | null;
@@ -243,20 +235,6 @@ export function projectItems(projectId: string): ProjectItems {
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;
}
/* ── 일위대가(39-1c) — 카드 요약 · 넣을 값 자료 ─────────────────────────────── */
/** 일위대가 카드에 보이는 것 — 복사본이면 복사본 계산 · 아니면 원본 층 계산(저장 안 함) */
@@ -308,15 +286,12 @@ export async function laborOf(
};
}
/** 넣을 값 카드 자료 — 담은 로직 입력 전부(자리 「선택」 으로 감싸 값 카드가 다 보임) */
/** 넣을 값 카드 자료 — 담은 로직 입력 전부 */
export async function laborValueData(
logics: string[],
values: ValueDoc,
): Promise<{ list: ItemInputs; link: InputLinks; values: ValueDoc }> {
): Promise<{ list: ItemInputs; values: ValueDoc }> {
const got = await fetchLogicInputs(logics);
const list: ItemInputs = { 일위대가: null, 로직: got.로직, 빠진로직: got.빠진로직 };
const 줄 = list.로직.flatMap((logic) =>
logic.입력.map((input) => ({ 로직: logic.로직, 입력: input.이름, 자리: "선택" as const })),
);
return { list, link: { 판: null, 줄 }, values };
return { list, values };
}
-3
View File
@@ -13,7 +13,6 @@ export const ui_locales_m2 = {
M02_KindDrawing: ["도면 양식", "Drawing templates"],
M02_KindStructure: ["구조물 도면", "Structure drawings"],
M02_KindArea: ["면적산출", "Area takeoff"],
M02_KindMaterial: ["재료산출", "Material takeoff"],
M02_NoTemplates: ["양식 없음", "None"],
M02_New: ["새로", "New"],
M02_Copy: ["본떠 만들기", "Copy as new"],
@@ -87,8 +86,6 @@ export const ui_locales_m2 = {
M02_PickerLoadFailed: ["항목을 못 읽음 — {value}", "Failed to load items — {value}"],
M02_PickerDrawing: ["도면", "Drawing"],
M02_PickerArea: ["면적산출", "Area takeoff"],
M02_PickerMaterial: ["재료산출", "Material takeoff"],
M02_PickerCombo: ["자재조합", "Material combo"],
M02_PickerUnitPrice: ["일위대가", "Unit price"],
M02_PickerUnit: ["단위", "Unit"],
M02_PickerNone: ["없음", "None"],