Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_2

This commit is contained in:
2026-09-28 19:11:00 +09:00
21 changed files with 1004 additions and 60 deletions
@@ -138,6 +138,8 @@ export interface StructureCombo extends StructureNumbers {
분류3: string | null;
단위: string | null;
일위대가: string | null;
/** 자재 조합 키 `UM` */
자재조합: string | null;
바인딩: unknown;
펼침: unknown;
}
@@ -153,6 +155,59 @@ export interface StructureCombos {
/** 조합 표 — 서버가 집계표 [저장] 때 맞춤(읽기만) */
export const fetchStructureCombos = (): Promise<StructureCombos> => call("/structure-combos");
/** 조합 갈라 내기 결과 — `새로만듦` false = 같은 담은 로직의 조합이 이미 있어 그 키를 가리킴 */
export interface BranchedCombo {
키: string;
이름: string;
새로만듦: boolean;
집계표판: string;
}
/** 이 열이 가리키는 일위대가 조합을 고친 담은 로직으로 갈라 냄 — 새 UA · 집계표 그 열만 새 키 ·
* `판`(집계표 판)을 주면 어긋날 때 StaleError · 원래 조합은 그대로 */
export const branchCombo = (
column: string,
body: {
본뜬키: string;
담은로직: { 로직: string; 메모?: string | null }[];
이름: string;
판?: string;
},
): Promise<BranchedCombo> =>
call(`/structures/${enc(column)}/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> =>
call(`/material-combos/${enc(key)}`);
/** 이 열의 자재 조합을 고친 담은 자재로 갈라 냄 — 규칙은 `branchCombo` 와 같음 ·
* `본뜬키` 빈 글 = 이 열의 첫 자재 조합 */
export const branchMaterialCombo = (
column: string,
body: { 본뜬키?: string; 담은자재: MaterialItem[]; 이름: string; 판?: string },
): Promise<BranchedCombo> =>
call(`/structures/${enc(column)}/material-combo`, {
method: "POST",
body: JSON.stringify(body),
});
/** 다른 열(`from`) 구조물 도면 내용을 받는 열(`column`)로 가져옴 — 도번 · 열은 받는 쪽 그대로 */
export const copyStructure = async (column: string, from: string): Promise<void> => {
await call(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
@@ -0,0 +1,151 @@
"""조합 갈라 내기 — 구조물 열 하나가 가리키는 조합을 고친 내용으로 새로 만들어 그 열만 새 키를
가리키게 함(PLAN 15장 · 검토 `14_조합편집검토.md` 1장).
· 일위대가 조합(UA · `일위대가조합.json`) — 새 줄은 M01 조합 저장소가 만듦(부르기만)
· 자재 조합(UM · `자재조합.json`) — M01 마스터 저장(`save`)으로 더함(키 = 대장 다음 번호)
조합은 여러 열 · 모든 프로젝트가 같이 씀 — 제자리 고치지 않음. 담은 것의 집합(차례 · 메모 무시)이
이미 있는 조합과 같으면 새로 안 만들고 그 키 · 새 줄의 본뜬키 = 원래 키 · 소유 현장.
"""
from __future__ import annotations
from typing import Any, Callable
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
_OWNER = "현장"
MATERIAL_FILE = "자재조합.json"
def _m01(fn, *args):
"""M01 막힘은 M02 막힘으로 — 상태 · 까닭 그대로."""
try:
return fn(*args)
except m01.StoreError as e:
raise store.StoreError(e.status, e.detail) from e
def _free_name(name: str, rows: list[dict]) -> str:
taken = {str(r.get("이름") or "") for r in rows}
n, out = 2, name
while out in taken:
out, n = f"{name} ({n})", n + 1
return out
def _branch(
column: str,
version: str,
field: str,
file: str,
slot: str,
ident: str,
items: list[dict],
source: str,
make: Callable[[str, list[dict]], dict],
name: str,
) -> dict[str, Any]:
"""집계표 판 검사 → 같은 내용 찾기 · 없으면 `make(이름, 줄들)` 로 새 줄 → 집계표 그 열 `field`.
`slot` = 담은 칸(담은로직 · 담은자재) · `ident` = 담은 줄의 알맹이 칸(로직 · 코드)."""
table = store.read("table", store.STRUCTURE_TABLE)
if version and table["판"] != version:
raise store.StoreError(409, {"stale": [store.STRUCTURE_TABLE], "판": table["판"]})
col = next((c for c in store._structure_columns() if c["id"] == column), None)
if col is None:
raise store.StoreError(404, f"{store.STRUCTURE_TABLE}에 없는 구조물 열 「{column}」")
if not isinstance(items, list) or not items:
raise store.StoreError(400, "담은 것이 비었음")
def body(got) -> frozenset[str]:
return frozenset(str(x.get(ident) or "") for x in got or [] if isinstance(x, dict))
rows = _m01(m01.read, file)[0].get("줄") or []
same = [r for r in rows if body(r.get(slot)) == body(items)]
hit = next((r for r in same if source and r.get("키") == source), same[0] if same else None)
if hit:
key, made_name, made = str(hit["키"]), str(hit.get("이름") or ""), False
else:
made_name = _free_name(name, rows)
key, made = make(made_name, rows), True
# ponytail: 조합 쓰기와 집계표 쓰기는 한 묶음 아님 — 사이에 집계표가 바뀌면 409 뒤 주인 없는
# 조합 하나가 남음(다시 하면 같은 내용이라 그 키를 씀)
doc = table["문서"]
for c in doc.get("열", []):
if isinstance(c, dict) and c.get("id") == column:
c[field] = key
info = store.write("table", store.STRUCTURE_TABLE, table["판"], doc) # SS 도 여기서 맞춤
return {"키": key, "이름": made_name, "새로만듦": made, "집계표판": info["판"]}
def branch_combo(
column: str, source: str, items: list[dict], name: str, version: str = ""
) -> dict[str, Any]:
"""일위대가 조합 갈라 내기 — 구분 · 상세구분 · 단위 · 출처 = 본뜬 것."""
was = _m01(combo_store.combo, source)["combo"]
items = [
{"로직": str(x.get("로직") or ""), "메모": x.get("메모")}
for x in items or []
if isinstance(x, dict)
]
def make(made_name: str, _rows) -> str:
row = {
"이름": made_name,
**{k: was.get(k) for k in ("구분", "상세구분", "단위")},
"담은로직": items,
"본뜬키": source,
"출처": was.get("출처"),
}
return str(_m01(combo_store.combo_new, row, _OWNER)["key"])
name = name.strip() or str(was.get("이름") or source)
return _branch(
column, version, "일위대가", combo_store.FILE, "담은로직", "로직", items, source, make, name
)
def _material_rows() -> list[dict]:
return _m01(m01.read, MATERIAL_FILE)[0].get("줄") or []
def material_combo(key: str) -> dict[str, Any]:
row = next((r for r in _material_rows() if r.get("키") == key), None)
if row is None:
raise store.StoreError(404, f"없는 자재 조합 「{key}」")
return row
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: list[dict]) -> str:
row = {"이름": made_name, "담은자재": items, "본뜬키": source or None, "소유": _OWNER}
before = {r.get("키") for r in rows}
_m01(
m01.save,
[
{
"file": MATERIAL_FILE,
"version": _m01(m01.read, MATERIAL_FILE)[1],
"changes": [{"op": "add", "row": {**row, "비고": None}}],
}
],
)
return str(next(r["키"] for r in _material_rows() if r.get("키") not in before))
heads = next((c for c in store._structure_columns() if c["id"] == column), {})
default = " ".join(h for h in store._heads(heads) if h) if heads else column
name = name.strip() or str(was.get("이름") or default or column)
return _branch(
column, version, "자재조합", MATERIAL_FILE, "담은자재", "코드", items, source, make, name
)
@@ -10,6 +10,7 @@ from typing import Any
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_Store as store
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"])
@@ -20,6 +21,20 @@ class SaveBody(BaseModel):
문서: Any
class BranchBody(BaseModel):
본뜬키: str
담은로직: list[dict[str, Any]]
이름: str = ""
판: str = "" # 집계표 판 — 주면 어긋날 때 409
class MaterialBranchBody(BaseModel):
본뜬키: str = "" # 빈 글 = 이 열의 첫 자재 조합
담은자재: list[dict[str, Any]]
이름: str = ""
판: str = ""
def _call(fn, *args):
try:
return fn(*args)
@@ -49,6 +64,26 @@ def post_structure(column: str) -> dict:
return _call(store.create_structure, column)
@router.post("/structures/{column}/combo")
def post_structure_combo(column: str, body: BranchBody) -> dict:
"""조합 갈라 내기 — 고친 담은 로직으로 새 UA(같은 로직 집합이면 있는 키) · 이 열만 가리킴."""
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.판
)
@router.get("/material-combos/{key}")
def get_material_combo(key: str) -> dict:
"""자재 조합 한 줄(키 UM) — 키 · 이름 · 담은자재 · 본뜬키 · 소유 · 비고."""
return _call(branch.material_combo, key)
# 복사 가져오기 — 다른 열(`from`) 내용을 받는 열로 · 번호 · 열은 받는 쪽 그대로
@router.post("/structures/{column}/copy")
def post_structure_copy(column: str, source: str = Query(alias="from")) -> dict:
+40 -4
View File
@@ -109,9 +109,12 @@ def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
만들고 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 지움(잠금 밖에서 · 안에서 부르면 죽음).
통합문서(area · material)는 서버가 같은 TS 엔진을 Node 로 돌려 `계산값` 을 새로 적음
(브라우저 값 버림 · 5장 ②) · 알약 `연결` 은 문서와 같이 그대로 적힘.
번호 칸(도번 · 면적번 · 재료번)은 서버 것 — 있던 파일 번호를 그대로 둠(화면 값을 안 받음)."""
번호 칸(도번 · 면적번 · 재료번)은 서버 것 — 있던 파일 번호를 그대로 둠(화면 값을 안 받음).
표 열의 `본뜬열`(분류 더하기로 복사한 새 열)은 적지 않음 — 집계표면 아직 파일이 없는 새 열이
원본 열의 일위대가 · 자재조합 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로)."""
path = _path(kind, name)
_check_skeleton(kind, doc)
origins = _take_origins(doc) if kind == "table" else {}
if kind in SHEETS:
doc = _server_values(doc)
with _LOCK:
@@ -124,6 +127,9 @@ def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
atomic_write_json(path, doc)
info = _info(kind, path)
if kind == "table" and name == STRUCTURE_TABLE:
for column, source in origins.items(): # 빈 파일 채우기 앞 — 원본 내용으로 만듦
for part in _KEY:
_ensure(part, column, source)
backfill_missing_structures() # 지우기 앞 — 같은 저장의 새 열이 지운 번호를 안 받게
prune_removed_structures()
sync_combos() # 번호가 다 선 뒤 — 조합 표 도번 · 면적번 · 재료번 칸
@@ -256,17 +262,46 @@ def _blank(kind: str, column: str) -> dict[str, Any]:
}
def _ensure(kind: str, column: str) -> bool:
"""그 열의 파일이 없으면 빈 문서를 번호(세 파일이 같은 8자리)와 같이 만듦 · 있으면 그대로."""
def _ensure(kind: str, column: str, source: str | None = None) -> bool:
"""그 열의 파일이 없으면 빈 문서를 번호(세 파일이 같은 8자리)와 같이 만듦 · 있으면 그대로.
`source` 열 파일이 있으면 빈 문서 대신 그 내용(알약 `연결` 포함 · 계산값은 서버가 다시 풂)."""
path = _path(kind, column)
if path.is_file():
return False
head = next(h for k, _, h, _ in PARTS if k == kind)
base = _blank(kind, column)
if source and _path(kind, source).is_file():
got = json.loads(_path(kind, source).read_bytes())
base = {**{k: v for k, v in got.items() if k != "계산값"}, "열": column}
if kind in SHEETS:
base = _server_values(base)
with _LOCK:
if path.is_file():
return False
atomic_write_json(path, {**_blank(kind, column), _KEY[kind]: head + _new_code(column)})
atomic_write_json(path, {**base, _KEY[kind]: head + _new_code(column)})
return True
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 []
)
by_id = {c.get("id"): c for c in cols}
out: dict[str, str] = {}
for col in cols:
source = col.pop("본뜬열", None)
if not source or source not in by_id or not col.get("id"):
continue
if (FOLDER / "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)
return out
def create_structure(column: str) -> dict[str, Any]:
"""구조물 열 하나의 구조물 도면 새로 — 도번 SD + 8자리 · 이미 있으면 409.
면적산출 · 재료산출 파일이 없으면 빈 통합문서도 같이 만듦(같은 8자리)."""
@@ -400,6 +435,7 @@ def _combo_row(key: str, col: dict[str, Any], numbers: dict[str, str]) -> dict[s
"분류3": level3,
"단위": col.get("단위"),
"일위대가": col.get("일위대가"),
"자재조합": col.get("자재조합"),
**{field: numbers.get(field, "") for _, field, _, _ in PARTS},
"바인딩": col.get("바인딩"),
"펼침": col.get("펼침"),