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

This commit is contained in:
2026-11-02 13:17:54 +09:00
27 changed files with 79 additions and 701 deletions
+1 -1
View File
@@ -324,7 +324,7 @@ def layered(got: dict[str, Any], rate: Any) -> dict[str, Any]:
def from_combo(key: str) -> dict[str, Any]:
"""옛 일위대가 조합(마스터 UA = 실무문서 · 회사 UC · 개인 UP) → 일위대가 문서 몸 —
"""옛 일위대가 조합(마스터 UA · 회사 UC · 개인 UP) → 일위대가 문서 몸 —
담은로직 · 단위 복사 · 입력값 {} · 수량 null · 자재 [] · `본뜬` = 그 조합(층 · 주인 · 키).
없는 조합은 404."""
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
+1 -1
View File
@@ -5,7 +5,7 @@
수량 null · 자재 []) — 여러 열이 쓰던 조합도 열마다 따로 복사
- 키 없는 열 = 빈 일위대가 · 번호 SU 는 저장소가 줌(도번과 같은 뒷자리)
- 몇 번 돌려도 같음 — 이미 그 키를 본뜬 문서 · 사람이 채운 문서(담은로직 · 자재가 있음)는 그대로
옛 조합 파일 · 집계표 열 `일위대가` 칸은 손대지 않음(실무문서 · 은퇴는 39-1b B9).
옛 조합 파일 · 집계표 열 `일위대가` 칸은 손대지 않음.
./venv/Scripts/python.exe M02_MasterTemplete/M02_Labor_Migrate.py # 시스템 층
./venv/Scripts/python.exe M02_MasterTemplete/M02_Labor_Migrate.py --storage # 회사 · 개인 층
@@ -312,13 +312,12 @@ export const fetchStructureCombos = (): Promise<StructureCombos> => scoped("/str
/** 조합 갈라 내기 결과 — `새로만듦` false = 같은 담은 로직의 조합이 이미 있어 그 키를 가리킴 */
/** 항목 하나 복사해 가져오기(39-5 · 39-1b) — 받는 열(`column`)에 덮어씀 · 번호 · 열은 그대로 ·
* `source` = 원본 열 id(실무문서면 조합 키) · `from` 없음 = 지금 층 · 층 · 주인 = 다른 층(원본 읽기 권한) ·
* `practice` = 실무문서 조합 → 일위대가(시스템 관리자만) */
* `source` = 원본 열 id · `from` 없음 = 지금 층 · 층 · 주인 = 다른 층(원본 읽기 권한) */
export const copyPartFrom = async (
kind: "structure" | "labor" | "area",
column: string,
source: string,
from: { layer: Layer | "practice"; owner: string | null } | null,
from: { layer: Layer; owner: string | null } | null,
pair = false,
): Promise<void> => {
const q = new URLSearchParams({ from: source });
@@ -333,11 +332,11 @@ export const copyPartFrom = async (
/** 일위대가 · 면적산출 함께 가져오기(43-9a · 카드 [가져오기]) — 원본 열의 면적산출(시트 · 알약 연결)과
* 일위대가(담은로직 · 입력값 · 수량 · 자재)로 둘 다 덮어씀 · 도면은 안 건드림 ·
* 원본에 면적산출이 없으면(실무문서) 일위대가만 · 인자는 `copyPartFrom` 과 같음 */
* 원본에 면적산출이 없으면 일위대가만 · 인자는 `copyPartFrom` 과 같음 */
export const copyLaborAreaFrom = (
column: string,
source: string,
from: { layer: Layer | "practice"; owner: string | null } | null,
from: { layer: Layer; owner: string | null } | null,
): Promise<void> => copyPartFrom("labor", column, source, from, true);
/** 항목 하나 비우기(40-4 · 카드 [삭제]) — 새 열과 같은 빈 문서 · 번호 · 열 그대로 · 지금 층 */
@@ -277,21 +277,6 @@ def edit(key: str, row: Any, version: str) -> dict[str, Any]:
return {"키": key, "판": _save(doc, version), "combo": fixed}
def drop_practice(version: str) -> dict[str, Any]:
"""실무문서(마스터 조합 표 = 옛 일위대가 조합 · PLAN 39-2) 통째 지우기 — 줄을 모두 비움 ·
구조물이 쓰는 조합이 있으면 409(아무것도 안 지움) · 판 다르면 409 · 마스터 층에서만."""
if heads()[-1] != MASTER:
raise store.StoreError(400, "실무문서는 마스터 층에만 있음")
doc, have = _load()
if have != (version or ""):
raise store.StoreError(409, {"stale": [FILE], "판": have})
keys = [str(r.get("키")) for r in doc.get("줄") or []]
_refuse_used(keys)
if keys:
_m01(combo_store._write, [{"op": "delete", "key": k} for k in keys], have)
return {**listing(), "지운조합": keys}
def delete(key: str, version: str) -> dict[str, Any]:
"""지우기 — 이 층 구조물 조합이 쓰면 409 + 항목 이름들(지우지 않음)."""
if heads()[-1] == MASTER:
@@ -1,10 +1,10 @@
/* =============================================================================
* M02_MasterTemplete_ItemPick.ts
* 가져올 곳 고르기(PLAN 39-5) — 탭(마스터 · 회사 · 개인 · 다른 사람 · 실무문서) + 항목 목록(`ItemList`).
* 가져올 곳 고르기(PLAN 39-5) — 탭(마스터 · 회사 · 개인 · 다른 사람) + 항목 목록(`ItemList`).
* 복사 생성(상세 칸 안 · `buildPicker`) · 복사해 가져오기(창 · `pickFrom`)가 같이 씀.
* 탭 목록 · 기본 탭 = `readableSources`(권한 몫 · `M02_MasterTemplete_Layer.ts`) · 탭은 목록만 바꿈
* (좌측 층 단추와 달리 화면을 다시 안 세움) · 모양은 층 단추(`m02-layers__btn`) 그대로.
* 다른 사람 = 사람 고르기 하나(`fetchOwners`) · 실무문서 = 마스터 일위대가 조합 줄(일위대가 고를 때만).
* 다른 사람 = 사람 고르기 하나(`fetchOwners`).
* ========================================================================== */
import { createButton, createSelectField, el, showConfirmDialog } from "@ui/ui_template_elements";
@@ -16,11 +16,9 @@ import {
ITEM_COLS,
loadItems,
missingPart,
type Col,
type ItemRow,
type ListKind,
} from "./M02_MasterTemplete_ItemList";
import { fetchCombos, type ComboSummary } from "./M02_MasterTemplete_Labor_Api";
import {
entryFromHash,
ownOwner,
@@ -42,28 +40,17 @@ const TEXT = {
makeCopy: "복사 생성",
};
/** 고른 곳 — `practice` = 실무문서(마스터 일위대가 조합) */
/** 고른 곳 */
export interface From {
layer: Layer;
owner: string | null;
label: string;
practice: boolean;
}
export type Picked = { from: From; row: ItemRow } | { from: From; combo: ComboSummary };
export type Picked = { from: From; row: ItemRow };
/** 고른 것 이름 — 확인 글에 씀 */
export const pickedName = (p: Picked): string =>
"row" in p ? leafName(p.row) : `${p.combo.키} ${p.combo.이름}`;
const PRACTICE_COLS: Col<ComboSummary>[] = [
{ head: "키", get: (c) => c.키 },
{ head: "이름", get: (c) => c.이름 },
{ head: "구분", get: (c) => c.구분 ?? "" },
{ head: "상세구분", get: (c) => c.상세구분 ?? "" },
{ head: "단위", get: (c) => c.단위 ?? "" },
{ head: "단가산출 기초 수", get: (c) => String(c.count) },
];
export const pickedName = (p: Picked): string => leafName(p.row);
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
/** 층 폴더가 아직 없는 사람(서버 사람 목록 `비어있음` · 39-6) */
@@ -73,7 +60,7 @@ export interface PickerOptions {
user: SessionUser | null;
/** 볼 회사(`viewCompany`) */
company: string | null;
/** 칸 종류 — 항목 통째(복사 생성) · 도면 · 면적산출 · 일위대가(실무문서 탭이 붙음) */
/** 칸 종류 — 항목 통째(복사 생성) · 도면 · 면적산출 · 일위대가 */
kind: ListKind;
/** 고를 수 없는 줄(받는 열 자신 등) — 목록에서 뺌 */
skip?: (from: From, row: ItemRow) => boolean;
@@ -82,9 +69,7 @@ export interface PickerOptions {
/** 탭 + 목록 — 부르는 쪽이 칸 안 · 창에 붙임 */
export function buildPicker(opt: PickerOptions): { root: HTMLElement } {
const { tabs, first } = readableSources(opt.user, opt.company, {
practice: opt.kind === "labor",
});
const { tabs, first } = readableSources(opt.user, opt.company);
const list = buildItemList();
const people = createSelectField({ options: [], compact: true, label: TEXT.person });
people.root.hidden = true;
@@ -109,12 +94,6 @@ export function buildPicker(opt: PickerOptions): { root: HTMLElement } {
people.root.hidden = tab.id !== "people";
list.message();
try {
if (tab.id === "practice") {
const combos = await fetchCombos();
if (mine !== seq) return;
const from = { layer: tab.layer, owner: null, label: tab.label, practice: true };
return list.show(combos, PRACTICE_COLS, (combo) => opt.onPick({ from, combo }));
}
let owner = tab.owner;
let label = tab.label;
if (tab.id === "people") {
@@ -139,7 +118,7 @@ export function buildPicker(opt: PickerOptions): { root: HTMLElement } {
}
const rows = await loadItems(tab.layer, owner);
if (mine !== seq) return;
const from = { layer: tab.layer, owner, label, practice: false };
const from = { layer: tab.layer, owner, label };
list.show(
rows.filter((row) => !opt.skip?.(from, row)),
ITEM_COLS[opt.kind],
@@ -1,8 +1,6 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Api.ts
* 일위대가 조합 서버 길 — 층(마스터 UA · 회사 UC · 개인 UP) 조합 표 `일위대가조합.json`
* 길 = `/api/m02/layers/{layer}/combos`(PLAN 27-2a) · 키 하나 = `/api/m02/combos/key/{키}` ·
* 담을 로직 읽기 = `/api/m02/logic-lib/…` · 모양 = `_화면_계약.md` 9장
* 일위대가 서버 길 — 담을 로직 읽기 = `/api/m02/logic-lib/…` · 모양 = `_화면_계약.md` 9장
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
@@ -21,61 +19,6 @@ 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[];
}
/** 조합 층 — 층 + 주인(회사 `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[];
}
async function request<T>(url: string, method = "GET", body?: unknown): Promise<T> {
const response = await fetch(`${API_BASE_URL}/m02${url}`, {
method,
@@ -89,25 +32,6 @@ async function request<T>(url: string, method = "GET", body?: unknown): Promise<
}
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.줄);
/** 키 하나 — 어느 층이든(그 층 읽기 권한) · `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.판 }));
/* --- 담을 로직(읽기만 · 로그인) — M01 로직 길과 같은 모양 --- */
export const fetchLibLogics = (): Promise<LogicSummary[]> =>
+3 -14
View File
@@ -131,19 +131,18 @@ export function manages(company: string | null, user: SessionUser | null): boole
/** 가져올 곳 탭 하나 — `people` = 다른 사람(주인은 부르는 쪽이 `fetchOwners` 로 고름) */
export interface Source {
id: "master" | "company" | "personal" | "people" | "practice";
id: "master" | "company" | "personal" | "people";
layer: ManageLayer;
/** 층 주인 — 자기 층 · 마스터 · 실무문서 · 다른 사람 = null */
/** 층 주인 — 자기 층 · 마스터 · 다른 사람 = null */
owner: string | null;
label: string;
}
/** 복사 생성 · 복사해 가져오기의 탭(39-6 · 답 2) — 읽을 수 있는 곳 + 기본 탭.
* 기본 = 시스템 관리자 마스터 · 그 밖 내 개인(회사 없으면 마스터) · `practice` = 일위대가 고를 때만 */
* 기본 = 시스템 관리자 마스터 · 그 밖 내 개인(회사 없으면 마스터) */
export function readableSources(
user: SessionUser | null,
company: string | null,
opt: { practice?: boolean } = {},
): { tabs: Source[]; first: Source["id"] } {
const isSystem = user?.role === "SYSTEM_ADMIN";
const own = ownOwner("personal", user);
@@ -179,16 +178,6 @@ export function readableSources(
},
]
: []),
...(opt.practice && isSystem
? [
{
id: "practice" as const,
layer: "system" as const,
owner: null,
label: L("M02_SourcePractice"),
},
]
: []),
];
return { tabs, first: isSystem || !own ? "master" : "personal" };
}
@@ -1,232 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Practice.ts
* 좌측 「실무문서」 컨테이너(구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만 · PLAN 39-2) —
* 줄 하나를 누르면 메인 칸에 옛 마스터 일위대가 조합(`일위대가조합.json` 통째) 목록 · 상세(읽기만) ·
* [전체 삭제](물음 두 번 → `DELETE /api/m02/practice` · 서버도 시스템 관리자 · 쓰는 곳 0 확인).
* 읽기 = 마스터 조합 길(`Labor_Api`) · 복사 원본 고르기는 목록 화면(ItemList)의 실무문서 탭 몫.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { loadingError, showLoading } from "@ui/ui_template_loading";
import { t as L } from "@ui/ui_template_locale";
import {
fetchCombo,
fetchComboList,
type ComboList,
type ComboOne,
} from "./M02_MasterTemplete_Labor_Api";
import { createEmpty } from "@ui/ui_template_empty";
export interface PracticeSectionOptions {
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
confirmLeave: () => Promise<boolean>;
/** 메인 칸에 화면을 띄움 */
show: (label: string, node: HTMLElement) => (dirty: boolean) => void;
/** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */
onOpen: () => void;
}
export interface PracticeSection {
title: string;
body: HTMLElement;
/** 다른 화면을 열었음 — 고름 표시만 지움 */
clear: () => void;
}
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
/** 서버 까닭 글 — `{errors}` · 판 다름 · 글 */
function reasonOf(detail: unknown, status: number): string {
if (typeof detail === "string") return detail;
const d = detail as { errors?: string[]; stale?: unknown } | null;
if (d?.errors?.length) return d.errors.join(" · ");
return d?.stale ? L("M02_PracticeStale") : `HTTP ${status}`;
}
/** 통째 지우기 — 줄을 모두 비움(판 = 읽은 표 판) */
async function dropPractice(판: string): Promise<void> {
const res = await fetch(`${API_BASE_URL}/m02/practice?판=${encodeURIComponent(판)}`, {
method: "DELETE",
credentials: "include",
});
if (res.ok) return;
const data = (await res.json().catch(() => ({}))) as { detail?: unknown };
throw new Error(reasonOf(data.detail, res.status));
}
const table = (heads: string[], rows: HTMLElement[]): HTMLElement =>
el("div", {
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
children: [
el("table", {
className: "ui-general-block__table m02-practice__table",
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
el("tbody", { children: rows }),
],
}),
],
});
const cells = (values: string[]): HTMLElement[] =>
values.map((v) => el("td", { text: v, attrs: { title: v } }));
export function buildPracticeSection(opt: PracticeSectionOptions): PracticeSection {
const row = el("button", {
className: "m02-side__item ui-sidebar-row",
text: L("M02_PracticeRow"),
attrs: { type: "button", "data-practice-row": "" },
});
const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [row] });
const view = el("div", { className: "m02-practice" });
let opened = false;
let nav = 0; // 늦게 온 응답 버림
const mark = (on: boolean): void => {
opened = on;
row.classList.toggle("is-active", on);
};
row.addEventListener("click", () => void open());
async function open(): Promise<void> {
if (opened || !(await opt.confirmLeave())) return;
opt.show(L("M02_PracticeTitle"), view);
opt.onOpen();
mark(true);
await drawList();
}
async function drawList(): Promise<void> {
const my = ++nav;
const stop = showLoading(view);
let list: ComboList;
try {
list = await fetchComboList();
} catch (error) {
stop();
if (my === nav) view.replaceChildren(loadingError(why(error), () => void drawList()));
return;
} finally {
stop();
}
if (my !== nav) return;
const drop = createButton({ label: L("M02_PracticeDrop"), variant: "danger", size: "sm" });
drop.disabled = !list.줄.length;
drop.addEventListener("click", () => void dropAll(list, drop));
const rows = list.줄.map((c) => {
const tr = el("tr", {
className: "m02-practice__row",
attrs: { "data-practice-key": c.키 },
children: cells([c.키, c.이름, c.구분 ?? "", c.단위 ?? "", String(c.count)]),
});
tr.addEventListener("click", () => void drawOne(c.키));
return tr;
});
view.replaceChildren(
el("div", {
className: "ui-toolbar m02-practice__bar",
children: [
el("span", {
className: "ui-text ui-text--muted",
attrs: { "data-text": "14" },
text: L("M02_PracticeCount").replace("{n}", String(list.줄.length)),
}),
drop,
],
}),
rows.length
? table(
[
L("M02_PracticeKey"),
L("M02_Name"),
L("M02_PracticeGroup"),
L("M02_PracticeUnit"),
L("M02_PracticeLogics"),
],
rows,
)
: createEmpty({ text: L("M02_PracticeEmpty"), small: true }),
);
}
/** 상세 — 읽기만(이름 · 키 · 구분 · 단위 · 비고 + 담은 로직 표) */
async function drawOne(key: string): Promise<void> {
const my = ++nav;
const stop = showLoading(view);
let one: ComboOne;
try {
one = await fetchCombo(key);
} catch (error) {
stop();
if (my === nav) view.replaceChildren(loadingError(why(error), () => void drawOne(key)));
return;
} finally {
stop();
}
if (my !== nav) return;
const back = createButton({ label: L("M02_PracticeBack"), variant: "ghost", size: "sm" });
back.addEventListener("click", () => void drawList());
const c = one.combo;
const info = [
[L("M02_PracticeKey"), c.키],
[L("M02_PracticeGroup"), c.구분 ?? ""],
[L("M02_PracticeUnit"), c.단위 ?? ""],
].map(([k, v]) =>
el("span", {
className: "ui-text ui-text--muted",
attrs: { "data-text": "14" },
text: `${k} ${v}`,
}),
);
const lines = one.줄.map((x) =>
el("tr", {
children: cells([
String(x.차례),
x.로직,
x.ok ? (x.이름 ?? "") : (x.까닭 ?? ""),
x.결과단위 ?? "",
x.메모 ?? "",
]),
}),
);
view.replaceChildren(
el("div", {
className: "ui-toolbar m02-practice__bar",
children: [back, el("strong", { text: c.이름 }), ...info],
}),
...(c.비고 ? [el("p", { className: "m02-practice__memo", text: c.비고 })] : []),
lines.length
? table(
[
L("M02_PracticeOrder"),
L("M02_PracticeKey"),
L("M02_Name"),
L("M02_PracticeUnit"),
L("M02_PracticeMemo"),
],
lines,
)
: createEmpty({ text: L("M02_PracticeNoLogic"), small: true }),
);
}
/** 통째 지우기 — 물음 두 번 · 서버가 쓰는 곳 · 판을 다시 봄 */
async function dropAll(list: ComboList, button: HTMLButtonElement): Promise<void> {
const n = String(list.줄.length);
if (!(await showConfirmDialog(L("M02_PracticeDropAsk").replace("{n}", n), L("M02_Delete"))))
return;
if (!(await showConfirmDialog(L("M02_PracticeDropAsk2"), L("M02_PracticeDrop")))) return;
button.disabled = true;
try {
await dropPractice(list.판);
showToast(L("M02_PracticeDropped").replace("{n}", n), "success");
} catch (error) {
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
}
await drawList();
}
return { title: L("M02_PracticeTitle"), body, clear: () => mark(false) };
}
@@ -1,12 +1,8 @@
"""M02 일위대가 조합 층 API(PLAN 27-2a) — `/api/m02/layers/{layer}/combos`(옛 `/api/m01/combo*`).
"""M02 담을 로직 읽기 API — `/api/m02/logic-lib/…`(로그인만).
⚠ 등록은 `main.py`(로그인만) · 층 권한은 `M02_Layer_Access`(`Router_Items._root`) 한 규칙 ·
읽기만(목록 · 키 하나) + 실무문서 통째 지우기 — 갈라 내기 · 키 적기 · 만들기 · 고치기 · 지우기 ·
알약 길은 은퇴(39-1d · 정본 = 열마다 `labor/<열>.json`).
저장소 = `M02_MasterTemplete_Combo_Store`.
⚠ 등록은 `main.py`(로그인만). 조합 목록 · 키 하나 · 통째 지우기 길은 없앰(732-1).
담을 로직 고르기 · 일위대가 입력 모달이 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은
`/api/m02/logic-lib/…` 읽기만(로그인) — 모양은 M01 길과 같음.
39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만(`_door` · 담을 로직 읽기 뺌).
85-4 — 원문 패널 읽기(`/logic-lib/source` · M01 길과 같은 몸) · 원문 pdf 통째
(`/logic-lib/source-pdf` · 책 이름만 받음 · 경로 안 받음)도 로그인만.
704 — 기준 목록(`/logic-lib/standard`) · 계산서(`/logic-lib/sheet`) 로그인만(`M02_Logic_Standard`).
@@ -26,61 +22,14 @@ from common_util.common_util_http_cache import cached_file_response
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Make as make
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_Logic_Standard as standard
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
def _door(request: Request, session: Session = Depends(verify_session)) -> None:
"""일위대가 조합 = 실무문서(39-6) — 조합 길 전부 시스템 관리자만 · 담을 로직 읽기는 로그인만."""
if "/logic-lib/" not in request.url.path and not access.is_system_admin(session):
raise HTTPException(status_code=403, detail="실무문서(일위대가 조합)는 시스템 관리자만 봄.")
router = APIRouter(
prefix="/api/m02", tags=["M02 MasterTemplete Combos"], dependencies=[Depends(_door)]
)
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Combos"])
if "pytest" not in sys.modules: # 기준 목록을 뒤에서 한 번 셈(저장본이 있으면 읽기만)
standard.warm()
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("/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.delete("/practice")
def delete_practice(판: str = "", session: Session = Depends(verify_session)) -> dict:
"""실무문서 통째 지우기(PLAN 39-2) — 시스템 관리자만 · 마스터 조합 표 줄을 모두 비움 ·
구조물이 쓰는 조합이 있으면 409."""
if not access.is_system_admin(session):
raise HTTPException(status_code=403, detail="실무문서는 시스템 관리자만")
return _call(_root(session, "system", None, write=True), combos.drop_practice, 판)
# ── 담을 로직(읽기만) — M01 `/logics` · `/subs?group=로직` · `/logic` · `/logic/auto` 모양 ──
def _read(fn, *args):
try:
@@ -301,15 +301,6 @@ def post_structure(
return _call(_root(session, layer, owner, write=True), store.create_structure, column)
def _copy_practice(column: str, key: str) -> dict[str, Any]:
"""실무문서 조합(마스터 UA) → 이 열 일위대가 문서(입력값 빈 값 · 39-1b)."""
from M02_MasterTemplete import M02_Labor_Doc as labor
if not key.startswith("UA"):
raise store.StoreError(400, f"실무문서 조합 키가 아님 「{key}」")
return store.copy_from("labor", column, key, body=labor.from_combo(key))
class AreaTableBody(BaseModel):
"""면적산출 범위 → 도면 표(41-1) — `시트` = 시트 id · 이름 · `범위` · `이름`(104-1) 이 있으면
그 이름이 지금 가리키는 범위로 품(연결표 다시 읽을 때) · `글자크기` 기본 2.5."""
@@ -358,23 +349,16 @@ def post_copy(
column: str,
source: str = Query(alias="from"),
owner: str | None = None,
from_layer: Literal["system", "company", "personal", "practice"] | None = None,
from_layer: Literal["system", "company", "personal"] | None = None,
from_owner: str | None = None,
pair: bool = False,
session: Session = Depends(verify_session),
):
"""항목 하나 복사해 가져오기(덮어씀 · 번호 · 열 그대로) — 원본 = 같은 층(`from_layer` 없음) ·
다른 층(원본 읽기 권한) · 실무문서 조합(`practice` · 일위대가만 · 시스템 관리자만) ·
`pair` = 일위대가 · 면적산출 함께(43-9a · 답 `{area?, labor}` · 실무문서는 일위대가만)."""
다른 층(원본 읽기 권한) ·
`pair` = 일위대가 · 면적산출 함께(43-9a · 답 `{area?, labor}`)."""
folder = _root(session, layer, owner, write=True)
part = "structure" if kind == "structures" else kind
if from_layer == "practice":
if part not in (("labor", "area") if pair else ("labor",)) or not access.is_system_admin(
session
):
raise HTTPException(status_code=403, detail="실무문서는 시스템 관리자가 일위대가로만")
got = _call(folder, _copy_practice, column, source)
return {"labor": got} if pair else got
origin = _root(session, from_layer, from_owner) if from_layer else None
if pair and part != "structure":
return _call(folder, store.copy_pair, column, source, origin)
@@ -449,7 +449,7 @@ def copy_from(
"""다른 열(`source`) 내용을 받는 열(`column`)로 가져옴 — 번호 · 열은 받는 쪽 그대로(두 열은
따로 움직임) · 통합문서는 알약 `연결` 도 같이 오고 계산값은 서버가 다시 풂 ·
`source_root` = 원본 층 폴더(없으면 지금 층 · 권한은 부르는 쪽) · `body` = 원본 문서를 바로 줌
(실무문서 조합 → 일위대가 · 39-1b)."""
(예: 문서를 만들어 바로 담음)."""
if kind not in _KEY:
raise StoreError(404, f"없는 종류 「{kind}」")
target = read(kind, column)["문서"]
@@ -472,18 +472,17 @@ def copy_pair(
column: str,
source: str,
source_root: Path | None = None,
labor_body: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""일위대가 ↔ 면적산출 함께 가져오기(43-9a) — 같은 원본 열의 면적산출(시트 · 알약 `연결`)과
일위대가(담은로직 · 입력값 · 수량 · 자재)로 둘 다 덮어씀 · 도면은 안 건드림 ·
원본에 면적산출이 없으면(실무문서 `labor_body` 포함) 일위대가만 · 면적 먼저(일위대가 계산값이
원본에 면적산출이 없으면 일위대가만 · 면적 먼저(일위대가 계산값이
새 면적 칸 값을 읽음 · 면적 계산이 막히면 일위대가도 안 씀)."""
with at(source_root or root()):
has_area = labor_body is None and _path("area", source).is_file()
has_area = _path("area", source).is_file()
out: dict[str, Any] = {}
if has_area:
out["area"] = copy_from("area", column, source, source_root)
out["labor"] = copy_from("labor", column, source, source_root, labor_body)
out["labor"] = copy_from("labor", column, source, source_root)
return out
@@ -304,7 +304,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
/** 다른 항목의 도면 · 면적산출(…)을 골라 내용만 복사(39-5) — 탭 = 읽을 수 있는 곳 · 덮어쓰기 확인 ·
* 번호 · 열은 그대로 · 보고 있던 열이면 다시 읽음 · 집계표 줄 「조회 후 선택」 · 카드 「가져오기」가 씀 ·
* 일위대가 · 면적산출은 함께(43-9b · 알약 연결이 이어져 있음 · 실무문서면 서버가 일위대가만) · 도면은 그 칸만 */
* 일위대가 · 면적산출은 함께(43-9b · 알약 연결이 이어져 있음) · 도면은 그 칸만 */
const pickCopy = async (kind: CardKind, to: StructureColumn | undefined): Promise<void> => {
const pair = kind !== "structure";
const word = PART_WORD[kind];
@@ -335,13 +335,11 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
const copy = (source: string, from: Parameters<typeof copyPartFrom>[3]) =>
pair ? copyLaborAreaFrom(to.id, source, from) : copyPartFrom(kind, to.id, source, from);
try {
// 같은 곳 = 다른 열 · 다른 층 · 사람 = 원본 층 · 실무문서 = 조합 키 → 일위대가(39-1b)
if ("combo" in picked) await copy(picked.combo.키, { layer: "practice", owner: null });
else
await copy(
picked.row.열,
same(picked.from) ? null : { layer: picked.from.layer, owner: picked.from.owner },
);
// 같은 곳 = 다른 열 · 다른 층 · 사람 = 원본 층
await copy(
picked.row.열,
same(picked.from) ? null : { layer: picked.from.layer, owner: picked.from.owner },
);
showToast(TEXT.copied, "success");
if (column === to.id) void load(shown);
} catch (error) {
@@ -2,9 +2,9 @@
* M02_MasterTemplete_UI_Side.ts
* 마스터 템플릿 좌측 패널 차례(716-3 · 717-2a) — 「단가산출」(층 단추 따로 · 기본 개인 · `UI_Side_Sangeun`) › 「일위대가」(층 단추 따로 · 2단 폴더 나무 · `UI_Side_Precombo` ·
* 50-3 · 134-3) › 「구조물 조합」(층 단추 · 나무) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 ·
* `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기) › 실무문서.
* `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기).
* 구조물 옵션 정의는 M01 로 옮김(716-1).
* 도면 양식 · 실무문서 = 시스템 관리자면 늘(마스터 층 것 · 구조물 조합 층과 무관 · 720-4).
* 도면 양식 = 시스템 관리자면 늘(마스터 층 것 · 구조물 조합 층과 무관 · 720-4).
* 서버 길은 계약 `6_계약.md`.
* 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2).
* 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로.
@@ -32,7 +32,6 @@ import {
type TemplateInfo,
} from "./M02_MasterTemplete_Api_Fetch";
import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
import { buildPracticeSection } from "./M02_MasterTemplete_Practice";
import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Check";
import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";
import type { Selection } from "./M02_MasterTemplete_UI_Main";
@@ -55,7 +54,7 @@ const STUB_DRAWING = {
};
export interface SideOptions {
/** 시스템 관리자 — 도면 양식(시스템 층에만 있음) · 실무문서 칸을 늘 보임(720-4) */
/** 시스템 관리자 — 도면 양식(시스템 층에만 있음)을 늘 보임(720-4) */
isSystem: boolean;
/** 지금 구조물 조합 층을 고칠 수 있나(층이 제자리에서 바뀜) — 일위대가 불러오기 장부 */
canWrite: () => boolean;
@@ -163,20 +162,12 @@ export function buildSide(opt: SideOptions): SideHandle {
document.addEventListener(HOPYO_BUILT, onBuilt);
// 도면 양식 칸 = 누구나 늘(읽기 · 고르기) · 새로 · 본떠 · 지우기 단추만 시스템 관리자
const drawing = section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing"));
// 실무문서(옛 마스터 일위대가 조합 · PLAN 39-2) — 구조물 조합 밖 · 도면 양식 아래 · 시스템 관리자만
const practice = opt.isSystem
? buildPracticeSection({
confirmLeave: opt.confirmLeave,
show: opt.showView,
onOpen: paint,
})
: null;
// 일위대가 불러오기(324-4 · 제목은 718-1) · 층 = 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐
const hopyo = buildHopyoSection({
canWrite: opt.canWrite,
confirmLeave: opt.confirmLeave,
show: opt.showView,
onOpen: () => (practice?.clear(), paint()),
onOpen: paint,
});
const listHost = el("div", {
className: "m02-side__lists ui-sidebar-panel",
@@ -187,7 +178,6 @@ export function buildSide(opt: SideOptions): SideHandle {
createDivider(),
section(hopyo.title, hopyo.body),
drawing,
...(practice ? [section(practice.title, practice.body)] : []),
],
});
attachCollapsible(listHost);
@@ -236,15 +226,13 @@ export function buildSide(opt: SideOptions): SideHandle {
for (const [kind, b] of editButtons) b.copy.disabled = b.del.disabled = cur?.종류 !== kind;
}
/** 고름 표시는 여기서만 — 목록 · 실무문서 줄을 함께 그려야 두 줄이 동시에 안 남음
* (양식이 열려 있으면 실무문서 고름은 지움) */
/** 고름 표시는 여기서만 */
function paint(): void {
paintList();
sangeun.paint();
precombo.paint();
hopyo.paint();
paintButtons();
if (opt.getOpen()) practice?.clear();
}
// 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음)
@@ -10,40 +10,6 @@
font-size: var(--text-body-sm);
}
/* 실무문서 화면(PLAN 39-2) — 위 막대(개수 · 통째 지우기 / 목록으로 · 이름 · 정보) + 표 */
.m02-practice {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m02-practice__bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m02-practice__bar > button:last-child:not(:first-child) {
margin-left: auto;
}
.m02-practice__memo {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
white-space: pre-wrap;
}
.m02-practice__row {
cursor: pointer;
}
.m02-practice__row:hover {
background: var(--color-accent-soft);
}
.m02-side__row {
display: flex;
flex-wrap: wrap;
@@ -78,7 +78,7 @@ def test_화면_층_단추_최상위_폴더_이름_바꾸기():
def test_구조물_조합_층_제자리_바꿈():
"""720-4 — 해시 이동 없음 · replaceState · 나무 · 층 줄 · 띠를 감싼 칸에 갈아 끼움 · 권한 = 함수 ·
도면 양식 = 누구나(단추는 관리자) · 실무문서 = 시스템 관리자 · 불러오기 장부 = 열 때 층 · 권한."""
도면 양식 = 누구나(단추는 관리자) · 불러오기 장부 = 열 때 층 · 권한."""
page = _text("M02_MasterTemplete_UI_Page.ts")
assert "navigateTo" not in page
switch = page.split("const switchStructure = async")[1].split("const go =")[0]
@@ -91,10 +91,10 @@ def test_구조물_조합_층_제자리_바꿈():
assert "() => isAdmin," in page and 'isSystem: user?.role === "SYSTEM_ADMIN",' in page
assert "main.root.isConnected ? void afterItem()" in page # 나무를 갈아도 듣기 그대로
side = _text("M02_MasterTemplete_UI_Side.ts")
assert "const drawing = section(" in side and "const practice = opt.isSystem" in side
assert "const drawing = section(" in side and "practice" not in side # 732-1
# 도면 양식 칸 = 누구나 · 단추만 관리자(720-4 덧)
assert "const drawing = opt.isSystem" not in side
assert 'if (!opt.isSystem) return [];' in side
assert "if (!opt.isSystem) return [];" in side
assert "items = await listTemplates(layer, null);" in side
hopyo = _text("M02_MasterTemplete_Hopyo_Side.ts")
assert "buildHopyoLedger({ scope: pageScope(), canWrite: opt.canWrite() })" in hopyo
+4 -5
View File
@@ -1,6 +1,6 @@
"""일위대가 조합(PLAN 4-2 · 27-2a) — 담은 단가산출 로직 목록만 두고 미리 보기로 비목별 합계.
길 = M02 층 길 마스터(`/api/m02/layers/system/combos` · 옛 `/api/m01/combo*` 를 옮김).
읽기 = M02 조합 저장소(`combos.one` · `combos.listing` · 서버 길은 없앰 732-1).
계약 `resources/master_data/_화면_계약.md` 9장 · 틀 `_틀.md` 10장 · 엔진 `scripts/master_combo.py`.
못박는 것
@@ -39,7 +39,6 @@ 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}
@@ -87,12 +86,12 @@ def _new(client: TestClient, body: dict, want: int = 200) -> dict:
def _one(client: TestClient, key: str) -> dict:
got = _get(client, f"{SYS}/{key}")
got = combos.one(key)
return {"version": got["판"], **got}
def _list(client: TestClient) -> list[dict]:
return _get(client, SYS)["줄"]
return combos.listing()["줄"]
def _edit(client: TestClient, key: str, version: str, row: dict, want: int = 200) -> dict:
@@ -177,7 +176,7 @@ def test_목록은_소유_담은_수_폴더를_준다(client: TestClient) -> Non
"""거르기(구분 · 소유 · 찾기)는 화면 몫 — 층 목록은 통째 · 폴더 = [구분, 상세구분]."""
_make(client, 이름="돌쌓기 한 벌")
_make(client, keys=(GATHER,), 이름="채집만", owner="공용")
got = _get(client, SYS)
got = combos.listing()
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
+9 -8
View File
@@ -1,4 +1,4 @@
"""조합 알약(`GET /api/m02/layers/system/combos/{키}/inputs`) — 결과단위 분모가 물량 하나.
"""조합 알약(저장소 `combo_inputs`) — 결과단위 분모가 물량 하나.
못박는 것
- 물량 = 로직 결과단위 분모(원/㎥ → ㎥) · 설계 조건(고르기 · 범위)은 안 넣음(`조건` 은 빈 자리).
@@ -26,7 +26,6 @@ from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
from M02_MasterTemplete import M02_MasterTemplete_Store as m02
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}
@@ -118,22 +117,24 @@ 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"{SYS}/{key}").json()["판"]
version = combos.one(key)["판"]
with pytest.raises(m02.StoreError) as caught: # 지우기 길은 은퇴(39-1d) — 저장소 함수로
combos.delete(key, version)
assert caught.value.status == 409
detail = caught.value.detail
assert detail["users"] == ["돌기슭막이 찰쌓기"]
assert "돌기슭막이 찰쌓기" in detail["errors"][0]
assert own.get(f"{SYS}/{key}").status_code == 200 # 그대로 남음
assert combos.one(key)["combo"]["키"] == key # 그대로 남음
def test_안_쓰는_조합은_지워진다(own) -> None:
key = _new(own, "안 쓰는 조합", ["GF000222"])
_use(combo_store.USERS[0], "UA000009") # 다른 조합을 씀
version = own.get(f"{SYS}/{key}").json()["판"]
version = combos.one(key)["판"]
combos.delete(key, version)
assert own.get(f"{SYS}/{key}").status_code == 404
with pytest.raises(m02.StoreError) as gone:
combos.one(key)
assert gone.value.status == 404
def test_본뜬키는_목록에_나오고_없는_키_자기_키는_400(own) -> None:
@@ -141,13 +142,13 @@ def test_본뜬키는_목록에_나오고_없는_키_자기_키는_400(own) -> N
combo = {"이름": "본뜬 것", "본뜬키": base, "담은로직": [{"로직": "GF000222", "메모": None}]}
got = combos.create(combo)
assert got["combo"]["본뜬키"] == base
rows = {r["키"]: r for r in own.get(SYS).json()["줄"]}
rows = {r["키"]: r for r in combos.listing()["줄"]}
assert rows[got["키"]]["본뜬키"] == base and rows[base]["본뜬키"] is None
bad = {**combo, "이름": "없는 원본", "본뜬키": "UA999999"}
with pytest.raises(m02.StoreError) as caught:
combos.create(bad)
assert caught.value.status == 400
version = own.get(f"{SYS}/{base}").json()["판"]
version = combos.one(base)["판"]
me = {"이름": "원본", "본뜬키": base, "담은로직": combo["담은로직"]}
with pytest.raises(store.StoreError) as caught: # 조합 고치기 길(PUT) 은퇴 — 저장소 함수로
combo_store.combo_edit(base, version, me)
+11 -42
View File
@@ -8,6 +8,7 @@
- 만들기 · 고치기 · 지우기 · 알약 · 고르기(use-combo) · 갈라 내기 길은 은퇴(39-1d) — 만들기 ·
지우기는 층 문(`_root`) + 저장소 함수로 봄.
- 세 층 목록 · 키 하나 읽기 · 조합 이름 · 알약(저장소) · 스냅샷 원본이 회사 · 개인 조합도 읽음.
- 조합 목록 · 키 하나 · 통째 지우기 서버 길은 없앰(732-1) — 읽기는 저장소 함수로 봄.
정본(마스터 · 마스터 템플릿)은 읽기만 · 쓰기는 임시 storage 와 마스터 사본.
"""
@@ -52,27 +53,9 @@ def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
app.include_router(items_router.router)
app.include_router(combos_router.router)
app.dependency_overrides[verify_session] = lambda: session
# 문(39-6 · 시스템 관리자만)은 `test_조합_길은_시스템_관리자만` 이 봄 — 여기는 그 안의 층 규칙
app.dependency_overrides[combos_router._door] = lambda: None
return {"client": TestClient(app), "session": session, "storage": storage}
def test_조합_길은_시스템_관리자만(world):
"""39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만 · 담을 로직은 로그인만."""
world["client"].app.dependency_overrides.pop(combos_router._door)
for role in ("USER", "ADMIN"):
client = _as(world, role)
assert client.get(URL.format("system")).status_code == 403
assert client.get("/api/m02/combos/key/UA000001").status_code == 403
assert client.get("/api/m02/logic-lib/logics").status_code == 200
world["session"]["is_master"] = True
assert _as(world, "USER").get(URL.format("system")).status_code == 403 # 회사 마스터도
world["session"]["is_master"] = False
admin = _as(world, "SYSTEM_ADMIN")
assert admin.get(URL.format("system")).status_code == 200
assert admin.get(URL.format("company")).status_code == 200
def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient:
world["session"].update(role=role, user_id=user)
return world["client"]
@@ -99,12 +82,6 @@ def _new(client: TestClient, layer: str, combo: dict, want: int = 200, **params)
return got if code == 200 else {"detail": detail}
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 test_회사_개인_키_장부_지운_번호는_다시_안_줌(world):
boss = _as(world, "ADMIN")
@@ -120,7 +97,8 @@ def test_회사_개인_키_장부_지운_번호는_다시_안_줌(world):
with store.at(layers.personal_dir(7, 42)): # 지우기 길은 은퇴(39-1d) — 저장소 함수로
combos.delete(mine["키"], mine["판"])
assert _new(me, "personal", _combo("다음 것"))["키"] == "UP42-000002"
got = me.get(f"{URL.format('personal')}/UP42-000002").json()
with store.at(layers.personal_dir(7, 42)):
got = combos.one("UP42-000002")
assert [x["로직"] for x in got["줄"]] == [A] and got["blocked"] is False
@@ -129,9 +107,6 @@ def test_권한_층_규칙(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
@@ -151,16 +126,15 @@ def test_검사_빈_조합_이름_겹침_본뜬키_판(world):
def test_조합_쓰기_알약_길은_은퇴(world):
"""39-1d — 만들기 · 고치기 · 지우기 · 알약 길 없음(읽기 · 실무문서 통째 지우기만 남음)."""
"""39-1d — 만들기 · 고치기 · 지우기 · 알약 길 없음(조합 서버 길 자체가 없음 · 732-1)."""
me = _as(world, "SYSTEM_ADMIN")
key = _new(me, "personal", _combo("원본"))["키"]
url = f"{URL.format('personal')}/{key}"
assert me.post(URL.format("personal"), json={"combo": _combo("새")}).status_code == 405
assert me.put(url, json={"combo": _combo("고침")}).status_code == 405
assert me.delete(url, params={"판": ""}).status_code == 405
assert me.get(f"{url}/inputs").status_code == 404
assert me.post(URL.format("personal"), json={"combo": _combo("새")}).status_code in (404, 405)
assert me.put(url, json={"combo": _combo("고침")}).status_code in (404, 405)
assert me.delete(url, params={"판": ""}).status_code in (404, 405)
assert me.get(url).status_code in (404, 405)
assert me.get(f"/api/m02/combos/key/{key}/inputs").status_code == 404
assert me.get(url).status_code == 200 # 읽기는 남음
# ── 마스터 사본 ───────────────────────────────────────────────────────
@@ -184,7 +158,7 @@ def master_copy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
# ── 세 층 고르기 · 갈라 내기 · 읽는 곳 ─────────────────────────────────
def test_세_층_목록_고르기_남의_층_403_갈라_낸_것은_그_층에(world):
def test_세_층_조합_찾기_갈라_낸_것은_그_층에(world):
boss = _as(world, "ADMIN")
uc = _new(boss, "company", _combo("회사 조합", (A, B)))["키"]
me = _as(world)
@@ -192,13 +166,8 @@ def test_세_층_목록_고르기_남의_층_403_갈라_낸_것은_그_층에(wo
assert me.get("/api/m02/combos/all").status_code == 404 # 세 층 고르기 길 은퇴(39-1d)
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
assert combos.where(up) == ("personal", "7/42") and combos.where(uc) == ("company", "7")
assert me.get(f"/api/m02/combos/key/{up}").status_code == 404 # 키 하나 읽기 길 없앰(732-1)
# 고르기(use-combo) · 갈라 내기 길은 은퇴(39-1d) — 열마다 labor 문서
base = "/api/m02/layers/personal/structures/c1"
assert me.post(f"{base}/use-combo", json={"종류": "일위대가", "키": up}).status_code == 404
-14
View File
@@ -96,20 +96,6 @@ def test_권한_없는_원본_층_403_없는_원본_404(world):
assert client.post(URL.format("system", "labor", "c1") + "?from=rv").status_code == 403
def test_실무문서_조합에서_일위대가로_시스템_관리자만(world):
url = URL.format("system", "labor", "c1") + "?from=UA000008&from_layer=practice"
assert _as(world).post(url).status_code == 403
admin = _as(world, "SYSTEM_ADMIN")
assert admin.post(url).status_code == 200
c1 = _labor(world["system"], "c1")
assert c1["본뜬"] == {"층": "system", "주인": None, "키": "UA000008"}
assert len(c1["담은로직"]) == 14 and all(x["입력값"] == {} for x in c1["담은로직"])
area = URL.format("system", "area", "c1") + "?from=UA000008&from_layer=practice"
assert admin.post(area).status_code == 403 # 실무문서는 일위대가로만
bad = URL.format("system", "labor", "c1") + "?from=UP1-000001&from_layer=practice"
assert admin.post(bad).status_code == 400
def test_구조물_조합_복사는_새_일위대가번_조합_키는_안_받음(world):
system, personal = world["system"], world["personal"]
with store.at(system):
+8 -8
View File
@@ -97,8 +97,8 @@ def test_주소_인자를_라우트에서_뗌():
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_가져올_곳_탭():
"""39-6 · 답 2 — 탭 = 마스터 · 볼 회사 · 내 개인 · 다른 사람(맡은 회사) · 실무문서(시스템 관리자 ·
일위대가) · 기본 = 시스템 관리자 마스터 · 그 밖 내 개인(회사 없으면 마스터)."""
"""39-6 · 답 2 — 탭 = 마스터 · 볼 회사 · 내 개인 · 다른 사람(맡은 회사) ·
기본 = 시스템 관리자 마스터 · 그 밖 내 개인(회사 없으면 마스터)."""
text = _text(LAYER)
parts = [
re.search(r"^export const ownOwner[\s\S]*?;\n", text, re.M),
@@ -114,8 +114,8 @@ def test_가져올_곳_탭():
"const L = (key: string): string => key;\n"
+ "\n".join(p.group(0).replace("export ", "") for p in parts if p)
+ "\nconst cases = JSON.parse(process.argv[1]);"
+ "\nconsole.log(JSON.stringify(cases.map(([u, c, practice]) => {"
+ "\n const got = readableSources(u, c, { practice });"
+ "\nconsole.log(JSON.stringify(cases.map(([u, c]) => {"
+ "\n const got = readableSources(u, c);"
+ "\n return [got.first, got.tabs.map((t) => t.id + (t.owner ? ':' + t.owner : ''))];"
+ "\n})));"
)
@@ -125,8 +125,8 @@ def test_가져올_곳_탭():
user = {"id": "7", "role": "USER", "company_id": 3}
orphan = {"id": "8", "role": "USER", "company_id": None}
cases = [
[sys1, "3", True], [sys1, "1", False], [admin, "3", True], [master, "3", False],
[user, "3", True], [orphan, None, False],
[sys1, "3"], [sys1, "1"], [admin, "3"], [master, "3"],
[user, "3"], [orphan, None],
] # fmt: skip
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
@@ -135,9 +135,9 @@ def test_가져올_곳_탭():
) # fmt: skip
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == [
["master", ["master", "company:3", "personal", "people", "practice"]], # 고객 회사 봄
["master", ["master", "company:3", "personal", "people"]], # 고객 회사 봄
["master", ["master", "company", "personal", "people"]], # 자기 회사 · 일위대가 아님
["personal", ["master", "company", "personal", "people"]], # 회사 관리자 · 실무문서 없음
["personal", ["master", "company", "personal", "people"]], # 회사 관리자
["personal", ["master", "company", "personal", "people"]], # is_master = 회사 관리자
["personal", ["master", "company", "personal"]], # 일반 = 다른 사람 없음
["master", ["master"]], # 회사 없음
-13
View File
@@ -68,19 +68,6 @@ def test_pair_없으면_예전처럼_한_칸만(world): # noqa: F811
assert "연결" not in _doc(personal, "area", "a")["시트"][0]
def test_실무문서는_일위대가만_면적_그대로(world): # noqa: F811
system = world["system"]
_link_rv(system)
area = _doc(system, "area", "rv")
for kind in ("labor", "area"):
url = URL.format("system", kind, "rv") + "?from=UA000008&from_layer=practice&pair=true"
assert _as(world).post(url).status_code == 403 # 시스템 관리자만
got = _as(world, "SYSTEM_ADMIN").post(url)
assert got.status_code == 200 and set(got.json()) == {"labor"}
assert len(_doc(system, "labor", "rv")["담은로직"]) == 14
assert _doc(system, "area", "rv") == area
def test_원본에_면적산출_없으면_일위대가만(world): # noqa: F811
system, personal = world["system"], world["personal"]
(system / "area" / "rv.json").unlink()
-59
View File
@@ -1,59 +0,0 @@
"""실무문서(PLAN 39-2) — 옛 마스터 일위대가 조합 표 통째 · 시스템 관리자만 통째 지우기.
못박는 것
- `DELETE /api/m02/practice?판=` = 시스템 관리자만(그 밖 403) · 판 다르면 409 ·
구조물이 쓰는 조합이 있으면 409(아무것도 안 지움) · 지우면 줄 0 · 마스터 틀 검사 그대로.
- 화면(`Practice.ts`) = 마스터 층 · 시스템 관리자만 · 물음 두 번 · 상세 읽기만.
정본(마스터)은 읽기만 · 쓰기는 임시 사본.
"""
from __future__ import annotations
import json
from pathlib import Path
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 resources.tester.test_m02_combo_layers import _as, _list, master_copy, world # noqa: F401
ROOT = Path(__file__).resolve().parents[2]
M02 = ROOT / "M02_MasterTemplete"
def _drop(client, 판: str, want: int) -> dict:
res = client.delete("/api/m02/practice", params={"판": 판})
assert res.status_code == want, res.text
return res.json()
def test_통째_지우기_시스템_관리자만_판_쓰는_곳(world, master_copy, monkeypatch): # noqa: F811
admin = _as(world, "SYSTEM_ADMIN")
before = _list(admin, "system")
assert before["줄"] # 사본에 옛 조합이 있음
for role in ("USER", "ADMIN"):
_drop(_as(world, role), before["판"], 403)
admin = _as(world, "SYSTEM_ADMIN")
_drop(admin, "옛판", 409)
used = before["줄"][0]["키"]
monkeypatch.setattr(combo_store, "combo_users", lambda k: ["항목 하나"] if k == used else [])
got = _drop(admin, before["판"], 409)
assert "항목 하나" in json.dumps(got, ensure_ascii=False)
assert len(_list(admin, "system")["줄"]) == len(before["줄"]) # 아무것도 안 지움
monkeypatch.setattr(combo_store, "combo_users", lambda k: [])
got = _drop(admin, before["판"], 200)
assert got["줄"] == [] and len(got["지운조합"]) == len(before["줄"])
doc = json.loads((master_copy / combos.FILE).read_text(encoding="utf-8"))
assert doc["줄"] == [] and m01.cm.check_form(combos.FILE, doc) == []
again = _list(admin, "system")
assert _drop(admin, again["판"], 200)["지운조합"] == [] # 빈 표 = 그대로
def test_화면_시스템_관리자_마스터만_물음_두_번_읽기만():
side = (M02 / "M02_MasterTemplete_UI_Side.ts").read_text("utf-8")
assert "const practice = opt.isSystem" in side # 720-4 — 시스템 관리자면 늘
order = side.split('className: "m02-side__lists ui-sidebar-panel",')[1].split("});")[0]
assert order.index("drawing,") < order.index("practice ?") # 도면 양식 아래
view = (M02 / "M02_MasterTemplete_Practice.ts").read_text("utf-8")
assert view.count("showConfirmDialog(") == 2
assert "dropPractice(" in view and "createInputField" not in view # 고칠 칸 없음
@@ -155,7 +155,6 @@ def test_왼쪽_칸_차례_일위대가_구조물조합_구분선_불러오기_7
"createDivider()",
"hopyo.title",
"drawing,",
"practice ?",
]
at = [order.index(m) for m in marks]
assert at == sorted(at)
@@ -109,12 +109,12 @@ def test_없는_종류_키_참거짓_아님은_422_낡은_판은_409(client) ->
def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에서_지움() -> None:
side = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts").read_text("utf-8")
# 716-3 — 옵션 줄은 M01 로 옮겨 좌측 칸에서 뺌(차례 = 일위대가 → 구조물 조합 → 불러오기 → 도면 양식 → 실무문서)
# 716-3 — 옵션 줄은 M01 로 옮겨 좌측 칸에서 뺌(차례 = 일위대가 → 구조물 조합 → 도면 양식)
assert "options.title" not in side and "options.clear" not in side
order = side.split('className: "m02-side__lists ui-sidebar-panel",')[1].split("});")[0]
assert order.index("summary") < order.index("drawing,") < order.index("practice")
# 실무문서를 열면 이 줄 고름 지움(자재 조합 · 「전체 항목」 없앰)
assert "onOpen: () => (practice?.clear(), paint())" in side
assert order.index("summary") < order.index("drawing,")
# 실무문서 칸은 걷음(732-1)
assert "practice" not in side
assert "matset" not in side
# 화면은 정의 한 벌로 읽고 씀(PLAN 48-2) · 보이기 체크는 정의 화면 옵션 줄
api = (ROOT / "M02_MasterTemplete/StructOptions/M02_SO_Api.ts").read_text("utf-8")
+1 -1
View File
@@ -194,7 +194,7 @@ def test_좌측_고름_표시는_열린_것_한_줄뿐():
# 다른 판도 같은 한 곳 — 열면 양식 · 열 줄 고름 지움 · 양식을 열면 실무자료 판 고름 지움
# (716-3 옵션 줄 뺌 — 좌측 판 = 실무자료)
assert re.search(r"sel = null;\s*onOpened\?\.\(\);", _text(MAIN))
assert "if (opt.getOpen()) practice?.clear();" in side.split("function paint(): void {")[1]
assert "practice" not in side # 실무문서 칸은 걷음(732-1)
# 편집 칸이 열 때마다 좌측 `paint`(일위대가 화면 해시 맞춤은 39-2 에서 지움)
assert "() => side.paint()," in _text(M02 / "M02_MasterTemplete_UI_Page.ts")
-5
View File
@@ -77,11 +77,6 @@ def test_실패_글과_다시_불러오기_M02_자리():
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
assert "const stop = showLoading(host);" in main
assert "loadingError(why, () => void open(next))" in main
# 옛 조합 나무 · 상세(Labor.ts · Labor_Edit.ts)는 39 에 지움 — 그 자리는 실무문서(같은 꼴)
practice = _text(M02 / "M02_MasterTemplete_Practice.ts")
assert "const stop = showLoading(view);" in practice
assert "loadingError(why(error), () => void drawList())" in practice
assert "loadingError(why(error), () => void drawOne(key))" in practice
cards = _text(M02 / "M02_MasterTemplete_ItemCards.ts")
# 일위대가 카드는 뺌 — 몸(Labor_View)을 덮었다 지우면 빈 카드(40 D1)
assert "cards.filter((c) => c !== laborCard).map((c) => c.shell.loading())" in cards
-27
View File
@@ -98,7 +98,6 @@ export const ui_locales_m2 = {
M02_RootReadOnly: ["읽기", "read-only"],
M02_RootNone: ["볼 수 있는 층이 없음", "Nothing to show"],
M02_SourcePeople: ["다른 사람", "Others"],
M02_SourcePractice: ["실무문서", "Practice documents"],
M02_PeopleEmpty: ["비어 있음", "empty"],
M02_TreeEmptyEdit: [
"항목이 없음 — 빈 자리 오른쪽 클릭 또는 층 버튼 옆 「+」 로 폴더 · 항목을 더함",
@@ -163,32 +162,6 @@ export const ui_locales_m2 = {
"나무를 고침 — 같은 이름이 있어 「{value}」 로 저장",
"Tree updated — name already taken, saved as “{value}”",
],
M02_PracticeTitle: ["실무문서", "Practice documents"],
M02_PracticeRow: ["실무문서 목록", "Practice document list"],
M02_PracticeCount: [
"옛 마스터 일위대가 조합 {n}개 — 보기 · 복사 원본만(고칠 수 없음)",
"{n} old master unit-cost combos — view and copy source only",
],
M02_PracticeEmpty: ["실무문서 없음", "No practice documents"],
M02_PracticeKey: ["키", "Key"],
M02_PracticeGroup: ["구분", "Group"],
M02_PracticeUnit: ["단위", "Unit"],
M02_PracticeLogics: ["로직 수", "Logics"],
M02_PracticeOrder: ["차례", "No."],
M02_PracticeMemo: ["메모", "Memo"],
M02_PracticeNoLogic: ["단가산출 기초 없음", "No logics"],
M02_PracticeBack: ["목록", "List"],
M02_PracticeDrop: ["전체 삭제", "Delete all"],
M02_PracticeDropAsk: ["실무문서 {n}개를 모두 지울까요?", "Delete all {n} practice documents?"],
M02_PracticeDropAsk2: [
"되돌릴 수 없음 — 정말 통째로 지울까요?",
"This cannot be undone — really delete all?",
],
M02_PracticeDropped: ["실무문서 {n}개를 지움", "Deleted {n} practice documents"],
M02_PracticeStale: [
"그 사이 실무문서가 바뀜 — 목록을 다시 받음",
"Practice documents changed — reloaded",
],
M02_HopyoImport: ["일위대가 불러오기", "Import unit-price sheets"],
B01_Dashboard_CardPrecombo: ["일위대가", "Unit-cost items"],
M02_PrecomboTitle: ["일위대가", "Unit-cost items"],