refactor(M02): 자재 조합(UM) 지움 — 좌측 컨테이너 · 재료산출 옆 판 · 길(material-combos · 갈라 내기 · 키 적기 자재조합) · 저장소 · 틀 검사 · 복사 길의 UM 줄 · 시스템 층 표 · fixture · 시험
- 자재 고르기 창은 M02_Material_Pick.ts 로 옮겨 남김 - storage 층(회사 · 개인 · 프로젝트) 자재조합.json 은 지우지 않음(읽기만 끊음) - check_master 틀 검사 · _틀.md 11장 · _화면_계약.md 자재 조합 절 정리 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
This commit is contained in:
@@ -225,7 +225,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():
|
||||
|
||||
@@ -51,7 +51,6 @@ KINDS = {
|
||||
"도면": "도면.json",
|
||||
"면적": "면적.json",
|
||||
"재료": "재료.json",
|
||||
"자재조합": "자재조합.json",
|
||||
"입력연결": LINK_FILE,
|
||||
"입력값": VALUES_FILE,
|
||||
"고른자재": PICKS_FILE,
|
||||
@@ -245,7 +244,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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
붙일 폴더(`dest`) 아래 새 항목 — 대상 층 새 키(SC · SP) · 열 `본뜬` = 원본(층 · 주인 · 키) ·
|
||||
같은 이름은 「이름 (2)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더 (2)」).
|
||||
파일(도면 · 면적 · 재료 · 입력 연결 · 자재 조합)은 `Items.copy_item` 이 옮김. 권한은 부르는 쪽 몫.
|
||||
파일(도면 · 면적 · 재료 · 입력 연결)은 `Items.copy_item` 이 옮김. 권한은 부르는 쪽 몫.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""프로젝트에서 개인 층으로 — [개인에 저장](save-as) · 프로젝트에서 새 항목(new) (PLAN 26-2b).
|
||||
|
||||
개인 층 새 항목 = 구조물집계표 새 열 하나 + 빈 도면 · 면적산출 · 재료산출(번호 · SS 키는 저장소가
|
||||
줌). [개인에 저장]은 구조물 복사본의 열 정의 · 도면 · 면적 · 재료 · 자재조합 · 입력연결을 담음 —
|
||||
줌). [개인에 저장]은 구조물 복사본의 열 정의 · 도면 · 면적 · 재료 · 입력연결을 담음 —
|
||||
단가 묶음 · 입력값 · 고른자재는 프로젝트 몫이라 안 가져감. 쓰기는 지금 층 저장소 함수(`store.at` ·
|
||||
`store.write` · `matset.create` · 항목 연결 `write_file`) — 나무 고침(`M02_MasterTemplete_Tree`)과
|
||||
`store.write` · 항목 연결 `write_file`) — 나무 고침(`M02_MasterTemplete_Tree`)과
|
||||
같은 집계표 저장 한 줄기(경로 · 머리 줄 수 맞춤 · 번호 · SS 장부)를 탐(PLAN 26-2a).
|
||||
권한(본인 개인 층 · 회사 없음 409)은 부르는 쪽 몫.
|
||||
"""
|
||||
@@ -17,11 +17,10 @@ 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 LINK_FILE, read_json
|
||||
|
||||
#: 복사본 파일 → 층 양식 종류
|
||||
PART_KINDS = (("도면.json", "structure"), ("면적.json", "area"), ("재료.json", "material"))
|
||||
@@ -45,17 +44,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 +65,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 +74,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) # 같은 앞마디 형제 곁(제목행 병합이 이어지게)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""구조물 집계 항목 복사본 — 프로젝트 `templates/structures/{구조물 id}/` (PLAN 25-2b · 26-2b).
|
||||
|
||||
복사본은 **구조물마다 하나** — 같은 항목을 고른 구조물 둘도 각자 도면 · 면적 · 재료 · 자재조합 ·
|
||||
복사본은 **구조물마다 하나** — 같은 항목을 고른 구조물 둘도 각자 도면 · 면적 · 재료 ·
|
||||
일위대가 · 입력연결 · 입력값 · 고른자재 · 단가를 가짐(길이 · 소재 · 높이 · 옵션이 구조물마다 다름).
|
||||
폴더 이름 = 구조물 id(`structure_id`) · 관 지점은 `pipe@측점`.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
모으는 것 = `{파일 이름: 내용}`:
|
||||
`집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA · `재료.json` SM ·
|
||||
`자재조합.json` UM 줄 · `일위대가.json` UA 줄 + 담은 로직 줄(M01 시스템) ·
|
||||
`일위대가.json` UA 줄 + 담은 로직 줄(M01 시스템) ·
|
||||
`입력연결.json` 층의 `inputs/<열id>.json`(없으면 빈 연결) ·
|
||||
`단가.json` 계산에 닿는 마스터 줄 전부
|
||||
`원본판` = 모은 내용의 해시 — 지금 층 · 마스터에서 다시 모은 판과 대어 「마스터가 바뀜」 을 앎.
|
||||
@@ -20,7 +20,6 @@ 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"))
|
||||
LINK_FILE = "입력연결.json"
|
||||
@@ -138,10 +137,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
|
||||
labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None
|
||||
if labor is not None:
|
||||
files[LABOR_FILE] = labor
|
||||
|
||||
@@ -207,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;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
개인에 저장 · 프로젝트에서 새 항목은 그 층의 새 키(본뜬 곳은 열 `본뜬`). 회사 · 개인 층에 남은
|
||||
옛 `SS` 키는 맞출 때 새 모양으로 옮기고(숫자 여섯 그대로 · 겹치면 다음 번호) 옛 키는 줄 `옛키` 에
|
||||
남김 — 프로젝트 복사본이 들고 있는 옛 키로도 찾음(`find`).
|
||||
줄 = `{키, 열, 경로, 이름, 종류, 분류1~3(경로 앞 넷 — 옛 코드가 읽음), 단위, 일위대가, 자재조합,
|
||||
줄 = `{키, 열, 경로, 이름, 종류, 분류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
|
||||
)
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
"""M02 층 집계표 항목 — 회사 · 개인 층 빈 뼈대 · 항목 목록 · 항목 복사(PLAN 21-1 · 21-2).
|
||||
|
||||
항목 = 구조물집계표조합(SS) 한 줄 — 그 열의 일위대가(UA) · 자재조합(UM) ·
|
||||
항목 = 구조물집계표조합(SS) 한 줄 — 그 열의 일위대가(UA) ·
|
||||
도번 SD · 면적번 SA · 재료번 SM.
|
||||
층 폴더(시스템 · 회사 · 개인)는 모양이 같아 저장소 함수를 `store.at(층 폴더)` 안에서 그대로 부름.
|
||||
권한은 부르는 쪽(`M02_MasterTemplete_Router_Items.py`) 몫 — 여기는 폴더와 파일만.
|
||||
@@ -15,7 +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`)
|
||||
@@ -29,7 +28,6 @@ ITEM_FIELDS = (
|
||||
"분류3",
|
||||
"단위",
|
||||
"일위대가",
|
||||
"자재조합",
|
||||
"도번",
|
||||
"면적번",
|
||||
"재료번",
|
||||
@@ -38,7 +36,7 @@ ITEM_FIELDS = (
|
||||
|
||||
def ensure(folder: Path) -> bool:
|
||||
"""회사 · 개인 층 첫 진입 — 빈 집계표(열 0 · 층 이름 · 줄 머리는 시스템 것) ·
|
||||
조합 장부(`다음` 1) · 자재 조합 빈 표 · 종류 폴더.
|
||||
조합 장부(`다음` 1) · 종류 폴더.
|
||||
이미 집계표가 있으면 아무것도 안 함(시스템 층은 늘 있음)."""
|
||||
table = Path(folder) / "table" / f"{store.STRUCTURE_TABLE}.json"
|
||||
if table.is_file():
|
||||
@@ -59,28 +57,23 @@ 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, 단위, 일위대가, 자재조합,
|
||||
"""그 층 항목 — 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 ("도번", "면적번", "재료번")
|
||||
},
|
||||
@@ -104,7 +97,7 @@ def copy_item(
|
||||
path: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""다른 층 항목(SS `key`) → `target` 층 새 열 하나 — 머리 · 단위 · 일위대가 · 바인딩 그대로 ·
|
||||
도면 · 면적산출 · 재료산출 내용(알약 연결 포함) · 항목 연결 · 자재 조합 줄(새 UM) 복사 ·
|
||||
도면 · 면적산출 · 재료산출 내용(알약 연결 포함) · 항목 연결 복사 ·
|
||||
새 번호 · 새 SS 키 ·
|
||||
열 `본뜬` = `origin`(원래 층 · 주인 · 키) · `path` = 새 자리(폴더 + 이름 · 없으면 원래 경로 ·
|
||||
붙여넣기 PLAN 30-2). 돌려줌 `{key, 열, 이름}`."""
|
||||
@@ -120,28 +113,13 @@ 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)})
|
||||
made = {**json.loads(json.dumps(col)), "id": new_id, "자재조합": new_um, "본뜬": origin}
|
||||
made = {**json.loads(json.dumps(col)), "id": new_id, "본뜬": origin}
|
||||
made.pop("자재조합", None) # 옛 자재 조합 칸은 안 옮김
|
||||
if path:
|
||||
made.update({"경로": list(path), "머리": list(path)}) # 머리 줄 수는 저장 맞춤이 채움
|
||||
doc["열"] = [*doc.get("열", []), made]
|
||||
|
||||
@@ -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] # {종류: {옵션 키: 참 · 거짓}} — 거짓만 남김
|
||||
@@ -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:
|
||||
"""`{판, 표시, 종류}` — 표시 = 숨긴 것만 · 종류 = 옵션 있는 종류와 그 옵션."""
|
||||
|
||||
@@ -22,13 +22,10 @@ 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,
|
||||
)
|
||||
|
||||
@@ -113,7 +110,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:
|
||||
@@ -123,7 +120,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
|
||||
@@ -291,19 +288,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,
|
||||
@@ -328,47 +312,3 @@ def post_copy(
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
part = "structure" if kind == "structures" else kind
|
||||
return _call(folder, store.copy_from, part, column, source)
|
||||
|
||||
|
||||
@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, 판)
|
||||
|
||||
@@ -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/material — PLAN 21-1)이라 아래 함수는 그대로 씀
|
||||
_ROOT: ContextVar[Path | None] = ContextVar("m02_layer_root", default=None)
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
|
||||
(브라우저 값 버림 · 5장 ②) · 알약 `연결` 은 문서와 같이 그대로 적힘.
|
||||
번호 칸(도번 · 면적번 · 재료번)은 서버 것 — 있던 파일 번호를 그대로 둠(화면 값을 안 받음).
|
||||
표 열의 `본뜬열`(분류 더하기로 복사한 새 열)은 적지 않음 — 집계표면 아직 파일이 없는 새 열이
|
||||
원본 열의 일위대가 · 자재조합 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로).
|
||||
원본 열의 일위대가 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로).
|
||||
`screen` = 화면 표 [저장](PLAN 26-7e) — 있던 항목의 경로 · 층 이름 · 빈 폴더는 저장본 그대로
|
||||
(정본 = 나무 · 머리는 경로에서 다시 만듦 · `tree.keep_paths`)."""
|
||||
path = _path(kind, name)
|
||||
@@ -323,7 +323,7 @@ def _ensure(kind: str, column: str, source: str | None = None) -> bool:
|
||||
|
||||
def _take_origins(doc: Any) -> dict[str, str]:
|
||||
"""표 열의 `본뜬열` 을 떼어 냄 → `{새 열: 원본 열}` (구조물 파일이 아직 없는 열만) ·
|
||||
그 열에 일위대가 · 자재조합 키가 비었으면 원본 열 것을 받음."""
|
||||
그 열에 일위대가 키가 비었으면 원본 열 것을 받음."""
|
||||
cols = (
|
||||
[c for c in (doc.get("열") or []) if isinstance(c, dict)] if isinstance(doc, dict) else []
|
||||
)
|
||||
@@ -336,8 +336,7 @@ def _take_origins(doc: Any) -> dict[str, str]:
|
||||
if (root() / "structure" / f"{col['id']}.json").is_file():
|
||||
continue
|
||||
out[col["id"]] = source
|
||||
for field in ("일위대가", "자재조합"):
|
||||
col[field] = col.get(field) or by_id[source].get(field)
|
||||
col["일위대가"] = col.get("일위대가") or by_id[source].get("일위대가")
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
type SheetEditOptions,
|
||||
type SheetKind,
|
||||
} from "./M02_MasterTemplete_ItemCards";
|
||||
import { buildMaterialPanel, loadMaterialState, toPills } from "./M02_MasterTemplete_Materials";
|
||||
import { layerSource } from "./M02_MasterTemplete_ItemSource";
|
||||
import { createInputsCard, inputPills, type InputsCardHandle } from "./M02_MasterTemplete_Inputs";
|
||||
import "./M02_MasterTemplete_Structure.css";
|
||||
@@ -302,61 +301,12 @@ 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 {
|
||||
@@ -417,7 +367,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
// 면적산출 · 재료산출 카드 미리보기 수정(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)).판,
|
||||
},
|
||||
|
||||
@@ -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"]})
|
||||
|
||||
@@ -26,21 +26,11 @@ import {
|
||||
type StructureColumn,
|
||||
type StructurePanelHandle,
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
import { inputPills } from "./M02_MasterTemplete_Inputs";
|
||||
import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs";
|
||||
import { buildComboBar } from "./M02_MasterTemplete_Combo";
|
||||
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;
|
||||
@@ -189,9 +179,7 @@ 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 cpanel: ComboPanel | null = null; // 면적산출 오른쪽 조합 패널(프로젝트 층 아님)
|
||||
let seq = 0;
|
||||
let dirty = false;
|
||||
/** 저장 안 한 고침 — 이 화면 + 카드(도면 31-4 · 면적산출 · 재료산출 31-3) */
|
||||
@@ -236,8 +224,6 @@ export function createMain(
|
||||
editor = null;
|
||||
sheet = null;
|
||||
sheetHandle = null;
|
||||
matPanel = null;
|
||||
matState = null;
|
||||
cpanel?.destroy();
|
||||
cpanel = null;
|
||||
panel?.destroy();
|
||||
@@ -263,37 +249,22 @@ export function createMain(
|
||||
}
|
||||
|
||||
/** 면적산출 · 재료산출 스프레드시트 — 위 알약 막대(sub7 `links`) · 면적 = 그 열 일위대가 입력값 ·
|
||||
* 재료 = 자재DB 찾기(`pillsFor`) · 시스템 층은 면적 = 조합 줄 + [조합 고치기] ·
|
||||
* 재료 = 그 열 자재 조합 담은 자재(알약 항목 = 옆 판 표 = 같은 목록) + 옆 판
|
||||
* (`pillItems` = 옆 판에서 고친 목록으로 알약 막대만 다시 만들 때) */
|
||||
* 재료 = 자재DB 찾기(`pillsFor`) · 시스템 층은 면적 = 조합 줄 + [조합 고치기] */
|
||||
async function mountSheet(
|
||||
doc: unknown,
|
||||
kind: "area" | "material",
|
||||
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],
|
||||
@@ -302,90 +273,47 @@ export function createMain(
|
||||
tabs: false, // 시트 하나 · 탭 줄 없음
|
||||
onChange: () => {
|
||||
dirty = !readOnly;
|
||||
matPanel?.refresh(); // 알약을 이음 · 풂 — 옆 판 「이은 칸」 바로 갱신
|
||||
},
|
||||
links,
|
||||
},
|
||||
);
|
||||
editor = handle;
|
||||
sheetHandle = handle;
|
||||
if (at.layer === "project") return;
|
||||
if (at.layer === "project" || kind !== "area") return;
|
||||
// 오른쪽 조합 패널 — 한 번 만들고 시트를 다시 붙여도 그대로(늘 시트 뒤)
|
||||
cpanel ??= buildComboPanel({
|
||||
kind,
|
||||
column: at.name,
|
||||
isAdmin,
|
||||
onUsed: () => remount(kind, at, readOnly, true),
|
||||
onUsed: () => remount(kind, at, readOnly),
|
||||
});
|
||||
host.append(cpanel.root);
|
||||
if (kind === "area") {
|
||||
const bar = await buildComboBar({
|
||||
column: at.name,
|
||||
label: at.label ?? at.name,
|
||||
isAdmin,
|
||||
beforeEdit: async () => true,
|
||||
onApplied: async () => {
|
||||
await remount(kind, at, readOnly, false);
|
||||
await cpanel?.refresh();
|
||||
},
|
||||
});
|
||||
if (mine === seq) cpanel?.setMine(bar);
|
||||
return;
|
||||
}
|
||||
if (matPanel || !matState) return;
|
||||
matPanel = buildMaterialPanel({
|
||||
const bar = await buildComboBar({
|
||||
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;
|
||||
beforeEdit: async () => true,
|
||||
onApplied: async () => {
|
||||
await remount(kind, at, readOnly);
|
||||
await cpanel?.refresh();
|
||||
},
|
||||
});
|
||||
cpanel.setMine(matPanel.root);
|
||||
if (mine === seq) cpanel?.setMine(bar);
|
||||
} 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side.ts
|
||||
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(구조물집계표 줄 하나 + 하위 구조물 도면 · 면적산출 ·
|
||||
* 재료산출 · 자재 조합) · 「도면 양식」(새로 · 본떠 · 지우기). 서버 길은 계약 `6_계약.md`.
|
||||
* 재료산출) · 「도면 양식」(새로 · 본떠 · 지우기). 서버 길은 계약 `6_계약.md`.
|
||||
* 구조물 도면 · 면적산출 · 재료산출 줄은 집계표 구조물 열(꼴=수)마다 하나씩 —
|
||||
* 서버가 미리 만들어 둠(도번 SD · 면적번 SA · 재료번 SM + 같은 8자리) ·
|
||||
* 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」). 「자재 조합」 컨테이너는 `M02_MasterTemplete_Matset.ts`.
|
||||
* 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」).
|
||||
* 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로.
|
||||
* 구조물 라이브러리는 좌측이 아니라 「구조물 조합」 상세 화면 왼쪽(PLAN 26-7c · 페이지가 붙임).
|
||||
* ========================================================================== */
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
structureLabel,
|
||||
type StructureColumn,
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
import { buildMatsetSection } from "./M02_MasterTemplete_Matset";
|
||||
import type { LaborSection } from "./M02_MasterTemplete_Labor";
|
||||
import { buildOptionsSection } from "./M02_MasterTemplete_Options";
|
||||
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
@@ -131,26 +130,14 @@ 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(), labor?.clear(), 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(), labor?.clear(), paint()),
|
||||
onOpen: () => (labor?.clear(), paint()),
|
||||
});
|
||||
const labor = opt.labor?.({ onOpen: () => (matset.clear(), options.clear(), paint()) });
|
||||
const labor = opt.labor?.({ onOpen: () => (options.clear(), paint()) });
|
||||
/** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */
|
||||
const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => {
|
||||
sec.querySelector(".ui-collapsible__title")?.addEventListener("click", () => {
|
||||
@@ -164,7 +151,6 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
L("M02_KindTable"),
|
||||
el("div", { className: "m02-side__items ui-sidebar-list", children: opt.tree ?? [] }),
|
||||
...COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i])),
|
||||
matsetSection,
|
||||
);
|
||||
summary.classList.remove("is-collapsed");
|
||||
summary.classList.add("m02-side__summary");
|
||||
@@ -255,13 +241,12 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
for (const [kind, b] of editButtons) b.copy.disabled = b.del.disabled = cur?.종류 !== kind;
|
||||
}
|
||||
|
||||
/** 고름 표시는 여기서만 — 목록 · 열 줄 · 자재 조합 줄을 함께 그려야 두 줄이 동시에 안 남음
|
||||
* (양식이 열려 있으면 자재 조합 고름은 지움 · 자재 조합 상세는 열린 양식이 없음) */
|
||||
/** 고름 표시는 여기서만 — 목록 · 열 줄 · 옵션 줄을 함께 그려야 두 줄이 동시에 안 남음
|
||||
* (양식이 열려 있으면 옵션 · 일위대가 조합 고름은 지움) */
|
||||
function paint(): void {
|
||||
paintList();
|
||||
paintColumns();
|
||||
paintButtons();
|
||||
if (opt.getOpen()) matset.clear();
|
||||
if (opt.getOpen()) options.clear();
|
||||
if (opt.getOpen()) labor?.clear();
|
||||
}
|
||||
@@ -307,7 +292,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
if (mine !== seq) return;
|
||||
paintList();
|
||||
paintButtons();
|
||||
await Promise.all([refreshStructure(), matset.refresh()]);
|
||||
await refreshStructure();
|
||||
}
|
||||
|
||||
/* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */
|
||||
|
||||
@@ -108,23 +108,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);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
POST …/items/{폴더}/reapply 재적용(입력값 · 고른자재는 같은 키면 살림 · `덮임` 알림)
|
||||
POST …/items/{폴더}/save-as {to, 경로, 이름} 개인 층에 새 항목으로(단가 묶음은 안 가져감)
|
||||
POST …/items/{폴더}/calc 복사본만으로 계산(세 비목 · 계 · 빠진 입력)
|
||||
종류 = 도면 · 면적 · 재료 · 자재조합 · 입력연결 · 입력값 · 고른자재 · `refresh` = 옛 이름(재적용).
|
||||
종류 = 도면 · 면적 · 재료 · 입력연결 · 입력값 · 고른자재 · `refresh` = 옛 이름(재적용).
|
||||
⚠ 등록은 `main.py`(로그인만) — 프로젝트 권한(같은 회사)은 여기서 `_project` 로 봄(403 · 404).
|
||||
"""
|
||||
|
||||
@@ -31,7 +31,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):
|
||||
|
||||
@@ -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`)가 만듦 — 같은 자재 집합이 있으면 그 키.
|
||||
|
||||
@@ -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,7 +0,0 @@
|
||||
{
|
||||
"그룹": "자재조합",
|
||||
"판": "2026",
|
||||
"비고": ["자재 조합 = 구조물 열 재료산출이 쓰는 자재 목록 묶음 · 수량·계산은 두지 않음(재료산출 몫) · 키 UM 은 「다음」 번호로 매김 · 지운 키는 다시 안 씀"],
|
||||
"다음": 1,
|
||||
"줄": []
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"그룹": "자재조합",
|
||||
"판": "2026",
|
||||
"비고": ["자재 조합 = 구조물 열 재료산출이 쓰는 자재 목록 묶음 · 수량·계산은 두지 않음(재료산출 몫) · 키 UM 은 「다음」 번호로 매김 · 지운 키는 다시 안 씀"],
|
||||
"다음": 1,
|
||||
"줄": []
|
||||
}
|
||||
@@ -52,7 +52,7 @@ 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"):
|
||||
shutil.copytree(REAL / kind, system / kind)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,7 +190,7 @@ 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 saved["일위대가"] == ua
|
||||
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()
|
||||
|
||||
@@ -82,7 +82,6 @@ def test_지운_뒤_항목_0_틀은_남음(empty):
|
||||
assert len(combos["지운번호"]) == 4 # 견본에 세 파일이 있던 넷(rv · c4 · pp_len · ms)
|
||||
for kind in ("structure", "area", "material", "inputs"):
|
||||
assert not list((system / kind).glob("*.json")), kind
|
||||
assert (system / "자재조합.json").is_file()
|
||||
|
||||
|
||||
def test_빈_마스터에서_첫_화면_나무_새_항목_지우기(empty):
|
||||
|
||||
@@ -151,7 +151,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"):
|
||||
@@ -189,7 +189,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"
|
||||
|
||||
@@ -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"
|
||||
@@ -78,7 +68,7 @@ def test_시스템_층은_그대로_항목_목록은_SS_줄(world):
|
||||
ss = store.read_combos()["줄"]
|
||||
assert [r["key"] for r in rows] == [r["키"] for r in ss] and rows[0]["key"] == "SS000001"
|
||||
assert set(rows[0]) == {"key", "names", *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
|
||||
@@ -102,7 +92,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)
|
||||
@@ -134,7 +124,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"]
|
||||
|
||||
@@ -146,17 +137,12 @@ 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,
|
||||
"자재조합": "회사 자재",
|
||||
"도번": "돌기슭막이 찰쌓기 회사형",
|
||||
"면적번": "돌기슭막이 찰쌓기 회사형",
|
||||
"재료번": "돌기슭막이 찰쌓기 회사형",
|
||||
@@ -175,13 +161,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
|
||||
|
||||
@@ -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 # 본떠 만들기는 이름 칸 비움
|
||||
@@ -128,9 +128,9 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에
|
||||
" ...(extras ? [extras.옵션, extras.도면] : []),",
|
||||
]
|
||||
assert "\n".join(order) in side
|
||||
# 자재 조합을 열면 이 줄 · 일위대가 고름 지움 / 이 줄을 열면 자재 조합 · 일위대가 고름 지움
|
||||
assert "onOpen: () => (options.clear(), labor?.clear(), paint())" in side
|
||||
assert "onOpen: () => (matset.clear(), labor?.clear(), paint())" in side
|
||||
# 일위대가를 열면 이 줄 고름 지움 / 이 줄을 열면 일위대가 고름 지움
|
||||
assert "onOpen: () => (options.clear(), paint())" in side
|
||||
assert "onOpen: () => (labor?.clear(), paint())" 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")
|
||||
|
||||
@@ -154,9 +154,9 @@ def test_좌측_구조물_집계_양식_아래_하위_넷_새로_본떠_지우
|
||||
assert "createSelectField" not in side # 새로 = 종류 고르기 없이 이름만
|
||||
assert "cur?.종류 !== kind" in side # 본떠 · 지우기 = 그 컨테이너에서 고른 것이 있을 때만
|
||||
assert "i.이름 === STRUCTURE_TABLE" in side # 표는 구조물집계표 한 줄만
|
||||
# 구조물 도면 · 면적산출 · 재료산출 · 자재 조합 = 구조물 집계 양식 컨테이너 안(같이 접힘)
|
||||
# 구조물 도면 · 면적산출 · 재료산출 = 구조물 집계 양식 컨테이너 안(같이 접힘) · 자재 조합 없앰
|
||||
summary = side.split("const summary = section(")[1].split(");")[0]
|
||||
assert "COLUMN_KINDS.map" in summary and "matsetSection" in summary
|
||||
assert "COLUMN_KINDS.map" in summary and "matset" not in side
|
||||
assert 'M02_KindTable: ["구조물 조합"' in _text( # 화면 이름(26-7a)
|
||||
ROOT / "ui_template" / "ui_template_locale_m2.ts"
|
||||
)
|
||||
@@ -170,8 +170,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_첫_화면과_닫은_뒤는_구조물집계표():
|
||||
@@ -189,9 +189,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`(27-2b 뒤 = 일위대가 화면 해시 맞춤도 같은 자리)
|
||||
assert re.search(r"\(\) => \{\s*side\.paint\(\);", _text(M02 / "M02_MasterTemplete_UI_Page.ts"))
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ 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"):
|
||||
shutil.copytree(REAL / kind, target / kind)
|
||||
|
||||
@@ -41,7 +41,6 @@ const row = (
|
||||
분류3: 분류[2],
|
||||
단위: "m",
|
||||
일위대가: "UA000004",
|
||||
자재조합: "UM000001",
|
||||
도번: "SD01010100",
|
||||
면적번: "SA01010100",
|
||||
재료번: "SM01010100",
|
||||
@@ -54,7 +53,6 @@ const rows: PickerRow[] = [
|
||||
row("SS000001", "rv", "돌기슭막이", ["메쌓기", "1.2m", "기초유"], "system", {
|
||||
names: {
|
||||
일위대가: "돌기슭막이 메쌓기 일위대가",
|
||||
자재조합: null,
|
||||
도번: "돌기슭막이 도면",
|
||||
면적번: "돌기슭막이 면적산출",
|
||||
재료번: "돌기슭막이 재료산출",
|
||||
@@ -221,16 +219,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", "material", "unitPrice"],
|
||||
);
|
||||
assert.deepEqual(lines[0], { kind: "drawing", name: "돌기슭막이 도면", key: "SD01010100" });
|
||||
assert.deepEqual(lines[3], { kind: "combo", name: null, key: "UM000001" }); // names 에 없음
|
||||
assert.deepEqual(lines[3], {
|
||||
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" })[3], {
|
||||
kind: "unitPrice",
|
||||
name: null,
|
||||
key: "",
|
||||
|
||||
@@ -120,15 +120,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 +139,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,8 +35,6 @@ export interface SheetColumn {
|
||||
바인딩?: SheetBinding | null;
|
||||
/** 일위대가 조합 키. */
|
||||
일위대가?: string | null;
|
||||
/** 자재 조합 키(`UM`). */
|
||||
자재조합?: string | null;
|
||||
/** 분류 더하기로 복사한 원본 열 id — 저장 때 서버가 원본 열 내용을 새 열로 복사하고 뗌. */
|
||||
본뜬열?: string | null;
|
||||
/** 마스터 한 칸 → 프로젝트에서 설계 값마다 열. */
|
||||
|
||||
@@ -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,7 +28,6 @@ export interface PickerRow {
|
||||
분류3?: string;
|
||||
단위?: string;
|
||||
일위대가?: string;
|
||||
자재조합?: string;
|
||||
도번?: string;
|
||||
면적번?: string;
|
||||
재료번?: string;
|
||||
@@ -102,13 +101,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 +167,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" | "material" | "unitPrice";
|
||||
|
||||
export interface FolderLine {
|
||||
kind: FolderKind;
|
||||
@@ -188,11 +181,10 @@ 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,
|
||||
|
||||
@@ -110,13 +110,11 @@ const TEXT = {
|
||||
calc: "복사본 계산 — 계 {total}원 · 빠진 입력 {gaps}개",
|
||||
calcStuck: " · 멈춘 로직 {stuck}개",
|
||||
noCopy: "복사본에 없음",
|
||||
combo: "자재조합",
|
||||
price: "일위대가",
|
||||
parts: {
|
||||
도면: "도면",
|
||||
면적: "면적산출",
|
||||
재료: "재료산출",
|
||||
자재조합: "자재조합",
|
||||
입력연결: "선택 입력 자리",
|
||||
} as Record<string, string>,
|
||||
};
|
||||
@@ -153,9 +151,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",
|
||||
@@ -166,9 +162,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}`)),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -21,8 +21,7 @@ import {
|
||||
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> = {
|
||||
|
||||
@@ -88,7 +88,6 @@ export const ui_locales_m2 = {
|
||||
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"],
|
||||
|
||||
Reference in New Issue
Block a user