Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 });
|
||||
@@ -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],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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("로직")]
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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(() => ({}));
|
||||
+124
-166
@@ -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,
|
||||
};
|
||||
}
|
||||
+8
-9
@@ -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 }));
|
||||
+11
-6
@@ -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> = {
|
||||
+5
-1
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user