Merge remote-tracking branch 'origin/dev' into sub_laptop_7

This commit is contained in:
2026-10-01 13:04:58 +09:00
8 changed files with 1167 additions and 0 deletions
@@ -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,
+1
View File
@@ -0,0 +1 @@
_data/
+127
View File
@@ -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<string, unknown>;
field?: SlField;
}
| {
id: string;
kind: "로직";
key: string;
inputs: Record<string, unknown>;
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<T>(path: string, body?: unknown): Promise<T> {
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<SlListItem[]> {
return (await send<{ logics: SlListItem[] }>("/list")).logics;
}
export async function slGet(key: string): Promise<SlLogic> {
return (await send<{ logic: SlLogic }>(`/get?${new URLSearchParams({ key }).toString()}`)).logic;
}
/** 저장 — 서버가 바깥 값을 마스터에서 다시 넣고 같은 엔진으로 다시 셈(브라우저 계산값은 버림) */
export async function slSave(logic: SlLogic): Promise<SlSaved> {
return send<SlSaved>("/save", { logic });
}
export async function slDelete(key: string): Promise<void> {
await send<{ key: string }>("/delete", { key });
}
/** 바깥 값 한 번에 — 열 때 · 예시값을 바꿀 때 화면이 받아 시트에 넣음 */
export async function slOutside(items: SlOutside[]): Promise<SlOutsideValue[]> {
return (await send<{ values: SlOutsideValue[] }>("/outside", { items })).values;
}
/** 실패 → 쉬운 말(EasyCalc `plainError` 그대로) */
export function slPlain(e: unknown): string {
return plainError(e);
}
+169
View File
@@ -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;
}
}
+330
View File
@@ -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, string | number> = {}): 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<SlListItem[]> => 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<boolean> =>
!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<void> => {
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<string, number>();
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;
}
+313
View File
@@ -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)
+5
View File
@@ -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)
+218
View File
@@ -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": []}