Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1

This commit is contained in:
2026-09-30 13:51:05 +09:00
32 changed files with 1786 additions and 562 deletions
@@ -176,20 +176,6 @@ export const fetchPick = (
q: string,
): Promise<{ total: number; items: PickItem[] }> => get("/pick", { kind, q, limit: 50 });
/** 조합 알약 물량 하나 — 키 = 「이름|단위」 · 담은 로직 결과단위 분모(같은 이름 · 단위는 합침) · `배` = 원/10㎡ 의 10 */
export interface ComboQuantity {
키: string;
이름: string;
단위: string;
로직: string[];
배?: number;
}
export const fetchComboInputs = (
combo: string,
): Promise<{ 키: string; 이름: string; 물량: ComboQuantity[]; 조건: unknown[] }> =>
get(`/combo/${encodeURIComponent(combo)}/inputs`, {});
/** 자재 찾기 — `/pick?kind=price` 감쌈 · 이름·규격 낱말이 모두 든 줄 */
export const searchMaterials = async (
q: string,
+1 -64
View File
@@ -88,32 +88,6 @@ class FormulaPreview(BaseModel):
별칭: list[dict[str, Any]] = []
class NewCombo(BaseModel):
"""일위대가 조합 — 담은 단가산출 로직 목록만(수량·비율·계산은 안 둠)."""
combo: dict[str, Any]
owner: str = combo_store.OWNERS[0]
class EditCombo(BaseModel):
key: str
version: str
combo: dict[str, Any]
owner: str = ""
class DeleteCombo(BaseModel):
key: str
version: str
class PreviewCombo(BaseModel):
"""저장 전 미리 보기 — `combo` 는 조합 줄 통째 또는 저장된 키 · `inputs` 는 로직마다."""
combo: dict[str, Any] | str
inputs: dict[str, dict[str, Any]] = {}
class DraftLogic(BaseModel):
text: str # 줄로 적은 식 — 줄마다 「이름 = 식」
decided: dict[str, Any] = {} # 변수마다 정한 것 {이름: {무엇, 단위, 비목, 요소·찾기·값}}
@@ -307,45 +281,8 @@ def post_logic_draft(body: DraftLogic) -> dict:
return _call(make.logic_draft, body.text, body.decided, body.head, body.save, body.owner)
# ── 일위대가 조합(계약 9장) ────────────────────────────────────────────
@router.get("/combos")
def get_combos(sub: str = "", detail: str = "", q: str = "", owner: str = "") -> dict:
return {"combos": _call(combo_store.combos, sub, detail, q, owner)}
@router.get("/combo")
def get_combo(key: str) -> dict:
return _call(combo_store.combo, key)
@router.get("/combo/{key}/inputs")
def get_combo_inputs(key: str) -> dict:
"""조합 알약 — 담은 로직 결과단위 분모마다 물량 하나(같은 이름·단위는 합침)."""
return _call(combo_store.combo_inputs, key)
# ── 일위대가 조합 — 조합 길은 M02 층 길(`/api/m02/layers/{layer}/combos` · PLAN 27-2a) ──
@router.get("/logic/combos")
def get_logic_combos(key: str) -> dict:
"""이 단가산출 로직을 담은 조합 — 로직 쪽에서 거꾸로 봄."""
return _call(combo_store.combos_of_logic, key)
@router.post("/combo/new")
def post_combo_new(body: NewCombo) -> dict:
return _call(combo_store.combo_new, body.combo, body.owner)
@router.post("/combo/edit")
def post_combo_edit(body: EditCombo) -> dict:
return _call(combo_store.combo_edit, body.key, body.version, body.combo, body.owner)
@router.post("/combo/delete")
def post_combo_delete(body: DeleteCombo) -> dict:
return _call(combo_store.combo_delete, body.key, body.version)
@router.post("/combo/preview")
def post_combo_preview(body: PreviewCombo) -> dict:
"""담은 로직마다 값을 뽑아 비목별로 갈라 합계 — 저장하지 않음."""
return _call(combo_store.combo_preview, body.combo, body.inputs)
+5 -1
View File
@@ -162,7 +162,11 @@ def combo_inputs(key: str) -> dict:
`배` = 분모 앞 수(원/10㎡ → 10 · 1 이면 안 줌) · 돈 아닌 로직은 빠짐.
`조건` 은 자리만(설계 조건 고르기 · 범위는 알약에 안 넣음).
"""
row = _find(_read()[0], key)
return combo_pills(_find(_read()[0], key))
def combo_pills(row: dict) -> dict:
"""조합 줄 하나(어느 층이든 · M02 조합 층 저장소가 부름)의 알약 — 규칙은 `combo_inputs`."""
whole = store.loaded()[1]
pills: dict[tuple[str, str], dict] = {}
for one in mcb.items(row):
@@ -1,108 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_Combo_Api.ts
* 일위대가 조합 화면이 부르는 서버 길 — 조합 마스터 `일위대가조합.json`(키 UA)
* 길 = `_화면_계약.md` 9장
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { ApiError } from "./M01_MasterData_UI_Logic_Api";
/** 담은 로직 한 줄 — 로직 키와 메모뿐(수량·계산은 마스터에 두지 않음 · 계약 9장) */
export interface ComboLogic {
로직: string;
메모: string | null;
}
/** 조합 한 줄 통째 — `_틀.md` 10장 · 키·소유는 서버 것이 이김 */
export interface Combo {
키: string;
이름: string;
구분: string | null;
상세구분: string | null;
단위: string | null;
담은로직: ComboLogic[];
출처: string | null;
소유: string | null;
/** 이 조합을 본떠 만든 원본 키 — 비면 원본(M02 가 갈라 낸 조합에 채워짐) */
본뜬키: string | null;
비고: string | null;
}
export interface ComboSummary extends Combo {
count: number;
blocked: boolean;
reasons: string[];
}
/** `GET /combo` 의 `줄` — 담은 로직에 이름을 붙인 것 */
export interface ComboLine {
차례: number;
로직: string;
메모: string | null;
ok: boolean;
까닭?: string;
이름?: string;
구분?: string;
상세구분?: string;
결과단위?: string;
}
export interface ComboOne {
file: string;
version: string;
combo: Combo;
줄: ComboLine[];
}
export interface PreviewLine {
차례: number;
로직: string;
이름: string;
ok: boolean;
까닭?: string;
갈래?: string;
결과?: number;
노무비?: number;
재료비?: number;
경비?: number;
계?: number;
}
export interface PreviewAnswer {
줄: PreviewLine[];
노무비: number;
재료비: number;
경비: number;
계: number;
멈춤: string[];
}
async function request<T>(path: string, body?: unknown): Promise<T> {
const response = await fetch(`${API_BASE_URL}/m01${path}`, {
method: body === undefined ? "GET" : "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText);
return data;
}
export const fetchCombos = (): Promise<ComboSummary[]> =>
request<{ combos: ComboSummary[] }>("/combos").then((d) => d.combos);
export const fetchCombo = (key: string): Promise<ComboOne> =>
request(`/combo?key=${encodeURIComponent(key)}`);
export const newCombo = (combo: Combo): Promise<{ file: string; version: string; key: string }> =>
request("/combo/new", { combo, owner: "현장" });
export const editCombo = (
key: string,
version: string,
combo: Combo,
): Promise<{ file: string; version: string }> => request("/combo/edit", { key, version, combo });
export const deleteCombo = (key: string, version: string): Promise<unknown> =>
request("/combo/delete", { key, version });
/** 보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 */
export const previewCombo = (
combo: Combo | string,
inputs: Record<string, Record<string, unknown>>,
): Promise<PreviewAnswer> => request("/combo/preview", { combo, inputs });
+5 -29
View File
@@ -1,6 +1,7 @@
/* =============================================================================
* M01_MasterData_UI_Page.ts
* 마스터 요소 화면 — 좌측 도킹 패널(컨테이너: 인력·재료·기계·소요량·계수·환율·요율·단가산출 로직·일위대가 조합)
* 마스터 요소 화면 — 좌측 도킹 패널(컨테이너: 인력·재료·기계·소요량·계수·환율·요율·단가산출 로직)
* · 일위대가 조합은 M02 마스터 템플릿(PLAN 27-2b)
* / 우측 요소 표 · 표 목록 · 로직 편집
*
* 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 —
@@ -38,7 +39,6 @@ import { loadTable } from "./M01_MasterData_UI_Logic_Note";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
import { renderTables } from "./M01_MasterData_UI_Tables";
import type { LogicHandle } from "./M01_MasterData_UI_LogicLab";
import type { ComboHandle } from "./M01_MasterData_UI_Combo";
import "./M01_MasterData_UI_Style.css";
const TABLE_GROUPS = ["소요량", "계수"];
@@ -59,7 +59,6 @@ function buildPage(): HTMLElement {
let query = "";
let dispose = (): void => {};
let logicMounted = false;
let comboHandle: ComboHandle | null = null;
/* --- 우측: 머리(제목·찾기·저장) + 알림 + 본문 --- */
const title = el("h2", { className: "m01-master__title", text: L("M01_PickFile") });
@@ -75,7 +74,6 @@ function buildPage(): HTMLElement {
children: [title, searchField.root, summary, drop, save],
});
const logicHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } });
const comboHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } });
const elementView = el("div", {
className: "m01-master__elements",
children: [
@@ -98,9 +96,8 @@ function buildPage(): HTMLElement {
onDraftChange(refreshBar);
refreshBar();
const showMode = (mode: "elements" | "logic" | "combo"): void => {
const showMode = (mode: "elements" | "logic"): void => {
logicHost.hidden = mode !== "logic";
comboHost.hidden = mode !== "combo";
elementView.hidden = mode !== "elements";
};
@@ -206,27 +203,6 @@ function buildPage(): HTMLElement {
});
};
/** 「일위대가 조합」 — 목록을 새로 받아 그림 · 로직 줄을 누르면 단가산출 로직 탭으로 */
const openComboTab = (): void => {
dispose();
dispose = (): void => {};
current = null;
showMode("combo");
if (comboHandle) {
void comboHandle.show();
return;
}
void import("./M01_MasterData_UI_Combo").then((m) => {
comboHandle = m.mountM01Combo(
comboHost,
side,
(key) => openLogicTab(key),
() => comboHost.hidden && openComboTab(),
);
void comboHandle.show();
});
};
/** 알약 → 마스터 화면 — 찾기 글을 그 키로 두고 열고, 알림줄에 「단가산출 로직으로 돌아가기」를 둠 */
const jump = async (target: MasterJump): Promise<void> => {
let group = target.kind === "element" ? target.group : "";
@@ -267,7 +243,7 @@ function buildPage(): HTMLElement {
setMasterJump((target) => void jump(target));
/* --- 좌측: 컨테이너 --- */
const side = buildSide(openFile, () => openLogicTab(), openComboTab);
const side = buildSide(openFile, () => openLogicTab());
const layout = el("div", { className: "ui-workflow-layout m01-master" });
const main = el("main", {
@@ -275,7 +251,7 @@ function buildPage(): HTMLElement {
children: [
el("div", {
className: "m01-master__panel",
children: [elementView, logicHost, comboHost],
children: [elementView, logicHost],
}),
],
});
+4 -20
View File
@@ -1,7 +1,7 @@
/* =============================================================================
* M01_MasterData_UI_Side.ts
* 왼쪽 패널 — 공용 접기 컨테이너(`ui-collapsible ui-sidebar-section`) 아홉 개
* 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 · 단가산출 로직 · 일위대가 조합
* 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 · 단가산출 로직(일위대가 조합은 M02 · PLAN 27-2b)
* 펼치면 하위 목록(거름 · 파일) · 환율/요율은 누르면 바로 표 · 로직은 안에 로직 목록
* ========================================================================== */
@@ -10,7 +10,6 @@ import { attachCollapsible } from "@ui/ui_template_collapsible";
import { t as L } from "@ui/ui_template_locale";
import { fetchFiles, fetchSubsAll, type FileInfo, type SubInfo } from "./M01_MasterData_Api_Fetch";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import { tc } from "./M01_MasterData_UI_Combo_Text";
import { renderTree, type MakeRow } from "./M01_MasterData_UI_Tree";
/** 컨테이너마다 기억하는 화면 상태 — 찾기 글 · 쪽 · 소요량·계수 표의 용도 대상 거름 (거름은 `m01.filter.*`) */
@@ -86,8 +85,6 @@ export interface SideHandle {
root: HTMLElement;
/** 단가산출 로직 컨테이너 안(로직 목록이 들어갈 자리) */
logicHost: HTMLElement;
/** 일위대가 조합 컨테이너 안(구분 · 상세구분 거름이 들어갈 자리) */
comboHost: HTMLElement;
/** 그룹의 파일 판본을 다시 받음(저장 뒤) — 돌려받는 것 = 새 목록 */
refresh: (group: string) => Promise<FileInfo[]>;
/** 그룹의 파일 목록(이미 받은 것) */
@@ -145,13 +142,8 @@ const LEAF: Group[] = ["환율", "요율"];
/** 거름이 있는 컨테이너 — 펼치면 「전체」(또는 닫기 전 거름) 이 바로 열림 */
const FILTERED: Group[] = ["인력", "기계", "소요량", "계수"];
const LOGIC_ID = "로직|";
const COMBO_ID = "조합|";
export function buildSide(
onOpen: (pick: Pick) => void,
onLogic: () => void,
onCombo: () => void = () => {},
): SideHandle {
export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): SideHandle {
const lists = new Map<Group, FileInfo[]>();
const bodies = new Map<Group, HTMLElement>();
const buttons = new Map<string, HTMLElement>();
@@ -452,17 +444,9 @@ ${first.file}`
onLogic();
});
const comboHost = el("div", { className: "m01-side__items ui-sidebar-list" });
const combo = section(tc("Title"), comboHost);
combo.querySelector(".ui-collapsible__title")?.addEventListener("click", () => {
if (!combo.classList.contains("is-collapsed")) return;
setActive(buttons.has(COMBO_ID) ? COMBO_ID : null);
onCombo();
});
const root = el("div", {
className: "m01-side ui-sidebar-panel",
children: [...groups, logic, combo],
children: [...groups, logic],
});
attachCollapsible(root);
void Promise.all(GROUPS.map(refresh)).catch((error) =>
@@ -472,5 +456,5 @@ ${first.file}`
onPicked = fn;
};
const files = (group: string): FileInfo[] => lists.get(group as Group) ?? [];
return { root, logicHost, comboHost, refresh, files, setActive, setOnPicked, filter };
return { root, logicHost, refresh, files, setActive, setOnPicked, filter };
}
+4 -3
View File
@@ -21,7 +21,6 @@ from M02_MasterTemplete import M02_Template_Layers as layers
TABLE_NAME = "구조물집계표"
UM_FILE = "자재조합.json"
UA_FILE = "일위대가조합.json" # M01 시스템(`resources/master_data/`)
#: 열 id → 스냅샷 파일(구조물 도면 · 면적산출 · 재료산출)
PART_FILES = (("structure", "도면.json"), ("area", "면적.json"), ("material", "재료.json"))
LINK_FILE = "입력연결.json"
@@ -81,10 +80,12 @@ def version_of(content: Any) -> str:
def labor_rows(ua_key: str) -> dict[str, Any] | None:
"""UA 줄 + 담은 로직 줄(M01 시스템 · 없는 로직은 빼고 키만 `빠진로직`)."""
"""조합 줄(마스터 UA · 회사 UC · 개인 UP) + 담은 로직 줄(M01 시스템 · 없는 로직은 빼고 키만
`빠진로직`)."""
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
ua = row_of(read_json(Path(m01.FOLDER) / UA_FILE), ua_key)
ua = combos.find(ua_key)
if ua is None:
return None
wanted = [str(entry.get("로직")) for entry in ua.get("담은로직") or [] if entry.get("로직")]
+20 -16
View File
@@ -1,6 +1,7 @@
"""조합 갈라 내기 — 구조물 열 하나가 가리키는 조합을 고친 내용으로 새로 만들어 그 열만 새 키를
가리키게 함(PLAN 15장 · 검토 `14_조합편집검토.md` 1장).
· 일위대가 조합(UA · `일위대가조합.json`) — 새 줄은 M01 조합 저장소가 만듦(부르기만)
· 일위대가 조합(UA · UC · UP) — 새 줄은 지금 층 조합 저장소가 만듦(`Combo_Store` · PLAN 27-2c:
마스터 층 = UA · 회사 층 = UC · 개인 층 = UP · 고르기는 세 층 중 이 층이 쓸 수 있는 것)
· 자재 조합(UM · 마스터 템플릿 `자재조합.json`) — M02 자재 조합 저장소가 만듦(표 「다음」 번호)
조합은 여러 열 · 모든 프로젝트가 같이 씀 — 제자리 고치지 않음. 담은 것(차례 · 메모까지)이
@@ -11,8 +12,7 @@ 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_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
@@ -20,14 +20,6 @@ from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
_OWNER = "현장"
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
@@ -94,7 +86,7 @@ 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) · 조합은 그대로(고르기만)."""
if field == "일위대가":
name = _m01(combo_store.combo, key)["combo"].get("이름")
name = _combo(key).get("이름")
elif field == "자재조합":
name = material_combo(key).get("이름")
else:
@@ -103,11 +95,23 @@ def use_combo(column: str, field: str, key: str, version: str = "") -> dict[str,
return {"키": key, "이름": str(name or ""), "새로만듦": False, "집계표판": 판}
def _combo(key: str) -> dict[str, Any]:
"""이 층이 쓸 수 있는 조합(마스터 · 같은 회사 · 내 것) — 없으면 404 · 남의 층 것은 403."""
row = combos.find(key)
if row is None:
raise store.StoreError(404, f"없는 조합 「{key}」")
if not key.startswith(combos.heads()):
raise store.StoreError(
403, f"이 층에서 못 쓰는 조합 「{key}」 — 마스터 · 같은 회사 · 내 것만"
)
return row
def branch_combo(
column: str, source: str, items: list[dict], name: str, version: str = ""
) -> dict[str, Any]:
"""일위대가 조합 갈라 내기 — 구분 · 상세구분 · 단위 · 출처 = 본뜬 것."""
was = _m01(combo_store.combo, source)["combo"]
"""일위대가 조합 갈라 내기 — 구분 · 상세구분 · 단위 · 출처 = 본뜬 것 · 새 줄은 지금 층에."""
was = _combo(source)
items = [
{"로직": str(x.get("로직") or ""), "메모": x.get("메모")}
for x in items or []
@@ -122,10 +126,10 @@ def branch_combo(
"본뜬키": source,
"출처": was.get("출처"),
}
return str(_m01(combo_store.combo_new, row, _OWNER)["key"])
return str(combos.create({**row, "소유": _OWNER})["키"])
name = name.strip() or str(was.get("이름") or source)
rows = _m01(m01.read, combo_store.FILE)[0].get("줄") or []
rows = combos.rows()
return _branch(
column, version, "일위대가", rows, "담은로직", ("로직", "메모"), items, source, make, name
)
@@ -14,8 +14,8 @@ import {
showToast,
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { fetchCombo, type ComboLine } from "../M01_MasterData/M01_MasterData_UI_Combo_Api";
import { openLogicPicker } from "../M01_MasterData/M01_MasterData_UI_Combo_Pick";
import { fetchCombo, type ComboLine } from "./M02_MasterTemplete_Labor_Api";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import { branchCombo, type BranchedCombo } from "./M02_MasterTemplete_Api_Fetch";
import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api";
import "./M02_MasterTemplete_Combo.css";
@@ -4,7 +4,8 @@
* 공용 폭 손잡이(`ui_template_resizer` · 폭 기억 · 최소 폭) · 공용 접기 손잡이(`ui_template_overlay`).
* · 「이 열의 조합」 — 면적 = 일위대가 조합 줄 + [조합 고치기](`M02_MasterTemplete_Combo.ts`) ·
* 재료 = 담은 자재 표(`M02_MasterTemplete_Materials.ts`) — 넣는 쪽이 `setMine` 으로 갈아 끼움
* · 조합 목록 — 면적 = 일위대가 조합(UA) · 재료 = 자재 조합(UM) · 찾기 · 이 열 조합 강조 ·
* · 조합 목록 — 면적 = 일위대가 조합(이 층이 쓸 수 있는 마스터 UA · 회사 UC · 개인 UP · 27-2c) ·
* 재료 = 자재 조합(UM) · 찾기 · 이 열 조합 강조 ·
* 하나 골라 [이 열에 쓰기] = 집계표 그 열 키만 바꿈(서버 `use-combo` · 조합은 그대로)
* ========================================================================== */
@@ -19,8 +20,8 @@ import { attachCollapsible } from "@ui/ui_template_collapsible";
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 { fetchCombos } from "../M01_MasterData/M01_MasterData_UI_Combo_Api";
import { listMaterialCombos, setColumnCombo } from "./M02_MasterTemplete_Api_Fetch";
import { fetchUsableCombos } from "./M02_MasterTemplete_Labor_Api";
import { getActiveLayer, listMaterialCombos, setColumnCombo } from "./M02_MasterTemplete_Api_Fetch";
import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api";
import "./M02_MasterTemplete_ComboPanel.css";
@@ -177,7 +178,7 @@ export function buildComboPanel(opts: ComboPanelOptions): ComboPanel {
const [base, got] = await Promise.all([
readColumnBase(opts.column),
area
? fetchCombos().then((cs) =>
? fetchUsableCombos(getActiveLayer()).then((cs) =>
cs.map((c) => ({
키: c.키,
이름: c.이름,
@@ -0,0 +1,405 @@
"""M02 일위대가 조합 층 저장소(PLAN 27-2a) — 마스터 · 회사 · 개인 세 층 한 길.
층 자리 · 키
마스터 `resources/master_data/일위대가조합.json` · 키 UA(`_키대장.json`)
쓰기는 M01 조합 저장소(`M01_MasterData_Store_Combo` 의 검사 · 대장 그대로)
회사 `storage/{회사}/templates/일위대가조합.json` · 키 `UC{회사}-######`
개인 `storage/{회사}/{사용자}/templates/일위대가조합.json` · 키 `UP{사용자}-######`
회사 · 개인 파일 = 마스터와 같은 머리 · 열 한 벌 + 머리 `다음`(키 장부 · 지운 키는 다시 안 씀).
지금 층 = `store.at(층 폴더)`(`store.root()`) — 키 머리도 그 자리에서 셈 · 권한은 라우터 몫.
나무 = 폴더 [구분] · [구분, 상세구분] 깊이 2 · 머리 `폴더` = 폴더 전부(빈 폴더 포함).
"""
from __future__ import annotations
import json
import os
import re
from datetime import date
from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
from M02_MasterTemplete import M02_Template_Layers as layers
mcb = combo_store.mcb
FILE = combo_store.FILE
OWNERS = combo_store.OWNERS
MASTER = mcb.TID # UA
_HEAD = re.compile(r"^U([CP])(\d+)-")
DEPTH = 2 # 구분 · 상세구분
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 heads() -> tuple[str, ...]:
"""지금 층이 쓸 수 있는 키 머리 — 마지막이 제 층 것(마스터 UA · 회사 UC · 개인 UP)."""
root = store.root().resolve()
if root == Path(store.FOLDER).resolve():
return (MASTER,)
try:
parts = root.relative_to(layers.storage_root()).parts
except ValueError:
parts = ()
if len(parts) == 2:
return (MASTER, f"UC{parts[0]}-")
if len(parts) == 3:
return (MASTER, f"UC{parts[0]}-", f"UP{parts[1]}-")
raise store.StoreError(400, "일위대가 조합 층 자리를 모름")
def _file(head: str) -> Path | None:
"""키 머리의 조합 파일 — 개인은 사용자 id 로 찾음(회사 폴더를 모름)."""
if head.startswith(MASTER):
return Path(m01.FOLDER) / FILE
got = _HEAD.match(head)
if not got:
return None
if got[1] == "C":
return layers.company_dir(got[2]) / FILE
pattern = f"*/{got[2]}/{layers.TEMPLATES_DIRNAME}/{FILE}"
return next(layers.storage_root().glob(pattern), None)
def _rows_at(path: Path | None) -> list[dict]:
if path is None or not path.is_file():
return []
return json.loads(path.read_bytes()).get("줄") or []
def find(key: str) -> dict[str, Any] | None:
"""어느 층 조합이든 키로 한 줄 — 없으면 None."""
key = str(key or "")
return next((r for r in _rows_at(_file(key)) if str(r.get("키")) == key), None)
def where(key: str) -> tuple[str, str | None] | None:
"""키의 층 · 주인 — ("system", None) · ("company", "7") · ("personal", "7/42") · 모르면 None."""
key = str(key or "")
got, path = _HEAD.match(key), _file(key)
if key.startswith(MASTER) or path is None or not got:
return ("system", None) if key.startswith(MASTER) else None
if got[1] == "C":
return ("company", got[2])
return ("personal", f"{path.parent.parent.parent.name}/{got[2]}") # {회사}/{사용자}/templates
def names() -> dict[str, str]:
"""지금 층이 쓸 수 있는 조합 키 → 이름(마스터 · 회사 · 개인)."""
out: dict[str, str] = {}
for head in heads():
for r in _rows_at(_file(head)):
out[str(r.get("키"))] = str(r.get("이름") or "")
return out
def _load() -> tuple[dict[str, Any], str]:
"""(지금 층 조합 표, 판) — 회사 · 개인 파일이 없으면 빈 표 · 판 빈 글."""
if heads()[-1] == MASTER:
return _m01(combo_store._read)
path = store.root() / FILE
if not path.is_file():
head = {"그룹": mcb.GROUP, "판": str(date.today().year), "폴더": [], "다음": 1}
return {**head, "줄": []}, ""
raw = path.read_bytes()
return json.loads(raw), store.version_of(raw)
def rows() -> list[dict[str, Any]]:
return _load()[0].get("줄") or []
def _find(doc: dict, key: str) -> dict[str, Any]:
row = next((r for r in doc.get("줄") or [] if str(r.get("키")) == key), None)
if row is None:
raise store.StoreError(404, f"없는 조합 「{key}」")
return row
# ── 나무 폴더 ─────────────────────────────────────────────────────────
def path_of(row: dict) -> list[str]:
"""조합의 폴더 — [구분] · [구분, 상세구분] · 구분이 없으면 뿌리."""
a, b = (str(row.get(k) or "").strip() for k in ("구분", "상세구분"))
return [a, b] if a and b else [a] if a else []
def _set_path(row: dict, path: list[str]) -> None:
row["구분"] = path[0] if path else None
row["상세구분"] = path[1] if len(path) > 1 else None
def folders_of(doc: dict) -> list[list[str]]:
"""폴더 전부 — 적어 둔 것 + 조합 자리 · 부모까지."""
out: list[list[str]] = []
written = [p for p in doc.get("폴더") or [] if isinstance(p, list)]
for path in [*written, *(path_of(r) for r in doc.get("줄") or [])]:
path = [str(x) for x in path][:DEPTH]
for at in range(1, len(path) + 1):
if path[:at] not in out:
out.append(path[:at])
return out
# ── 읽기 ──────────────────────────────────────────────────────────────
def _brief(whole, row: dict) -> dict[str, Any]:
problems = mcb.check_row(whole, row)
return {
**{k: row.get(k) for k in mcb.COLS if k != "담은로직"},
"count": len(mcb.items(row)),
"blocked": bool(problems),
"reasons": problems,
}
def listing() -> dict[str, Any]:
"""지금 층 — `{판, 폴더, 줄}` · 줄 = 담은 로직 수 · 막힘 까닭(담은 로직 목록은 빼고)."""
doc, version = _load()
whole = m01.loaded()[1]
줄 = [_brief(whole, r) for r in doc.get("줄") or []]
return {"판": version, "폴더": folders_of(doc), "줄": 줄}
def one(key: str) -> dict[str, Any]:
"""조합 하나 — 담은 로직에 이름 · 단위를 붙여 줌."""
doc, version = _load()
row = _find(doc, key)
whole = m01.loaded()[1]
problems = mcb.check_row(whole, row)
brief = combo_store._brief(whole, row)
return {
"판": version,
"combo": row,
"줄": brief,
"blocked": bool(problems),
"reasons": problems,
}
def pills(key: str) -> dict[str, Any]:
return combo_store.combo_pills(_find(_load()[0], key))
def preview(combo: dict | str, inputs: dict | None = None) -> dict[str, Any]:
"""저장 안 함 — `combo` 는 조합 줄 통째 또는 지금 층 키."""
row = _find(_load()[0], combo) if isinstance(combo, str) else combo
return _m01(combo_store.combo_preview, row, inputs)
# ── 쓰기(회사 · 개인) ──────────────────────────────────────────────────
def _clean(row: Any, key: str, was: 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, "조합 줄에 「이름」 이 없거나 글자 아님")
owner = row.get("소유") or (was or {}).get("소유") or OWNERS[0]
if owner not in OWNERS:
raise store.StoreError(400, f"모르는 소유 「{owner}」 — {' · '.join(OWNERS)}")
origin = row.get("본뜬키", (was or {}).get("본뜬키"))
if origin is not None and (origin == key or find(origin) is None):
raise store.StoreError(400, f"본뜬키 「{origin}」 — 비었거나 있는 다른 조합 키여야 함")
one = {k: row.get(k) for k in mcb.COLS}
one.update({"키": key, "이름": name.strip(), "소유": owner, "본뜬키": origin})
one["출처"] = one["출처"] or "자체"
return one
def _name_errors(doc: dict, one: dict) -> list[str]:
name = one["이름"]
errors = [f"이름이 너무 김 — {mcb.MAX_이름}자까지"] if len(name) > mcb.MAX_이름 else []
if any(r.get("키") != one["키"] and str(r.get("이름", "")).strip() == name for r in doc["줄"]):
errors.append(f"「{name}」 이름의 조합이 이미 있음 — 다른 이름으로")
return errors
def _check(doc: dict, one: dict) -> None:
"""이름 길이 · 겹침 · 담은 로직(없는 키 · 두 번 · 빈 조합) — 막히면 422."""
if errors := _name_errors(doc, one) + mcb.check_row(m01.loaded()[1], one):
raise store.StoreError(422, {"errors": errors})
def _save(doc: dict, version: str) -> str:
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})
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(path, doc)
return store.version_of(path.read_bytes())
def _users(key: str) -> list[str]:
"""이 층 구조물 조합에서 이 조합을 쓰는 항목 이름."""
if heads()[-1] == MASTER:
return combo_store.combo_users(key)
return [
tree.name_of(tree.path_of(c)) or c["id"]
for c in store._structure_columns()
if c.get("일위대가") == key
]
def _refuse_used(keys: list[str]) -> None:
used = [u for key in keys for u in _users(key)]
if used:
msg = f"구조물 {len(used)} 곳이 쓰는 조합이라 못 지움 — {' · '.join(used)}"
raise store.StoreError(409, {"errors": [msg], "users": used})
def create(row: Any, version: str | None = None) -> dict[str, Any]:
"""새 조합 — 키 = 층 머리 + 「다음」 번호. `version` None = 지금 판(갈라 내기)."""
if heads()[-1] == MASTER:
owner = row.get("소유") if isinstance(row, dict) else None
done = _m01(combo_store.combo_new, row, owner or OWNERS[0])
return {"키": done["key"], "판": done["version"], "combo": done["combo"]}
doc, have = _load()
number = int(doc.get("다음") or 1)
fixed = _clean(row, f"{heads()[-1]}{number:06d}")
_check(doc, fixed)
doc = {**doc, "다음": number + 1, "줄": [*doc["줄"], fixed]}
return {
"키": fixed["키"],
"판": _save(doc, have if version is None else version),
"combo": fixed,
}
def edit(key: str, row: Any, version: str) -> dict[str, Any]:
if heads()[-1] == MASTER:
owner = row.get("소유") if isinstance(row, dict) else ""
done = _m01(combo_store.combo_edit, key, version, row, owner or "")
return {"키": key, "판": done["version"], "combo": done["combo"]}
doc, _ = _load()
fixed = _clean(row, key, _find(doc, key))
_check(doc, fixed)
doc = {**doc, "줄": [fixed if r.get("키") == key else r for r in doc["줄"]]}
return {"키": key, "판": _save(doc, version), "combo": fixed}
def delete(key: str, version: str) -> dict[str, Any]:
"""지우기 — 이 층 구조물 조합이 쓰면 409 + 항목 이름들(지우지 않음)."""
if heads()[-1] == MASTER:
return {"판": _m01(combo_store.combo_delete, key, version)["version"]}
doc, _ = _load()
_find(doc, key)
_refuse_used([key])
return {"판": _save({**doc, "줄": [r for r in doc["줄"] if r.get("키") != key]}, version)}
# ── 나무 고침 ─────────────────────────────────────────────────────────
def _moved(path: list[str], old: list[str], new: list[str]) -> list[str]:
return new + path[len(old) :] if path[: len(old)] == old else path
def _folder(folders: list[list[str]], path: list[str] | None, what: str) -> list[str]:
path = [str(x) for x in path or []]
if not path or path not in folders:
raise store.StoreError(404, f"{what} 폴더 「{' › '.join(path)}」 없음")
return path
def _fresh(name: Any) -> str:
text = str(name or "").strip()
if not text or "›" in text or len(text) > mcb.MAX_이름:
raise store.StoreError(422, "폴더 이름 — 빈 이름 · 「›」 · 너무 긴 이름은 못 씀")
return text
def _apply(doc: dict, body: dict) -> tuple[dict, list[str]]:
"""나무 고침 한 번 → (새 표, 지운 조합 키) · 조합 더하기 · 담은 로직은 `create` · `edit` 몫."""
op, key = body.get("op"), body.get("키")
folders, rows = folders_of(doc), [dict(r) for r in doc.get("줄") or []]
gone: list[str] = []
target = [str(x) for x in body.get("새경로") or []]
if op in ("move", "add-folder") and target and target not in folders:
raise store.StoreError(404, f"없는 폴더 「{' › '.join(target)}」")
if op == "add-folder":
made = [*[str(x) for x in body.get("경로") or []], _fresh(body.get("이름"))]
if len(made) > DEPTH or (made[:-1] and made[:-1] not in folders):
raise store.StoreError(422, f"폴더는 {DEPTH} 단까지 · 있는 폴더 아래에만")
if made in folders:
raise store.StoreError(409, f"이미 있는 폴더 「{' › '.join(made)}」")
folders.append(made)
elif op == "rename" and key:
_find({"줄": rows}, str(key))["이름"] = _fresh(body.get("이름"))
elif op == "move" and key:
_set_path(_find({"줄": rows}, str(key)), target)
elif op == "delete" and key:
_find({"줄": rows}, str(key))
gone = [str(key)]
elif op in ("rename", "move", "delete"):
old = _folder(folders, body.get("경로"), "고칠")
if op == "delete":
gone = [str(r["키"]) for r in rows if path_of(r)[: len(old)] == old]
folders = [f for f in folders if f[: len(old)] != old]
else:
new = [*old[:-1], _fresh(body.get("이름"))] if op == "rename" else [*target, old[-1]]
deepest = max(len(f) for f in folders if f[: len(old)] == old)
if new[: len(old)] == old or deepest - len(old) + len(new) > DEPTH:
raise store.StoreError(422, f"폴더는 {DEPTH} 단까지 · 제 안으로 못 옮김")
if new in folders:
raise store.StoreError(409, f"이미 있는 폴더 「{' › '.join(new)}」")
folders = [_moved(f, old, new) for f in folders]
for r in rows:
if path_of(r)[: len(old)] == old:
_set_path(r, _moved(path_of(r), old, new))
else:
raise store.StoreError(
422, f"모르는 나무 고침 「{op}」 — add-folder · rename · move · delete"
)
rows = [r for r in rows if str(r.get("키")) not in gone]
return {**doc, "폴더": folders, "줄": rows}, gone
def edit_tree(body: dict) -> dict[str, Any]:
"""폴더 더하기 · 이름 바꾸기(폴더 · 조합) · 옮기기(폴더 · 조합) · 지우기(폴더 = 안 조합까지 ·
조합) → 새 목록 · 쓰이는 조합이 들면 409 · 판 다르면 409 · 조합 이름 겹침 422."""
doc, have = _load()
if have != (body.get("판") or ""):
raise store.StoreError(409, {"stale": [FILE], "판": have})
new, gone = _apply(doc, body)
_refuse_used(gone)
if heads()[-1] != MASTER:
if body.get("op") == "rename" and body.get("키"):
one = _find(new, str(body["키"]))
if errors := _name_errors(new, one):
raise store.StoreError(422, {"errors": errors})
_save(new, have)
return {**listing(), "지운조합": gone}
was = {str(r["키"]): r for r in doc["줄"]}
changes = [{"op": "delete", "key": k} for k in gone] + [
{"op": "edit", "key": str(r["키"]), "row": r} for r in new["줄"] if was[str(r["키"])] != r
]
version = _m01(combo_store._write, changes, have)["version"] if changes else have
_write_master_folders(new["폴더"], version)
return {**listing(), "지운조합": gone}
def _write_master_folders(folders: list[list[str]], version: str) -> None:
"""마스터 조합 파일 머리 `폴더` — 줄은 안 건드림(줄 고침은 M01 저장 길)."""
path = Path(m01.FOLDER) / FILE
with m01._LOCK:
doc, have = m01.read(FILE)
if have != version:
raise store.StoreError(409, {"stale": [FILE], "판": have})
if doc.get("폴더") == folders:
return
head = {k: v for k, v in doc.items() if k != "줄"}
text = m01.dump({**head, "폴더": folders, "줄": doc.get("줄") or []})
temporary = path.with_name(f".{FILE}.tmp")
temporary.write_text(text, encoding="utf-8", newline="\n")
os.replace(temporary, path)
m01.cache.stale([FILE])
@@ -21,6 +21,7 @@ from typing import Any
from common_util.common_util_json import atomic_write_json
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
from M02_MasterTemplete import M02_MasterTemplete_Store as store
PLACES = ("면적", "재료", "선택", None)
@@ -52,8 +53,7 @@ def _item(key: str) -> dict[str, Any]:
def _combo_row(ua: str) -> dict[str, Any] | None:
rows = m01.read(combo_store.FILE)[0].get("줄") or []
return next((r for r in rows if str(r.get("키")) == ua), None)
return combos.find(ua) # 마스터 UA · 회사 UC · 개인 UP(27-2c)
def combo_logics(ua: str | None) -> tuple[dict[str, Any] | None, list[dict], list[str]]:
@@ -188,15 +188,15 @@ def write_links(key: str, version: Any, rows: Any) -> dict[str, Any]:
def pills(key: str, place: str) -> dict[str, Any]:
"""그 시트 알약 — 면적이면 조합 물량 알약(M01 `combo_inputs` · 권한 무관 읽기) + 자리를
"""그 시트 알약 — 면적이면 조합 물량 알약(M01 `combo_pills` · 권한 무관 읽기) + 자리를
그 시트로 이은 입력 알약 · 재료면 이은 입력 알약만. 입력 알약 = `{키, 이름, 단위, 로직, 입력}` —
이름은 로직마다 갈라 「입력 · 로직 이름」(같은 이름 입력이 여러 로직에 있어도 안 합침)."""
if place not in ("면적", "재료"):
raise store.StoreError(422, f"모르는 자리 「{place}」 — 면적 · 재료")
ua = _item(key).get("일위대가")
quantity: list[dict[str, Any]] = []
if place == "면적" and ua and _combo_row(str(ua)):
quantity = combo_store.combo_inputs(str(ua))["물량"]
if place == "면적" and ua and (combo := _combo_row(str(ua))):
quantity = combo_store.combo_pills(combo)["물량"]
_, logics, _ = combo_logics(ua)
by_key = {str(lg.get("키")): lg for lg in logics}
linked = []
+2 -12
View File
@@ -13,8 +13,7 @@ from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_MasterTemplete_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
@@ -66,23 +65,14 @@ def ensure(folder: Path) -> bool:
return True
def _combo_names() -> dict[str, str]:
"""일위대가 조합 키 → 이름(M01 시스템 마스터 · 층 무관) — 못 읽으면 빈 표."""
try:
rows = m01.read(combo_store.FILE)[0].get("줄") or []
except m01.StoreError:
return {}
return {str(r.get("키")): str(r.get("이름") or "") for r in rows}
def items(folder: Path) -> list[dict[str, Any]]:
"""그 층 항목 — SS 줄마다 `{key, 열, 경로, 이름, 종류, 분류1~3, 단위, 일위대가, 자재조합,
도번, 면적번, 재료번, names}` · 경로 = 폴더 이름 배열 · 이름 = 경로 이음(26-1 · 종류 ·
분류1~3 은 경로 앞 넷) · `names` = 화면 이름(키 대신 · 없으면 null) —
일위대가 = 조합 이름 · 자재조합 = 그 층 자재 조합 이름 ·
도번 · 면적번 · 재료번 = 항목(열) 이름."""
ua = _combo_names()
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 = []
@@ -0,0 +1,159 @@
/* =============================================================================
* M02_MasterTemplete_Labor.ts
* 좌측 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 누르면 메인 칸에 일위대가 라이브러리:
* 왼쪽 뿌리 셋(마스터 · 회사 · 개인 · `TreePanel_Roots`) 아래 조합 나무(폴더 [구분, 상세구분] 깊이 2 ·
* 구조물 라이브러리와 같은 `TreePanel`) · 오른쪽 조합 목록 · 상세(`Labor_Edit`).
* 나무 고침 = `POST /api/m02/layers/{층}/combos/tree` · 「조합 더하기」 = 그 폴더를 채운 빈 조합(저장해야 생김).
* ========================================================================== */
import { el, showConfirmDialog } from "@ui/ui_template_elements";
import { ApiError } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { StaleError } from "./M02_MasterTemplete_Api_Fetch";
import {
editComboTree,
fetchComboList,
type ComboList,
type ComboTreeEdit,
type LaborScope,
} from "./M02_MasterTemplete_Labor_Api";
import { createLaborEditor } from "./M02_MasterTemplete_Labor_Edit";
import { noteOf, toView } from "./M02_MasterTemplete_Labor_Tree";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { createTreePanel } from "./M02_MasterTemplete_TreePanel";
/** 나무 부품이 아는 막힘으로 — 판 다름 = `StaleError("HTTP 409")` · 나머지는 서버 까닭 글 */
function treeError(error: unknown): Error {
if (!(error instanceof ApiError))
return error instanceof Error ? error : new Error(String(error));
const d = error.detail as { errors?: string[]; stale?: unknown } | string | null;
const text = typeof d === "string" ? d : d?.errors?.join(" · ") || `HTTP ${String(error.status)}`;
return error.status === 409 ? new StaleError(text) : new Error(text);
}
export interface LaborLibraryOptions {
scope: LaborScope;
canEdit: boolean;
/** 뿌리 셋 — 고른 뿌리 아래에 이 나무(`panel`)를 붙인 것 */
roots: (panel: HTMLElement) => HTMLElement;
}
export interface LaborLibrary {
root: HTMLElement;
refresh: () => Promise<void>;
/** 메인 칸 「저장 안 한 고침」 손잡이 */
bind: (setDirty: (dirty: boolean) => void) => void;
}
export function buildLaborLibrary(opt: LaborLibraryOptions): LaborLibrary {
const { scope } = opt;
let setDirty: (dirty: boolean) => void = () => {};
let keys = new Set<string>(); // 지금 목록의 조합 키 — 열린 조합이 지워졌나
const took = (list: ComboList): ComboList => {
keys = new Set(list.줄.map((c) => c.키));
editor.setList(list.줄);
return list;
};
const editor = createLaborEditor({
scope,
canEdit: opt.canEdit,
onDirty: (dirty) => setDirty(dirty),
onSaved: async (key) => {
await tree.refresh();
tree.select(key);
},
});
const tree = createTreePanel({
layer: scope.layer,
owner: scope.owner,
label: tc("Title"),
canEdit: opt.canEdit,
maxDepth: 2,
item: { add: tc("AddCombo"), name: tc("ComboName"), onAdd: (path) => editor.startNew(path) },
api: {
fetch: async () => {
return toView(took(await fetchComboList(scope)));
},
edit: async (_layer, edit, 판) => {
const body = { ...edit, 판 } as ComboTreeEdit;
const got = await editComboTree(body, scope).catch((error: unknown) => {
throw treeError(error);
});
return toView(took(got), noteOf(edit, got.지운조합));
},
},
onPick: (item) => {
tree.select(item.key);
void editor.open(item.key);
},
// 나무를 고치기 전 — 저장 안 한 조합 고침은 버릴지 물음(버리면 목록으로)
settle: async () => {
if (!editor.isDirty()) return "clean";
if (!(await showConfirmDialog(tc("BackAsk")))) return false;
editor.showList();
return "changed";
},
// 나무가 바뀜 — 열린 조합은 새 판으로 다시 엶(이름 · 폴더가 바뀌었을 수 있음) · 지워졌으면 목록
onChanged: async () => {
const open = editor.current();
if (!open) return;
if (keys.has(open)) await editor.open(open);
else editor.showList();
},
});
const aside = el("aside", {
className: "m02-shell__tree",
children: [
el("h3", { className: "m02-shell__title", text: tc("Library") }),
opt.roots(tree.root),
],
});
return {
root: el("div", { className: "m02-shell m02-labor", children: [aside, editor.root] }),
refresh: () => tree.refresh(),
bind: (fn) => (setDirty = fn),
};
}
export interface LaborSectionOptions {
/** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */
show: (label: string, node: HTMLElement) => (dirty: boolean) => void;
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
confirmLeave: () => Promise<boolean>;
/** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */
onOpen: () => void;
make: () => LaborLibrary;
}
export interface LaborSection {
title: string;
body: HTMLElement;
open: () => Promise<void>;
/** 다른 화면을 열었음 — 고름 표시만 지움 */
clear: () => void;
}
export function buildLaborSection(opt: LaborSectionOptions): LaborSection {
const row = el("button", {
className: "m02-side__item ui-sidebar-row",
text: tc("Library"),
attrs: { type: "button", "data-labor-row": "" },
});
const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [row] });
let library: LaborLibrary | null = null;
let opened = false;
const mark = (on: boolean): void => {
opened = on;
row.classList.toggle("is-active", on);
};
async function open(): Promise<void> {
if (opened || !(await opt.confirmLeave())) return;
library ??= opt.make();
library.bind(opt.show(tc("Library"), library.root));
opt.onOpen();
mark(true);
await library.refresh();
}
row.addEventListener("click", () => void open());
return { title: tc("Title"), body, open, clear: () => mark(false) };
}
@@ -0,0 +1,226 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Api.ts
* 일위대가 조합 서버 길 — 층(마스터 UA · 회사 UC · 개인 UP) 조합 표 `일위대가조합.json`
* 길 = `/api/m02/layers/{layer}/combos`(PLAN 27-2a) · 키 하나 = `/api/m02/combos/key/{키}` ·
* 세 층 = `/api/m02/combos/all` · 담을 로직 읽기 = `/api/m02/logic-lib/…` · 모양 = `_화면_계약.md` 9장
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import {
ApiError,
type LogicOne,
type LogicSummary,
type SubBrief,
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
/** 담은 로직 한 줄 — 로직 키와 메모뿐(수량·계산은 마스터에 두지 않음 · 계약 9장) */
export interface ComboLogic {
로직: string;
메모: string | null;
}
/** 조합 한 줄 통째 — `_틀.md` 10장 · 키·소유는 서버 것이 이김 */
export interface Combo {
키: string;
이름: string;
구분: string | null;
상세구분: string | null;
단위: string | null;
담은로직: ComboLogic[];
출처: string | null;
소유: string | null;
/** 이 조합을 본떠 만든 원본 키 — 비면 원본(M02 가 갈라 낸 조합에 채워짐) */
본뜬키: string | null;
비고: string | null;
}
export interface ComboSummary extends Combo {
count: number;
blocked: boolean;
reasons: string[];
}
/** `GET /combo` 의 `줄` — 담은 로직에 이름을 붙인 것 */
export interface ComboLine {
차례: number;
로직: string;
메모: string | null;
ok: boolean;
까닭?: string;
이름?: string;
구분?: string;
상세구분?: string;
결과단위?: string;
}
export interface ComboOne {
file: string;
version: string;
combo: Combo;
줄: ComboLine[];
}
export interface PreviewLine {
차례: number;
로직: string;
이름: string;
ok: boolean;
까닭?: string;
갈래?: string;
결과?: number;
노무비?: number;
재료비?: number;
경비?: number;
계?: number;
}
export interface PreviewAnswer {
줄: PreviewLine[];
노무비: number;
재료비: number;
경비: number;
계: number;
멈춤: string[];
}
/** 조합 층 — 층 + 주인(회사 `7` · 개인 `7/42` · 없으면 내 것) */
export interface LaborScope {
layer: Exclude<Layer, "project">;
owner: string | null;
}
export const SYSTEM_SCOPE: LaborScope = { layer: "system", owner: null };
/** 폴더 = [구분] · [구분, 상세구분] — 깊이 2(PLAN 27-2a) */
export interface ComboList {
판: string;
폴더: string[][];
줄: ComboSummary[];
}
export interface ComboLayerList extends ComboList {
layer: LaborScope["layer"];
owner: string | null;
}
export interface ComboTreeEdit {
op: "add-folder" | "rename" | "move" | "delete";
경로?: string[];
이름?: string;
새경로?: string[];
키?: string;
판: string;
}
async function request<T>(url: string, method = "GET", body?: unknown): Promise<T> {
const response = await fetch(`${API_BASE_URL}/m02${url}`, {
method,
credentials: "include",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText);
return data;
}
const enc = encodeURIComponent;
/** 층 길 — `path` 뒤에 주인 · 더 붙일 물음 */
const at = (scope: LaborScope, path = "", more: Record<string, string> = {}): string => {
const params = new URLSearchParams(more);
if (scope.owner) params.set("owner", scope.owner);
const q = params.toString();
return `/layers/${scope.layer}/combos${path}${q ? `?${q}` : ""}`;
};
export const fetchComboList = (scope: LaborScope = SYSTEM_SCOPE): Promise<ComboList> =>
request(at(scope));
export const fetchCombos = (scope: LaborScope = SYSTEM_SCOPE): Promise<ComboSummary[]> =>
fetchComboList(scope).then((d) => d.줄);
/** 세 층 목록 — 고르기(use-combo · 갈라 내기) */
export const fetchAllCombos = (): Promise<ComboLayerList[]> => request("/combos/all");
/** 조합 고르기(PLAN 27-2c) — 이 층 항목이 쓸 수 있는 조합: 마스터 · 회사(회사 · 개인 층) · 개인(개인 층) ·
* 차례 = 마스터 → 회사 → 개인 · `layer` = 그 조합의 층 */
export async function fetchUsableCombos(
layer: Layer,
): Promise<(ComboSummary & { layer: LaborScope["layer"] })[]> {
const usable: LaborScope["layer"][] =
layer === "personal" || layer === "project"
? ["system", "company", "personal"]
: layer === "company"
? ["system", "company"]
: ["system"];
const all = await fetchAllCombos();
return all
.filter((group) => usable.includes(group.layer))
.flatMap((group) => group.줄.map((c) => ({ ...c, layer: group.layer })));
}
/** 조합 알약 물량 하나 — 키 = 「이름|단위」 · 담은 로직 결과단위 분모(같은 이름 · 단위는 합침) · `배` = 원/10㎡ 의 10 */
export interface ComboQuantity {
키: string;
이름: string;
단위: string;
로직: string[];
배?: number;
}
/** 조합 알약(어느 층이든 · 옛 M01 `/combo/{키}/inputs`) */
export const fetchComboInputs = (
key: string,
): Promise<{ 키: string; 이름: string; 물량: ComboQuantity[]; 조건: unknown[] }> =>
request(`/combos/key/${enc(key)}/inputs`);
/** 키 하나 — 어느 층이든(그 층 읽기 권한) · `version` = 그 층 조합 표 판 */
export const fetchCombo = (key: string): Promise<ComboOne & LaborScope> =>
request<Omit<ComboOne, "file" | "version"> & LaborScope & { 판: string }>(
`/combos/key/${enc(key)}`,
).then((d) => ({ ...d, file: "일위대가조합.json", version: d.판 }));
export const newCombo = (
combo: Combo,
scope: LaborScope = SYSTEM_SCOPE,
): Promise<{ version: string; key: string }> =>
request<{ 키: string; 판: string }>(at(scope), "POST", { combo }).then((d) => ({
version: d.판,
key: d.키,
}));
export const editCombo = (
key: string,
version: string,
combo: Combo,
scope: LaborScope = SYSTEM_SCOPE,
): Promise<{ version: string }> =>
request<{ 판: string }>(at(scope, `/${enc(key)}`), "PUT", { combo, 판: version }).then((d) => ({
version: d.판,
}));
export const deleteCombo = (
key: string,
version: string,
scope: LaborScope = SYSTEM_SCOPE,
): Promise<unknown> => request(at(scope, `/${enc(key)}`, { 판: version }), "DELETE");
/** 나무 고침 — 폴더 더하기 · 이름 · 옮기기(폴더 · 조합) · 지우기 → 새 목록 */
export const editComboTree = (
body: ComboTreeEdit,
scope: LaborScope = SYSTEM_SCOPE,
): Promise<ComboList & { 지운조합: string[] }> => request(at(scope, "/tree"), "POST", body);
/** 보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 */
export const previewCombo = (
combo: Combo | string,
inputs: Record<string, Record<string, unknown>>,
scope: LaborScope = SYSTEM_SCOPE,
): Promise<PreviewAnswer> => request(at(scope, "/preview"), "POST", { combo, inputs });
/* --- 담을 로직(읽기만 · 로그인) — M01 로직 길과 같은 모양 --- */
export const fetchLibLogics = (): Promise<LogicSummary[]> =>
request<{ logics: LogicSummary[] }>("/logic-lib/logics").then((d) => d.logics);
export const fetchLibLogicSubs = (): Promise<SubBrief[]> =>
request<{ subs: SubBrief[] }>("/logic-lib/subs").then((d) => d.subs);
export const fetchLibLogic = (key: string): Promise<LogicOne> =>
request(`/logic-lib/logic?key=${enc(key)}`);
export const fetchLibAuto = (key: string): Promise<Record<string, unknown>> =>
request<{ 값?: Record<string, unknown> }>(`/logic-lib/auto?key=${enc(key)}`)
.then((d) => d.값 ?? {})
.catch(() => ({}));
@@ -1,8 +1,8 @@
/* =============================================================================
* M01_MasterData_UI_Combo.ts
* 일위대가 조합 화면 — 왼쪽 컨테이너(구분 · 상세구분 거름) / 오른쪽 목록 · 상세
* 상세 = 이름·구분·단위·비고 + 담은 로직 표(차례 · 키 · 이름 · 결과 단위 · 메모 · 순서) + 미리 보기
* 저장·지우기는 서버 조합 마스터(`일위대가조합.json` · 키 UA) — 길은 `Combo_Api` 한 곳
* M02_MasterTemplete_Labor_Edit.ts
* 일위대가 라이브러리 오른쪽 — 조합 목록(찾기) / 상세(이름 · 구분 · 상세구분 · 단위 · 비고 + 담은 로직 표
* (차례 · 키 · 이름 · 결과 단위 · 메모 · 순서) + 미리 보기). 옛 M01 조합 화면을 옮겨 씀(PLAN 27-2b).
* 저장 · 지우기 = 고른 층 조합 표(마스터 UA · 회사 UC · 개인 UP · `Labor_Api`) · 읽기만 층은 단추 없음.
* ========================================================================== */
import {
@@ -12,30 +12,29 @@ import {
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { ApiError, fetchLogics, type LogicSummary } from "./M01_MasterData_UI_Logic_Api";
import { ApiError, type LogicSummary } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import {
deleteCombo,
editCombo,
fetchCombo,
fetchCombos,
fetchLibLogics,
newCombo,
type Combo,
type ComboSummary,
} from "./M01_MasterData_UI_Combo_Api";
import { openLogicPicker } from "./M01_MasterData_UI_Combo_Pick";
import { buildPreview } from "./M01_MasterData_UI_Combo_Preview";
import { tc } from "./M01_MasterData_UI_Combo_Text";
import type { SideHandle } from "./M01_MasterData_UI_Side";
import "./M01_MasterData_UI_Logic_Style.css";
import "./M01_MasterData_UI_LogicLab_Style.css";
import "./M01_MasterData_UI_Combo.css";
type LaborScope,
} from "./M02_MasterTemplete_Labor_Api";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import { buildPreview } from "./M02_MasterTemplete_Labor_Preview";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css";
import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css";
import "./M02_MasterTemplete_Labor.css";
const FILTER_ID = "조합|";
const blankCombo = (): Combo => ({
const blankCombo = (path: string[] = []): Combo => ({
키: "",
이름: "",
구분: null,
상세구분: null,
구분: path[0] ?? null,
상세구분: path[1] ?? null,
단위: null,
담은로직: [],
출처: "자체",
@@ -44,135 +43,75 @@ const blankCombo = (): Combo => ({
비고: null,
});
/** 서버가 준 까닭 — 422 는 검사 글 목록 · 409 는 낡은 판본 */
function reason(error: unknown): string {
/** 서버가 준 까닭 — 422 는 검사 글 목록 · 409 는 낡은 판본 · 쓰는 곳 */
export function reason(error: unknown): string {
if (error instanceof ApiError && typeof error.detail === "object" && error.detail) {
const d = error.detail as { errors?: string[]; stale?: string[] };
// 서버 검사 글 머리(「파일.json · 키 」)는 떼고 보임 — 새로 만들 때 키는 아직 없는 번호라 헷갈림
if (d.errors)
return d.errors.map((x) => x.replace(/^\S+\.json · (U[AM]\d{6} )?/, "")).join(" / ");
return d.errors
.map((x) => x.replace(/^(\S+\.json · )?(U[AM]\d{6} |U[CP]\d+-\d{6} )?/, ""))
.join(" / ");
if (d.stale) return tc("Stale");
}
return error instanceof Error ? error.message : tc("Load_Failed");
}
export interface ComboHandle {
/** 컨테이너를 펼칠 때 — 목록을 새로 받아 그림 */
show: () => Promise<void>;
export interface LaborEditOptions {
scope: LaborScope;
canEdit: boolean;
/** 저장 · 지움 뒤 — 나무 · 목록을 다시 받고 그 조합(`key`)을 고름 */
onSaved: (key: string | null) => Promise<void>;
/** 저장 안 한 고침이 있나 — 페이지가 떠날 때 물음 */
onDirty: (dirty: boolean) => void;
}
export function mountM01Combo(
host: HTMLElement,
side: SideHandle,
onOpenLogic: (key: string) => void,
/** 왼쪽 거름 · 찾기를 만지면 — 다른 화면이 떠 있으면 이 화면으로 */
activate: () => void = () => {},
): ComboHandle {
export interface LaborEditor {
root: HTMLElement;
/** 나무가 받은 목록 — 상세가 안 열려 있으면 목록을 다시 그림 */
setList: (items: ComboSummary[]) => void;
open: (key: string) => Promise<void>;
/** 나무 「조합 더하기」 — 그 폴더(구분 · 상세구분)를 채운 빈 조합 */
startNew: (path: string[]) => void;
showList: () => void;
isDirty: () => boolean;
/** 열린 조합 키 — 목록이면 null · 새 조합이면 빈 글 */
current: () => string | null;
}
export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
const edit = opt.canEdit;
let items: ComboSummary[] = [];
let logics = new Map<string, LogicSummary>();
let pick = { sub: "", detail: "" };
/** 화면을 바꾼 횟수 — 늦게 온 답은 이 값이 달라졌으면 버림 */
let nav = 0;
/** 열어 둔 상세 — 다른 화면에 갔다 와도 그대로 돌아오게 기억 */
/** `saved` = 연 때의 모양 — 다시 그려도 「저장 안 한 고침」 판정이 안 풀리게 */
/** `saved` = 연 때의 모양 — 「저장 안 한 고침」 판정 */
let current: { combo: Combo; version: string; saved: string } | null = null;
let logicsReady: Promise<void> = Promise.resolve();
let q = "";
const filterHost = el("div");
const view = el("div", { className: "m01-logic__editor m01-combo m02-labor__edit" });
const find = createInputField({ type: "search", placeholder: tc("Search") });
find.input.addEventListener("input", () => {
q = find.input.value.trim().toLowerCase();
activate();
if (!current) drawList();
});
side.comboHost.replaceChildren(find.root, filterHost);
const view = el("div", { className: "m01-logic__editor m01-combo" });
host.replaceChildren(view);
/** 목록만 받음 — 로직 이름표(1300여 개)는 따로 받아 목록을 붙잡지 않음 */
const load = async (redraw = true): Promise<void> => {
const my = nav;
try {
items = await fetchCombos();
} catch (error) {
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
}
drawFilter();
if (redraw && my === nav) drawList();
};
const loadLogics = (): void => {
logicsReady = fetchLogics()
.then((all) => {
logics = new Map(all.map((x) => [x.키, x]));
})
.catch((error) =>
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"),
);
};
const show = async (): Promise<void> => {
if (!logics.size) loadLogics();
if (!items.length && !current) {
view.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Loading") }));
}
await load(false);
if (!current) drawList();
await logicsReady;
if (current) draw(current.combo, current.version, current.saved);
};
/* --- 왼쪽 거름 --- */
const drawFilter = (): void => {
const subs = [...new Set(items.map((x) => x.구분).filter((v): v is string => !!v))].map(
(name) => ({
name,
book: null,
details: [...new Set(items.filter((x) => x.구분 === name).map((x) => x.상세구분))].filter(
(v): v is string => !!v,
),
}),
const logicsReady = fetchLibLogics()
.then((all) => {
logics = new Map(all.map((x) => [x.키, x]));
})
.catch((error) =>
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"),
);
filterHost.replaceChildren(
side.filter({
id: FILTER_ID,
store: "m01.filter.조합",
subs,
total: items.length,
subLabel: tc("Sub"),
detailLabel: tc("Detail"),
onPick: (sub, detail) => {
pick = { sub, detail };
activate();
drawList();
},
}),
);
};
const dirty = (): boolean => !!current && JSON.stringify(current.combo) !== current.saved;
const markDirty = (): void => opt.onDirty(dirty());
/* --- 목록 --- */
const toolbar = (...more: HTMLElement[]): HTMLElement =>
el("div", {
className: "m01-logic__section-head",
children: [
createButton({
label: tc("New"),
variant: "filled",
onClick: () => draw(blankCombo(), ""),
}),
...more,
],
});
const drawList = (): void => {
nav++;
current = null;
const shown = items.filter(
(x) =>
(!pick.sub || x.구분 === pick.sub) &&
(!pick.detail || x.상세구분 === pick.detail) &&
(!q || `${x.키} ${x.이름}`.toLowerCase().includes(q)),
);
opt.onDirty(false);
const shown = items.filter((x) => !q || `${x.키} ${x.이름}`.toLowerCase().includes(q));
const heads = [
tc("Head_Key"),
tc("Head_Name"),
@@ -200,7 +139,15 @@ export function mountM01Combo(
return tr;
});
view.replaceChildren(
toolbar(),
el("div", {
className: "m01-logic__section-head",
children: [
find.root,
...(edit
? [createButton({ label: tc("New"), variant: "filled", onClick: () => startNew([]) })]
: [el("span", { className: "m01-logic__muted", text: tc("ReadOnly") })]),
],
}),
shown.length
? el("div", {
className: "m01-master__grid-wrap",
@@ -232,19 +179,24 @@ export function mountM01Combo(
}
};
const startNew = (path: string[]): void => draw(blankCombo(path), "");
/* --- 상세 --- */
const draw = (combo: Combo, version: string, saved = JSON.stringify(combo)): void => {
nav++;
current = { combo, version, saved };
opt.onDirty(dirty());
const field = (label: string, name: "이름" | "구분" | "상세구분" | "단위" | "비고") => {
const f = createInputField({ type: "text", label });
f.input.value = combo[name] ?? "";
f.input.readOnly = !edit;
f.input.setAttribute("data-combo-field", name);
if (name === "이름") f.input.maxLength = 60; // 서버 한도(`MAX_이름`)와 같음
f.input.addEventListener("input", () => {
const v = f.input.value;
if (name === "이름") combo.이름 = v;
else combo[name] = v || null;
markDirty();
});
return f.root;
};
@@ -256,53 +208,39 @@ export function mountM01Combo(
if (j < 0 || j >= combo.담은로직.length) return;
[combo.담은로직[i], combo.담은로직[j]] = [combo.담은로직[j], combo.담은로직[i]];
drawTable();
markDirty();
};
const trs = combo.담은로직.map((row, i) => {
const info = logics.get(row.로직);
const memo = createInputField({ type: "text" });
memo.input.value = row.메모 ?? "";
memo.input.addEventListener("input", () => (row.메모 = memo.input.value || null));
const name = el("button", {
className: "ui-btn ui-btn--ghost ui-btn--sm",
text: info?.이름 ?? row.로직,
attrs: { type: "button", title: tc("Open") },
});
name.addEventListener("click", async () => {
if (JSON.stringify(combo) !== saved && !(await showConfirmDialog(tc("LeaveAsk")))) return;
onOpenLogic(row.로직);
memo.input.readOnly = !edit;
memo.input.addEventListener("input", () => {
row.메모 = memo.input.value || null;
markDirty();
});
const small = (label: string, run: () => void): HTMLElement =>
createButton({ label, variant: "ghost", size: "sm", onClick: run });
const btns = el("div", {
className: "m01-logic__chips",
children: [
createButton({
label: tc("Up"),
variant: "ghost",
size: "sm",
onClick: () => move(i, -1),
}),
createButton({
label: tc("Down"),
variant: "ghost",
size: "sm",
onClick: () => move(i, 1),
}),
createButton({
label: tc("Remove"),
variant: "ghost",
size: "sm",
onClick: () => {
combo.담은로직.splice(i, 1);
drawTable();
},
}),
],
children: edit
? [
small(tc("Up"), () => move(i, -1)),
small(tc("Down"), () => move(i, 1)),
small(tc("Remove"), () => {
combo.담은로직.splice(i, 1);
drawTable();
markDirty();
}),
]
: [],
});
return el("tr", {
attrs: { "data-combo-logic": row.로직 },
children: [
el("td", { text: String(i + 1) }),
el("td", { text: row.로직 }),
el("td", { children: [name] }),
el("td", { text: info?.이름 ?? row.로직 }),
el("td", { text: info?.결과단위 ?? "" }),
el("td", { children: [memo.root] }),
el("td", { children: [btns] }),
@@ -340,6 +278,7 @@ export function mountM01Combo(
if (!logics.has(x.키)) logics.set(x.키, x);
combo.담은로직.push({ 로직: x.키, 메모: null });
drawTable();
markDirty();
}),
});
const save = createButton({
@@ -347,19 +286,20 @@ export function mountM01Combo(
variant: "filled",
onClick: async () => {
if (!combo.이름.trim()) return showToast(tc("NeedName"), "error");
const my = nav;
save.disabled = true; // 연달아 눌러도 한 번만
try {
const key = combo.키 ? combo.키 : (await newCombo(combo)).key;
if (combo.키) await editCombo(combo.키, version, combo);
let key = combo.키;
if (key) await editCombo(key, version, combo, opt.scope);
else key = (await newCombo(combo, opt.scope)).key;
showToast(tc("Saved"), "success");
await load(false);
if (my !== nav) {
if (current?.combo === combo) current = null; // 저장 사이에 나감 — 옛 판본을 붙들지 않음
return;
}
current = null; // 저장함 — 다시 여는 동안 「고침 있음」 으로 안 물음
opt.onDirty(false);
await opt.onSaved(key);
await open(key);
} catch (error) {
showToast(reason(error), "error");
} finally {
save.disabled = false;
}
},
});
@@ -373,9 +313,11 @@ export function mountM01Combo(
)
return;
try {
await deleteCombo(combo.키, version);
await deleteCombo(combo.키, version, opt.scope);
showToast(tc("Deleted"), "success");
await load();
current = null;
await opt.onSaved(null);
drawList();
} catch (error) {
showToast(reason(error), "error");
}
@@ -385,7 +327,7 @@ export function mountM01Combo(
label: "←",
variant: "ghost",
onClick: async () => {
if (JSON.stringify(combo) !== saved && !(await showConfirmDialog(tc("BackAsk")))) return;
if (dirty() && !(await showConfirmDialog(tc("BackAsk")))) return;
drawList();
},
});
@@ -396,7 +338,11 @@ export function mountM01Combo(
view.replaceChildren(
el("div", {
className: "m01-logic__section-head",
children: [back, el("h3", { text: combo.키 || tc("New") }), remove, save],
children: [
back,
el("h3", { text: combo.키 || tc("New") }),
...(edit ? [remove, save] : []),
],
}),
el("section", {
className: "m01lab__box m01-logic__section",
@@ -419,7 +365,7 @@ export function mountM01Combo(
children: [
el("div", {
className: "m01-logic__section-head",
children: [el("h3", { text: tc("Logics_Title") }), add],
children: [el("h3", { text: tc("Logics_Title") }), ...(edit ? [add] : [])],
}),
table,
],
@@ -429,5 +375,17 @@ export function mountM01Combo(
drawTable();
};
return { show };
view.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Loading") }));
return {
root: view,
setList: (next) => {
items = next;
if (!current) drawList();
},
open,
startNew,
showList: drawList,
isDirty: dirty,
current: () => current?.combo.키 ?? null,
};
}
@@ -1,5 +1,5 @@
/* =============================================================================
* M01_MasterData_UI_Combo_Pick.ts
* M02_MasterTemplete_Labor_Pick.ts
* [로직 더하기] 창 — 구분 → 상세구분 → 찾기로 사용자가 로직을 하나씩 고름(자동 추천 없음)
* ========================================================================== */
@@ -11,15 +11,14 @@ import {
showToast,
} from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import type { LogicSummary, SubBrief } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import {
fetchLogics,
fetchLogicSubs,
type LogicSummary,
type SubBrief,
} from "./M01_MasterData_UI_Logic_Api";
import { tc } from "./M01_MasterData_UI_Combo_Text";
import "./M01_MasterData_UI_Logic_Style.css"; // 창 겉틀 — M02 에서 열 때도 이 모듈이 불러옴
import "./M01_MasterData_UI_Combo.css";
fetchLibLogics as fetchLogics,
fetchLibLogicSubs as fetchLogicSubs,
} from "./M02_MasterTemplete_Labor_Api";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀
import "./M02_MasterTemplete_Labor.css";
const opts = (values: string[]): { value: string; text: string }[] =>
[L("M01_All"), ...values].map((v, i) => ({ value: i ? v : "", text: v }));
@@ -1,14 +1,19 @@
/* =============================================================================
* M01_MasterData_UI_Combo_Preview.ts
* 조합 미리 보기 — 담은 로직 저마다 받은 입력값을 서버 미리 보기(`POST /combo/preview`)에 실어 비목별 합계를 받음
* M02_MasterTemplete_Labor_Preview.ts
* 조합 미리 보기 — 담은 로직 저마다 받은 입력값을 서버 미리 보기(`POST …/combos/preview`)에 실어 비목별 합계를 받음
* 입력값(수량)은 이 화면 안에만 두고 조합에 저장하지 않음(계약 9장)
* ========================================================================== */
import { createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { fetchAuto, fetchLogic, type LogicInput } from "./M01_MasterData_UI_Logic_Api";
import { previewCombo, type Combo } from "./M01_MasterData_UI_Combo_Api";
import { tc, type ComboTextKey } from "./M01_MasterData_UI_Combo_Text";
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
import type { LogicInput } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
import {
fetchLibAuto as fetchAuto,
fetchLibLogic as fetchLogic,
previewCombo,
type Combo,
} from "./M02_MasterTemplete_Labor_Api";
import { tc, type ComboTextKey } from "./M02_MasterTemplete_Labor_Text";
const COSTS = ["노무비", "재료비", "경비"] as const;
const LABEL: Record<(typeof COSTS)[number], ComboTextKey> = {
@@ -1,5 +1,5 @@
/* =============================================================================
* M01_MasterData_UI_Combo_Text.ts
* M02_MasterTemplete_Labor_Text.ts
* 일위대가 조합 화면 글자 — [한국어, 영어]
* ========================================================================== */
@@ -7,6 +7,10 @@ import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Title: ["일위대가 조합", "Unit-price combination"],
Library: ["일위대가 라이브러리", "Unit-price library"],
AddCombo: ["조합 더하기", "Add combination"],
ComboName: ["조합 이름", "Combination name"],
ReadOnly: ["읽기만 — 이 층은 고칠 수 없음", "Read only — this layer cannot be edited"],
Sub: ["구분", "Category"],
Detail: ["상세구분", "Detail"],
All: ["전체", "All"],
@@ -0,0 +1,45 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Tree.ts
* 일위대가 라이브러리 나무 자료(화면 없음) — 조합 목록 → 나무 부품(`TreePanel`) 모양 · 나무 고침 뒤 알림.
* 조합 폴더 = [구분] · [구분, 상세구분](깊이 2 · PLAN 27-2a) · 항목 = 조합(열 = 키).
* ========================================================================== */
import type { TreeEdit, TreeNote, TreeView } from "./M02_MasterTemplete_Api_Fetch";
import type { ComboList, ComboSummary } from "./M02_MasterTemplete_Labor_Api";
/** 조합의 폴더 — [구분] · [구분, 상세구분] · 구분 없으면 맨 위 */
export const pathOf = (c: ComboSummary): string[] =>
c.구분 ? (c.상세구분 ? [c.구분, c.상세구분] : [c.구분]) : [];
/** 조합 목록 → 나무 부품 모양(항목 = 조합 · 열 = 키) */
export const toView = (list: ComboList, note?: TreeNote): TreeView => ({
판: list.판,
폴더: list.폴더,
items: list.줄.map((c) => ({
key: c.키,
열: c.키,
경로: [...pathOf(c), c.이름],
이름: c.이름,
단위: c.단위,
})),
...(note ? { 알림: note } : {}),
});
/** 나무 고침 뒤 알림 — 새 · 바뀐 폴더를 펼치고 고친 조합은 고른 채 */
export function noteOf(edit: TreeEdit, gone: string[]): TreeNote {
const at = edit.경로 ?? [];
const last = at[at.length - 1] ?? "";
if (edit.키 && edit.op !== "delete") {
const where = edit.op === "move" ? (edit.새경로 ?? []) : at;
return { op: edit.op, 열: edit.키, 경로: [...where, edit.이름 ?? ""], 지운항목: gone };
}
const path =
edit.op === "add-folder"
? [...at, edit.이름 ?? ""]
: edit.op === "rename"
? [...at.slice(0, -1), edit.이름 ?? ""]
: edit.op === "move"
? [...(edit.새경로 ?? []), last]
: undefined;
return { op: edit.op, ...(path ? { 경로: path } : {}), 지운항목: gone };
}
@@ -0,0 +1,188 @@
"""M02 일위대가 조합 층 API(PLAN 27-2a) — `/api/m02/layers/{layer}/combos`(옛 `/api/m01/combo*`).
⚠ 등록은 `main.py`(로그인만) · 층 권한은 `M02_Layer_Access`(`Router_Items._root`) 한 규칙:
system 읽기 = 로그인 누구나 · 쓰기 = 시스템 관리자(마스터 조합 · 키 UA)
company 읽기 = 같은 회사 · 쓰기 = 회사 관리자(키 UC{회사}-######)
personal 읽기 · 쓰기 = 본인(키 UP{사용자}-######)
저장소 = `M02_MasterTemplete_Combo_Store`.
담을 로직 고르기 · 미리 보기가 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은
`/api/m02/logic-lib/…` 읽기만(로그인) — 모양은 M01 길과 같음.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Combos"])
class ComboBody(BaseModel):
combo: dict[str, Any]
판: str | None = None # 새로 만들 때 빈 글 = 지금 판 · 고칠 때 어긋나면 409
class ComboTreeBody(BaseModel):
"""`{op: add-folder|rename|move|delete, 경로?, 이름?, 새경로?, 키?, 판}` — 폴더 깊이 2."""
op: str
경로: list[str] | None = None
이름: str | None = None
새경로: list[str] | None = None
키: str | None = None
판: str
class PreviewBody(BaseModel):
"""저장 전 미리 보기 — `combo` 는 조합 줄 통째 또는 그 층 키 · `inputs` 는 로직마다."""
combo: dict[str, Any] | str
inputs: dict[str, dict[str, Any]] = {}
@router.get("/combos/all")
def get_all(session: Session = Depends(verify_session)) -> list[dict]:
"""고르기(use-combo · 갈라 내기) — 세 층 `[{layer, owner, 판, 폴더, 줄}]` · 회사 없음 빈 층."""
out = []
for layer in ("system", "company", "personal"):
who = None if layer == "system" else access.own_owner(session, layer)
if layer != "system" and who is None:
out.append({"layer": layer, "owner": None, "판": "", "폴더": [], "줄": []})
continue
got = _call(_root(session, layer, who), combos.listing)
out.append({"layer": layer, "owner": who, **got})
return out
def _where(key: str) -> tuple[str, str | None]:
got = combos.where(key)
if got is None:
raise HTTPException(status_code=404, detail=f"없는 조합 「{key}」")
return got
@router.get("/combos/key/{key}")
def get_by_key(key: str, session: Session = Depends(verify_session)) -> dict:
"""키 하나(어느 층이든 · 그 층 읽기 권한) — `{layer, owner, 판, combo, 줄, …}`."""
layer, owner = _where(key)
return {"layer": layer, "owner": owner, **_call(_root(session, layer, owner), combos.one, key)}
@router.get("/combos/key/{key}/inputs")
def get_inputs_by_key(key: str, session: Session = Depends(verify_session)) -> dict:
"""조합 알약(어느 층이든) — 구조물 조합 면적산출 연결이 부름."""
layer, owner = _where(key)
return _call(_root(session, layer, owner), combos.pills, key)
@router.get("/layers/{layer}/combos")
def get_combos(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)):
return _call(_root(session, layer, owner), combos.listing)
@router.get("/layers/{layer}/combos/{key}")
def get_combo(
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
):
return _call(_root(session, layer, owner), combos.one, key)
@router.get("/layers/{layer}/combos/{key}/inputs")
def get_combo_inputs(
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
):
"""조합 알약 — 담은 로직 결과단위 분모마다 물량 하나(M02 면적산출이 받음)."""
return _call(_root(session, layer, owner), combos.pills, key)
@router.post("/layers/{layer}/combos")
def post_combo(
layer: Layer,
body: ComboBody,
owner: str | None = None,
session: Session = Depends(verify_session),
):
folder = _root(session, layer, owner, write=True)
return _call(folder, combos.create, body.combo, body.판 or None)
@router.put("/layers/{layer}/combos/{key}")
def put_combo(
layer: Layer,
key: str,
body: ComboBody,
owner: str | None = None,
session: Session = Depends(verify_session),
):
folder = _root(session, layer, owner, write=True)
return _call(folder, combos.edit, key, body.combo, body.판 or "")
@router.delete("/layers/{layer}/combos/{key}")
def delete_combo(
layer: Layer,
key: str,
판: str = "",
owner: str | None = None,
session: Session = Depends(verify_session),
):
return _call(_root(session, layer, owner, write=True), combos.delete, key, 판)
@router.post("/layers/{layer}/combos/tree")
def post_tree(
layer: Layer,
body: ComboTreeBody,
owner: str | None = None,
session: Session = Depends(verify_session),
):
folder = _root(session, layer, owner, write=True)
return _call(folder, combos.edit_tree, body.model_dump())
@router.post("/layers/{layer}/combos/preview")
def post_preview(
layer: Layer,
body: PreviewBody,
owner: str | None = None,
session: Session = Depends(verify_session),
):
"""보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 · 읽기 권한."""
return _call(_root(session, layer, owner), combos.preview, body.combo, body.inputs)
# ── 담을 로직(읽기만) — M01 `/logics` · `/subs?group=로직` · `/logic` · `/logic/auto` 모양 ──
def _read(fn, *args):
try:
return fn(*args)
except m01.StoreError as e:
raise HTTPException(status_code=e.status, detail=e.detail) from e
@router.get("/logic-lib/logics", dependencies=[Depends(verify_session)])
def get_logics() -> dict:
return {"logics": _read(m01.logics, "", "", "", None)}
@router.get("/logic-lib/subs", dependencies=[Depends(verify_session)])
def get_logic_subs() -> dict:
return _read(m01.subs, "", "로직")
@router.get("/logic-lib/logic", dependencies=[Depends(verify_session)])
def get_logic(key: str) -> dict:
return _read(m01.logic, key)
@router.get("/logic-lib/auto", dependencies=[Depends(verify_session)])
def get_logic_auto(key: str) -> dict:
"""견본 입력 — 미리 보기용 자동값."""
return _read(m01.auto, key)
@@ -4,8 +4,8 @@
PLAN 27-1c) · `owner` = 회사 `{C}` · 개인 `{C}/{U}`(없으면 자기 층) · 회사 없음 = 409.
옛 길(`/api/m02/structures/…` 등 · `M02_MasterTemplete_Router.py`)은 시스템 층 별칭으로 그대로.
회사 · 개인 층은 처음 부를 때 빈 뼈대를 만듦(`Items.ensure`).
결정(PLAN 21-9): 일위대가(UA) 갈라 내기는 새 UA 가 M01 시스템 마스터에 생기므로 층 길에서도
SYSTEM_ADMIN 만 · 첫 진입 뒤 구조물집계표가 이미 있어 같은 이름 새로 쓰기(판 없이)는 409 그대로.
일위대가 갈라 내기(PLAN 27-2c): 새 조합은 그 층에(마스터 UA · 회사 UC · 개인 UP) · 층 쓰기 권한 ·
첫 진입 뒤 구조물집계표가 이미 있어 같은 이름 새로 쓰기(판 없이)는 409 그대로.
"""
from __future__ import annotations
@@ -240,10 +240,8 @@ def post_combo(
owner: str | None = None,
session: Session = Depends(verify_session),
):
"""일위대가 조합 갈라 내기 — 새 UA 는 M01 시스템 마스터에 생기므로 SYSTEM_ADMIN 만."""
"""일위대가 조합 갈라 내기 — 새 조합은 이 층 조합 표에(`Combo_Store`)."""
folder = _root(session, layer, owner, write=True)
if not access.is_system_admin(session):
raise HTTPException(status_code=403, detail="일위대가 조합은 시스템 관리자만 새로 만듦.")
return _call(
folder, branch.branch_combo, column, body.본뜬키, body.담은로직, body.이름, body.판
)
@@ -23,8 +23,8 @@ import {
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
import { fetchComboInputs, searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch";
import { type ComboSummary, fetchCombos } from "../M01_MasterData/M01_MasterData_UI_Combo_Api";
import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch";
import { fetchComboInputs, fetchUsableCombos } from "./M02_MasterTemplete_Labor_Api";
import {
copyArea,
copyMaterial,
@@ -86,13 +86,9 @@ const fill = (text: string, value: string): string => text.replace("{value}", va
/** `{열 id: {도번, 면적번, 재료번}}` — 집계표 번호 줄(없는 열은 키 없음 = 「없음」) */
export type ColumnNumbers = Record<string, StructureNumbers>;
/** 일위대가 목록 — 처음 한 번만 받음(못 받으면 다음에 다시) */
let combos: Promise<ComboSummary[]> | null = null;
const loadCombos = (): Promise<ComboSummary[]> =>
(combos ??= fetchCombos().catch((error: unknown) => {
combos = null;
throw error;
}));
/** 일위대가 목록 — 이 층이 쓸 수 있는 세 층 조합(27-2c) · 고를 때마다 받음(층을 바꿔 다시 들어와도 맞게) */
const loadCombos = () => fetchUsableCombos(getActiveLayer());
const LAYER_WORD = { system: "마스터", company: "회사", personal: "개인" } as const;
interface Choice {
value: string;
@@ -309,7 +305,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
};
const pickPrice = async (colId: string, columns: StructureColumn[]): Promise<string | null> => {
let list: ComboSummary[];
let list: Awaited<ReturnType<typeof loadCombos>>;
try {
list = await loadCombos();
} catch (error) {
@@ -321,7 +317,12 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
value: c.키,
key: c.키,
// 이름에 단위가 이미 붙은 조합이 있음 — 겹치면 뺌
label: [c.이름, c.단위 && !c.이름.includes(`(${c.단위})`) && `(${c.단위})`, c.구분]
label: [
c.이름,
c.단위 && !c.이름.includes(`(${c.단위})`) && `(${c.단위})`,
c.구분,
`· ${LAYER_WORD[c.layer]}`,
]
.filter(Boolean)
.join(" "),
}));
@@ -56,6 +56,10 @@ export interface TreePanelOptions {
};
/** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */
canEdit: boolean;
/** 폴더 깊이 한도 — 닿은 폴더엔 「폴더 더하기」 없음(일위대가 라이브러리 = 2) */
maxDepth?: number;
/** 항목 부르는 말 · 「항목 더하기」 대신 부르는 쪽이 새 항목 화면을 엶(일위대가 조합) */
item?: { add: string; name: string; onAdd?: (path: string[]) => void };
/** 항목을 누름 — 표에서 그 열을 고름 */
onPick: (item: TreeItem) => void;
/** 고치기 전에 저장 안 한 표 고침을 처리 — 취소면 false · 저장했으면 "changed" */
@@ -106,8 +110,13 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
return [
...(target.kind === "folder"
? [
{ label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) },
{ label: L("M02_TreeAddItem"), run: () => void addItem(target.path) },
...(opt.maxDepth !== undefined && target.path.length >= opt.maxDepth
? []
: [{ label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) }]),
{
label: opt.item?.add ?? L("M02_TreeAddItem"),
run: () => void addItem(target.path),
},
]
: []),
...(isTop
@@ -272,6 +281,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
async function addItem(parent: string[]): Promise<void> {
if (!(await ready())) return;
if (opt.item?.onAdd) return opt.item.onAdd(parent);
const got = await askText(L("M02_TreeAddItem"), [
{ key: "name", label: L("M02_TreeItemName") },
{ key: "unit", label: L("M02_TreeUnit") },
@@ -291,7 +301,10 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
const got = await askText(L("M02_TreeRename"), [
{
key: "name",
label: L(target.kind === "item" ? "M02_TreeItemName" : "M02_TreeFolderName"),
label:
target.kind === "item"
? (opt.item?.name ?? L("M02_TreeItemName"))
: L("M02_TreeFolderName"),
value: nameOf(target),
},
]);
@@ -23,8 +23,10 @@ import {
entryQuery,
layerOfRoot,
ownOwner,
type OpenTarget,
type Root,
} from "./M02_MasterTemplete_Layer";
import { buildLaborLibrary, buildLaborSection } from "./M02_MasterTemplete_Labor";
import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
import { createTreePanel } from "./M02_MasterTemplete_TreePanel";
import { createLibraryRoots } from "./M02_MasterTemplete_TreePanel_Roots";
@@ -55,11 +57,26 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
settle: () => main.settle(),
onChanged: (colId) => main.openTable(layer, colId, true),
});
const go = async (next: Root, nextOwner: string | null): Promise<void> => {
const go = async (
next: Root,
nextOwner: string | null,
open: OpenTarget = "구조물조합",
): Promise<void> => {
// 같은 주소 안 이동은 머리 가드가 안 물음 — 저장 안 한 고침은 여기서
if (!(await main.confirmLeave())) return;
navigateTo(ROUTES.M02_MASTER_TEMPLATE, entryQuery({ ...entry, root: next, owner: nextOwner }));
navigateTo(
ROUTES.M02_MASTER_TEMPLATE,
entryQuery({ ...entry, open, root: next, owner: nextOwner }),
);
};
// 일위대가 라이브러리(PLAN 27-2b) — 같은 뿌리 셋 · 다른 뿌리를 누르면 그 뿌리의 일위대가로 다시 엶
const laborRoots = (panel: HTMLElement): HTMLElement =>
createLibraryRoots({
user,
active: { root: entry.root, owner },
panel,
go: (next, nextOwner) => void go(next, nextOwner, "일위대가"),
});
const roots = createLibraryRoots({
user,
active: { root: entry.root, owner },
@@ -97,6 +114,14 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
getOpen: main.current,
confirmLeave: main.confirmLeave,
showView: main.showView,
labor: ({ onOpen }) =>
buildLaborSection({
show: main.showView,
confirmLeave: main.confirmLeave,
onOpen,
make: () =>
buildLaborLibrary({ scope: { layer, owner }, canEdit: isAdmin, roots: laborRoots }),
}),
});
const layout = el("div", { className: "ui-workflow-layout m02-master" });
@@ -116,9 +141,9 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
root.innerHTML = "";
root.append(layout);
void main.open(home);
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만(일위대가는 27-2 컨테이너가 생기면) ·
// 옵션 화면이 뒤에 열리면 편집 칸 차례(`seq`)가 늦게 온 집계표를 버림
if (entry.open === "옵션" || entry.open === "도면") side.reveal(entry.open);
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만 · 일위대가는 세 뿌리 ·
// 옵션 · 일위대가 화면이 뒤에 열리면 편집 칸 차례(`seq`)가 늦게 온 집계표를 버림
if (entry.open !== "구조물조합") side.reveal(entry.open);
void tree.refresh();
// 이 화면을 떠나면 층 길을 시스템 · 자기 층으로 되돌림(다른 화면의 항목 고르기가 남의 층을 안 부르게)
return { covers: () => false, show: () => {}, dispose: () => setActiveLayer("system") };
@@ -39,6 +39,7 @@ import {
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";
@@ -73,6 +74,8 @@ export interface SideOptions {
confirmLeave: () => Promise<boolean>;
/** 메인 칸에 양식 밖 화면(자재 조합 상세)을 띄움 */
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
/** 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 페이지가 만듦(뿌리 셋이 페이지 것) */
labor?: (hooks: { onOpen: () => void }) => LaborSection;
}
export interface SideHandle {
@@ -81,7 +84,7 @@ export interface SideHandle {
/** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */
paint: () => void;
/** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 시스템 층에만 있음 */
reveal: (target: "옵션" | "도면") => void;
reveal: (target: "옵션" | "도면" | "일위대가") => void;
}
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
@@ -131,7 +134,7 @@ export function buildSide(opt: SideOptions): SideHandle {
confirmLeave: opt.confirmLeave,
show: opt.showView,
close: () => opt.onOpen(null),
onOpen: () => (options.clear(), paint()),
onOpen: () => (options.clear(), labor?.clear(), paint()),
});
const matsetSection = section(matset.title, matset.body, ...matset.actions);
// 펼칠 때마다 새로 받음 — 재료산출 옆 판이 갈라 낸 조합도 바로 보이게
@@ -143,8 +146,9 @@ export function buildSide(opt: SideOptions): SideHandle {
isAdmin: canEdit(layer, opt.isAdmin),
confirmLeave: opt.confirmLeave,
show: opt.showView,
onOpen: () => (matset.clear(), paint()),
onOpen: () => (matset.clear(), labor?.clear(), paint()),
});
const labor = opt.labor?.({ onOpen: () => (matset.clear(), options.clear(), paint()) });
// 구조물 집계 양식 — 집계표 줄 아래 하위 컨테이너 넷(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음)
const summary = section(
L("M02_KindTable"),
@@ -164,7 +168,11 @@ export function buildSide(opt: SideOptions): SideHandle {
: null;
const listHost = el("div", {
className: "m02-side__lists ui-sidebar-panel",
children: [summary, ...(extras ? [extras.옵션, extras.도면] : [])],
children: [
...(labor ? [section(labor.title, labor.body)] : []),
summary,
...(extras ? [extras.옵션, extras.도면] : []),
],
});
attachCollapsible(listHost);
@@ -240,6 +248,7 @@ export function buildSide(opt: SideOptions): SideHandle {
paintButtons();
if (opt.getOpen()) matset.clear();
if (opt.getOpen()) options.clear();
if (opt.getOpen()) labor?.clear();
}
// 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음)
@@ -363,7 +372,11 @@ export function buildSide(opt: SideOptions): SideHandle {
}
}
const reveal = (target: "옵션" | "도면"): void => {
const reveal = (target: "옵션" | "도면" | "일위대가"): void => {
if (target === "일위대가") {
labor?.body.closest("section")?.classList.remove("is-collapsed");
return void labor?.open();
}
extras?.[target].classList.remove("is-collapsed");
if (target === "옵션") options.body.querySelector<HTMLElement>("button")?.click();
};
+2
View File
@@ -62,6 +62,7 @@ from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router
from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router
from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router
from M02_MasterTemplete.M02_MasterTemplete_Router_Combos import router as m02_combos_router
from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import router as m02_drawing_router
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import router as m02_items_router
from M02_MasterTemplete.M02_MasterTemplete_Router_Layers import router as m02_layers_router
@@ -641,6 +642,7 @@ app.include_router(m01_master_data_router, dependencies=system_admin_only)
app.include_router(m02_master_template_router, dependencies=system_admin_only)
app.include_router(m02_layers_router, dependencies=protected)
app.include_router(m02_items_router, dependencies=protected)
app.include_router(m02_combos_router, dependencies=protected)
app.include_router(m02_drawing_router, dependencies=protected)
# 프로젝트 집계 항목 복사본(PLAN 25-2b) — 로그인만 · 프로젝트 권한은 길 안에서.
app.include_router(m02_project_items_router, dependencies=protected)
+63 -47
View File
@@ -1,4 +1,6 @@
"""M01 일위대가 조합(PLAN 4-2) — 담은 단가산출 로직 목록만 두고 미리 보기로 비목별 합계.
"""일위대가 조합(PLAN 4-2 · 27-2a) — 담은 단가산출 로직 목록만 두고 미리 보기로 비목별 합계.
길 = M02 층 길 마스터(`/api/m02/layers/system/combos` · 옛 `/api/m01/combo*` 를 옮김).
계약 `resources/master_data/_화면_계약.md` 9장 · 틀 `_틀.md` 10장 · 엔진 `scripts/master_combo.py`.
못박는 것
@@ -20,8 +22,10 @@ import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import verify_session
from M01_MasterData import M01_MasterData_Router as router_module
from M01_MasterData import M01_MasterData_Store as store
from M02_MasterTemplete.M02_MasterTemplete_Router_Combos import router as combos_router
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "master_data/scripts"))
@@ -32,6 +36,8 @@ STACK = "GF000219" # 13-4-1 돌쌓기 메쌓기(인력)
LAYER = "GF000220" # 13-4-2 돌쌓기 메쌓기(장비)
GATHER = "GF000211" # 13-2-1 모래·자갈·약돌 채집(인력)
SAMPLE = (STACK, LAYER, GATHER) # 견본 조합 — 로직 셋
SYS = "/api/m02/layers/system/combos" # 마스터 층 조합 길
ADMIN = {"user_id": 1, "company_id": 1, "role": "SYSTEM_ADMIN", "is_master": False}
@pytest.fixture
@@ -46,6 +52,8 @@ def client(tmp_path: Path, monkeypatch) -> TestClient:
monkeypatch.setattr(store, "FOLDER", tmp_path)
app = FastAPI()
app.include_router(router_module.router)
app.include_router(combos_router)
app.dependency_overrides[verify_session] = lambda: ADMIN
return TestClient(app)
@@ -61,6 +69,28 @@ def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict:
return res.json()
def _new(client: TestClient, body: dict, want: int = 200) -> dict:
"""옛 `/combo/new` 본문 `{combo, owner?}` → 층 길(소유는 줄 칸) · 옛 이름 `key` 도 붙임."""
combo = {**body["combo"], **({"소유": body["owner"]} if "owner" in body else {})}
got = _post(client, SYS, {"combo": combo}, want)
return {"key": got.get("키"), **got}
def _one(client: TestClient, key: str) -> dict:
got = _get(client, f"{SYS}/{key}")
return {"version": got["판"], **got}
def _list(client: TestClient) -> list[dict]:
return _get(client, SYS)["줄"]
def _edit(client: TestClient, key: str, version: str, row: dict, want: int = 200) -> dict:
res = client.put(f"{SYS}/{key}", json={"combo": row, "판": version})
assert res.status_code == want, res.text
return res.json()
def _inputs(key: str) -> dict:
"""그 로직의 일괄 시험 입력 한 벌(`check_master.시험입력`)."""
row = store.cm.master(REAL).logic(key)
@@ -81,7 +111,7 @@ def _make(client: TestClient, keys=SAMPLE, 이름="돌쌓기 한 벌", owner: st
"단위": "㎡",
"담은로직": [{"로직": k, "메모": None} for k in keys],
}
return _post(client, "/api/m01/combo/new", {"combo": body, "owner": owner})
return _new(client, {"combo": body, "owner": owner})
# ── 정본 파일 ─────────────────────────────────────────────────────────
@@ -119,7 +149,7 @@ def test_조합을_만들면_키를_서버가_주고_담은_로직에_이름이_
assert tuple(made["combo"]) == mcb.COLS # 열 한 벌 · 차례까지
assert "원문번호" not in made["combo"]
got = _get(client, "/api/m01/combo", key=key)
got = _one(client, key)
assert [one["로직"] for one in got["줄"]] == list(SAMPLE) # 담은 차례 그대로
assert got["줄"][0]["이름"] == "돌쌓기 메쌓기(인력)"
assert got["줄"][0]["결과단위"] == "원/㎡"
@@ -128,13 +158,16 @@ def test_조합을_만들면_키를_서버가_주고_담은_로직에_이름이_
assert set(got["combo"]["담은로직"][0]) == {"로직", "메모"}
def test_목록은_구분_소유_찾기로_거른다(client: TestClient) -> None:
def test_목록은_소유_담은_수_폴더를_준다(client: TestClient) -> None:
"""거르기(구분 · 소유 · 찾기)는 화면 몫 — 층 목록은 통째 · 폴더 = [구분, 상세구분]."""
_make(client, 이름="돌쌓기 한 벌")
_make(client, keys=(GATHER,), 이름="채집만", owner="공용")
assert len(_get(client, "/api/m01/combos")["combos"]) == 2
assert [c["이름"] for c in _get(client, "/api/m01/combos", q="채집")["combos"]] == ["채집만"]
only = _get(client, "/api/m01/combos", owner="공용")["combos"]
got = _get(client, SYS)
assert len(got["줄"]) == 2 and got["판"]
only = [c for c in got["줄"] if c["소유"] == "공용"]
assert [c["이름"] for c in only] == ["채집만"] and only[0]["count"] == 1
assert "담은로직" not in only[0]
assert got["폴더"] == [["자체"], ["자체", "13장 구조물"]]
def test_이_로직을_쓰는_조합을_거꾸로_본다(client: TestClient) -> None:
@@ -149,38 +182,30 @@ def test_이_로직을_쓰는_조합을_거꾸로_본다(client: TestClient) ->
# ── 고치기 · 지우기 ───────────────────────────────────────────────────
def test_로직을_빼고_더하고_지울_수_있다(client: TestClient) -> None:
key = _make(client)["key"]
got = _get(client, "/api/m01/combo", key=key)
got = _one(client, key)
row = got["combo"]
row["담은로직"] = [{"로직": LAYER, "메모": "장비만 남김"}]
done = _post(
client, "/api/m01/combo/edit", {"key": key, "version": got["version"], "combo": row}
)
done = _edit(client, key, got["version"], row)
assert [one["로직"] for one in done["combo"]["담은로직"]] == [LAYER]
assert done["combo"]["담은로직"][0]["메모"] == "장비만 남김"
assert done["combo"]["키"] == key # 키는 안 바뀜
now = _get(client, "/api/m01/combo", key=key)
_post(client, "/api/m01/combo/delete", {"key": key, "version": now["version"]})
assert _get(client, "/api/m01/combos")["combos"] == []
now = _one(client, key)
assert client.delete(f"{SYS}/{key}", params={"판": now["version"]}).status_code == 200
assert _list(client) == []
def test_낡은_판본으로_고치면_409(client: TestClient) -> None:
key = _make(client)["key"]
row = _get(client, "/api/m01/combo", key=key)["combo"]
got = _post(
client,
"/api/m01/combo/edit",
{"key": key, "version": "낡은판본", "combo": row},
want=409,
)
row = _one(client, key)["combo"]
got = _edit(client, key, "낡은판본", row, want=409)
assert got["detail"] == {"stale": [mcb.FILE]}
# ── 검사 ──────────────────────────────────────────────────────────────
def test_없는_로직_키는_저장을_막는다(client: TestClient) -> None:
got = _post(
got = _new(
client,
"/api/m01/combo/new",
{"combo": {"이름": "엉터리", "담은로직": [{"로직": "GF999999", "메모": None}]}},
want=422,
)
@@ -188,9 +213,8 @@ def test_없는_로직_키는_저장을_막는다(client: TestClient) -> None:
def test_같은_로직을_두_번_담으면_막는다(client: TestClient) -> None:
got = _post(
got = _new(
client,
"/api/m01/combo/new",
{"combo": {"이름": "두 번", "담은로직": [{"로직": STACK}, {"로직": STACK}]}},
want=422,
)
@@ -198,15 +222,14 @@ def test_같은_로직을_두_번_담으면_막는다(client: TestClient) -> Non
def test_빈_조합은_막는다(client: TestClient) -> None:
got = _post(client, "/api/m01/combo/new", {"combo": {"이름": "빈 것", "담은로직": []}}, 422)
got = _new(client, {"combo": {"이름": "빈 것", "담은로직": []}}, 422)
assert any("빈 조합" in x for x in got["detail"]["errors"]), got
def test_조합은_조합을_담지_못한다_1단만(client: TestClient) -> None:
key = _make(client)["key"]
got = _post(
got = _new(
client,
"/api/m01/combo/new",
{"combo": {"이름": "겹친 것", "담은로직": [{"로직": key}]}},
want=422,
)
@@ -214,10 +237,9 @@ def test_조합은_조합을_담지_못한다_1단만(client: TestClient) -> Non
def test_이름_없는_조합과_모르는_소유는_400(client: TestClient) -> None:
_post(client, "/api/m01/combo/new", {"combo": {"담은로직": [{"로직": STACK}]}}, want=400)
_post(
_new(client, {"combo": {"담은로직": [{"로직": STACK}]}}, want=400)
_new(
client,
"/api/m01/combo/new",
{"combo": {"이름": "가", "담은로직": [{"로직": STACK}]}, "owner": "아무개"},
want=400,
)
@@ -225,18 +247,16 @@ def test_이름_없는_조합과_모르는_소유는_400(client: TestClient) ->
def test_이름이_글자_아니면_400(client: TestClient) -> None:
"""재현: 이름 ["x"] 가 200 으로 저장됨(일감 24 어긋난 것 2) — 이제 400."""
_post(
_new(
client,
"/api/m01/combo/new",
{"combo": {"이름": ["x"], "담은로직": [{"로직": STACK}]}},
want=400,
)
def test_이름이_너무_길면_막는다(client: TestClient) -> None:
got = _post(
got = _new(
client,
"/api/m01/combo/new",
{"combo": {"이름": "가" * 61, "담은로직": [{"로직": STACK}]}},
want=422,
)
@@ -245,9 +265,8 @@ def test_이름이_너무_길면_막는다(client: TestClient) -> None:
def test_같은_이름_두_번_담으면_막는다(client: TestClient) -> None:
_make(client, keys=(STACK,), 이름="돌쌓기 한 벌")
got = _post(
got = _new(
client,
"/api/m01/combo/new",
{"combo": {"이름": "돌쌓기 한 벌", "담은로직": [{"로직": LAYER}]}},
want=422,
)
@@ -256,9 +275,8 @@ def test_같은_이름_두_번_담으면_막는다(client: TestClient) -> None:
def test_메모가_글자_아니면_막는다(client: TestClient) -> None:
"""재현: 메모 {"수량": 3} 이 200 으로 저장됨(일감 24 어긋난 것 1) — 이제 422."""
got = _post(
got = _new(
client,
"/api/m01/combo/new",
{"combo": {"이름": "메모 흠", "담은로직": [{"로직": STACK, "메모": {"수량": 3}}]}},
want=422,
)
@@ -274,7 +292,7 @@ def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별
for name in ("노무비", "재료비", "경비", "계")
}
got = _post(client, "/api/m01/combo/preview", {"combo": key, "inputs": each})
got = _post(client, f"{SYS}/preview", {"combo": key, "inputs": each})
assert got["멈춤"] == [], got["멈춤"]
assert [one["로직"] for one in got["줄"]] == list(SAMPLE)
for name in ("노무비", "재료비", "경비", "계"):
@@ -287,17 +305,17 @@ def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별
def test_미리보기는_저장_전_조합_줄도_받고_아무것도_저장하지_않는다(client: TestClient) -> None:
before = _get(client, "/api/m01/combos")["combos"]
before = _list(client)
row = {"이름": "저장 안 한 것", "담은로직": [{"로직": STACK, "메모": None}]}
got = _post(client, "/api/m01/combo/preview", {"combo": row, "inputs": {STACK: _inputs(STACK)}})
got = _post(client, f"{SYS}/preview", {"combo": row, "inputs": {STACK: _inputs(STACK)}})
assert got["멈춤"] == [] and Decimal(str(got["계"])) > 0
assert _get(client, "/api/m01/combos")["combos"] == before # 아무것도 안 생김
assert _list(client) == before # 아무것도 안 생김
def test_못_도는_줄은_까닭만_적고_나머지_합계는_낸다(client: TestClient) -> None:
key = _make(client, keys=(STACK, LAYER))["key"]
only = {STACK: _inputs(STACK)} # LAYER 입력은 안 줌 — 그 줄만 멈춤
got = _post(client, "/api/m01/combo/preview", {"combo": key, "inputs": only})
got = _post(client, f"{SYS}/preview", {"combo": key, "inputs": only})
stuck = [one for one in got["줄"] if not one["ok"]]
assert len(stuck) == 1 and stuck[0]["로직"] == LAYER and stuck[0]["까닭"]
assert len(got["멈춤"]) == 1
@@ -308,8 +326,6 @@ def test_못_도는_줄은_까닭만_적고_나머지_합계는_낸다(client: T
def test_정본_로직은_조합을_만들어도_안_바뀐다(client: TestClient) -> None:
before = _get(client, "/api/m01/logic", key=STACK)
key = _make(client)["key"]
_post(
client, "/api/m01/combo/preview", {"combo": key, "inputs": {k: _inputs(k) for k in SAMPLE}}
)
_post(client, f"{SYS}/preview", {"combo": key, "inputs": {k: _inputs(k) for k in SAMPLE}})
after = _get(client, "/api/m01/logic", key=STACK)
assert after["version"] == before["version"] and after["logic"] == before["logic"]
+31 -26
View File
@@ -1,4 +1,4 @@
"""M01 조합 알약(`GET /api/m01/combo/{키}/inputs`) — 담은 로직 결과단위 분모가 물량 하나.
"""조합 알약(`GET /api/m02/layers/system/combos/{키}/inputs`) — 결과단위 분모가 물량 하나.
못박는 것
- 물량 = 로직 결과단위 분모(원/㎥ → ㎥) · 설계 조건(고르기 · 범위)은 안 넣음(`조건` 은 빈 자리).
@@ -19,20 +19,29 @@ import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from M01_MasterData import M01_MasterData_Router as router_module
from common_util.common_util_auth import verify_session
from M01_MasterData import M01_MasterData_Store as store
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete.M02_MasterTemplete_Router_Combos import router as combos_router
SYS = "/api/m02/layers/system/combos"
ADMIN = {"user_id": 1, "company_id": 1, "role": "SYSTEM_ADMIN", "is_master": False}
def _app() -> TestClient:
app = FastAPI()
app.include_router(combos_router)
app.dependency_overrides[verify_session] = lambda: ADMIN
return TestClient(app)
@pytest.fixture(scope="module")
def client() -> TestClient:
app = FastAPI()
app.include_router(router_module.router)
return TestClient(app)
return _app()
def _pills(client: TestClient, key: str) -> dict:
res = client.get(f"/api/m01/combo/{key}/inputs")
res = client.get(f"{SYS}/{key}/inputs")
assert res.status_code == 200, res.text
return res.json()
@@ -61,7 +70,7 @@ def test_준공표지석_물량_여섯(client) -> None:
def test_없는_조합은_404(client) -> None:
assert client.get("/api/m01/combo/UA999999/inputs").status_code == 404
assert client.get(f"{SYS}/UA999999/inputs").status_code == 404
@pytest.fixture
@@ -74,16 +83,14 @@ def own(tmp_path: Path, monkeypatch) -> TestClient:
sheet = tmp_path / "집계표.json"
sheet.write_text(json.dumps({"열": []}), encoding="utf-8")
monkeypatch.setattr(combo_store, "USERS", (sheet,))
app = FastAPI()
app.include_router(router_module.router)
return TestClient(app)
return _app()
def _new(client: TestClient, name: str, codes: list[str]) -> str:
combo = {"이름": name, "담은로직": [{"로직": c, "메모": None} for c in codes]}
res = client.post("/api/m01/combo/new", json={"combo": combo})
res = client.post(SYS, json={"combo": combo})
assert res.status_code == 200, res.text
return res.json()["key"]
return res.json()["키"]
def test_알약_키는_로직을_빼고_더하고_차례를_바꿔도_같다(own) -> None:
@@ -109,35 +116,33 @@ def _use(sheet: Path, key: str) -> None:
def test_쓰는_열이_있으면_조합을_못_지우고_구조물_이름을_알린다(own) -> None:
key = _new(own, "쓰는 조합", ["GF000222"])
_use(combo_store.USERS[0], key)
version = own.get(f"/api/m01/combo?key={key}").json()["version"]
res = own.post("/api/m01/combo/delete", json={"key": key, "version": version})
version = own.get(f"{SYS}/{key}").json()["판"]
res = own.delete(f"{SYS}/{key}", params={"판": version})
assert res.status_code == 409
detail = res.json()["detail"]
assert detail["users"] == ["돌기슭막이 찰쌓기"]
assert "돌기슭막이 찰쌓기" in detail["errors"][0]
assert own.get(f"/api/m01/combo?key={key}").status_code == 200 # 그대로 남음
assert own.get(f"{SYS}/{key}").status_code == 200 # 그대로 남음
def test_안_쓰는_조합은_지워진다(own) -> None:
key = _new(own, "안 쓰는 조합", ["GF000222"])
_use(combo_store.USERS[0], "UA000009") # 다른 조합을 씀
version = own.get(f"/api/m01/combo?key={key}").json()["version"]
assert (
own.post("/api/m01/combo/delete", json={"key": key, "version": version}).status_code == 200
)
assert own.get(f"/api/m01/combo?key={key}").status_code == 404
version = own.get(f"{SYS}/{key}").json()["판"]
assert own.delete(f"{SYS}/{key}", params={"판": version}).status_code == 200
assert own.get(f"{SYS}/{key}").status_code == 404
def test_본뜬키는_목록에_나오고_없는_키_자기_키는_400(own) -> None:
base = _new(own, "원본", ["GF000222"])
combo = {"이름": "본뜬 것", "본뜬키": base, "담은로직": [{"로직": "GF000222", "메모": None}]}
got = own.post("/api/m01/combo/new", json={"combo": combo}).json()
got = own.post(SYS, json={"combo": combo}).json()
assert got["combo"]["본뜬키"] == base
rows = {r["키"]: r for r in own.get("/api/m01/combos").json()["combos"]}
assert rows[got["key"]]["본뜬키"] == base and rows[base]["본뜬키"] is None
rows = {r["키"]: r for r in own.get(SYS).json()["줄"]}
assert rows[got["키"]]["본뜬키"] == base and rows[base]["본뜬키"] is None
bad = {**combo, "이름": "없는 원본", "본뜬키": "UA999999"}
assert own.post("/api/m01/combo/new", json={"combo": bad}).status_code == 400
version = own.get(f"/api/m01/combo?key={base}").json()["version"]
assert own.post(SYS, json={"combo": bad}).status_code == 400
version = own.get(f"{SYS}/{base}").json()["판"]
me = {"이름": "원본", "본뜬키": base, "담은로직": combo["담은로직"]}
res = own.post("/api/m01/combo/edit", json={"key": base, "version": version, "combo": me})
res = own.put(f"{SYS}/{base}", json={"combo": me, "판": version})
assert res.status_code == 400
+311
View File
@@ -0,0 +1,311 @@
"""일위대가 조합 층(PLAN 27-2a · 27-2c) — 세 층 한 길 `/api/m02/layers/{layer}/combos`.
못박는 것
- 키 = 회사 `UC{회사}-######` · 개인 `UP{사용자}-######` · 지운 번호 다시 안 줌 · 마스터 UA.
- 권한 = 층 규칙(마스터 쓰기 시스템 관리자 · 회사 쓰기 회사 관리자 · 개인 쓰기 본인 · 읽기 규칙).
- 검사 = 마스터와 같음(빈 조합 · 이름 겹침 · 없는 본뜬키) · 판 어긋남 409.
- 나무 = 폴더 [구분, 상세구분] 깊이 2 · 더하기 · 이름 · 옮기기 · 지우기(쓰는 조합 409).
- 마스터 나무 고침 = 줄은 M01 저장 길 · 빈 폴더는 머리 `폴더` · 틀 검사 그대로 통과.
- 고르기(use-combo) · 갈라 내기 = 세 층 조합 · 남의 층 것 403 · 갈라 낸 새 조합은 그 층에.
- 조합 이름 · 알약 · 스냅샷 원본이 회사 · 개인 조합도 읽음.
정본(마스터 · 마스터 템플릿)은 읽기만 · 쓰기는 임시 storage 와 마스터 사본.
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from typing import Any
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import verify_session
from config import config_system
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_Item_Source as source
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
from M02_MasterTemplete import M02_MasterTemplete_Router_Combos as combos_router
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.test_m02_structure import write_sample_table
A, B = "GF000222", "GF000221" # 찰쌓기 인력 · 장비
URL = "/api/m02/layers/{}/combos"
@pytest.fixture()
def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
system = tmp_path / "system"
system.mkdir()
write_sample_table(system)
monkeypatch.setattr(store, "FOLDER", system)
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
storage = tmp_path / "storage"
storage.mkdir()
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False}
app = FastAPI()
app.include_router(items_router.router)
app.include_router(combos_router.router)
app.dependency_overrides[verify_session] = lambda: session
return {"client": TestClient(app), "session": session, "storage": storage}
def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient:
world["session"].update(role=role, user_id=user)
return world["client"]
def _combo(name: str, codes=(A,), **more) -> dict:
return {"이름": name, "담은로직": [{"로직": c, "메모": None} for c in codes], **more}
def _new(client: TestClient, layer: str, combo: dict, want: int = 200, **params) -> dict:
res = client.post(URL.format(layer), json={"combo": combo}, params=params)
assert res.status_code == want, res.text
return res.json()
def _list(client: TestClient, layer: str, **params) -> dict:
res = client.get(URL.format(layer), params=params)
assert res.status_code == 200, res.text
return res.json()
def _tree(client: TestClient, layer: str, want: int = 200, **body) -> dict:
body.setdefault("판", _list(client, layer)["판"])
res = client.post(URL.format(layer) + "/tree", json=body)
assert res.status_code == want, res.text
return res.json()
# ── 키 · 자리 ─────────────────────────────────────────────────────────
def test_회사_개인_키_장부_지운_번호는_다시_안_줌(world):
boss = _as(world, "ADMIN")
one = _new(boss, "company", _combo("회사 하나"))
assert one["키"] == "UC7-000001" and one["combo"]["소유"] == "현장"
assert tuple(one["combo"]) == combo_store.mcb.COLS # 열 한 벌 · 마스터와 같음
file = world["storage"] / "7" / "templates" / combos.FILE
assert json.loads(file.read_text(encoding="utf-8"))["다음"] == 2
me = _as(world)
mine = _new(me, "personal", _combo("내 것", (A, B)))
assert mine["키"] == "UP42-000001"
assert (world["storage"] / "7" / "42" / "templates" / combos.FILE).is_file()
gone = me.delete(f"{URL.format('personal')}/{mine['키']}", params={"판": mine["판"]})
assert gone.status_code == 200
assert _new(me, "personal", _combo("다음 것"))["키"] == "UP42-000002"
got = me.get(f"{URL.format('personal')}/UP42-000002").json()
assert [x["로직"] for x in got["줄"]] == [A] and got["blocked"] is False
def test_권한_층_규칙(world):
user = _as(world)
_new(user, "system", _combo("마스터"), 403)
_new(user, "company", _combo("회사"), 403)
_new(user, "personal", _combo("남의 것"), 403, owner="7/43")
assert user.get(URL.format("system")).status_code == 200 # 마스터 읽기 = 누구나
assert user.get(URL.format("company")).status_code == 200 # 같은 회사 읽기
assert user.get(URL.format("personal"), params={"owner": "8/9"}).status_code == 403
_as(world, "ADMIN")
_new(user, "company", _combo("회사"))
world["session"]["company_id"] = None
assert user.post(URL.format("personal"), json={"combo": _combo("x")}).status_code == 409
def test_검사_빈_조합_이름_겹침_본뜬키_판(world):
me = _as(world)
got = _new(me, "personal", _combo("빈 것", ()), 422)
assert any("빈 조합" in x for x in got["detail"]["errors"])
first = _new(me, "personal", _combo("원본"))
assert "이미 있음" in _new(me, "personal", _combo("원본"), 422)["detail"]["errors"][0]
_new(me, "personal", _combo("엉터리 계보", 본뜬키="UP42-999999"), 400)
master_key = m01.read(combo_store.FILE)[0]["줄"][0]["키"]
branched = _new(me, "personal", _combo("마스터 본뜸", 본뜬키=master_key))
assert branched["combo"]["본뜬키"] == master_key # 층을 넘는 계보
url = f"{URL.format('personal')}/{first['키']}"
assert me.put(url, json={"combo": _combo("원본 고침"), "판": "낡은판"}).status_code == 409
now = _list(me, "personal")["판"]
res = me.put(url, json={"combo": _combo("원본 고침", (B,)), "판": now})
assert res.status_code == 200 and res.json()["combo"]["담은로직"][0]["로직"] == B
# ── 나무 ─────────────────────────────────────────────────────────────
def test_나무_폴더_깊이_2_이름_옮기기_지우기(world):
me = _as(world)
key = _new(me, "personal", _combo("돌쌓기 한 벌"))["키"]
_tree(me, "personal", op="add-folder", 경로=[], 이름="돌쌓기")
_tree(me, "personal", op="add-folder", 경로=["돌쌓기"], 이름="찰쌓기")
_tree(me, "personal", 422, op="add-folder", 경로=["돌쌓기", "찰쌓기"], 이름="셋째")
_tree(me, "personal", 409, op="add-folder", 경로=[], 이름="돌쌓기")
_tree(me, "personal", 404, op="move", 키=key, 새경로=["없음"])
got = _tree(me, "personal", op="move", 키=key, 새경로=["돌쌓기", "찰쌓기"])
row = next(r for r in got["줄"] if r["키"] == key)
assert (row["구분"], row["상세구분"]) == ("돌쌓기", "찰쌓기")
got = _tree(me, "personal", op="rename", 경로=["돌쌓기"], 이름="석축")
assert got["폴더"] == [["석축"], ["석축", "찰쌓기"]]
assert next(r for r in got["줄"] if r["키"] == key)["구분"] == "석축"
_tree(me, "personal", 422, op="move", 경로=["석축"], 새경로=["석축", "찰쌓기"])
got = _tree(me, "personal", op="move", 경로=["석축", "찰쌓기"], 새경로=[])
assert got["폴더"] == [["석축"], ["찰쌓기"]] # 빈 「석축」 은 남음
row = next(r for r in got["줄"] if r["키"] == key)
assert (row["구분"], row["상세구분"]) == ("찰쌓기", None)
_tree(me, "personal", 409, op="add-folder", 경로=[], 이름="석축", 판="낡은판")
_new(me, "personal", _combo("다른 것"))
got = _tree(me, "personal", op="rename", 키=key, 이름="찰쌓기 한 벌")
assert next(r for r in got["줄"] if r["키"] == key)["이름"] == "찰쌓기 한 벌"
assert (
"이미 있음"
in _tree(me, "personal", 422, op="rename", 키=key, 이름="다른 것")["detail"]["errors"][0]
)
other = next(r["키"] for r in _list(me, "personal")["줄"] if r["이름"] == "다른 것")
_tree(me, "personal", op="delete", 키=other)
got = _tree(me, "personal", op="delete", 경로=["찰쌓기"])
assert got["지운조합"] == [key] and got["줄"] == [] and got["폴더"] == [["석축"]]
def test_쓰는_조합이_든_폴더는_못_지움_이름을_알림(world):
me = _as(world)
key = _new(me, "personal", _combo("쓰는 것", 구분="석축"))["키"]
tree = me.post(
"/api/m02/layers/personal/tree",
json={"op": "add-item", "경로": ["옹벽"], "이름": "찰쌓기", "단위": "m", "판": _판(me)},
).json()
column = tree["알림"]["열"]
use = me.post(
f"/api/m02/layers/personal/structures/{column}/use-combo",
json={"종류": "일위대가", "키": key},
)
assert use.status_code == 200, use.text
got = _tree(me, "personal", 409, op="delete", 경로=["석축"])
assert got["detail"]["users"] == ["옹벽 찰쌓기"]
res = me.delete(f"{URL.format('personal')}/{key}", params={"판": _list(me, "personal")["판"]})
assert res.status_code == 409
assert [r["키"] for r in _list(me, "personal")["줄"]] == [key] # 그대로 남음
def _판(client: TestClient) -> str:
return client.get("/api/m02/layers/personal/tree").json()["판"]
@pytest.fixture()
def master_copy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
folder = tmp_path / "master"
folder.mkdir()
for path in m01.FOLDER.glob("*.json"):
if not path.name.startswith("_") or path.name == m01.mk.BOOK.name:
shutil.copy(path, folder / path.name)
monkeypatch.setattr(m01, "FOLDER", folder)
return folder
def test_마스터_나무는_M01_저장_길_빈_폴더는_머리_틀_검사_통과(world, master_copy):
admin = _as(world, "SYSTEM_ADMIN")
before = _list(admin, "system")
first = before["줄"][0]["키"]
got = _tree(admin, "system", op="rename", 키=first, 이름="이름 바꾼 견본")
assert next(r for r in got["줄"] if r["키"] == first)["이름"] == "이름 바꾼 견본"
before = _list(admin, "system")
assert ["실무 일위대가"] in before["폴더"]
_tree(admin, "system", op="add-folder", 경로=[], 이름="빈 폴더")
got = _tree(admin, "system", op="rename", 경로=["실무 일위대가"], 이름="실무")
assert ["실무"] in got["폴더"] and ["빈 폴더"] in got["폴더"]
assert ["실무 일위대가"] not in got["폴더"]
doc = json.loads((master_copy / combos.FILE).read_text(encoding="utf-8"))
assert ["빈 폴더"] in doc["폴더"]
assert sum(r["구분"] == "실무" for r in doc["줄"]) == len(
[r for r in before["줄"] if r["구분"] == "실무 일위대가"]
)
assert m01.cm.check_form(combos.FILE, doc) == [] # 마스터 틀 그대로
_tree(_as(world), "system", 403, op="add-folder", 경로=[], 이름="막힘")
# ── 세 층 고르기 · 갈라 내기 · 읽는 곳 ─────────────────────────────────
def test_세_층_목록_고르기_남의_층_403_갈라_낸_것은_그_층에(world):
boss = _as(world, "ADMIN")
uc = _new(boss, "company", _combo("회사 조합", (A, B)))["키"]
me = _as(world)
up = _new(me, "personal", _combo("내 조합"))["키"]
all_ = me.get("/api/m02/combos/all").json()
assert [g["layer"] for g in all_] == ["system", "company", "personal"]
assert [r["키"] for r in all_[1]["줄"]] == [uc] and [r["키"] for r in all_[2]["줄"]] == [up]
assert combos.find(uc)["이름"] == "회사 조합" and combos.find(up)["이름"] == "내 조합"
assert combos.find("UP42-999999") is None and combos.find("엉터리") is None
by_key = me.get(f"/api/m02/combos/key/{up}").json()
assert (by_key["layer"], by_key["owner"], by_key["combo"]["키"]) == ("personal", "7/42", up)
assert me.get(f"/api/m02/combos/key/{uc}").json()["owner"] == "7"
assert me.get("/api/m02/combos/key/UP42-999999").status_code == 404
world["session"]["company_id"] = 8 # 다른 회사 사람 — 남의 회사 조합은 못 봄
assert me.get(f"/api/m02/combos/key/{uc}").status_code == 403
world["session"]["company_id"] = 7
column = me.post(
"/api/m02/layers/personal/tree",
json={"op": "add-item", "경로": [], "이름": "돌붙임", "단위": "m", "판": _판(me)},
).json()["알림"]["열"]
base = f"/api/m02/layers/personal/structures/{column}"
for key in (uc, up):
res = me.post(f"{base}/use-combo", json={"종류": "일위대가", "키": key})
assert res.status_code == 200 and res.json()["키"] == key
lines = [{"로직": A}, {"로직": B, "메모": "장비"}] # 이 층에 같은 내용 없음 = 새로
made = me.post(f"{base}/combo", json={"본뜬키": uc, "담은로직": lines, "이름": "회사 것 고침"})
assert made.status_code == 200, made.text
new = made.json()["키"]
assert new == "UP42-000002" and combos.find(new)["본뜬키"] == uc
folder = layers.personal_dir(7, 42)
item = next(i for i in items_store.items(folder) if i["열"] == column)
assert item["일위대가"] == new and item["names"]["일위대가"] == "회사 것 고침"
assert source.labor_rows(new)["줄"]["키"] == new # 프로젝트 스냅샷 원본도 층 조합
_as(world, "ADMIN")
company_col = boss.post(
"/api/m02/layers/company/tree",
json={
"op": "add-item",
"경로": [],
"이름": "회사 항목",
"단위": "m",
"판": boss.get("/api/m02/layers/company/tree").json()["판"],
},
).json()["알림"]["열"]
res = boss.post(
f"/api/m02/layers/company/structures/{company_col}/use-combo",
json={"종류": "일위대가", "키": up},
)
assert res.status_code == 403 # 회사 항목은 개인 조합을 못 씀
def test_면적_알약이_개인_조합을_읽음(world):
me = _as(world)
up = _new(me, "personal", _combo("찰쌓기", (A, B)))["키"]
got = me.get(f"{URL.format('personal')}/{up}/inputs").json()
assert [p["키"] for p in got["물량"]] == ["돌쌓기 찰쌓기|㎡"]
assert me.get(f"/api/m02/combos/key/{up}/inputs").json() == got
tree = me.post(
"/api/m02/layers/personal/tree",
json={"op": "add-item", "경로": [], "이름": "찰쌓기 옹벽", "단위": "m", "판": _판(me)},
).json()
column = tree["알림"]["열"]
me.post(
f"/api/m02/layers/personal/structures/{column}/use-combo",
json={"종류": "일위대가", "키": up},
)
key = next(i["key"] for i in tree["items"] if i["열"] == column)
pills = me.get(f"/api/m02/layers/personal/items/{key}/pills", params={"place": "면적"})
assert pills.status_code == 200, pills.text
assert [p["키"] for p in pills.json()["물량"]] == ["돌쌓기 찰쌓기|㎡"]
def test_담을_로직_읽기는_로그인이면_됨(world):
user = _as(world)
logics = user.get("/api/m02/logic-lib/logics").json()["logics"]
assert A in {x["키"] for x in logics}
assert user.get("/api/m02/logic-lib/logic", params={"key": A}).json()["logic"]["키"] == A
assert user.get("/api/m02/logic-lib/logic", params={"key": "GF999999"}).status_code == 404
assert user.get("/api/m02/logic-lib/subs").json()["subs"]
assert user.get("/api/m02/logic-lib/auto", params={"key": A}).status_code == 200
+76
View File
@@ -0,0 +1,76 @@
"""일위대가 라이브러리 나무 자료(PLAN 27-2b · `Labor_Tree.ts`) — Node 로 TS 를 돌려 판정.
못박는 것
- 조합 → 나무 항목: 경로 = [구분, 상세구분, 이름](빈 칸 건너뜀) · 열 = 키.
- 나무 고침 알림: 새 · 바뀐 · 옮긴 폴더 경로 · 조합 고침은 그 조합(열)을 고른 채 ·
지우기는 고름 없음.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SCRIPT = r"""
const fs = require("fs");
const ts = require(process.argv[1] + "/config/node_modules/typescript");
const file = "/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts";
const src = fs.readFileSync(process.argv[1] + file, "utf8");
const js = ts.transpileModule(src, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
const m = box.exports;
const row = (키, 이름, 구분, 상세구분) => ({ 키, 이름, 구분, 상세구분, 단위: "m" });
const list = {
판: "v1",
폴더: [["석축"], ["석축", "찰쌓기"], ["빈"]],
줄: [row("UP1-000001", "가", "석축", "찰쌓기"), row("UP1-000002", "나", "석축", null),
row("UP1-000003", "다", null, "무시")],
};
const view = m.toView(list);
const notes = [
{ op: "add-folder", 경로: ["석축"], 이름: "메쌓기" },
{ op: "rename", 경로: ["석축", "찰쌓기"], 이름: "찰" },
{ op: "move", 경로: ["석축", "찰쌓기"], 새경로: [] },
{ op: "move", 경로: ["석축"], 키: "UP1-000002", 새경로: ["빈"] },
{ op: "rename", 경로: ["석축"], 키: "UP1-000002", 이름: "나나" },
{ op: "delete", 경로: ["석축"], 키: "UP1-000002" },
{ op: "delete", 경로: ["빈"] },
].map((e) => m.noteOf(e, e.op === "delete" ? ["UP1-000002"] : []));
console.log(JSON.stringify({ view, notes }));
"""
@pytest.fixture(scope="module")
def got():
if shutil.which("node") is None:
pytest.skip("node 가 없음")
proc = subprocess.run(
["node", "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8"
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_조합이_나무_항목으로(got):
items = got["view"]["items"]
assert [i["경로"] for i in items] == [["석축", "찰쌓기", "가"], ["석축", "나"], ["다"]]
assert [i["열"] for i in items] == ["UP1-000001", "UP1-000002", "UP1-000003"]
assert got["view"]["폴더"] == [["석축"], ["석축", "찰쌓기"], ["빈"]]
assert "알림" not in got["view"]
def test_나무_고침_알림(got):
add, rename, move, move_item, rename_item, delete_item, delete_folder = got["notes"]
assert add["경로"] == ["석축", "메쌓기"] and "열" not in add
assert rename["경로"] == ["석축", "찰"]
assert move["경로"] == ["찰쌓기"]
assert move_item["열"] == "UP1-000002" and move_item["경로"][:-1] == ["빈"]
assert rename_item["경로"] == ["석축", "나나"]
assert "열" not in delete_item and delete_item["지운항목"] == ["UP1-000002"]
assert "경로" not in delete_folder