diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 934988e52..18f02794c 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -43,6 +43,8 @@ import { tn } from "./M01_MasterData_UI_LogicLab_New_Text"; import { createEasyCalcButton } from "./EasyCalc/M01_EasyCalc_Entry"; // 로직 만들기 시험 길 — 폴더째 지우면 이 두 줄도 지움 import { createLogicBuilderButton } from "./LogicBuilder/M01_LB_Entry"; +// 시트 로직 시험 길 — 폴더째 지우면 이 두 줄도 지움 +import { createSheetLogicButton } from "./SheetLogic/M01_SL_Entry"; import "./M01_MasterData_UI_Logic_Style.css"; /** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */ @@ -406,6 +408,8 @@ export async function mountM01LogicLab( createEasyCalcButton(), // 로직 만들기 시험 길 — 폴더째 지우면 이 두 줄도 지움 createLogicBuilderButton(), + // 시트 로직 시험 길 — 폴더째 지우면 이 두 줄도 지움 + createSheetLogicButton(), createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }), discardButton, saveButton, diff --git a/M01_MasterData/SheetLogic/.gitignore b/M01_MasterData/SheetLogic/.gitignore new file mode 100644 index 000000000..c10c784e3 --- /dev/null +++ b/M01_MasterData/SheetLogic/.gitignore @@ -0,0 +1 @@ +_data/ diff --git a/M01_MasterData/SheetLogic/M01_SL_Api.ts b/M01_MasterData/SheetLogic/M01_SL_Api.ts new file mode 100644 index 000000000..d937f784c --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Api.ts @@ -0,0 +1,127 @@ +/* ============================================================================= + * M01_SL_Api.ts + * 시트 로직 Api — 목록 · 열기 · 저장(서버가 바깥 값을 다시 넣고 같은 엔진으로 다시 셈) · 지우기 · + * 바깥 값 한 번에 받기(품셈 표 · 노임 · 자재 · 장비 · 기존 로직 결과) + * 서버 = `M01_SL_Router.py`(`/api/m01/sl/*`) · 정함 `docs/raw/M01_쉬운단가/시트로직_정함.md` 4 · 5 · 6 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types"; +import { plainError } from "../EasyCalc/M01_EasyCalc_Api"; +import { ApiError } from "../M01_MasterData_UI_Logic_Api"; + +/** 바깥 값 하나 — 값은 열 때 · 저장 때 마스터에서 새로(표는 사본으로 들고 있지 않음) + * · 표 = 품셈 표 통째(머리 줄 + 줄 · 범위 조건 · 값은 「이름·아래」 「이름·위」 두 칸) + * · 노임 · 자재 = 그 줄의 단가(`/calc` 와 같은 셈 · 준용 · 낮은 값) + * · 장비 = 기계경비 로직 부름 글(EasyCalc `machineElement` 의 element) + 넘길 입력값 → 한 시간 값 + * · 로직 = 기존 로직 결과(`/calc` · 돈 로직은 `field` 비목 · 기본 계) */ +export type SlOutside = + | { id: string; kind: "표"; table: string } + | { id: string; kind: "노임"; key: string } + | { id: string; kind: "자재"; key: string } + | { + id: string; + kind: "장비"; + element: string; + inputs: Record; + field?: SlField; + } + | { + id: string; + kind: "로직"; + key: string; + inputs: Record; + field?: SlField; + }; +export type SlField = "노무비" | "재료비" | "경비" | "계"; + +/** 바깥 값이 들어갈 자리 — 시트 id · 칸(표는 이 칸을 왼쪽 위로 펼침) */ +export interface SlOutsideAt { + item: SlOutside; + sheet: string; + cell: string; +} + +/** 바깥 값 답 — 표는 `rows`(머리 줄 포함) · 그 밖은 `value` · 못 구하면 ok false + 쉬운 말 */ +export interface SlOutsideValue { + id: string; + ok: boolean; + value?: number | null; + rows?: (string | number | null)[][]; + name?: string; + unit?: string; + reason?: string; +} + +/** 시트 로직 한 벌 = 통합문서 하나(상자 = 시트) + 바깥 값 자리 */ +export interface SlLogic { + /** 새 로직 null · 저장하면 서버가 `SL-0001` 꼴로 줌 */ + key: string | null; + 이름: string; + 결과단위: string; + book: Workbook; + outside: SlOutsideAt[]; + /** 다른 창이 더하는 칸(상자 · 알약 · 선 …)은 그대로 저장 · 돌려줌 */ + [extra: string]: unknown; +} + +/** 목록 한 줄 */ +export interface SlListItem { + key: string; + 이름: string; + 결과단위: string; + /** 마지막 저장 때(ISO) */ + saved: string; +} + +/** 저장 답 — `logic.book.계산값` 은 서버가 다시 셈한 값 · `values` 는 넣은 바깥 값 */ +export interface SlSaved { + key: string; + logic: SlLogic; + values: SlOutsideValue[]; +} + +async function send(path: string, body?: unknown): Promise { + let response: Response; + try { + response = await fetch(`${API_BASE_URL}/m01/sl${path}`, { + method: body === undefined ? "GET" : "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch { + throw new ApiError(0, ""); + } + const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T; + if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText); + return data; +} + +/** 내 시트 로직 목록(저장 차례) */ +export async function slList(): Promise { + return (await send<{ logics: SlListItem[] }>("/list")).logics; +} + +export async function slGet(key: string): Promise { + return (await send<{ logic: SlLogic }>(`/get?${new URLSearchParams({ key }).toString()}`)).logic; +} + +/** 저장 — 서버가 바깥 값을 마스터에서 다시 넣고 같은 엔진으로 다시 셈(브라우저 계산값은 버림) */ +export async function slSave(logic: SlLogic): Promise { + return send("/save", { logic }); +} + +export async function slDelete(key: string): Promise { + await send<{ key: string }>("/delete", { key }); +} + +/** 바깥 값 한 번에 — 열 때 · 예시값을 바꿀 때 화면이 받아 시트에 넣음 */ +export async function slOutside(items: SlOutside[]): Promise { + return (await send<{ values: SlOutsideValue[] }>("/outside", { items })).values; +} + +/** 실패 → 쉬운 말(EasyCalc `plainError` 그대로) */ +export function slPlain(e: unknown): string { + return plainError(e); +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry.css b/M01_MasterData/SheetLogic/M01_SL_Entry.css new file mode 100644 index 000000000..4849660f5 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Entry.css @@ -0,0 +1,169 @@ +/* 시트 로직 — 큰 창 · 시작 화면 · 위 띠 · 다이어그램 / 시트 판 / 결과 자리 */ + +/* 공용 모달(위 고정)을 화면 대부분으로 — 높이 고정 */ +.ui-modal.m01-sl { + --modal-max: 92vh; + width: min(1680px, 98vw); + height: var(--modal-max); + font-size: var(--text-body); +} + +.m01-sl .ui-modal__body { + flex: 1; + min-height: 0; + gap: var(--spacing-12); +} + +.m01-sl__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m01-sl__trial-note { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m01-sl__big { + min-height: 56px; + padding: 0 var(--spacing-24); + font-size: var(--text-body); +} + +/* 시작 화면 — 왼쪽 새로 · 내 것 · 오른쪽 가져오기 */ +.m01-sl__start { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); + gap: var(--spacing-24); + min-height: 0; + overflow: auto; +} + +.m01-sl__start-col { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; +} + +.m01-sl__start-col h4, +.m01-sl__start-col h5 { + margin: var(--spacing-8) 0 0; +} + +.m01-sl__list { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.m01-sl__item { + display: flex; + align-items: center; + gap: var(--spacing-8); + width: 100%; + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + text-align: left; + cursor: pointer; +} + +.m01-sl__item:hover { + border-color: var(--color-accent); +} + +.m01-sl__item-name { + flex: 1; + min-width: 0; +} + +.m01-sl__item-unit { + color: var(--color-text-muted); + font-size: var(--text-body-sm); + white-space: nowrap; +} + +/* 위 띠 */ +.m01-sl__strip { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-12); + padding-bottom: var(--spacing-12); + border-bottom: 1px solid var(--color-border); +} + +.m01-sl__name { + flex: 1 1 280px; +} + +.m01-sl__actions { + display: flex; + gap: var(--spacing-8); + margin-left: auto; +} + +/* 위 가로 다이어그램 · 아래 왼쪽 시트 판 · 아래 오른쪽 결과 */ +.m01-sl__layout { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); + grid-template-rows: minmax(160px, 2fr) minmax(0, 3fr); + grid-template-areas: + "diagram diagram" + "panel result"; + gap: var(--spacing-12); +} + +.m01-sl__diagram { + grid-area: diagram; +} + +.m01-sl__panel { + grid-area: panel; +} + +.m01-sl__result { + grid-area: result; +} + +.m01-sl__part { + display: flex; + flex-direction: column; + min-height: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); +} + +.m01-sl__part h4 { + margin: 0; + padding: var(--spacing-8) var(--spacing-12); + border-bottom: 1px solid var(--color-border); +} + +.m01-sl__part-body { + flex: 1; + min-height: 0; + overflow: auto; + padding: var(--spacing-12); +} + +/* 좁은 화면 — 자리를 위아래로 */ +@media (max-width: 1100px) { + .m01-sl__layout { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto; + grid-template-areas: + "diagram" + "panel" + "result"; + overflow: auto; + } +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry.ts b/M01_MasterData/SheetLogic/M01_SL_Entry.ts new file mode 100644 index 000000000..d8ac397c7 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Entry.ts @@ -0,0 +1,330 @@ +/* ============================================================================= + * M01_SL_Entry.ts + * 시트 로직(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 고치는 화면 틀(위 다이어그램 · 아래 시트 판 · 결과) · 위 띠 + * 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄 + 서버 등록 한 줄). + * 1걸음 = 틀 — 다이어그램 · 시트 판 · 결과 · 저장 · 지우기 · 실행 취소 · 가져오기는 2걸음에 이음. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { + createButton, + createInputField, + createSelectField, + createTag, + el, + showConfirmDialog, + showToast, +} from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { listEasy, plainError, type EasyListItem } from "../EasyCalc/M01_EasyCalc_Api"; +import { slList, type SlListItem } from "./M01_SL_Api"; +import { newLogic, type SheetLogic } from "./M01_SL_Model"; +import "./M01_SL_Entry.css"; + +const TEXT = { + Open: ["시트 로직(시험)", "Sheet logic (trial)"], + Title: ["시트 로직", "Sheet logic"], + CloseWindow: ["창 닫기", "Close window"], + Close: ["닫기", "Close"], + Loading: ["불러오는 중", "Loading"], + LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"], + Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"], + Start_Mine: ["내 시트 로직", "My sheet logics"], + Start_MineNone: ["아직 없음", "None yet"], + Start_New: ["새로 만들기(빈 판)", "New (blank)"], + Start_Import: ["기존 로직 가져오기", "Import an existing logic"], + Start_Trial: ["먼저 볼 시험 로직", "Trial logics to start with"], + Start_Search: ["이름으로 찾기 — 예: 돌쌓기", "Search by name — e.g. stone wall"], + Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"], + Start_None: ["찾은 것 없음", "Nothing found"], + Per: ["{u} 당", "per {u}"], + Name: ["이름", "Name"], + NamePlaceholder: ["예: 돌쌓기 내 방식", "e.g. my stone wall"], + Unit: ["결과 단위", "Result unit"], + Undo: ["실행 취소", "Undo"], + Save: ["저장", "Save"], + Delete: ["지우기", "Delete"], + Unsaved: ["저장 안 함", "Not saved"], + Diagram: ["상자와 선", "Boxes and lines"], + Panel: ["고른 상자의 시트", "Sheet of the chosen box"], + Result: ["결과", "Result"], + NotYet: ["준비 중", "Coming soon"], + Leave_Ask: [ + "저장 안 한 것이 있음 — 저장하지 않고 닫을까요?", + "There are unsaved changes — close without saving?", + ], + Leave_Ok: ["저장 안 하고 닫기", "Close without saving"], +} as const; + +type TextKey = keyof typeof TEXT; + +const te = (key: TextKey, vars: Record = {}): string => + Object.entries(vars).reduce( + (text, [k, v]) => text.replace(`{${k}}`, String(v)), + (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string, + ); + +/** 「기존 로직 가져오기」 에 먼저 보이는 시험 로직 여섯(정함 9) */ +const TRIAL_KEYS = ["GF000208", "GF000219", "GF000025", "GF000249", "GF000133", "GC000051"]; +/** 찾은 것 목록에 한 번에 보이는 줄 수 */ +const SHOWN = 50; + +/** 「원/㎡」 → 「㎡」 · 「100㎡」 은 그대로 앞에 1 을 안 붙임 */ +const amountOf = (resultUnit: string): string => { + const unit = resultUnit.replace(/^원\//, ""); + return /^\d/.test(unit) ? unit : `1${unit}`; +}; +const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/"); + +/* ponytail: SL Api(sub4) · Model(sub7) 속이 「준비 중」 인 동안만 대신함 — 채워지면 두 catch 를 지움 */ +const loadMine = (): Promise => slList().catch(() => []); +const blankLogic = (name: string, unit: string): SheetLogic | null => { + try { + return newLogic(name, unit); + } catch { + return null; + } +}; + +/** 「로직 만들기(시험)」 옆에 놓는 단추 — 누르면 큰 창 */ +export function createSheetLogicButton(): HTMLButtonElement { + return createButton({ label: te("Open"), variant: "filled", onClick: openSheetLogic }); +} + +function openSheetLogic(): void { + let body: HTMLElement; + /** 고치는 화면이 열려 있을 때만 */ + let paper: { dirty(): boolean; sl: SheetLogic | null } | null = null; + let logics: EasyListItem[] | null = null; + let query = ""; + + const mayLeave = async (): Promise => + !paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); + const notYet = (): void => showToast(te("NotYet"), "info"); + + /* ---------- 시작 화면 ---------- */ + + const importRow = (item: EasyListItem): HTMLElement => { + const row = el("button", { + className: "m01-sl__item", + attrs: { type: "button", title: item.where }, + children: [ + el("span", { className: "m01-sl__item-name", text: item.name }), + el("span", { + className: "m01-sl__item-unit", + text: isMoney(item.resultUnit) + ? te("Per", { u: amountOf(item.resultUnit) }) + : item.resultUnit, + }), + ], + }); + row.addEventListener("click", notYet); + return row; + }; + + const mineRow = (item: SlListItem): HTMLElement => { + const row = el("button", { + className: "m01-sl__item", + attrs: { type: "button" }, + children: [ + el("span", { className: "m01-sl__item-name", text: item.이름 }), + el("span", { className: "m01-sl__item-unit", text: item.결과단위 }), + ], + }); + row.addEventListener("click", notYet); + return row; + }; + + const showStart = async (): Promise => { + paper = null; + const status = el("p", { className: "m01-sl__muted", text: te("Loading") }); + const trialHost = el("div", { className: "m01-sl__list" }); + const foundHost = el("div", { className: "m01-sl__list" }); + const mineHost = el("div", { className: "m01-sl__list" }); + void loadMine().then((mine) => + mineHost.replaceChildren( + ...(mine.length ? mine.map(mineRow) : [muted(te("Start_MineNone"))]), + ), + ); + const search = createInputField({ + type: "search", + placeholder: te("Start_Search"), + value: query, + onInput: (v) => { + query = v; + drawFound(); + }, + }); + + const drawFound = (): void => { + if (!logics) return; + const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean); + const hits = words.length + ? logics.filter((x) => words.every((w) => x.name.toLowerCase().includes(w))) + : []; + status.textContent = !words.length + ? "" + : !hits.length + ? te("Start_None") + : hits.length > SHOWN + ? te("Start_Count", { n: hits.length.toLocaleString(), m: SHOWN }) + : ""; + foundHost.replaceChildren(...hits.slice(0, SHOWN).map(importRow)); + }; + + body.replaceChildren( + el("p", { className: "m01-sl__trial-note", text: te("Trial") }), + el("div", { + className: "m01-sl__start", + children: [ + el("div", { + className: "m01-sl__start-col", + children: [ + bigButton(te("Start_New"), () => showPaper()), + el("h4", { text: te("Start_Mine") }), + mineHost, + ], + }), + el("div", { + className: "m01-sl__start-col", + children: [ + el("h4", { text: te("Start_Import") }), + el("h5", { text: te("Start_Trial") }), + trialHost, + search.root, + status, + foundHost, + ], + }), + ], + }), + ); + + try { + logics ??= await listEasy(""); + } catch (error) { + status.textContent = te("LoadFailed", { v: plainError(error) }); + return; + } + const byKey = new Map(logics.map((x) => [x.key, x])); + trialHost.replaceChildren( + ...TRIAL_KEYS.flatMap((k) => { + const item = byKey.get(k); + return item ? [importRow(item)] : []; + }), + ); + status.textContent = ""; + drawFound(); + }; + + /** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */ + const unitChoices = (): string[] => { + const count = new Map(); + for (const x of logics ?? []) { + if (isMoney(x.resultUnit)) count.set(x.resultUnit, (count.get(x.resultUnit) ?? 0) + 1); + } + const units = [...count.entries()] + .filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u)) + .sort((a, b) => b[1] - a[1]) + .map(([u]) => u); + return units.length ? units : ["원/㎡"]; + }; + + /* ---------- 고치는 화면 ---------- */ + + const showPaper = (): void => { + let dirty = false; + const units = unitChoices(); + paper = { dirty: () => dirty, sl: blankLogic("", units[0]) }; + + const unsaved = createTag(te("Unsaved"), "warning"); + unsaved.hidden = true; + const touched = (): void => { + dirty = true; + unsaved.hidden = false; + }; + const name = createInputField({ + label: te("Name"), + placeholder: te("NamePlaceholder"), + onInput: touched, + }); + name.root.classList.add("m01-sl__name"); + const unit = createSelectField({ + label: te("Unit"), + options: units.map((u) => ({ value: u, text: u.replace(/^원\//, "") })), + value: units[0], + onChange: touched, + }); + const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet }); + undo.disabled = true; + const close = createButton({ + label: te("Close"), + variant: "ghost", + onClick: () => + void (async () => { + if (await mayLeave()) void showStart(); + })(), + }); + + body.replaceChildren( + el("div", { + className: "m01-sl__strip", + children: [ + name.root, + unit.root, + unsaved, + el("div", { + className: "m01-sl__actions", + children: [ + undo, + createButton({ label: te("Save"), onClick: notYet }), + createButton({ label: te("Delete"), variant: "danger", onClick: notYet }), + close, + ], + }), + ], + }), + el("p", { className: "m01-sl__trial-note", text: te("Trial") }), + el("div", { + className: "m01-sl__layout", + children: [ + part(te("Diagram"), "m01-sl__diagram"), + part(te("Panel"), "m01-sl__panel"), + part(te("Result"), "m01-sl__result"), + ], + }), + ); + name.input.focus(); + }; + + // 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름 + openModal({ + title: te("Title"), + closeLabel: te("CloseWindow"), + dialogClass: "m01-sl", + beforeClose: mayLeave, + mount: (host) => { + body = host; + void showStart(); + }, + }); +} + +const muted = (text: string): HTMLElement => el("p", { className: "m01-sl__muted", text }); + +/** 고치는 화면의 자리 하나 — 제목 + 몸(2걸음에 다이어그램 · 시트 판 · 결과가 들어옴) */ +function part(title: string, className: string): HTMLElement { + return el("section", { + className: `m01-sl__part ${className}`, + children: [ + el("h4", { text: title }), + el("div", { className: "m01-sl__part-body", children: [muted(te("NotYet"))] }), + ], + }); +} + +function bigButton(label: string, onClick: () => void): HTMLButtonElement { + const button = createButton({ label, variant: "filled", onClick }); + button.classList.add("m01-sl__big"); + return button; +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Router.py b/M01_MasterData/SheetLogic/M01_SL_Router.py new file mode 100644 index 000000000..72794c134 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Router.py @@ -0,0 +1,313 @@ +"""시트 로직 시험 길(PLAN 35-5) — 목록 · 열기 · 저장 · 지우기 · 바깥 값. + + GET /api/m01/sl/list + GET /api/m01/sl/get?key=SL-0001 + POST /api/m01/sl/save {logic} → {key, logic, values} + POST /api/m01/sl/delete {key} + POST /api/m01/sl/outside {items: SlOutside[]} → {values: SlOutsideValue[]} + +정함 `docs/raw/M01_쉬운단가/시트로직_정함.md` 4 · 5 · 6 — 저장 때 바깥 값(품셈 표 · 노임 · +자재 · 장비 · 기존 로직 결과)을 마스터에서 다시 넣고 우리 스프레드시트 엔진(Node · +`common_util_spreadsheet`)으로 다시 셈 · 브라우저 계산값은 버림. 단가는 `/calc`(`Store.calc`)와 +같은 셈. 자리 = 이 폴더 `_data/`(git 밖) · 키 `SL-0001` 꼴(`_키대장.json` 안 씀) · 마스터 파일은 +안 씀. 권한 = `/api/m01` 과 같음(`main.py` 등록 한 줄). +""" + +from __future__ import annotations + +import json +import os +import re +from datetime import datetime +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from common_util import common_util_spreadsheet as spreadsheet +from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData.M01_MasterData_Router import _num + +cm = store.cm +router = APIRouter(prefix="/api/m01/sl", tags=["M01 SheetLogic (시험)"]) + +DATA = Path(__file__).resolve().parent / "_data" # 시험은 임시 폴더로 바꿈 +_KEY = re.compile(r"^SL-(\d{4,})$") +_CELL = re.compile(r"^([A-Z]+)(\d+)$") +FIELDS = (*cm.mf.COST_ITEMS, "계") + + +class SaveBody(BaseModel): + logic: dict[str, Any] + + +class KeyBody(BaseModel): + key: str + + +class OutsideBody(BaseModel): + items: list[dict[str, Any]] + + +# ── 자리 ────────────────────────────────────────────────────────────── +def _path(key: str) -> Path: + if not _KEY.match(key or ""): + raise store.StoreError(400, f"시트 로직 번호 모양이 아님 「{key}」") + return DATA / f"{key}.json" + + +def _read(key: str) -> dict: + path = _path(key) + if not path.is_file(): + raise store.StoreError(404, f"없는 시트 로직 「{key}」") + return json.loads(path.read_text(encoding="utf-8")) + + +def _next_key() -> str: + used = [int(m.group(1)) for p in DATA.glob("SL-*.json") if (m := _KEY.match(p.stem))] + return f"SL-{max(used, default=0) + 1:04d}" + + +def listing() -> dict: + out = [] + for path in sorted(DATA.glob("SL-*.json")): + logic = json.loads(path.read_text(encoding="utf-8")) + out.append( + { + "key": path.stem, + "이름": logic.get("이름") or "", + "결과단위": logic.get("결과단위") or "", + "saved": logic.get("saved") or "", + } + ) + return {"logics": out} + + +def get(key: str) -> dict: + return {"logic": _read(key)} + + +def delete(key: str) -> dict: + _read(key) + _path(key).unlink() + return {"key": key} + + +# ── 바깥 값 ──────────────────────────────────────────────────────────── +def _row(line: dict, names) -> dict: + """바깥 단가를 `/calc` 와 같은 셈으로 — 호표 한 줄(수량 1)짜리 저장 안 하는 견본 로직.""" + return { + "키": "", + "원문번호": "", + "이름": "바깥 값", + "결과단위": "원/식", + "출처": "자체", + "입력": [{"이름": n} for n in names], + "중간": [], + "호표": [line], + "덧줄": [], + "끝수": None, + } + + +def _price(kind: str, key: str) -> dict: + """노임 · 자재 한 줄 단가 — 준용 · 낮은 값 · 출처는 엔진 그대로.""" + whole = store.loaded()[1] + try: + brief = whole.get(key) + except cm.mf.FormulaError as e: + return {"ok": False, "reason": str(e)} + group = store.mk.GROUP_OF.get(store.mk.id_of(key)) + want = {"노임": ("인력", "노무비"), "자재": ("재료", "재료비")}[kind] + if group != want[0]: + return {"ok": False, "reason": f"「{key}」 는 {kind} 아님"} + unit = brief.get("단위") + line = {"종류": want[0], "요소": key, "이름": brief.get("이름") or key, "수량": "1"} + got = store.calc("", {}, _row({**line, "단위": unit or "", "비목": want[1]}, ()), None) + if not got["ok"]: + return got + one = got["lines"][0] + return {"ok": one["단가"] is not None, "value": one["단가"], "reason": one.get("까닭")} | { + "name": brief.get("이름"), + "unit": unit, + } + + +def _money(got: dict, field: str | None) -> dict: + if not got["ok"]: + return got + if "result" in got: + return {"ok": True, "value": got["result"]} + pick = field or "계" + if pick not in FIELDS: + return {"ok": False, "reason": f"모르는 칸 「{pick}」"} + return {"ok": True, "value": got["sums"][pick]} + + +def _table(key: str) -> dict: + """품셈 표 통째 → 머리 줄 + 줄(범위 · 범위 값은 「이름·아래」 「이름·위」 두 칸).""" + files = store.loaded()[0] + table = next( + ( + t + for d in files.values() + if d.get("그룹") in cm.mf.TABLE_GROUPS + for t in d.get("표") or [] + if str(t.get("키")) == key + ), + None, + ) + if table is None: + return {"ok": False, "reason": f"없는 표 「{key}」"} + names = [*(table.get("조건") or {}), *(table.get("값칸") or {})] + split = { + n + for n in names + if (table.get("조건") or {}).get(n) == "범위" + or any(isinstance(r.get(n), list) for r in table.get("줄") or []) + } + head = [h for n in names for h in ((f"{n}·아래", f"{n}·위") if n in split else (n,))] + rows = [] + for r in table.get("줄") or []: + line = [] + for n in names: + v = r.get(n) + if n in split: + low, high = v if isinstance(v, list) else (v, v) + line += [low, high] + else: + line.append(v if not isinstance(v, (list, dict)) else str(v)) + rows.append(line) + return {"ok": True, "rows": [head, *rows], "name": table.get("이름"), "unit": table.get("기준")} + + +def one_outside(item: dict) -> dict: + kind, at = item.get("kind"), item.get("id") or "" + try: + if kind == "표": + got = _table(str(item.get("table") or "")) + elif kind in ("노임", "자재"): + got = _price(kind, str(item.get("key") or "")) + elif kind == "장비": + inputs = item.get("inputs") or {} + line = {"종류": "로직", "요소": item.get("element") or "", "이름": "장비"} + row = _row({**line, "단위": "시간", "수량": "1"}, inputs) + got = _money(store.calc("", inputs, row, None), item.get("field")) + elif kind == "로직": + got = _money( + store.calc(str(item.get("key") or ""), item.get("inputs") or {}), item.get("field") + ) + else: + got = {"ok": False, "reason": f"모르는 바깥 값 「{kind}」"} + except store.StoreError as e: + got = {"ok": False, "reason": str(e.detail)} + return {"id": at, **got} + + +def outside(items: list[dict]) -> dict: + # ponytail: 하나씩 셈(한 번 0.3초쯤) — 바깥 값이 수십이면 마스터 사본 한 벌로 묶어 셈 + return {"values": [one_outside(i) for i in items]} + + +# ── 저장 ────────────────────────────────────────────────────────────── +def _col(letters: str) -> int: + n = 0 + for ch in letters: + n = n * 26 + ord(ch) - 64 + return n + + +def _letters(n: int) -> str: + out = "" + while n: + n, rest = divmod(n - 1, 26) + out = chr(65 + rest) + out + return out + + +def _put(book: dict, sheet_id: str, cell: str, value: Any) -> None: + """바깥 값 칸 하나 — 값 칸으로 덮어씀(수식이 있었어도) · 빈 값은 칸을 비움.""" + sheet = next((s for s in book.get("시트") or [] if s.get("id") == sheet_id), None) + if sheet is None: + raise store.StoreError(400, f"없는 시트 「{sheet_id}」") + cells = sheet.setdefault("칸", {}) + old = cells.get(cell) or {} + if value is None: + cells.pop(cell, None) + return + keep = {"서식": old["서식"]} if "서식" in old else {} + cells[cell] = {"값": value, **keep} + + +def _fill(book: dict, at: dict, got: dict) -> None: + m = _CELL.match(str(at.get("cell") or "").upper()) + if not m: + raise store.StoreError(400, f"칸 주소 모양이 아님 「{at.get('cell')}」") + sheet, col, row = at.get("sheet") or "", _col(m.group(1)), int(m.group(2)) + if "rows" in got: + for dy, line in enumerate(got["rows"]): + for dx, value in enumerate(line): + _put(book, sheet, f"{_letters(col + dx)}{row + dy}", value) + else: + _put(book, sheet, f"{m.group(1)}{row}", got.get("value") if got.get("ok") else None) + + +def save(logic: dict) -> dict: + if not isinstance(logic.get("book"), dict): + raise store.StoreError(400, "통합문서(book)가 없음") + if not str(logic.get("이름") or "").strip(): + raise store.StoreError(400, "이름을 넣어 주세요") + key = logic.get("key") or None + if key: + _read(key) # 있는 것만 고침 — 없으면 404 + places = logic.get("outside") or [] + values = [one_outside(at.get("item") or {}) for at in places] + book = _num(logic["book"]) + for at, got in zip(places, values): + _fill(book, at, _num(got)) + try: + book = spreadsheet.with_server_values(book) # 브라우저 계산값은 버리고 서버 값 + except spreadsheet.RecalcError as e: + raise store.StoreError(503, str(e)) from e + DATA.mkdir(parents=True, exist_ok=True) + key = key or _next_key() + stamp = datetime.now().isoformat(timespec="seconds") + done = {**logic, "key": key, "book": book, "saved": stamp} + tmp = DATA / f".{key}.tmp" + tmp.write_text(json.dumps(done, ensure_ascii=False, indent=2), encoding="utf-8", newline="\n") + os.replace(tmp, DATA / f"{key}.json") + return {"key": key, "logic": done, "values": values} + + +# ── 길 ──────────────────────────────────────────────────────────────── +def _call(fn, *args): + try: + return _num(fn(*args)) + except store.StoreError as e: + raise HTTPException(status_code=e.status, detail=e.detail) from e + + +@router.get("/list") +def get_list() -> dict: + return _call(listing) + + +@router.get("/get") +def get_one(key: str) -> dict: + return _call(get, key) + + +@router.post("/save") +def post_save(body: SaveBody) -> dict: + return _call(save, body.logic) + + +@router.post("/delete") +def post_delete(body: KeyBody) -> dict: + return _call(delete, body.key) + + +@router.post("/outside") +def post_outside(body: OutsideBody) -> dict: + return _call(outside, body.items) diff --git a/main.py b/main.py index 911eade9d..5cef3bf8c 100644 --- a/main.py +++ b/main.py @@ -643,6 +643,11 @@ app.include_router( __import__("M01_MasterData.LogicBuilder.M01_LB_Calc", fromlist=["router"]).router, dependencies=system_admin_only, ) +# 시트 로직 시험 길 — 폴더째 지우면 이 줄도 지움 +app.include_router( + __import__("M01_MasterData.SheetLogic.M01_SL_Router", fromlist=["router"]).router, + dependencies=system_admin_only, +) # 마스터 템플릿 — 시스템 층은 시스템 관리자만 · 층(회사·개인·프로젝트)·도면 길은 로그인만(권한은 길 안에서). app.include_router(m02_master_template_router, dependencies=system_admin_only) app.include_router(m02_layers_router, dependencies=protected) diff --git a/resources/tester/test_m01_sl_router.py b/resources/tester/test_m01_sl_router.py new file mode 100644 index 000000000..7aea0c036 --- /dev/null +++ b/resources/tester/test_m01_sl_router.py @@ -0,0 +1,218 @@ +"""시트 로직 시험 길(`M01_MasterData/SheetLogic/M01_SL_Router.py`) 시험 — PLAN 35-5. + +· 임시 `_data` 로 저장 → 열기 → 목록 → 지우기 · 번호 SL-0001 꼴 +· 저장 때 바깥 값을 다시 넣고 서버가 같은 엔진(Node)으로 다시 셈 — 브라우저 계산값은 버림 +· 바깥 값 — 견본 표(범위 조건은 두 칸) · 노임 · 기존 로직 결과 · 틀린 종류 +· 권한 없음 403 · 마스터 사본 파일은 안 바뀜 +견본 표 · 로직 · 통합문서는 시험 안에 둠(임시 폴더 사본에 견본 표를 더함). +""" + +from __future__ import annotations + +import json +import shutil +from decimal import Decimal +from pathlib import Path + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import require_system_admin, verify_session +from M01_MasterData import M01_MasterData_Router as router_module +from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData.SheetLogic import M01_SL_Router as sl + +REAL = store.FOLDER +OWN = "로직_자체.json" +SAMPLE = "QF999997" +TABLE = { + "키": SAMPLE, + "원문번호": "", + "구분": "", + "상세구분": "", + "이름": "견본 높이 표", + "기준": "1㎡", + "출처": "자체", + "조건": {"돌": "고르기", "높이": "범위"}, + "범위규칙": "초과·이하", + "값칸": {"석공": "인"}, + "줄": [ + {"돌": "깬돌", "높이": [None, 3], "석공": 0.5}, + {"돌": "깬돌", "높이": [3, None], "석공": 0.7}, + ], + "주": [], +} +NO_NODE = shutil.which("node") is None + + +def _labor() -> tuple[str, Decimal]: + rows = json.loads((store.FOLDER / "인력.json").read_text(encoding="utf-8"))["줄"] + row = next( + r + for r in rows + if r.get("상태") == "공표" and not r.get("준용") and isinstance(r.get("값"), (int, float)) + ) + return row["키"], Decimal(str(row["값"])) + + +@pytest.fixture +def folder(tmp_path: Path, monkeypatch) -> Path: + master = tmp_path / "master" + master.mkdir() + for path in REAL.glob("*.json"): + if path.name == OWN: + continue + if not path.name.startswith("_") or path.name == store.mk.BOOK.name: + shutil.copy(path, master / path.name) + host = sorted(master.glob("소요량_*.json"))[0] + data = json.loads(host.read_text(encoding="utf-8")) + data["표"].append(TABLE) + host.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + monkeypatch.setattr(store, "FOLDER", master) + monkeypatch.setattr(sl, "DATA", tmp_path / "_data") + return master + + +@pytest.fixture +def client(folder: Path) -> TestClient: + app = FastAPI() + app.include_router(router_module.router) + app.include_router(sl.router) + return TestClient(app) + + +def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict: + res = client.post(url, json=body) + assert res.status_code == want, res.text + return res.json() + + +def _logic(labor: str, key: str | None = None) -> dict: + """노임 바깥 값(A1) × 2 = B1 · 표 바깥 값은 둘째 시트 A1 부터 · 브라우저 계산값은 엉터리.""" + return { + "key": key, + "이름": "견본 시트 로직", + "결과단위": "원/㎡", + "book": { + "종류": "통합문서", + "판": 1, + "서식": [{}], + "시트": [ + {"id": "s1", "이름": "계산", "칸": {"A1": {"값": 1}, "B1": {"식": "A1*2"}}}, + {"id": "s2", "이름": "표", "칸": {}}, + ], + "계산값": {"s1": {"B1": {"수": "999"}}}, + }, + "outside": [ + {"item": {"id": "a", "kind": "노임", "key": labor}, "sheet": "s1", "cell": "A1"}, + {"item": {"id": "b", "kind": "표", "table": SAMPLE}, "sheet": "s2", "cell": "A1"}, + ], + "상자": [{"id": "x", "종류": "계산"}], # 다른 창 칸 — 그대로 저장 · 돌려줌 + } + + +@pytest.mark.skipif(NO_NODE, reason="node 가 없음") +def test_저장_열기_목록_지우기_서버가_다시_셈(client: TestClient, folder: Path) -> None: + labor, price = _labor() + before = {p.name: p.read_bytes() for p in folder.glob("*.json")} + + saved = _post(client, "/api/m01/sl/save", {"logic": _logic(labor)}) + assert saved["key"] == "SL-0001" + book = saved["logic"]["book"] + assert book["시트"][0]["칸"]["A1"]["값"] == price # 바깥 값을 마스터에서 다시 넣음 + assert Decimal(book["계산값"]["s1"]["B1"]["수"]) == 2 * price # 서버 다시 셈(999 는 버림) + table = book["시트"][1]["칸"] + assert [table[c]["값"] for c in ("A1", "B1", "C1", "D1")] == [ + "돌", + "높이·아래", + "높이·위", + "석공", + ] + assert ( + "B2" not in table and table["C2"]["값"] == 3 and table["D3"]["값"] == 0.7 + ) # 빈 끝은 빈 칸 + assert saved["logic"]["상자"] == [{"id": "x", "종류": "계산"}] + assert [v["id"] for v in saved["values"]] == ["a", "b"] + + second = _post(client, "/api/m01/sl/save", {"logic": _logic(labor)}) + assert second["key"] == "SL-0002" + renamed = {**saved["logic"], "이름": "고친 이름"} + assert _post(client, "/api/m01/sl/save", {"logic": renamed})["key"] == "SL-0001" + + listed = client.get("/api/m01/sl/list").json()["logics"] + assert [(x["key"], x["이름"]) for x in listed] == [ + ("SL-0001", "고친 이름"), + ("SL-0002", "견본 시트 로직"), + ] + got = client.get("/api/m01/sl/get", params={"key": "SL-0001"}).json()["logic"] + assert got["book"]["계산값"] == book["계산값"] + + _post(client, "/api/m01/sl/delete", {"key": "SL-0002"}) + assert client.get("/api/m01/sl/get", params={"key": "SL-0002"}).status_code == 404 + _post(client, "/api/m01/sl/delete", {"key": "SL-0002"}, want=404) + _post(client, "/api/m01/sl/save", {"logic": _logic(labor, "SL-0009")}, want=404) + _post(client, "/api/m01/sl/save", {"logic": {**_logic(labor), "이름": " "}}, want=400) + assert client.get("/api/m01/sl/get", params={"key": "../x"}).status_code == 400 + + assert {p.name: p.read_bytes() for p in folder.glob("*.json")} == before # 마스터 사본 그대로 + assert sorted(p.name for p in sl.DATA.iterdir()) == ["SL-0001.json"] + + +def test_바깥_값_표_노임_로직_틀린_종류(client: TestClient) -> None: + labor, price = _labor() + made = _post( + client, + "/api/m01/logic/new", + { + "logic": { + "원문번호": "", + "이름": "견본 노무", + "결과단위": "원/㎡", + "출처": "자체", + "입력": [{"이름": "품"}], + "중간": [], + "호표": [ + { + "종류": "인력", + "요소": labor, + "이름": "석공", + "단위": "인", + "수량": "품", + "비목": "노무비", + } + ], + "덧줄": [], + "끝수": None, + } + }, + ) + items = [ + {"id": "t", "kind": "표", "table": SAMPLE}, + {"id": "l", "kind": "노임", "key": labor}, + {"id": "g", "kind": "로직", "key": made["key"], "inputs": {"품": 0.5}}, + {"id": "g2", "kind": "로직", "key": made["key"], "inputs": {"품": 0.5}, "field": "재료비"}, + {"id": "w", "kind": "자재", "key": labor}, + {"id": "x", "kind": "표", "table": "QF999996"}, + {"id": "z", "kind": "모름"}, + ] + got = {v["id"]: v for v in _post(client, "/api/m01/sl/outside", {"items": items})["values"]} + assert got["t"]["rows"][0] == ["돌", "높이·아래", "높이·위", "석공"] + assert got["t"]["rows"][1:] == [["깬돌", None, 3, 0.5], ["깬돌", 3, None, 0.7]] + assert Decimal(str(got["l"]["value"])) == price and got["l"]["unit"] + assert Decimal(str(got["g"]["value"])) == Decimal("0.5") * price + assert got["g2"]["value"] == 0 + assert [got[k]["ok"] for k in ("w", "x", "z")] == [False, False, False] + + +def test_시스템_관리자가_아니면_403(folder: Path) -> None: + app = FastAPI() + app.include_router( + sl.router, dependencies=[Depends(verify_session), Depends(require_system_admin)] + ) + app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1} + client = TestClient(app) + assert client.get("/api/m01/sl/list").status_code == 403 + assert client.post("/api/m01/sl/outside", json={"items": []}).status_code == 403 + app.dependency_overrides[verify_session] = lambda: {"role": "SYSTEM_ADMIN", "company_id": None} + assert client.get("/api/m01/sl/list").json() == {"logics": []}