Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_5', 'origin/sub_laptop_6' and 'origin/sub_laptop_8' into sub_laptop_3

This commit is contained in:
2026-11-01 14:51:53 +09:00
19 changed files with 1208 additions and 67 deletions
+42 -15
View File
@@ -1,7 +1,7 @@
/* =============================================================================
* m_settings_nav.ts
* 시스템 설정 바로가기(334 · 요구 225-2) — 대시보드 「시스템 설정」 카드 · M01 · M02 ☰ 가 같이 쓰는
* 역할 규칙 한 벌(`settingLinks`) · ☰ 진행 단계 자리에 🏠 대시보드 + 카드(`mountSettingsDrawer`).
* 시스템 설정 바로가기(334 · 요구 225-2 · 716-2) — 대시보드 설계 기본값 줄(`settingLinks` · 칸마다 한 줄 ·
* `?open=`) / M01 · M02 ☰ 메뉴(`pageLinks` · 페이지 둘 · `mountSettingsDrawer`).
* ========================================================================== */
import { ROUTES, type RoutePath } from "@config/config_frontend";
@@ -16,52 +16,63 @@ interface SettingUser {
company_id?: number | null;
}
/** key = M01 은 「m01」 · 계산 자리 창은 「calcSites」(대시보드 카드만 · 504-2) · M02 는 진입 인자 `open` */
/** key = 페이지 열쇠(☰ · 「m01」 「m02」) 또는 칸 열쇠(대시보드 줄 · 계산 자리 「calcSites」 = 창 504-2) ·
* group = 대시보드 설계 기본값 묶음 제목 */
export interface SettingLink {
key: string;
title: string;
desc: string;
route: RoutePath;
query?: Record<string, string>;
group?: string;
}
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
const ICONS: Record<string, string> = { m01: "🗄️", 구조물조합: "🧱", 옵션: "🎛️", 도면: "📑" };
const ICONS: Record<string, string> = { m01: "🗄️", m02: "🧱" };
/** 시스템 설정 — 역할로 카드 숨김 · M02 카드는 `?open=&root=` 로 진입(27-1d) */
/** 설계 기본값 줄(대시보드) — 칸마다 한 줄 · 역할로 숨김 · M01 줄 `?open=`(칸 고름) · M02 줄 `?open=&root=`(27-1d).
* 칸을 더할 때는 아래 표에 한 줄(m01 / m02 호출)만 더함. */
export function settingLinks(user: SettingUser, companyId: number | string | null): SettingLink[] {
// 시스템 관리자는 고른 회사를 해시에 실음(29-1)
const company: Record<string, string> =
user.role === "SYSTEM_ADMIN" && companyId ? { company: String(companyId) } : {};
const m01 = (key: string, title: string, desc: string, open?: string): SettingLink => ({
key,
title,
desc,
route: ROUTES.M01_MASTER_DATA,
query: open ? { open } : undefined,
group: "마스터 데이터",
});
const m02 = (title: string, open: string, root: string, desc: string): SettingLink => ({
key: open,
title,
desc,
route: ROUTES.M02_MASTER_TEMPLATE,
query: { open, root, ...company },
group: "마스터 템플릿",
});
// 구조물 조합 기본 층 = 개인(123-1 · 누구든) · 개인 층 없음(회사 없음) = 아래 역할 규칙
const comboRoot = (fallback: string): string => (user.company_id ? "personal" : fallback);
if (user.role === "SYSTEM_ADMIN") {
const desc = L("B01_Dashboard_PermMasterWrite");
const read = L("B01_Dashboard_PermSourceRead");
return [
{
key: "m01",
title: L("B01_Dashboard_MasterElements"),
desc: L("B01_Dashboard_PermSourceRead"),
route: ROUTES.M01_MASTER_DATA,
},
m01("m01", L("M01_Title"), read),
m01("로직", L("B01_Dashboard_CardLogic"), read, "로직"),
m01("옵션", L("B01_Dashboard_CardStructOptionDef"), desc, "옵션"),
m02(L("B01_Dashboard_CardPrecombo"), "일위대가조합", comboRoot("master"), desc),
m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc),
m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc),
m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc),
{
key: "calcSites",
title: L("B01_CalcSites_Title"),
desc: L("B01_CalcSites_Desc"),
route: ROUTES.B01_ACCOUNT,
group: "계산",
},
];
}
@@ -76,14 +87,30 @@ export function settingLinks(user: SettingUser, companyId: number | string | nul
return [m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot(root), desc)];
}
/** ☰ 진행 단계 자리 — 🏠 대시보드 + 카드 · 지금 칸 강조 · 같은 길(M02 → M02)은 머리 가드가 안 물어 여기서 먼저 */
/** ☰ 메뉴 — 페이지 둘만(M01 = 시스템 관리자 · M02 = 누구나) · 열쇠 「m01」 「m02」 */
export function pageLinks(user: SettingUser, companyId: number | string | null): SettingLink[] {
const combo = settingLinks(user, companyId).find((link) => link.key === "구조물조합");
const links: SettingLink[] = [];
if (user.role === "SYSTEM_ADMIN")
links.push({
key: "m01",
title: L("B01_Dashboard_MasterElements"),
desc: "",
route: ROUTES.M01_MASTER_DATA,
});
if (combo)
links.push({ ...combo, key: "m02", title: L("M02_Title"), desc: "", group: undefined });
return links;
}
/** ☰ 진행 단계 자리 — 🏠 대시보드 + 페이지 둘 · 지금 페이지 강조(`activeKey` = 「m01」 「m02」) ·
* 같은 길(M02 → M02)은 머리 가드가 안 물어 여기서 먼저 */
export function mountSettingsDrawer(
user: SettingUser,
activeKey: string,
companyId: number | string | null,
): void {
// 계산 자리 = 대시보드 창(504-2) — 페이지가 아니라 ☰ 에 안 둠
const links = settingLinks(user, companyId).filter((link) => link.key !== "calcSites");
const links = pageLinks(user, companyId);
const bar = createStepBar(
links.map((link) => link.title),
links.findIndex((link) => link.key === activeKey),
@@ -1,7 +1,8 @@
/* =============================================================================
* B01_Dashboard_UI_View_SysDefaults.ts
* 시스템 관리 › 설계 기본값(549-2) — 마스터 데이터 · 구조물 조합 · 구조물 옵션 · 도면 양식 · 계산 자리.
* 카드 규칙은 `settingLinks` 한 벌(334) — 여기는 모양만(묶음 제목 + 둥근 상자 안 줄) · 계산 자리 = 창(504-2).
* 시스템 관리 › 설계 기본값(549-2 · 716-2) — 마스터 데이터(마스터 요소 · 단가산출 로직 · 구조물 옵션 정의) ·
* 마스터 템플릿(일위대가 · 구조물 조합 · 도면 양식) · 계산 자리. 칸마다 한 줄(누르면 그 칸을 고른 채 열림 · `?open=`).
* 줄 · 묶음 규칙은 `settingLinks` 한 벌(334) — 여기는 모양만(묶음 제목 + 둥근 상자 안 줄) · 계산 자리 = 창(504-2).
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
@@ -12,22 +13,13 @@ import { pageFrame, settingCard, settingRow } from "./B01_Dashboard_UI_PageKit";
import { group } from "./B01_Dashboard_UI_View_Parts";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
/** 카드 열쇠 → 묶음 제목 — 모르는 열쇠는 「그 밖」 */
const GROUP_OF: Record<string, string> = {
m01: "마스터 데이터",
구조물조합: "구조물 · 도면",
옵션: "구조물 · 도면",
도면: "구조물 · 도면",
calcSites: "계산",
};
const open = (link: SettingLink): void =>
link.key === "calcSites" ? void openCalcSitesDialog() : navigateTo(link.route, link.query);
export function mountSysDefaults(host: HTMLElement, ctx: ViewCtx): void {
const groups = new Map<string, HTMLElement[]>();
for (const link of settingLinks(ctx.me, ctx.companyId)) {
const title = GROUP_OF[link.key] ?? "그 밖";
const title = link.group ?? "그 밖";
const openBtn = createButton({ label: "열기", variant: "ghost", onClick: () => open(link) });
groups.set(title, [...(groups.get(title) ?? []), settingRow(link.title, link.desc, openBtn)]);
}
+3 -2
View File
@@ -32,6 +32,7 @@ from B09_Estimation import B09_Estimation_Store as store
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Store as m02_store
router = APIRouter()
PATH = "/{project_id}/estimation/link"
@@ -51,8 +52,8 @@ def _folders(session: dict[str, Any]) -> dict[str, tuple[Path, bool]]:
def recalled(session: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""맞춤 기억 — 회사 위에 개인을 덮음."""
out: dict[str, dict[str, Any]] = {}
"""맞춤 기억 — 마스터(시스템 관리자 승인 · 718-2) 위에 회사 · 그 위에 개인을 덮음."""
out: dict[str, dict[str, Any]] = dict(hopyo_store.memory(m02_store.FOLDER, linker.MEMORY))
for folder, _write in _folders(session).values():
out.update(hopyo_store.memory(folder, linker.MEMORY))
return out
+215
View File
@@ -0,0 +1,215 @@
"""M01 별칭 장부 시스템 모으기(PLAN 718-2 · 요구서 내역12 4장 23 · 4-1 확정 6).
모든 회사 · 개인 층의 별칭 장부(`실무자료/맞춤.json` · `M02_Hopyo_Ledger`)와 B09 공사 내역 맞춤
기억(`실무자료/내역맞춤.json` · 707-4)을 읽어 후보로 모음 — 읽기만(공유 storage 안 씀).
후보 = (종류, 열쇠, 서명) · 서명 = 짝 내용 지문(같은 짝이면 같은 서명) · 회사 수 · 사람 수만
(누가 · 처음파일 · 층 주인은 안 내보냄 = 익명) · 충돌 = 같은 열쇠 다른 서명 · 마스터와 다름.
승인 = 시스템 관리자만 → 마스터 층에 씀(맞춤 = `ledger.put` 재검사 · 내역맞춤 = 기억 파일) ·
반려 = 마스터 층 `실무자료/별칭반려.json` 에 서명 기억(같은 짝은 다시 안 보임) · 자동 반영 없음.
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from common_util.common_util_auth import verify_session
from common_util.common_util_time import utc_now_text
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
ALIAS, BILL = "맞춤", "내역맞춤"
KINDS = (ALIAS, BILL)
REJECT = "별칭반려"
router = APIRouter(prefix="/api/m01/alias-ledger", tags=["M01 별칭 장부"])
# ── 읽기 ─────────────────────────────────────────────────────────────
def layer_folders() -> list[tuple[str, str | None, Path]]:
"""(회사, 사용자 | None, 층 폴더) — storage 의 회사 · 개인 층(숫자 폴더만 · 두 단만 훑음)."""
root = layers.storage_root()
out: list[tuple[str, str | None, Path]] = []
for c in sorted(root.iterdir() if root.is_dir() else []):
if not (c.is_dir() and c.name.isdigit()):
continue
out.append((c.name, None, layers.company_dir(c.name)))
for u in sorted(c.iterdir()):
if u.is_dir() and u.name.isdigit():
out.append((c.name, u.name, layers.personal_dir(c.name, u.name)))
return out
def _body(kind: str, row: dict[str, Any]) -> dict[str, Any]:
"""서명에 드는 내용 — 맞춤 = 갈래 · 짝 · 내역맞춤 = 확정 줄 키 · 이름."""
if kind == ALIAS:
return {"갈래": row.get("갈래"), "짝": row.get("짝") or []}
return {"확정": row.get("확정"), "이름": row.get("이름")}
def sign(kind: str, row: dict[str, Any]) -> str:
raw = json.dumps(_body(kind, row), ensure_ascii=False, sort_keys=True)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
def _book(kind: str, folder: Path) -> dict[str, dict[str, Any]]:
if kind == ALIAS:
return ledger.read(folder)
got = hopyo_store.memory(folder, BILL)
return {
k: v
for k, v in got.items()
if isinstance(v, dict) and v.get("확정") and str(k).count("|") == 2
} # 잊음 · 깨진 열쇠 뺌
def _origin(full: str, row: dict[str, Any]) -> dict[str, str]:
if isinstance(row.get("원래"), dict):
return {k: str(row["원래"].get(k) or "") for k in ("품명", "규격", "단위")}
return ledger._plain_of(full)
def rejected() -> dict[str, dict[str, Any]]:
return hopyo_store.memory(store.FOLDER, REJECT)
def _reject_id(kind: str, full: str, sig: str) -> str:
return f"{kind}|{sig}|{full}"
def gather() -> dict[str, Any]:
"""후보 모음 `{후보, 모은곳{회사, 사람}, 반려}` — 마스터와 같은 짝 · 반려한 짝은 뺌."""
seen: dict[tuple[str, str, str], dict[str, Any]] = {}
firms, people = set(), set()
for company, user, folder in layer_folders():
for kind in KINDS:
for full, row in _book(kind, folder).items():
sig = sign(kind, row)
who = (
(row.get("누가") or {}).get("id") if isinstance(row.get("누가"), dict) else None
)
person = f"{company}/{user}" if user else f"{company}/{who or '-'}"
one = seen.setdefault(
(kind, full, sig),
{"종류": kind, "열쇠": full, "서명": sig, "원래": _origin(full, row),
**_body(kind, row), "회사": set(), "사람": set(), "때": None},
) # fmt: skip
one["회사"].add(company)
one["사람"].add(person)
firms.add(company)
people.add(person)
stamp = row.get("때")
if stamp and (one["때"] is None or str(stamp) > str(one["때"])):
one["때"] = stamp
master = {kind: _book(kind, Path(store.FOLDER)) for kind in KINDS}
gone = rejected()
live = []
for (kind, full, sig), one in seen.items():
mine = master[kind].get(full)
if (mine and sign(kind, mine) == sig) or _reject_id(kind, full, sig) in gone:
continue
one["마스터"] = "다름" if mine else None
live.append(one)
many: dict[tuple[str, str], int] = {}
for one in live:
many[(one["종류"], one["열쇠"])] = many.get((one["종류"], one["열쇠"]), 0) + 1
out = []
for one in sorted(live, key=lambda x: (x["종류"], x["열쇠"], -len(x["사람"]), x["서명"])):
clash = many[(one["종류"], one["열쇠"])] > 1 or one["마스터"] == "다름"
out.append({**one, "회사": len(one["회사"]), "사람": len(one["사람"]), "충돌": clash})
return {"후보": out, "모은곳": {"회사": len(firms), "사람": len(people)}, "반려": len(gone)}
def _find(kind: str, full: str, sig: str) -> dict[str, Any]:
if kind not in KINDS:
raise store.StoreError(400, f"종류는 {' · '.join(KINDS)}")
got = next(
(x for x in gather()["후보"] if (x["종류"], x["열쇠"], x["서명"]) == (kind, full, sig)),
None,
)
if got is None:
raise store.StoreError(404, f"후보가 없음(이미 승인 · 반려 · 바뀜) 「{full}」")
return got
# ── 쓰기(마스터 층만) ─────────────────────────────────────────────────
def approve(kind: str, full: str, sig: str, who: Any) -> dict[str, Any]:
"""후보 하나 → 마스터 장부(서버가 다시 모아 찾음 · 맞춤은 `ledger.put` 재검사)."""
one = _find(kind, full, sig)
if kind == ALIAS:
ledger.put(store.FOLDER, {full: {"원래": one["원래"], **_body(kind, one)}}, who)
else:
row = {**_body(kind, one), "누가": who, "때": utc_now_text()}
hopyo_store.remember(store.FOLDER, {full: row}, BILL)
return gather()
def reject(kind: str, full: str, sig: str, who: Any) -> dict[str, Any]:
_find(kind, full, sig)
row = {"종류": kind, "열쇠": full, "서명": sig, "누가": who, "때": utc_now_text()}
hopyo_store.remember(store.FOLDER, {_reject_id(kind, full, sig): row}, REJECT)
return gather()
def master_bill() -> list[dict[str, Any]]:
"""마스터 내역맞춤 줄(보기 · 지움) — 맞춤 장부는 `/api/m02/layers/system/hopyo-ledger`."""
book = _book(BILL, Path(store.FOLDER))
return [
{"열쇠": k, "원래": ledger._plain_of(k), **_body(BILL, v)} for k, v in sorted(book.items())
]
def forget_bill(full: str) -> list[dict[str, Any]]:
if full not in _book(BILL, Path(store.FOLDER)):
raise store.StoreError(404, f"마스터 내역맞춤에 없음 「{full}」")
hopyo_store.remember(store.FOLDER, {full: None}, BILL)
return master_bill()
# ── 길(시스템 관리자만 — main.py 에서 system_admin_only) ───────────────
def _who(session: dict[str, Any]) -> dict[str, Any]:
return {"id": session.get("user_id"), "이름": session.get("name")}
def _call(fn, *args):
try:
return fn(*args)
except store.StoreError as error:
raise HTTPException(error.status, error.detail) from error
class Pick(BaseModel):
종류: str = Field(max_length=10)
열쇠: str = Field(min_length=1, max_length=400)
서명: str = Field(min_length=16, max_length=16)
@router.get("")
def candidates() -> dict[str, Any]:
return {**gather(), "마스터내역": master_bill()}
@router.post("/approve")
def approve_one(body: Pick, session: dict = Depends(verify_session)) -> dict[str, Any]:
return _call(approve, body.종류, body.열쇠, body.서명, _who(session))
@router.post("/reject")
def reject_one(body: Pick, session: dict = Depends(verify_session)) -> dict[str, Any]:
return _call(reject, body.종류, body.열쇠, body.서명, _who(session))
class Forget(BaseModel):
열쇠: str = Field(min_length=1, max_length=400)
@router.post("/master-bill/forget")
def forget_one(body: Forget) -> dict[str, Any]:
return {"마스터내역": _call(forget_bill, body.열쇠)}
+53 -6
View File
@@ -1,8 +1,8 @@
/* =============================================================================
* M01_MasterData_UI_Page.ts
* 마스터 요소 화면 — 좌측 도킹 패널(컨테이너: 인력·재료·기계·소요량·계수·환율·요율·단가산출 로직)
* · 일위대가 조합은 M02 마스터 템플릿(PLAN 27-2b)
* / 우측 요소 표 · 표 목록 · 로직 편집
* · 일위대가 조합은 M02 마스터 템플릿(PLAN 27-2b) · 구조물 옵션 정의(716-1 · 화면은 M02 `StructOptions/`)
* / 우측 요소 표 · 표 목록 · 로직 편집 · 옵션 정의 · 주소 `?open=로직|옵션` = 그 칸을 고른 채 열림(716-2)
*
* 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 —
* 자동저장 없음.
@@ -26,7 +26,7 @@ import { t as L } from "@ui/ui_template_locale";
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
import { ROUTES } from "@config/config_frontend";
import { mountSettingsDrawer } from "../A00_Common/m_settings_nav";
import { navigateTo } from "../A00_Common/router";
import { currentQuery, navigateTo, setLeaveGuard } from "../A00_Common/router";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { saveFiles, type FileInfo } from "./M01_MasterData_Api_Fetch";
import { discard, isStale, onDraftChange, payload, totalCount } from "./M01_MasterData_Draft";
@@ -89,6 +89,7 @@ function buildPage(): HTMLElement {
children: [headBar.root, buildHeadFilters([searchField.root])],
});
const logicHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } });
const optionsHost = el("div", { className: "m02-sod__host", attrs: { hidden: "" } });
const elementView = el("div", {
className: "m01-master__elements",
children: [
@@ -112,12 +113,50 @@ function buildPage(): HTMLElement {
onDraftChange(refreshBar);
refreshBar();
const showMode = (mode: "elements" | "logic"): void => {
const showMode = (mode: "elements" | "logic" | "options"): void => {
logicHost.hidden = mode !== "logic";
elementView.hidden = mode !== "elements";
optionsHost.hidden = mode !== "options";
if (mode !== "options") side.setOptionsMark(false);
};
/* --- 구조물 옵션 정의(716-1) — 저장 안 한 고침은 칸을 바꿀 때 · 페이지를 떠날 때 물음 --- */
let optionsDirty = false;
const leaveOptions = async (): Promise<boolean> => {
if (!optionsDirty || optionsHost.hidden) return true;
if (!(await showConfirmDialog(L("M02_DiscardConfirm")))) return false;
optionsDirty = false;
return true;
};
/** 옵션 정의에 고침이 있으면 물은 뒤에 `run` — 취소면 옵션 줄 표시를 되돌려 머묾 */
const afterLeave = (run: () => void): boolean => {
if (!optionsDirty || optionsHost.hidden) return true;
void leaveOptions().then((ok) => {
if (ok) return run();
side.setActive(null);
side.setOptionsMark(true);
});
return false;
};
const showOptions = (label: string, node: HTMLElement): ((dirty: boolean) => void) => {
dispose();
dispose = (): void => {};
current = null;
optionsDirty = false;
optionsHost.replaceChildren(
el("div", {
className: "m01-master__headbox",
children: [buildHeadBar({ title: label, buttons: [] }).root],
}),
node,
);
showMode("options");
setLeaveGuard(leaveOptions);
return (dirty) => (optionsDirty = dirty);
};
const openFile = (pick: Pick): void => {
if (!afterLeave(() => openFile(pick))) return;
dispose();
showMode("elements");
if (pick.view) {
@@ -203,6 +242,7 @@ function buildPage(): HTMLElement {
/** 「단가산출 로직」 탭으로 전환 — `key` 가 있으면 그 로직을 바로 엶. */
const openLogicTab = (key?: string): void => {
if (key) setPendingLogicKey(key);
if (!afterLeave(() => openLogicTab())) return;
dispose();
dispose = (): void => {};
current = null;
@@ -259,7 +299,11 @@ function buildPage(): HTMLElement {
setMasterJump((target) => void jump(target));
/* --- 좌측: 컨테이너 --- */
const side = buildSide(openFile, () => openLogicTab());
const side = buildSide(openFile, () => openLogicTab(), {
confirmLeave: leaveOptions,
show: showOptions,
onOpen: () => side.setActive(null),
});
const layout = el("div", { className: "ui-workflow-layout m01-master" });
const main = el("main", {
@@ -267,7 +311,7 @@ function buildPage(): HTMLElement {
children: [
el("div", {
className: "m01-master__panel",
children: [elementView, logicHost],
children: [elementView, logicHost, optionsHost],
}),
],
});
@@ -287,5 +331,8 @@ function buildPage(): HTMLElement {
);
// 다른 화면(일위대가 조합 다이어그램 등)이 로직 키를 들고 왔으면 로직 탭으로 바로 엶
if (hasPendingLogicKey()) openLogicTab();
// 주소 `?open=` — 대시보드 설계 기본값 줄 · 옛 M02 `open=옵션` 즐겨찾기가 그 칸을 고른 채 엶
const want = currentQuery().get("open");
if (want === "로직" || want === "옵션") side.reveal(want);
return layout;
}
+45 -4
View File
@@ -1,13 +1,19 @@
/* =============================================================================
* M01_MasterData_UI_Side.ts
* 왼쪽 패널 — 공용 접기 컨테이너(`ui-collapsible ui-sidebar-section`) 아홉 개
* 왼쪽 패널 — 공용 접기 컨테이너(`ui-collapsible ui-sidebar-section`)
* 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 · 단가산출 로직(일위대가 조합은 M02 · PLAN 27-2b)
* ─ 구분선 ─ 구조물 옵션 정의(716-1 · 화면 코드는 M02 `StructOptions/` 그대로 · 입구만 여기)
* 펼치면 하위 목록(거름 · 파일) · 환율/요율은 누르면 바로 표 · 로직은 안에 로직 목록
* ========================================================================== */
import { createSelectField, el, showToast } from "@ui/ui_template_elements";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { createDivider } from "@ui/ui_template_divider";
import { currentLanguageIndex, t as L } from "@ui/ui_template_locale";
import {
buildOptionsSection,
type OptionsSectionOptions,
} from "../M02_MasterTemplete/StructOptions/M02_SO_Options";
import { fetchFiles, fetchSubsAll, type FileInfo, type SubInfo } from "./M01_MasterData_Api_Fetch";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import { renderTree, type MakeRow } from "./M01_MasterData_UI_Tree";
@@ -103,6 +109,10 @@ export interface SideHandle {
setOnPicked: (fn: () => void) => void;
/** 「전체」 + 드롭다운 거름 한 벌 — 로직 컨테이너도 씀 */
filter: (args: FilterArgs) => HTMLElement;
/** 주소 `open` — 그 컨테이너를 펼치고 고름(로직 = 로직 목록 · 옵션 = 정의 화면까지) */
reveal: (target: "로직" | "옵션") => void;
/** 옵션 줄 고름 표시 지움(다른 화면을 열었음) · 켬(떠나기를 취소해 머묾) */
setOptionsMark: (on: boolean) => void;
}
export interface FilterArgs {
@@ -157,7 +167,11 @@ const LEAF: Group[] = ["환율", "요율"];
const FILTERED: Group[] = ["인력", "기계", "소요량", "계수"];
const LOGIC_ID = "로직|";
export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): SideHandle {
export function buildSide(
onOpen: (pick: Pick) => void,
onLogic: () => void,
optionsOpt: Omit<OptionsSectionOptions, "isAdmin">,
): SideHandle {
const lists = new Map<Group, FileInfo[]>();
const bodies = new Map<Group, HTMLElement>();
const buttons = new Map<string, HTMLElement>();
@@ -483,9 +497,12 @@ ${first.file}`
onLogic();
});
// 구조물 옵션 정의 — 로직 칸 아래 구분선 + 칸 하나(시스템 관리자 화면이라 고침 가능)
const options = buildOptionsSection({ ...optionsOpt, isAdmin: true });
const optionsSec = section(options.title, options.body);
const root = el("div", {
className: "m01-side ui-sidebar-panel",
children: [...groups, logic],
children: [...groups, logic, createDivider(), optionsSec],
});
attachCollapsible(root);
void Promise.all(GROUPS.filter((g) => g !== FUEL_GROUP).map(refresh)).catch((error) =>
@@ -497,5 +514,29 @@ ${first.file}`
// 재료는 유가 · 전력 파일까지 다(알약 → 마스터 화면 길이 파일로 찾음) · 유가 · 전력은 그 파일만
const files = (group: string): FileInfo[] =>
group === FUEL_GROUP ? shown(FUEL_GROUP) : (lists.get(group as Group) ?? []);
return { root, logicHost, refresh, files, setActive, setOnPicked, filter };
const reveal = (target: "로직" | "옵션"): void => {
if (target === "옵션") {
optionsSec.classList.remove("is-collapsed");
options.body.querySelector<HTMLElement>("button")?.click();
return;
}
logic.classList.remove("is-collapsed");
setActive(buttons.has(LOGIC_ID) ? LOGIC_ID : null);
onLogic();
};
const setOptionsMark = (on: boolean): void => {
if (!on) return options.clear();
options.body.querySelector("button")?.classList.add("is-active");
};
return {
root,
logicHost,
refresh,
files,
setActive,
setOnPicked,
filter,
reveal,
setOptionsMark,
};
}
@@ -51,6 +51,14 @@ export function entryFromHash(user?: SessionUser | null): Entry {
return { root, owner, open, company: query.get("company") || null, pre };
}
/** 옛 주소 `open=옵션`(구조물 옵션 정의는 M01 로 옮김 · 716-1) — 시스템 관리자면 M01 그 칸으로 돌려보냄 ·
* 돌려보냈으면 참(그 밖 역할은 읽기 화면이 없어져 구조물 조합으로 열림) */
export function redirectOldOptions(user: SessionUser | null): boolean {
if (user?.role !== "SYSTEM_ADMIN" || currentQuery().get("open") !== "옵션") return false;
navigateTo(ROUTES.M01_MASTER_DATA, { open: "옵션" });
return true;
}
/** 진입 해시 인자 — `navigateTo(ROUTES.M02_MASTER_TEMPLATE, entryQuery(…))` */
export const entryQuery = (entry: Entry): Record<string, string> => ({
open: entry.open,
@@ -31,6 +31,7 @@ import {
entryQuery,
layerOfRoot,
ownOwner,
redirectOldOptions,
viewCompany,
type OpenTarget,
type Root,
@@ -61,6 +62,8 @@ const NEW_ASK =
export async function renderM02MasterTemplate(root: HTMLElement): Promise<MountedPage> {
const user = await fetchSessionUser().catch(() => null);
// 구조물 옵션 정의는 M01 로 옮김(716-1) — 옛 주소는 M01 그 칸으로
if (redirectOldOptions(user)) return { covers: () => false, show: () => {}, dispose: () => {} };
const entry = entryFromHash(user); // 134-4 — 해시에 층 없음 = 개인(회사 없으면 마스터)
const company = viewCompany(entry, user); // 볼 회사(시스템 관리자는 고른 회사)
let layer = layerOfRoot(entry.root);
@@ -283,7 +286,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만 ·
// 옵션 화면이 뒤에 열리면 편집 칸 차례(`seq`)가 늦게 온 집계표를 버림
if (entry.open !== "구조물조합") side.reveal(entry.open);
mountSettingsDrawer(user ?? {}, entry.open, entry.company); // ☰ 시스템 설정 바로가기(334)
mountSettingsDrawer(user ?? {}, "m02", entry.company); // ☰ 시스템 설정 바로가기(334)
void tree.refresh();
// 이 화면을 떠나면 층 길을 시스템 · 자기 층으로 되돌림(다른 화면의 항목 고르기가 남의 층을 안 부르게)
return { covers: () => false, show: () => {}, dispose: () => setActiveLayer("system") };
+387
View File
@@ -0,0 +1,387 @@
"""M02 단가산출(SK) 문서 — 꼴 · 검사 · 줄 정체 · 고친 줄(PLAN 715-1a · 요구서 내역12 4장 12 · 13).
문서 `{층}/sangeun/SK-######.json` = M01 로직에 연결 + 바꾼 것만 —
{종류: "단가산출", 키, 이름, 단위(필수), 구분, 상세구분, 본뜬,
바탕: {로직: GF…} | {참조: {층, 주인, 키: SK-…}} | null(바탕 없음 = 더한줄만),
입력값 {이름: 값}, 열린입력 [이름](설계 때 정함 · 그 SK 품 덮개 = 배율만), 켜쌓기율?,
고름 {cid: MT키}, 자재표시 {cid: {세 칸}},
덮개 {cid: {qty | 배율, labor, material, expense, 원값}},
더한줄 [{종류: 인력|재료|기계, 요소, 이름, 단위, 수량, 비목?, 단가?}
| {종류: 로직, 로직, 입력값, 수량} | {참조 | 고정, 이름?, 단위?, 수량}],
뺀줄 [cid], 바탕판}
줄 정체 cid = `kind:키#n`(B09 702 덮개 꼴) — kind = labor · material · machine · logic · pct(덧줄) ·
키 = 요소 키(로직 줄 = 부르는 GC 키 · 조건 재료 = 이름 · 덧줄 = 이름 · 더한줄 참조 = 그 키) ·
n = 같은 머리 차례 · 바탕 줄(M01 로직 줄 · 바탕 SK 의 고친 줄)에서 셈 — 뺀줄 · 더한줄 뒤 같은 줄.
고름 · 자재표시도 cid 열쇠(호표 차례 열쇠면 뺀줄 뒤 엉뚱한 줄에 붙음) → `patched` 가 차례로 바꿈.
덮개 = 숫자만(식 안 받음 · 4-1 확정 4) — qty = 품 수 · 배율 = 품 × 율 · labor · material · expense =
줄 단가(로직 줄 = 노 · 재 · 경 묶음) · 덧줄 = qty(율 %) · 배율만 · `원값` · `바탕판` = 저장 때
서버가 셈(브라우저 값 안 받음). 짝 잃은 덮개(M01 개정으로 줄이 사라짐) = 지우지 않고 `짝잃음` 알림.
엔진 입력 덧칸(715-1b · `master_pick`) = 줄 `단가`(수) · 로직 줄 `값묶음{노무비 · 재료비 · 경비}`
"""
from __future__ import annotations
import re
from decimal import Decimal, InvalidOperation
from typing import Any
KIND = "sangeun"
TITLE = "단가산출"
PREFIX = "SK"
KEY = re.compile(r"^SK-\d{6}$")
PARTS = {"labor": "노무비", "material": "재료비", "expense": "경비"}
COVER_KEYS = ("qty", "배율", *PARTS)
KINDS = {"인력": "labor", "재료": "material", "기계": "machine", "로직": "logic"}
ADD_PART = {"인력": "노무비", "재료": "재료비", "기계": "경비"}
_CID = re.compile(r"^(labor|material|machine|logic|pct):.*#\d+$")
_CALL = re.compile(r"^\s*로직\(\s*([A-Z]{2}\d{6})")
_PCT = re.compile(r"^(?P<x>.+)\*(?P<y>[^*]+)/\s*100\s*$", re.S) # 「X * Y / 100」
_NAME = re.compile(r"^[\w가-힣]+$")
class DocError(ValueError):
def __init__(self, text: str, status: int = 400) -> None:
super().__init__(text)
self.status = status
def _num(raw: Any, where: str) -> Decimal:
try:
got = Decimal(str(raw).replace(",", "").strip())
except (InvalidOperation, TypeError):
got = None
if raw is None or isinstance(raw, bool) or got is None or not got.is_finite():
raise DocError(f"{where} · 수가 아님 「{raw}」")
return got
def plain(value: Decimal) -> int | float:
return int(value) if value == value.to_integral_value() else float(value)
def text(value: Any) -> str:
"""식 안에 넣을 수 글 — 지수 꼴 없이."""
got = value if isinstance(value, Decimal) else Decimal(str(value))
return format(got.normalize(), "f")
# ── 줄 정체 ───────────────────────────────────────────────────────────
def _head(item: dict, extra: bool) -> str:
if extra:
return f"pct:{item.get('이름') or ''}"
kind = KINDS.get(str(item.get("종류")), "logic")
elem = item.get("요소")
if isinstance(item.get("참조"), dict):
key = str(item["참조"].get("키") or "")
elif isinstance(item.get("고정"), dict):
key = str(item["고정"].get("이름") or "")
elif kind == "logic":
found = _CALL.match(str(elem or ""))
key = found.group(1) if found else str(item.get("이름") or "")
else:
key = elem if isinstance(elem, str) else str(item.get("이름") or "")
return f"{kind}:{key}"
def line_ids(row: dict) -> list[str]:
"""로직 줄(호표 → 덧줄 차례)마다 cid — 같은 머리 n번째 = `#n`."""
seen: dict[str, int] = {}
out = []
pairs = [(x, False) for x in row.get("호표") or []] + [(x, True) for x in row.get("덧줄") or []]
for item, extra in pairs:
head = _head(item, extra)
seen[head] = seen.get(head, -1) + 1
out.append(f"{head}#{seen[head]}")
return out
# ── 정리 · 검사 ───────────────────────────────────────────────────────
def _cid(raw: Any, where: str) -> str:
if not isinstance(raw, str) or not _CID.match(raw):
raise DocError(f"{where} · 줄 정체는 kind:키#n 「{raw}」")
return raw
def _base(raw: Any) -> dict | None:
if raw is None:
return None
if isinstance(raw, dict) and isinstance(raw.get("로직"), str) and raw["로직"].strip():
return {"로직": raw["로직"].strip()}
if isinstance(raw, dict) and isinstance(raw.get("참조"), dict):
ref = raw["참조"]
if not KEY.match(str(ref.get("키") or "")):
raise DocError("바탕 참조 키는 SK-######")
return {"참조": {"층": ref.get("층"), "주인": ref.get("주인"), "키": str(ref["키"])}}
raise DocError("바탕은 {로직} · {참조} · null")
def _given(raw: Any, where: str) -> dict:
if raw is None:
return {}
if not isinstance(raw, dict):
raise DocError(f"{where} · 입력값이 묶음 아님")
return {str(k): v for k, v in raw.items() if v not in (None, "")}
def _cover(raw: Any, where: str, opened: bool) -> dict:
if not isinstance(raw, dict):
raise DocError(f"{where} · 덮개는 묶음")
unknown = set(raw) - {*COVER_KEYS, "원값"}
if unknown:
raise DocError(f"{where} · 모르는 덮개 칸 {sorted(unknown)}")
out = {k: plain(_num(raw[k], f"{where} {k}")) for k in COVER_KEYS if raw.get(k) is not None}
if "qty" in out and "배율" in out:
raise DocError(f"{where} · 품은 qty · 배율 중 하나")
if opened and "qty" in out:
raise DocError(f"{where} · 옵션을 열어 둔 단가산출은 품 배율만")
if any(v < 0 for v in out.values()):
raise DocError(f"{where} · 음수 덮개")
if not out:
raise DocError(f"{where} · 빈 덮개")
return out
def _added(raw: Any, where: str, whole: Any) -> dict:
"""더한줄 하나 — 수량은 숫자(식 안 받음)."""
if not isinstance(raw, dict):
raise DocError(f"{where} · 묶음 아님")
qty = plain(_num(raw.get("수량"), f"{where} 수량"))
name, unit = str(raw.get("이름") or "").strip(), str(raw.get("단위") or "").strip()
if isinstance(raw.get("참조"), dict):
ref = raw["참조"]
if not re.match(r"^(SK|SC)-\d{6}$", str(ref.get("키") or "")):
raise DocError(f"{where} · 참조 키는 SK · SC")
head: dict[str, Any] = {"참조": {k: ref.get(k) for k in ("층", "주인", "키")}}
elif isinstance(raw.get("고정"), dict):
from M02_MasterTemplete import M02_Labor_Fixed as fixed
try:
head = {"고정": fixed.clean(raw["고정"], where, whole) if whole else raw["고정"]}
except Exception as e: # noqa: BLE001 — 고정 줄 검사 = StoreError(400)
raise DocError(str(getattr(e, "detail", e))) from e
if head["고정"].get("비율"):
raise DocError(f"{where} · 비율 고정 줄은 단가산출에 못 담음 — 덧줄 율로")
elif raw.get("종류") == "로직":
code = str(raw.get("로직") or "")
row = _logic(whole, code, where)
names = {str(s.get("이름")) for s in (row or {}).get("입력") or []}
given = _given(raw.get("입력값"), where)
head = {"종류": "로직", "로직": code, "입력값": given}
if row is not None:
head["입력값"] = {k: v for k, v in given.items() if k in names}
name = name or str(row.get("이름") or code)
elif raw.get("종류") in ADD_PART:
key = str(raw.get("요소") or "")
got = _element(whole, key, where)
part = str(raw.get("비목") or ADD_PART[raw["종류"]])
if part not in PARTS.values():
raise DocError(f"{where} · 비목은 노무비 · 재료비 · 경비")
head = {"종류": raw["종류"], "요소": key, "비목": part}
if raw.get("단가") is not None:
head["단가"] = plain(_num(raw["단가"], f"{where} 단가"))
name = name or str((got or {}).get("이름") or key)
unit = unit or str((got or {}).get("단위") or "")
else:
raise DocError(f"{where} · 종류는 인력 · 재료 · 기계 · 로직 · 참조 · 고정")
return {**head, "이름": name, "단위": unit, "수량": qty}
def _logic(whole: Any, code: str, where: str) -> dict | None:
if whole is None:
return None
try:
return whole.logic(code)
except Exception as e: # noqa: BLE001 — 엔진 FormulaError(가져오기 둘레 피함)
raise DocError(f"{where} · {e}") from e
def _element(whole: Any, key: str, where: str) -> dict | None:
if not key:
raise DocError(f"{where} · 요소 키가 빔")
if whole is None:
return None
try:
return whole.get(key)
except Exception as e: # noqa: BLE001
raise DocError(f"{where} · 없는 요소 「{key}」") from e
def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str, Any]:
"""서버가 받는 꼴로 — 틀리면 `DocError`(400). `base_row` = 패치를 얹을 바탕 줄(입력 이름 ·
열린입력 검사) · `whole` = M01(요소 · 로직 · 자재 키 검사). 짝 없는 cid 는 받음(셈 때 알림) ·
`원값` · `바탕판` 은 서버 몫이라 버림."""
if not isinstance(doc, dict):
raise DocError("단가산출 문서가 묶음 아님")
key = str(doc.get("키") or "")
if not KEY.match(key):
raise DocError("단가산출 키는 SK-######")
name, unit = str(doc.get("이름") or "").strip(), str(doc.get("단위") or "").strip()
if not name:
raise DocError("단가산출 이름이 빔")
if not unit:
raise DocError("단가산출 단위가 빔 — 1 단위 값의 단위")
base = _base(doc.get("바탕"))
if base and "로직" in base and whole is not None and base_row is None:
base_row = _logic(whole, base["로직"], "바탕")
names = {str(s.get("이름")) for s in (base_row or {}).get("입력") or []}
given = _given(doc.get("입력값"), "입력값")
opened = [str(x) for x in doc.get("열린입력") or []]
if base_row is not None:
given = {k: v for k, v in given.items() if k in names}
bad = [x for x in opened if x not in names or x in given]
if bad:
raise DocError(f"열린입력은 바탕 입력 중 값 안 넣은 것만 {bad}")
out: dict[str, Any] = {"종류": TITLE, "키": key, "이름": name, "단위": unit, "바탕": base}
out.update({k: str(doc.get(k) or "").strip() for k in ("구분", "상세구분")})
out["본뜬"] = doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None
out["입력값"], out["열린입력"] = given, opened
if doc.get("켜쌓기율") is not None:
out["켜쌓기율"] = plain(_num(doc["켜쌓기율"], "켜쌓기율"))
picks = {}
for cid, mt in (doc.get("고름") or {}).items():
mt = str(mt or "")
if not mt.startswith("MT") or (whole is not None and _element(whole, mt, "고름") is None):
raise DocError(f"고름 「{cid}」 · M01 자재품목 키 아님 「{mt}」")
picks[_cid(cid, "고름")] = mt
out["고름"] = picks
out["자재표시"] = _marks(doc.get("자재표시"))
out["덮개"] = {
_cid(cid, "덮개"): _cover(one, f"덮개 「{cid}」", bool(opened))
for cid, one in (doc.get("덮개") or {}).items()
}
out["더한줄"] = [
_added(one, f"더한줄 {at}째", whole) for at, one in enumerate(doc.get("더한줄") or [], 1)
]
drop = [_cid(x, "뺀줄") for x in doc.get("뺀줄") or []]
if len(set(drop)) != len(drop):
raise DocError("뺀줄에 같은 줄 두 번")
out["뺀줄"] = drop
return out
def _marks(raw: Any) -> dict[str, dict[str, str]]:
from M02_MasterTemplete import M02_Labor_Doc as labor
out = {}
for cid, one in (raw or {}).items() if isinstance(raw, dict) else ():
got = labor.marks(one)
if got:
out[_cid(cid, "자재표시")] = got
return out
# ── 고친 줄 ───────────────────────────────────────────────────────────
def _covered(item: dict, cover: dict, extra: bool) -> tuple[dict, str | None]:
"""덮개 한 줄을 로직 줄에 — (고친 줄, 못 덮은 까닭)."""
item = dict(item)
if extra: # 덧줄 = 율(qty) · 배율만
if any(k in cover for k in PARTS):
return item, "덧줄은 율만 고침"
formula = str(item.get("식") or "")
if "배율" in cover:
item["식"] = f"({formula}) * {text(cover['배율'])}"
return item, None
found = _PCT.match(formula)
if not found:
return item, "덧줄 식이 「X * 율 / 100」 꼴 아님"
item["식"] = f"{found.group('x').strip()} * {text(cover['qty'])} / 100"
return item, None
if "qty" in cover:
item["수량"] = text(cover["qty"])
elif "배율" in cover:
item["수량"] = f"({item.get('수량')}) * {text(cover['배율'])}"
prices = {PARTS[k]: Decimal(str(cover[k])) for k in PARTS if k in cover}
if not prices:
return item, None
if item.get("종류") == "로직":
item["값묶음"] = {**dict(item.get("값묶음") or {}), **prices}
return item, None
part = str(item.get("비목") or "")
if set(prices) != {part}:
return item, f"비목 다름 — 이 줄은 {part or '비목 없음'}"
item["단가"] = prices[part]
return item, None
def _add_item(one: dict) -> dict:
"""더한줄 → 엔진 호표 줄 — 참조 · 고정 줄 `값묶음` 은 셈(`M02_Sangeun_Calc`)이 채움."""
head = {"이름": one.get("이름") or "", "단위": one.get("단위") or ""}
qty = text(one["수량"])
if "참조" in one or "고정" in one:
kept = {k: one[k] for k in ("참조", "고정") if k in one}
return {**head, "종류": "로직", "요소": None, "수량": qty, **kept, "값묶음": None}
if one.get("종류") == "로직":
args = "".join(
f", {k if _NAME.match(k) else repr(k)}="
+ (text(v) if isinstance(v, (int, float, Decimal)) else f"'{v}'")
for k, v in (one.get("입력값") or {}).items()
)
return {**head, "종류": "로직", "요소": f"로직({one['로직']}{args})", "수량": qty}
item = {**head, "종류": one["종류"], "요소": one["요소"], "수량": qty, "비목": one["비목"]}
return {**item, **({"단가": Decimal(str(one["단가"]))} if "단가" in one else {})}
def patched(
row: dict, doc: dict, picks: dict | None = None, shown: dict | None = None
) -> dict[str, Any]:
"""바탕 줄(M01 로직 줄 · 바탕 SK 의 고친 줄) + SK 패치 → 고친 줄(순수 · 엔진 안 부름).
`picks` · `shown` = 바탕 쪽 차례 열쇠 고름 · 자재표시(바탕 SK 사슬) → 새 차례로 옮김.
돌려줌 = {줄, 고름 {차례: MT}, 자재표시 {차례: 칸}, 정체 [새 줄 cid · 더한줄 = `add:#k`],
짝잃음 [{cid, 까닭}]}."""
items, extras = list(row.get("호표") or []), list(row.get("덧줄") or [])
ids = line_ids(row)
covers, drop = doc.get("덮개") or {}, set(doc.get("뺀줄") or [])
mine = doc.get("고름") or {}
marks = doc.get("자재표시") or {}
lost = [
{"cid": c, "까닭": "뺀줄 — 바탕에 없는 줄"} for c in doc.get("뺀줄") or [] if c not in ids
]
new_items, new_extras, cids, out_picks, out_marks = [], [], [], {}, {}
for at, cid in enumerate(ids):
if cid in drop:
continue
extra = at >= len(items)
item = extras[at - len(items)] if extra else items[at]
if cid in covers:
item, why = _covered(item, covers[cid], extra)
if why:
lost.append({"cid": cid, "까닭": why})
if extra:
new_extras.append(item)
continue
n = str(len(new_items))
old = (picks or {}).get(str(at))
if cid in mine and item.get("종류") != "재료":
lost.append({"cid": cid, "까닭": "고름 — 재료 줄 아님"})
elif cid in mine or old:
out_picks[n] = mine.get(cid) or old
if (shown or {}).get(str(at)) or cid in marks:
out_marks[n] = marks.get(cid) or shown[str(at)]
new_items.append(item)
cids.append(cid)
for k, one in enumerate(doc.get("더한줄") or []):
new_items.append(_add_item(one))
cids.append(f"add:#{k}")
cids += [c for at, c in enumerate(ids) if at >= len(items) and c not in drop]
for name, keys in (("덮개", covers), ("고름", mine), ("자재표시", marks)):
lost += [{"cid": c, "까닭": f"{name} — 바탕에 없는 줄"} for c in keys if c not in ids]
return {
"줄": {**row, "호표": new_items, "덧줄": new_extras},
"고름": out_picks,
"자재표시": out_marks,
"정체": cids,
"짝잃음": lost,
}
def empty_row(doc: dict) -> dict[str, Any]:
"""바탕 없는 단가산출의 바탕 줄 — 입력 · 줄 없음(더한줄만 셈)."""
return {
"키": doc.get("키"),
"이름": doc.get("이름"),
"결과단위": f"원/{doc.get('단위') or ''}",
"입력": [],
"중간": [],
"호표": [],
"덧줄": [],
}
@@ -404,3 +404,11 @@ table.m02-sod__table--wrap {
word-break: keep-all;
overflow-wrap: anywhere;
}
/* M01 본 칸(716-1) — 정의 화면을 얹는 틀: 머리 줄 아래 화면 */
.m02-sod__host {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
min-width: 0;
}
+4
View File
@@ -544,6 +544,10 @@ app.include_router(b09_estimation_router, dependencies=protected_with_company)
# 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다.
system_admin_only = [Depends(verify_session), Depends(require_system_admin)]
app.include_router(m01_master_data_router, dependencies=system_admin_only)
app.include_router(
__import__("M01_MasterData.M01_MasterData_Alias", fromlist=["router"]).router,
dependencies=system_admin_only,
) # 별칭 장부 모으기(718-2)
# 단가산출 흐름도 시험 길 — 폴더째 지우면 이 줄도 지움
app.include_router(
__import__("M02_MasterTemplete.DataFlow.M02_DF_Router", fromlist=["router"]).router,
@@ -1,10 +1,14 @@
"""197-1 대시보드 「구조물 옵션」 카드 → M02 「구조물 옵션 정의」 화면이 바로 열림 시험 — 조작은 사용자.
"""197-1 · 716-1 대시보드 「구조물 옵션 정의」 줄 → M01 「구조물 옵션 정의」 화면이 바로 열림 시험 — 조작은 ORCA.
716-1 — 화면 입구가 M02 에서 M01 로 옮겨감(화면 코드 · 서버 길은 M02 폴더 그대로) · 옛 M02 `open=옵션` 주소는
시스템 관리자면 M01 `?open=옵션` 으로 돌려보냄.
원인 — 진입 해시 `open=옵션` 이 옵션 줄을 눌러 정의 화면을 띄우는 사이 첫 화면(집계표)의 `mount` 가
모듈 · 번호를 기다리다 뒤늦게 끝나 정의 화면 위에 집계표를 덮음(`mount` 에 낡음 검사 없었음).
· 카드 셋 → 해시(open=구조물조합 · 옵션 · 도면) · 해시 → 진입(`entryFromHash`) Node 로 바로 돌림
· `reveal` 가 옵션만 줄을 누르고 도면은 펼치기만 · 구조물조합은 아무것도 안 함
· 줄 → 해시(M01 open=로직 · 옵션 / M02 open=일위대가조합 · 구조물조합 · 도면) · 해시 → 진입(`entryFromHash`) Node
· M02 `reveal` 가 옵션만 줄을 누르고 도면은 펼치기만 · 구조물조합은 아무것도 안 함 · M01 `reveal` 도 같은 꼴
· 옛 `open=옵션` 돌려보냄(`redirectOldOptions`)
· `mount` 집계표 갈래(진짜 소스 조각)를 Node 로 돌림 — 기다리는 사이 다른 화면이 열리면 그리지 않음
"""
@@ -41,16 +45,19 @@ def _run(script: str, *args: str) -> object:
@needs_node
def test_카드_셋_해시():
def test_줄_해시_옵션은_M01():
from test_b01_settings_cards import _cards
cards = _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3)
assert [c[2].split("?")[1].split("&")[0] for c in cards[1:4]] == [
"open=구조물조합",
"open=옵션",
"open=도면",
heads = [c[2].split("?")[0] + "?" + c[2].split("?")[1].split("&")[0] for c in cards[1:6]]
assert heads == [
"m01-master-data?open=로직",
"m01-master-data?open=옵션",
"m02-master-template?open=일위대가조합",
"m02-master-template?open=구조물조합",
"m02-master-template?open=도면",
]
assert all("&root=master" in c[2] for c in cards[1:4])
assert all("&root=master" in c[2] for c in cards[3:6])
@needs_node
@@ -69,7 +76,7 @@ def test_해시_진입_셋():
+ fn.group(0).replace("export function", "function")
+ "\nconsole.log(JSON.stringify(entryFromHash(null)));"
)
for want in ("구조물조합", "옵션", "도면"):
for want in ("구조물조합", "옵션", "도면", "일위대가조합"):
got = _run(script, f"open={want}&root=master&company=3")
assert got["open"] == want and got["root"] == "master" and got["company"] == "3"
@@ -139,3 +146,38 @@ def test_집계표_갈래_기다리는_사이_다른_화면이_열리면_그리
assert got["none"] == ["create", "panel:set"] # 안 끼어들면 그대로 그림
assert got["mod"] == ["panel:null"] # 모듈 기다리는 사이 다른 화면 — 그리지 않음
assert got["num"] == ["panel.destroy", "panel:null"] # 번호 기다리는 사이 — 패널도 버림
@needs_node
def test_옛_옵션_주소_M01로_돌려보냄():
layer = _text(M02 / "M02_MasterTemplete_Layer.ts")
fn = re.search(r"^export function redirectOldOptions\([\s\S]*?^}", layer, re.M)
assert fn, "redirectOldOptions 를 못 찾음"
script = (
"const currentQuery = () => new URLSearchParams(process.argv[1]);\n"
"const ROUTES = { M01_MASTER_DATA: 'm01' }; const sent: string[] = [];\n"
"const navigateTo = (r: string, q: any) => sent.push(r + JSON.stringify(q));\n"
"type SessionUser = any;\n"
+ fn.group(0).replace("export function", "function")
+ "\nconst a = redirectOldOptions({ role: 'SYSTEM_ADMIN' });"
+ "\nconst b = redirectOldOptions({ role: 'USER' });"
+ "\nconst c = redirectOldOptions(null);"
+ "\nconsole.log(JSON.stringify([a, b, c, sent]));"
)
assert _run(script, "open=옵션&root=master") == [True, False, False, ['m01{"open":"옵션"}']]
# 옵션이 아니면 안 돌려보냄
assert _run(script, "open=도면&root=master") == [False, False, False, []]
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
assert "if (redirectOldOptions(user))" in page
assert 'mountSettingsDrawer(user ?? {}, "m02", entry.company);' in page
def test_M01_open_인자_로직_옵션():
m01 = ROOT / "M01_MasterData"
page = _text(m01 / "M01_MasterData_UI_Page.ts")
assert 'if (want === "로직" || want === "옵션") side.reveal(want);' in page
assert "setLeaveGuard(leaveOptions)" in page # 저장 안 한 정의 고침 — 떠날 때 물음
side = _text(m01 / "M01_MasterData_UI_Side.ts")
assert "buildOptionsSection({ ...optionsOpt, isAdmin: true })" in side
# 로직 칸 아래 구분선 부품 + 옵션 칸
assert "logic, createDivider(), optionsSec" in side
+1 -1
View File
@@ -263,7 +263,7 @@ def test_카드_시스템_관리자만_글_다_있음():
links = nav.split("export function mountSettingsDrawer")[0]
admin, rest = links.split('if (user.role === "SYSTEM_ADMIN") {')[1].split("// 회사 수정 =")
assert 'key: "calcSites"' in admin and "calcSites" not in rest
assert 'settingLinks(user, companyId).filter((link) => link.key !== "calcSites")' in nav
assert "calcSites" not in nav.split("export function pageLinks")[1] # ☰ 는 페이지 둘만(716-2)
page = _text("B01_Dashboard/B01_Dashboard_UI_View_SysDefaults.ts")
assert 'link.key === "calcSites" ? void openCalcSitesDialog()' in page
locale = _text("ui_template/ui_template_locale_m1.ts")
+5 -1
View File
@@ -211,6 +211,9 @@ def test_못_잇는_짝(geo):
)
def test_기억_층(tmp_path, monkeypatch, role, layer):
monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer)
monkeypatch.setattr(route.m02_store, "FOLDER", tmp_path / "system")
master = {"a||": {"확정": "earth:9"}, "m||": {"확정": "earth:1"}} # 시스템 승인(718-2)
hopyo_store.remember(tmp_path / "system", master, lk.MEMORY)
session = {"company_id": 7, "user_id": 3, "role": role}
assert route.keep(session, {"a||": {"확정": "earth:4", "이름": "절토(토사)"}}) == layer
assert route.keep(session, {}) is None
@@ -220,7 +223,8 @@ def test_기억_층(tmp_path, monkeypatch, role, layer):
got = route.recalled(session)
assert got["a||"]["확정"] == ("earth:4" if layer == "personal" else "earth:5")
assert got["b||"]["확정"] == "x"
assert route.recalled({"user_id": 3, "role": role}) == {} # 회사 없음 = 층 없음
assert got["m||"]["확정"] == "earth:1" # 회사 · 개인 줄이 마스터보다 앞섬(a = 아래 층)
assert route.recalled({"user_id": 3, "role": role}) == master # 회사 없음 = 마스터만
def _same(a, b):
+179
View File
@@ -0,0 +1,179 @@
"""M02 단가산출(SK) 문서 꼴 · 줄 정체 · 고친 줄(PLAN 715-1a · `M02_Sangeun_Doc`) — 엔진 안 부름."""
from decimal import Decimal
import pytest
from M02_MasterTemplete import M02_Sangeun_Doc as sd
ROW = {
"키": "GX000001",
"결과단위": "원/㎡",
"입력": [{"이름": "높이"}, {"이름": "돌", "고르기": ["깬잡석", "견치돌"]}],
"호표": [
{
"종류": "인력",
"요소": "LB000033",
"이름": "석공",
"수량": "높이 * 0.1",
"비목": "노무비",
},
{"종류": "재료", "요소": {"검색어": "모래"}, "이름": "모래", "수량": "1", "비목": "재료비"},
{"종류": "인력", "요소": "LB000033", "이름": "석공", "수량": "0.2", "비목": "노무비"},
{"종류": "재료", "요소": {"검색어": "자갈"}, "이름": "자갈", "수량": "2", "비목": "재료비"},
{
"종류": "로직",
"요소": "로직(GC000276, 기계='5401-0017')",
"이름": "굴착기",
"수량": "0.5",
},
],
"덧줄": [{"이름": "공구손료", "식": "(석공 * LB000033) * 3 / 100", "비목": "경비"}],
}
def _doc(**extra):
return {
"키": "SK-000001",
"이름": "돌쌓기 내 값",
"단위": "㎡",
"바탕": {"로직": "GX000001"},
**extra,
}
def test_line_ids_kind_key_n():
assert sd.line_ids(ROW) == [
"labor:LB000033#0",
"material:모래#0",
"labor:LB000033#1",
"material:자갈#0",
"logic:GC000276#0",
"pct:공구손료#0",
]
def test_clean_shape_and_refusals():
got = sd.clean(_doc(입력값={"높이": 3, "모름": 1, "돌": ""}, 원값=1), ROW)
assert got["입력값"] == {"높이": 3} and got["종류"] == "단가산출" and got["뺀줄"] == []
for bad, why in (
({"키": "SC-000001"}, "SK-"),
({"이름": " "}, "이름"),
({"단위": ""}, "단위"),
({"바탕": {"참조": {"키": "SC-000001"}}}, "SK-"),
({"덮개": {"labor:LB000033#0": {"qty": "식"}}}, "수가 아님"),
({"덮개": {"labor:LB000033#0": {"qty": 1, "배율": 2}}}, "하나"),
({"덮개": {"labor:LB#x": {"qty": 1}}}, "kind:키#n"),
({"뺀줄": ["material:모래#0", "material:모래#0"]}, "두 번"),
({"열린입력": ["높이"], "입력값": {"높이": 3}}, "열린입력"),
({"더한줄": [{"종류": "인력", "요소": "LB000002"}]}, "수량"),
({"더한줄": [{"종류": "식", "수량": 1}]}, "종류"),
):
with pytest.raises(sd.DocError, match=why):
sd.clean(_doc(**bad), ROW)
# 원값 · 바탕판 = 서버 몫 — 브라우저 값 버림
got = sd.clean(_doc(덮개={"labor:LB000033#0": {"qty": 0.5, "원값": 9}}), ROW)
assert got["덮개"] == {"labor:LB000033#0": {"qty": 0.5}}
def test_open_inputs_only_ratio():
with pytest.raises(sd.DocError, match="배율만"):
sd.clean(_doc(열린입력=["돌"], 덮개={"labor:LB000033#0": {"qty": 1}}), ROW)
got = sd.clean(_doc(열린입력=["돌"], 덮개={"labor:LB000033#0": {"배율": 1.1}}), ROW)
assert got["덮개"]["labor:LB000033#0"] == {"배율": 1.1}
def test_patched_cover_qty_price_ratio():
doc = sd.clean(
_doc(
덮개={
"labor:LB000033#1": {"qty": 0.25, "labor": 200000},
"labor:LB000033#0": {"배율": 1.5},
"logic:GC000276#0": {"expense": 1000},
"pct:공구손료#0": {"qty": 5},
}
),
ROW,
)
got = sd.patched(ROW, doc)
rows = got["줄"]["호표"]
assert rows[0]["수량"] == "(높이 * 0.1) * 1.5"
assert rows[2]["수량"] == "0.25" and rows[2]["단가"] == Decimal(200000)
assert rows[4]["값묶음"] == {"경비": Decimal(1000)}
assert got["줄"]["덧줄"][0]["식"] == "(석공 * LB000033) * 5 / 100"
assert got["짝잃음"] == [] and ROW["호표"][2]["수량"] == "0.2" # 바탕은 안 바뀜
def test_drop_keeps_pick_on_its_line():
"""뺀줄 뒤 고름 제자리 — 자갈(바탕 3째) 고름이 뺀줄 뒤 새 2째로 옮겨 감 · 모래 줄에 안 붙음."""
doc = sd.clean(_doc(뺀줄=["material:모래#0"], 고름={"material:자갈#0": "MT000010"}), ROW)
got = sd.patched(ROW, doc)
names = [x["이름"] for x in got["줄"]["호표"]]
assert names == ["석공", "석공", "자갈", "굴착기"]
assert got["고름"] == {"2": "MT000010"}
assert got["정체"][:4] == [
"labor:LB000033#0",
"labor:LB000033#1",
"material:자갈#0",
"logic:GC000276#0",
]
def test_added_lines_before_extras():
doc = sd.clean(
_doc(
더한줄=[
{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": 0.1},
{
"종류": "로직",
"로직": "GC000001",
"입력값": {"규격": "가", "두께": 2},
"수량": 1,
},
{"참조": {"층": "system", "주인": None, "키": "SK-000002"}, "수량": 2},
]
),
ROW,
)
got = sd.patched(ROW, doc)
added = got["줄"]["호표"][5:]
assert added[0]["비목"] == "노무비" and added[0]["수량"] == "0.1"
assert added[1]["요소"] == "로직(GC000001, 규격='가', 두께=2)"
assert added[2]["참조"]["키"] == "SK-000002" and added[2]["값묶음"] is None
assert got["정체"][5:] == ["add:#0", "add:#1", "add:#2", "pct:공구손료#0"]
def test_lost_covers_kept_and_listed():
"""짝 잃은 덮개 = 문서에 남김 + 목록(지우지 않음) · 비목 다른 단가 · 재료 아닌 고름."""
doc = sd.clean(
_doc(
덮개={"labor:LB999999#0": {"qty": 1}, "labor:LB000033#0": {"material": 5}},
고름={"labor:LB000033#1": "MT000010"},
뺀줄=["material:없음#0"],
),
ROW,
)
got = sd.patched(ROW, doc)
why = {x["cid"]: x["까닭"] for x in got["짝잃음"]}
assert why["labor:LB999999#0"].startswith("덮개") and "비목 다름" in why["labor:LB000033#0"]
assert why["labor:LB000033#1"].startswith("고름") and why["material:없음#0"].startswith("뺀줄")
assert "labor:LB999999#0" in doc["덮개"]
def test_chain_moves_base_picks():
"""바탕 SK 사슬 — 바탕 쪽 차례 고름이 아래 SK 뺀줄 뒤에도 그 줄에."""
first = sd.patched(ROW, sd.clean(_doc(고름={"material:자갈#0": "MT000010"}), ROW))
child = sd.clean({**_doc(뺀줄=["labor:LB000033#0"]), "바탕": None}, first["줄"])
got = sd.patched(first["줄"], child, first["고름"])
assert got["고름"] == {"2": "MT000010"} and got["줄"]["호표"][2]["이름"] == "자갈"
def test_empty_base():
row = sd.empty_row({"키": "SK-000003", "이름": "자체", "단위": "식"})
doc = sd.clean(
{"키": "SK-000003", "이름": "자체", "단위": "식", "바탕": None,
"더한줄": [{"종류": "재료", "요소": "MT000010", "수량": 3, "단가": 1200}]},
row,
) # fmt: skip
got = sd.patched(row, doc)
assert got["줄"]["호표"][0]["단가"] == Decimal(1200) and got["줄"]["결과단위"] == "원/식"
+134
View File
@@ -0,0 +1,134 @@
"""별칭 장부 시스템 모으기(PLAN 718-2 · `M01_MasterData_Alias`) — 회사 · 개인 층 맞춤 +
내역맞춤 모음 · 같은 짝 묶기 · 회사 수 · 사람 수(익명) · 충돌 · 승인 → 마스터 줄 ·
반려 기억 · 다른 회사 새 사용자가 마스터 줄을 씀
· 시스템 관리자만 · master_data 원본 그대로. 층 · storage 는 임시 폴더.
"""
import json
from pathlib import Path
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from B09_Estimation import B09_Estimation_Bill_Link as lk
from B09_Estimation import B09_Estimation_Router_Link as route
from common_util.common_util_auth import require_system_admin, verify_session
from M01_MasterData import M01_MasterData_Alias as alias
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
from M02_MasterTemplete import M02_Hopyo_Store as hs
from M02_MasterTemplete import M02_Template_Layers as layers
# 올리기 길 시험의 `client` 마당(임시 storage · 마스터 층)을 같이 씀
# ruff: noqa: F811
from resources.tester.test_m02_hopyo_excel import client, master_hash # noqa: F401
DIG = {"이름": "구조물터파기(인력100%)", "규격": "토사", "단위": "㎥"}
DIG_KEY = "구조물터파기(인력100%)|토사|m3"
FORM_KEY = "합판거푸집|4회|m2"
CUT_KEY = "토사절취||m3"
ROOT = Path(__file__).resolve().parents[2]
def _pair(code):
return ledger.check(DIG_KEY, {"갈래": "짝", "짝": [{"로직": code, "배": 1}]})
def _seed():
"""회사 7(개인 42 · 43 = 같은 짝) · 회사 8(개인 50 = 다른 짝 · 회사 층 = 안 씀) · 내역맞춤."""
for user, who in ((42, "갑"), (43, "을")):
mine = layers.personal_dir(7, user)
ledger.write(mine, {DIG_KEY: _pair("GF000101")}, {"id": user, "이름": who}, "가공사.xls")
ledger.write(layers.personal_dir(8, 50), {DIG_KEY: _pair("GF000103")}, {"id": 50}, "나.xls")
skip = ledger.check(FORM_KEY, {"갈래": "안 씀"})
ledger.write(layers.company_dir(8), {FORM_KEY: skip}, {"id": 51, "이름": "병"}, "다.xls")
live = {"확정": "earth:4", "이름": "절토(토사)"}
hs.remember(layers.personal_dir(7, 42), {CUT_KEY: live, "a||": {lk.FORGOT: True}}, lk.MEMORY)
hs.remember(layers.company_dir(8), {CUT_KEY: live}, lk.MEMORY)
def _one(got, kind, key, code=None):
rows = [x for x in got["후보"] if (x["종류"], x["열쇠"]) == (kind, key)]
if code:
rows = [x for x in rows if x["짝"][0]["로직"] == code]
assert len(rows) == 1, rows
return rows[0]
def test_모음_묶기_익명_충돌(client):
before = master_hash()
_seed()
got = alias.gather()
assert got["모은곳"] == {"회사": 2, "사람": 5} and got["반려"] == 0
assert len(got["후보"]) == 4 # 터파기 둘(충돌) · 거푸집 안 씀 · 토사절취 내역맞춤(같은 짝 하나)
same = _one(got, alias.ALIAS, DIG_KEY, "GF000101")
assert (same["회사"], same["사람"], same["충돌"], same["마스터"]) == (1, 2, True, None)
assert _one(got, alias.ALIAS, DIG_KEY, "GF000103")["사람"] == 1
form = _one(got, alias.ALIAS, FORM_KEY)
assert (form["갈래"], form["충돌"], form["사람"]) == ("안 씀", False, 1)
cut = _one(got, alias.BILL, CUT_KEY)
assert (cut["회사"], cut["사람"], cut["확정"], cut["충돌"]) == (2, 2, "earth:4", False)
text = json.dumps(got, ensure_ascii=False)
for secret in ("갑", "을", "병", "가공사.xls", "누가", "처음파일", "주인"):
assert secret not in text # 익명 — 회사 수 · 사람 수만
assert master_hash() == before
def test_승인_마스터_줄_반려_기억(client, tmp_path, monkeypatch):
before = master_hash()
_seed()
newbie = layers.personal_dir(9, 99) # 처음 쓰는 다른 회사 사용자
assert ledger.recall(DIG, newbie) is None
sig = _one(alias.gather(), alias.ALIAS, DIG_KEY, "GF000101")["서명"]
got = alias.approve(alias.ALIAS, DIG_KEY, sig, {"id": 1, "이름": "관리자"})
hit = ledger.recall(DIG, newbie)
assert (hit["층"], hit["짝"][0]["로직"]) == ("system", "GF000101") # 승인 → 모두가 씀
master = ledger.read(client.system)
assert master[DIG_KEY]["처음파일"] is None # 마스터 줄에도 남의 파일 이름 없음
other = _one(got, alias.ALIAS, DIG_KEY) # 승인한 짝 = 마스터와 같음 → 후보에서 빠짐
assert other["짝"][0]["로직"] == "GF000103"
assert (other["마스터"], other["충돌"]) == ("다름", True)
got = alias.reject(alias.ALIAS, DIG_KEY, other["서명"], {"id": 1})
assert got["반려"] == 1 and not [x for x in got["후보"] if x["열쇠"] == DIG_KEY]
assert alias.gather()["반려"] == 1 # 반려 기억 = 다시 모아도 안 보임
with pytest.raises(alias.store.StoreError) as error:
alias.approve(alias.ALIAS, DIG_KEY, other["서명"], {"id": 1})
assert error.value.status == 404
with pytest.raises(alias.store.StoreError):
alias.approve("모름", DIG_KEY, sig, {"id": 1})
# 내역맞춤 승인 → 마스터 기억 → 다른 회사 새 사용자 B09 잇기 기억에 나옴
cut = _one(alias.gather(), alias.BILL, CUT_KEY)
alias.approve(alias.BILL, CUT_KEY, cut["서명"], {"id": 1})
assert [r["열쇠"] for r in alias.master_bill()] == [CUT_KEY]
monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer)
user = {"company_id": 9, "user_id": 99, "role": "USER"}
assert route.recalled(user)[CUT_KEY]["확정"] == "earth:4"
assert alias.forget_bill(CUT_KEY) == [] and CUT_KEY not in route.recalled(user)
assert master_hash() == before
def test_길_시스템_관리자만(client):
_seed()
app = FastAPI()
app.include_router(alias.router, dependencies=[Depends(require_system_admin)])
who = {"user_id": 1, "company_id": None, "role": "USER", "name": "갑"}
app.dependency_overrides[verify_session] = lambda: who
web = TestClient(app)
for role in ("USER", "ADMIN"):
who["role"] = role
assert web.get("/api/m01/alias-ledger").status_code == 403
who["role"] = "SYSTEM_ADMIN"
got = web.get("/api/m01/alias-ledger").json()
assert len(got["후보"]) == 4 and got["마스터내역"] == []
form = _one(got, alias.ALIAS, FORM_KEY)
pick = {"종류": alias.ALIAS, "열쇠": FORM_KEY, "서명": form["서명"]}
res = web.post("/api/m01/alias-ledger/approve", json=pick)
assert res.status_code == 200 and len(res.json()["후보"]) == 3
assert ledger.read(client.system)[FORM_KEY]["갈래"] == "안 씀"
assert web.post("/api/m01/alias-ledger/approve", json=pick).status_code == 404
assert web.post("/api/m01/alias-ledger/reject", json={**pick, "서명": "x"}).status_code == 422
gone = web.post("/api/m01/alias-ledger/master-bill/forget", json={"열쇠": CUT_KEY})
assert gone.status_code == 404
main = (ROOT / "main.py").read_text(encoding="utf-8")
at = main.index("M01_MasterData.M01_MasterData_Alias")
assert "dependencies=system_admin_only" in main[at : at + 120]
+61 -15
View File
@@ -58,7 +58,36 @@ def _cards(user: dict, company_id: int | None = None) -> list:
return json.loads(result.stdout)
def _pages(user: dict, company_id: int | None = None) -> list:
# ☰ 메뉴 = pageLinks(settingLinks 의 구조물 조합 줄을 바탕으로 페이지 둘만)
script = (
"const ROUTES = { M01_MASTER_DATA: 'm01-master-data',"
" M02_MASTER_TEMPLATE: 'm02-master-template', B01_ACCOUNT: 'b01-account' };\n"
"const L = (k: string) => k;\n"
"let went = '';\n"
"const navigateTo = (r: string, q?: Record<string, string>) =>"
" { went = r + (q ? '?' + new URLSearchParams(q) : ''); };\n"
+ _fn(_text(NAV), "settingLinks")
+ "\n"
+ _fn(_text(NAV), "pageLinks")
+ "\nconst user = JSON.parse(process.argv[1]);"
+ "\nconst links = pageLinks(user, JSON.parse(process.argv[2]));"
+ "\nconsole.log(JSON.stringify(links.map((l: any) => {"
+ " navigateTo(l.route, l.query); return [l.key, l.title, decodeURIComponent(went)]; })));"
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script, json.dumps(user), json.dumps(company_id)],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
M01 = "m01-master-data"
M02 = "m02-master-template"
READ_SRC = "B01_Dashboard_PermSourceRead"
WRITE_M = "B01_Dashboard_PermMasterWrite"
# 계산 자리(504-2) — 시스템 관리자만 · 이동 대신 창
CALC_SITES = ["B01_CalcSites_Title", "B01_CalcSites_Desc", "calc-sites-dialog"]
READ_CO = (
@@ -68,21 +97,40 @@ WRITE_CO = READ_CO.replace("CompanyRead", "CompanyWrite")
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_시스템_관리자_카드_다섯_고른_회사_해시():
m = "B01_Dashboard_PermMasterWrite"
def test_시스템_관리자_줄_칸별_일곱_고른_회사_해시():
# 716-2 — 대시보드 설계 기본값 = 칸별 줄: M01 3(마스터 요소 · 단가산출 로직 · 구조물 옵션 정의) +
# M02 3(일위대가 · 구조물 조합 · 도면 양식) + 계산 자리
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) == [
["B01_Dashboard_MasterElements", "B01_Dashboard_PermSourceRead", "m01-master-data"],
["B01_Dashboard_CardStructCombo", m, f"{M02}?open=구조물조합&root=master&company=3"],
["B01_Dashboard_CardStructOption", m, f"{M02}?open=옵션&root=master&company=3"],
["B01_Dashboard_CardDrawing", m, f"{M02}?open=도면&root=master&company=3"],
["M01_Title", READ_SRC, M01],
["B01_Dashboard_CardLogic", READ_SRC, f"{M01}?open=로직"],
["B01_Dashboard_CardStructOptionDef", WRITE_M, f"{M01}?open=옵션"],
["B01_Dashboard_CardPrecombo", WRITE_M, f"{M02}?open=일위대가조합&root=master&company=3"],
["B01_Dashboard_CardStructCombo", WRITE_M, f"{M02}?open=구조물조합&root=master&company=3"],
["B01_Dashboard_CardDrawing", WRITE_M, f"{M02}?open=도면&root=master&company=3"],
CALC_SITES,
]
# 회사가 없으면 해시에 안 실음
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[1][2] == (
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[4][2] == (
f"{M02}?open=구조물조합&root=master"
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_메뉴_페이지_둘만_역할별():
# 716-2 — ☰ = M01 · M02 두 페이지(시스템 관리자) / M02 한 페이지(그 밖) · 열쇠 m01 · m02
admin = _pages({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": 1}, 3)
assert admin == [
["m01", "B01_Dashboard_MasterElements", M01],
["m02", "M02_Title", f"{M02}?open=구조물조합&root=personal&company=3"],
]
assert _pages({"role": "ADMIN", "is_master": False, "company_id": None}) == [
["m02", "M02_Title", f"{M02}?open=구조물조합&root=company"],
]
assert _pages({"role": "USER", "is_master": False, "company_id": 1}) == [
["m02", "M02_Title", f"{M02}?open=구조물조합&root=personal"],
]
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_회사_관리자_개인_카드_하나():
company = [
@@ -191,14 +239,12 @@ def test_회사_찾기_거르기():
def test_구조물_조합_기본_층_개인():
"""123-1 — 회사 있는 사람은 역할 무관 구조물 조합 = 개인 · 회사 없으면 지금 규칙 · 다른 곳 그대로 ·
134-4 — 층 없는 주소도 개인(회사 없으면 마스터) · 사전조합 기본 층도 같은 규칙."""
m = "B01_Dashboard_PermMasterWrite"
combo = f"{M02}?open=구조물조합&root=personal"
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": 1}, 3) == [
["B01_Dashboard_MasterElements", "B01_Dashboard_PermSourceRead", "m01-master-data"],
["B01_Dashboard_CardStructCombo", m, f"{combo}&company=3"],
["B01_Dashboard_CardStructOption", m, f"{M02}?open=옵션&root=master&company=3"],
["B01_Dashboard_CardDrawing", m, f"{M02}?open=도면&root=master&company=3"],
CALC_SITES,
rows = _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": 1}, 3)
assert rows[3:6] == [
["B01_Dashboard_CardPrecombo", WRITE_M, f"{M02}?open=일위대가조합&root=personal&company=3"],
["B01_Dashboard_CardStructCombo", WRITE_M, f"{combo}&company=3"],
["B01_Dashboard_CardDrawing", WRITE_M, f"{M02}?open=도면&root=master&company=3"],
]
assert _cards({"role": "ADMIN", "is_master": False, "company_id": 1}) == [
["B01_Dashboard_CardStructCombo", WRITE_CO, combo],
@@ -206,7 +252,7 @@ def test_구조물_조합_기본_층_개인():
assert _cards({"role": "USER", "is_master": True, "company_id": 1})[0][2] == combo
assert _cards({"role": "USER", "is_master": False, "company_id": 1})[0][2] == combo
# 개인 층 없음(회사 없음) = 지금 규칙
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": None})[1][2] == (
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": None})[4][2] == (
f"{M02}?open=구조물조합&root=master"
)
assert _cards({"role": "ADMIN", "is_master": False, "company_id": None})[0][2] == (
+2
View File
@@ -171,4 +171,6 @@ export const ui_locales_m1 = {
B01_CalcSites_Saved: ["계산 자리 저장함", "Calc sites saved"],
B01_CalcSites_Leave: ["저장 안 한 계산 자리가 있음", "You have unsaved calc sites"],
B01_CalcSites_LoadFailed: ["계산 자리를 못 읽음", "Failed to load calc sites"],
B01_Dashboard_CardLogic: ["단가산출 로직", "Cost-logic library"],
B01_Dashboard_CardStructOptionDef: ["구조물 옵션 정의", "Structure option definition"],
} as const;
+1
View File
@@ -188,4 +188,5 @@ export const ui_locales_m2 = {
"그 사이 실무문서가 바뀜 — 목록을 다시 받음",
"Practice documents changed — reloaded",
],
B01_Dashboard_CardPrecombo: ["일위대가", "Unit-cost items"],
} as const;