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

This commit is contained in:
2026-10-01 13:05:55 +09:00
10 changed files with 1800 additions and 58 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,
+33 -14
View File
@@ -5,7 +5,10 @@
* 서버 = `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";
/** 바깥 값 하나 — 값은 열 때 · 저장 때 마스터에서 새로(표는 사본으로 들고 있지 않음)
* · 표 = 품셈 표 통째(머리 줄 + 줄 · 범위 조건 · 값은 「이름·아래」 「이름·위」 두 칸)
@@ -78,31 +81,47 @@ export interface SlSaved {
values: SlOutsideValue[];
}
const notReady = (): never => {
throw new Error("준비 중");
};
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 notReady();
return (await send<{ logics: SlListItem[] }>("/list")).logics;
}
export async function slGet(_key: string): Promise<SlLogic> {
return notReady();
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 notReady();
/** 저장 — 서버가 바깥 값을 마스터에서 다시 넣고 같은 엔진으로 다시 셈(브라우저 계산값은 버림) */
export async function slSave(logic: SlLogic): Promise<SlSaved> {
return send<SlSaved>("/save", { logic });
}
export async function slDelete(_key: string): Promise<void> {
return notReady();
export async function slDelete(key: string): Promise<void> {
await send<{ key: string }>("/delete", { key });
}
export async function slOutside(_items: SlOutside[]): Promise<SlOutsideValue[]> {
return notReady();
/** 바깥 값 한 번에 — 열 때 · 예시값을 바꿀 때 화면이 받아 시트에 넣음 */
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 notReady();
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;
}
+130 -13
View File
@@ -2,9 +2,14 @@
* M01_SL_Eval.ts
* 시트 로직 화면 계산 — 바깥 값 넣기 → 우리 스프레드시트 엔진(브라우저) → 출력 알약 · 결과 읽기 · 검사
* 저장 때는 서버가 같은 엔진을 Node 로(정함 5) · DOM 없음
* 엔진을 정적으로 가져옴 — 화면(Entry)은 SheetLogic 을 import() 로 늦게 불러 M01 첫 묶음에 안 들어가게
* ========================================================================== */
import type { SheetLogic, SlOutside } from "./M01_SL_Model";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { parseA1, toA1 } from "../../A00_Common/spreadsheet/spreadsheet_address";
import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc";
import type { CalcValue, Cell } from "../../A00_Common/spreadsheet/spreadsheet_types";
import { nodeSheet, type SheetLogic, type SlNode, type SlOutside } from "./M01_SL_Model";
/** 알약 값 — 수 · 글 · 참거짓 · 빈 칸 */
export type SlValue = number | string | boolean | null;
@@ -19,31 +24,143 @@ export interface SlOutsideValue {
export interface SlEvalResult {
/** 상자 id → 출력 알약 이름 → 값 */
pills: Record<string, Record<string, SlValue>>;
/** 상자 id → 입력 알약 이름 → 값(화면 알약 위 숫자) */
pillsIn: Record<string, Record<string, SlValue>>;
result: { 노무비: number | null; 재료비: number | null; 경비: number | null; 계: number | null };
/** 쉬운 말 */
errors: string[];
}
const notReady = (): never => {
throw new Error("준비 중");
};
const TEXT = {
/** {box} {cell} {err} */
CellError: [
"「{box}」 {cell} 칸 계산이 막힘 — {err}",
"「{box}」 cell {cell} can't be worked out — {err}",
],
Cycle: ["돌고 도는 참조", "circular reference"],
/** {box} {pill} */
EmptyIn: [
"「{box}」의 입력 알약 「{pill}」이 비어 있음",
"Input pill 「{pill}」 of 「{box}」 is empty",
],
NoOutside: [
"「{box}」의 바깥 값 「{pill}」을 아직 안 받음",
"Outside value 「{pill}」 of 「{box}」 not filled yet",
],
NoResult: ["결과 상자가 없음", "There is no result box"],
NoLine: ["결과 상자에 이어진 선이 없음", "No line reaches the result box"],
} as const;
const tl = (key: keyof typeof TEXT, fill: Record<string, string> = {}): string =>
Object.entries(fill).reduce(
(s, [k, v]) => s.replace(`{${k}}`, v),
TEXT[key][currentLanguageIndex === 1 ? 1 : 0] as string,
);
/* ── 바깥 값 ────────────────────────────────────────────────────────────── */
/** 바깥 값 칸 목록 — 화면 · 서버가 이 칸에 값을 넣음 */
export function outsideCells(_sl: SheetLogic): SlOutside[] {
return notReady();
export function outsideCells(sl: SheetLogic): SlOutside[] {
const kindOf = (n: SlNode): SlOutside["kind"] =>
n.kind === "table" ? "table" : n.kind === "logic" ? "logic" : "price";
return sl.nodes.flatMap((n) =>
[...n.pillsIn, ...n.pillsOut]
.filter((p) => p.outside)
.map((p) => ({ node: n.id, cell: p.cell, kind: kindOf(n), ref: n.ref ?? "" })),
);
}
/** 칸 하나에 값 — null 이면 값을 뺌(서식은 둠) */
function putValue(칸: Record<string, Cell>, cell: string, value: SlValue): void {
const style = 칸[cell]?.서식;
if (value === null) {
if (style) 칸[cell] = { 서식: style };
else delete 칸[cell];
} else 칸[cell] = { 값: value, ...(style ? { 서식: style } : {}) };
}
/** 바깥 값 넣기 — 새 시트 로직(받은 것은 안 바꿈) */
export function applyOutside(_sl: SheetLogic, _values: SlOutsideValue[]): SheetLogic {
return notReady();
export function applyOutside(sl: SheetLogic, values: SlOutsideValue[]): SheetLogic {
const out = structuredClone(sl);
for (const v of values) {
const sheet = nodeSheet(out, v.node);
const at = parseA1(v.cell);
if (!sheet || !at) continue;
if (!Array.isArray(v.value)) {
putValue(sheet.칸, toA1(at.r, at.c), v.value);
continue;
}
v.value.forEach((line, dr) =>
line.forEach((x, dc) => putValue(sheet.칸, toA1(at.r + dr, at.c + dc), x)),
);
}
return out;
}
/* ── 계산 ───────────────────────────────────────────────────────────────── */
const fromCalc = (v: CalcValue | undefined): SlValue => {
if (!v) return null;
if ("수" in v) return Number(v.수);
if ("글" in v) return v.글;
if ("참" in v) return v.참;
return v.오류;
};
const isNum = (v: SlValue): v is number => typeof v === "number" && Number.isFinite(v);
/** 화면 계산 */
export function evalLogic(_sl: SheetLogic): SlEvalResult {
return notReady();
export function evalLogic(sl: SheetLogic): SlEvalResult {
const values = recalcWorkbook(sl.workbook);
const read = (n: SlNode, cell: string): SlValue => {
const c = nodeSheet(sl, n.id)?.칸[cell.toUpperCase()];
if (!c) return null;
if (c.식 !== undefined) return fromCalc(values[n.sheetId]?.[cell.toUpperCase()]);
return c.값 ?? null;
};
const pills: SlEvalResult["pills"] = {};
const pillsIn: SlEvalResult["pillsIn"] = {};
for (const n of sl.nodes) {
pills[n.id] = Object.fromEntries(n.pillsOut.map((p) => [p.name, read(n, p.cell)]));
pillsIn[n.id] = Object.fromEntries(n.pillsIn.map((p) => [p.name, read(n, p.cell)]));
}
const errors: string[] = [];
for (const n of sl.nodes)
for (const [cell, v] of Object.entries(values[n.sheetId] ?? {}))
if ("오류" in v) {
const err = v.오류 === "#CYCLE!" ? `${v.오류} ${tl("Cycle")}` : v.오류;
errors.push(tl("CellError", { box: n.name, cell, err }));
}
const box = sl.nodes.find((n) => n.kind === "result");
const num = (v: SlValue | undefined): number | null => (isNum(v ?? null) ? (v as number) : null);
const result: SlEvalResult["result"] = box
? {
노무비: num(pillsIn[box.id]["노무비"]),
재료비: num(pillsIn[box.id]["재료비"]),
경비: num(pillsIn[box.id]["경비"]),
계: num(pills[box.id]["계"]),
}
: { 노무비: null, 재료비: null, 경비: null, 계: null };
return { pills, pillsIn, result, errors };
}
/** 검사 — 빈 입력 알약 · 순환 · 오류 칸(쉬운 말 목록) */
export function checkLogic(_sl: SheetLogic): string[] {
return notReady();
/** 검사 — 빈 입력 알약 · 받지 않은 바깥 값 · 순환 · 오류 칸 · 결과 상자 없음(쉬운 말 목록) */
export function checkLogic(sl: SheetLogic): string[] {
const out: string[] = [];
for (const n of sl.nodes) {
const sheet = nodeSheet(sl, n.id);
for (const p of [...n.pillsIn, ...n.pillsOut]) {
const c = sheet?.칸[p.cell.toUpperCase()];
const empty = !c || (c.식 === undefined && (c.값 === undefined || c.값 === ""));
if (!empty) continue;
if (p.outside) out.push(tl("NoOutside", { box: n.name, pill: p.name }));
// 결과 상자의 비목 알약은 비어도 0 — 안 쓰는 비목이 늘 있음
else if (n.pillsIn.includes(p) && n.kind !== "result")
out.push(tl("EmptyIn", { box: n.name, pill: p.name }));
}
}
const box = sl.nodes.find((n) => n.kind === "result");
if (!box) out.push(tl("NoResult"));
else if (!sl.edges.some((e) => e.to.node === box.id)) out.push(tl("NoLine"));
return [...out, ...evalLogic(sl).errors];
}
+335 -31
View File
@@ -1,11 +1,15 @@
/* =============================================================================
* M01_SL_Model.ts
* 시트 로직 Model — 로직 한 벌 = 통합문서 하나 · 상자 하나 = 시트 하나 · 알약 = 이름 붙은 칸
* 선 = 받는 입력 알약 칸에 「'시트 이름'!칸」 참조 수식 · DOM 없음
* 선 = 받는 입력 알약 칸에 「'시트 이름'!칸」 참조 수식(여러 선이면 「+」 로 이음) · DOM 없음
* 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` 정함 1~5 그대로
*
* 모든 함수는 새 시트 로직을 돌려줌(받은 것은 안 바꿈) · 엔진(A00_Common/spreadsheet)은 가져다 쓰기만
* ========================================================================== */
import type { Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address";
import type { Cell, Sheet, Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types";
/** 상자 종류 — 입력값 · 고정값 · 표 · 계산 · 항목 · 다른 로직 · 결과 */
export type SlKind = "input" | "const" | "table" | "calc" | "item" | "logic" | "result";
@@ -73,61 +77,361 @@ export interface SlEnd {
pill: string;
}
const notReady = (): never => {
throw new Error("준비 중");
const TEXT = {
Name: ["이름", "Name"],
Value: ["값", "Value"],
Note: ["설명", "Note"],
Sample: ["예시값", "Sample value"],
Fixed: ["늘 이 값", "Always this value"],
Cond: ["조건", "Condition"],
Found: ["결과", "Result"],
FromTable: ["표에서 찾은 값", "Value found in the table"],
ValueA: ["값1", "Value 1"],
ValueB: ["값2", "Value 2"],
Times: ["값1 × 값2", "Value 1 × Value 2"],
Qty: ["수량", "Quantity"],
Price: ["단가", "Unit price"],
Amount: ["금액", "Amount"],
QtyNote: ["선으로 받음", "Comes in by a line"],
PriceNote: ["바깥 값 — 열 때 넣음", "Outside value — filled on open"],
LogicNote: ["다른 로직 결과 — 열 때 넣음", "Other logic's result — filled on open"],
Sum: ["계", "Total"],
NoPill: ["알약이 없음", "No such pill"],
Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"],
Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"],
Twice: ["이미 이어져 있음", "Already linked"],
} as const;
const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
/** 비목 이름(결과 · 항목 칸) — 화면 글 아님 · 알약 이름 열쇠 */
const COSTS = ["노무비", "재료비", "경비"] as const;
/* ── 작은 도움 ──────────────────────────────────────────────────────────── */
const STAGE: Record<SlKind, number> = {
input: 0,
const: 0,
table: 1,
logic: 1,
calc: 2,
item: 3,
result: 4,
};
const clone = <T>(v: T): T => structuredClone(v);
/** 다음 번호 — 있는 id 들(「n3」)보다 하나 큰 것 */
function nextId(prefix: string, ids: string[]): string {
const nums = ids
.filter((x) => x.startsWith(prefix))
.map((x) => Number(x.slice(prefix.length)))
.filter((n) => Number.isInteger(n));
return `${prefix}${Math.max(0, ...nums) + 1}`;
}
/** 시트 이름 — 통합문서 안에서 하나(같으면 「 (2)」) */
function uniqueName(book: Workbook, name: string): string {
const base = name.trim() || "상자";
const used = new Set(book.시트.map((s) => s.이름));
if (!used.has(base)) return base;
for (let n = 2; ; n++) if (!used.has(`${base} (${n})`)) return `${base} (${n})`;
}
const sheetOf = (sl: SheetLogic, node: SlNode): Sheet => {
const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId);
if (!sheet) throw new Error(`없는 시트 ${node.sheetId}`);
return sheet;
};
const nodeOf = (sl: SheetLogic, id: string): SlNode | undefined =>
sl.nodes.find((n) => n.id === id);
const pillOf = (node: SlNode, dir: "in" | "out", name: string): SlPill | undefined =>
(dir === "in" ? node.pillsIn : node.pillsOut).find((p) => p.name === name);
/* ── 상자 틀 ─────────────────────────────────────────────────────────────── */
type Row = [string, Cell | null, string];
/** 종류별 틀 — 1행 머리(이름 · 값 · 설명) · 2행부터 알약 줄 */
function template(kind: SlKind, name: string): { rows: Row[]; ins: SlPill[]; outs: SlPill[] } {
const v = (값: number | string): Cell => ({ 값 });
const f = (식: string): Cell => ({ 식 });
switch (kind) {
case "input":
return { rows: [[name, v(0), tl("Sample")]], ins: [], outs: [{ name, cell: "B2" }] };
case "const":
return { rows: [[name, v(0), tl("Fixed")]], ins: [], outs: [{ name, cell: "B2" }] };
case "table":
return {
rows: [
[tl("Cond"), null, tl("QtyNote")],
[tl("Found"), null, tl("FromTable")],
],
ins: [{ name: tl("Cond"), cell: "B2" }],
outs: [{ name: tl("Found"), cell: "B3" }],
};
case "calc":
return {
rows: [
[tl("ValueA"), null, tl("QtyNote")],
[tl("ValueB"), null, tl("QtyNote")],
[tl("Found"), f("B2*B3"), tl("Times")],
],
ins: [
{ name: tl("ValueA"), cell: "B2" },
{ name: tl("ValueB"), cell: "B3" },
],
outs: [{ name: tl("Found"), cell: "B4" }],
};
case "item":
return {
rows: [
[tl("Qty"), null, tl("QtyNote")],
[tl("Price"), null, tl("PriceNote")],
[tl("Amount"), f("B2*B3"), ""],
["노무비", f("B4"), ""],
["재료비", v(0), ""],
["경비", v(0), ""],
],
ins: [
{ name: tl("Qty"), cell: "B2" },
{ name: tl("Price"), cell: "B3", outside: true },
],
outs: [
{ name: tl("Amount"), cell: "B4" },
...COSTS.map((c, i) => ({ name: c, cell: `B${5 + i}` })),
],
};
case "logic":
return {
rows: [[tl("Found"), null, tl("LogicNote")]],
ins: [],
outs: [{ name: tl("Found"), cell: "B2", outside: true }],
};
case "result":
return {
rows: [
["노무비", null, tl("QtyNote")],
["재료비", null, tl("QtyNote")],
["경비", null, tl("QtyNote")],
[tl("Sum"), f("SUM(B2:B4)"), ""],
],
ins: COSTS.map((c, i) => ({ name: c, cell: `B${2 + i}` })),
outs: [{ name: "계", cell: "B5" }],
};
}
}
/* ── 만들기 · 상자 ───────────────────────────────────────────────────────── */
/** 빈 시트 로직 */
export function newLogic(_name?: string, _resultUnit?: string): SheetLogic {
return notReady();
export function newLogic(name = "", resultUnit = "원/㎡"): SheetLogic {
return {
key: null,
name,
resultUnit,
workbook: { 종류: "통합문서", 판: 1, 열: "SL", 서식: [{}], 시트: [] },
nodes: [],
edges: [],
};
}
/** 상자 더하기 — 그 종류 틀(칸 A 이름 · B 값 · C 설명)로 시트를 만듦 */
export function addNode(
_sl: SheetLogic,
_kind: SlKind,
_name: string,
sl: SheetLogic,
kind: SlKind,
name: string,
): { sl: SheetLogic; node: SlNode } {
return notReady();
const out = clone(sl);
const book = out.workbook;
const sheetId = nextId(
"s",
book.시트.map((s) => s.id),
);
const title = uniqueName(book, name);
const t = template(kind, title);
const 칸: Record<string, Cell> = {
A1: { 값: tl("Name") },
B1: { 값: tl("Value") },
C1: { 값: tl("Note") },
};
t.rows.forEach(([label, cell, note], i) => {
칸[`A${i + 2}`] = { 값: label };
if (cell) 칸[`B${i + 2}`] = cell;
if (note) 칸[`C${i + 2}`] = { 값: note };
});
book.시트.push({ id: sheetId, 이름: title, 칸 });
book.활성 ??= sheetId;
const node: SlNode = {
id: nextId(
"n",
out.nodes.map((n) => n.id),
),
kind,
name: title,
sheetId,
pillsIn: t.ins,
pillsOut: t.outs,
stage: STAGE[kind],
};
out.nodes.push(node);
return { sl: out, node };
}
/** 상자 지우기 — 시트 · 이어진 선 · 받던 참조 수식을 함께 지움 */
export function removeNode(_sl: SheetLogic, _node: string): SheetLogic {
return notReady();
export function removeNode(sl: SheetLogic, id: string): SheetLogic {
const node = nodeOf(sl, id);
if (!node) return sl;
let out = sl;
for (const e of sl.edges.filter((x) => x.from.node === id || x.to.node === id))
out = disconnect(out, e.id);
out = clone(out);
out.nodes = out.nodes.filter((n) => n.id !== id);
out.workbook.시트 = out.workbook.시트.filter((s) => s.id !== node.sheetId);
if (out.workbook.활성 === node.sheetId) out.workbook.활성 = out.workbook.시트[0]?.id;
return out;
}
/* ── 선 ─────────────────────────────────────────────────────────────────── */
/** 받는 알약 칸 수식 = 그 알약으로 오는 선들의 참조를 「+」 로 · 선이 없으면 칸을 비움 */
function rewriteTarget(sl: SheetLogic, to: SlEnd): void {
const node = nodeOf(sl, to.node);
const pill = node && pillOf(node, "in", to.pill);
if (!node || !pill) return;
const refs = sl.edges
.filter((e) => e.to.node === to.node && e.to.pill === to.pill)
.map((e) => {
const src = nodeOf(sl, e.from.node);
const out = src && pillOf(src, "out", e.from.pill);
return src && out ? `${quoteSheet(sheetOf(sl, src).이름)}!${out.cell}` : null;
})
.filter((x): x is string => x !== null);
const sheet = sheetOf(sl, node);
const keepStyle = sheet.칸[pill.cell]?.서식;
if (refs.length)
sheet.칸[pill.cell] = { 식: refs.join("+"), ...(keepStyle ? { 서식: keepStyle } : {}) };
else if (keepStyle) sheet.칸[pill.cell] = { 서식: keepStyle };
else delete sheet.칸[pill.cell];
}
/** from 상자에서 선을 따라 to 상자에 닿나 */
function reaches(sl: SheetLogic, from: string, to: string): boolean {
const seen = new Set<string>();
const walk = (id: string): boolean => {
if (id === to) return true;
if (seen.has(id)) return false;
seen.add(id);
return sl.edges.filter((e) => e.from.node === id).some((e) => walk(e.to.node));
};
return walk(from);
}
/** 선 잇기 — 받는 알약 칸에 참조 수식 · 순환 · 없는 알약이면 막고 까닭(쉬운 말) */
export function connect(
_sl: SheetLogic,
_from: SlEnd,
_to: SlEnd,
sl: SheetLogic,
from: SlEnd,
to: SlEnd,
): { sl: SheetLogic; why: string | null } {
return notReady();
const a = nodeOf(sl, from.node);
const b = nodeOf(sl, to.node);
if (!a || !b || !pillOf(a, "out", from.pill) || !pillOf(b, "in", to.pill))
return { sl, why: tl("NoPill") };
if (a.id === b.id) return { sl, why: tl("Self") };
const same = (e: SlEdge): boolean =>
e.from.node === from.node &&
e.from.pill === from.pill &&
e.to.node === to.node &&
e.to.pill === to.pill;
if (sl.edges.some(same)) return { sl, why: tl("Twice") };
if (reaches(sl, b.id, a.id)) return { sl, why: tl("Loop") };
const out = clone(sl);
out.edges.push({
id: nextId(
"e",
out.edges.map((e) => e.id),
),
from: { ...from },
to: { ...to },
});
rewriteTarget(out, to);
return { sl: out, why: null };
}
/** 선 끊기 — 그 참조를 받는 칸 수식에서 뺌 */
export function disconnect(_sl: SheetLogic, _edge: string): SheetLogic {
return notReady();
export function disconnect(sl: SheetLogic, edgeId: string): SheetLogic {
const edge = sl.edges.find((e) => e.id === edgeId);
if (!edge) return sl;
const out = clone(sl);
out.edges = out.edges.filter((e) => e.id !== edgeId);
rewriteTarget(out, edge.to);
return out;
}
/** 알약 정하기 — 같은 칸이나 같은 이름이 있으면 바꿈 */
/* ── 알약 ───────────────────────────────────────────────────────────────── */
/** 알약 정하기 — 같은 칸이나 같은 이름이 있으면 바꿈(이름이 바뀌면 이어진 선도 따라감) */
export function setPill(
_sl: SheetLogic,
_node: string,
_cell: string,
_dir: "in" | "out",
_name: string,
_extra?: Pick<SlPill, "unit" | "outside">,
sl: SheetLogic,
nodeId: string,
cell: string,
dir: "in" | "out",
name: string,
extra: Pick<SlPill, "unit" | "outside"> = {},
): SheetLogic {
return notReady();
const out = clone(sl);
const node = nodeOf(out, nodeId);
if (!node) return sl;
const list = dir === "in" ? node.pillsIn : node.pillsOut;
const at = list.findIndex((p) => p.cell === cell || p.name === name);
const pill: SlPill = { name, cell: cell.toUpperCase(), ...extra };
if (at < 0) {
list.push(pill);
return out;
}
const old = list[at];
list[at] = pill;
for (const e of out.edges) {
const end = dir === "in" ? e.to : e.from;
if (end.node === nodeId && end.pill === old.name) end.pill = name;
}
// 칸이 바뀌면 선이 가리키던 칸도 새로 씀
if (old.cell !== pill.cell) {
const linked = out.edges.some((e) => e.to.node === nodeId && e.to.pill === name);
if (dir === "in" && linked) {
delete sheetOf(out, node).칸[old.cell];
rewriteTarget(out, { node: nodeId, pill: name });
} else if (dir === "out")
for (const e of out.edges.filter((x) => x.from.node === nodeId && x.from.pill === name))
rewriteTarget(out, e.to);
}
return out;
}
/** 알약 빼기 — 이어진 선도 끊음 */
export function removePill(
_sl: SheetLogic,
_node: string,
_dir: "in" | "out",
_name: string,
sl: SheetLogic,
nodeId: string,
dir: "in" | "out",
name: string,
): SheetLogic {
return notReady();
let out = sl;
for (const e of sl.edges) {
const end = dir === "in" ? e.to : e.from;
if (end.node === nodeId && end.pill === name) out = disconnect(out, e.id);
}
out = clone(out);
const node = nodeOf(out, nodeId);
if (!node) return out;
if (dir === "in") node.pillsIn = node.pillsIn.filter((p) => p.name !== name);
else node.pillsOut = node.pillsOut.filter((p) => p.name !== name);
return out;
}
/** 상자 · 시트 찾기(Eval · 화면이 씀) */
export function nodeSheet(sl: SheetLogic, nodeId: string): Sheet | null {
const node = nodeOf(sl, nodeId);
return node ? sheetOf(sl, node) : null;
}
+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)
+263
View File
@@ -0,0 +1,263 @@
"""M01 시트 로직 Model · Eval 시험(35-5) — Node 로 `M01_SL_Model.ts` · `M01_SL_Eval.ts` 와 진짜 스프레드시트 엔진을 돌림.
· 견본 시트 로직 둘은 이 시험 안에서 만듦 — 마스터 · `_data` 에 기대지 않음 · 저장 안 함
· 입력값 → 계산 → 항목 → 결과 · 선 잇기 / 끊기 → 값 바뀜 · 순환 막음 · 바깥 값 넣기 · 알약 · 상자 지우기 · 검사
· 엔진 · Model 은 TypeScript 를 그 자리에서 CommonJS 로 옮겨 부름(`helper_spreadsheet_engine.cjs` 와 같은 길)
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SL = ROOT / "M01_MasterData" / "SheetLogic"
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
LOADER = r"""
const fs = require("fs");
const path = require("path");
const ROOT = process.argv[1];
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
const cache = new Map();
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full).exports;
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText;
const mod = { exports: {} };
cache.set(full, mod);
const req = (r) => {
if (r === "@ui/ui_template_locale") return { currentLanguageIndex: 0 };
if (r.endsWith(".css")) return {};
if (r.startsWith("@ui/")) return loadTs(path.join(ROOT, "ui_template", `${r.slice(4)}.ts`));
if (r.startsWith(".")) return loadTs(path.join(path.dirname(full), r.endsWith(".ts") ? r : `${r}.ts`));
return require(r);
};
new Function("exports", "module", "require", js)(mod.exports, mod, req);
return mod.exports;
}
const M = loadTs(path.join(ROOT, "M01_MasterData", "SheetLogic", "M01_SL_Model.ts"));
const E = loadTs(path.join(ROOT, "M01_MasterData", "SheetLogic", "M01_SL_Eval.ts"));
const out = (x) => console.log(JSON.stringify(x));
const sheet = (sl, id) => M.nodeSheet(sl, id);
const link = (sl, a, ap, b, bp) => {
const r = M.connect(sl, { node: a.id, pill: ap }, { node: b.id, pill: bp });
if (r.why) throw new Error(r.why);
return r.sl;
};
"""
def _run(body: str) -> object:
result = subprocess.run(
["node", "-e", LOADER + body, str(ROOT)],
capture_output=True, text=True, encoding="utf-8", timeout=120, cwd=ROOT,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout.strip().splitlines()[-1])
# 견본 하나 — 품(입력값) → 보통 인부(항목 · 노무) → 결과
ONE = r"""
let sl = M.newLogic("시험 하나", "원/㎡");
let r = M.addNode(sl, "input", "품"); sl = r.sl; const pum = r.node;
r = M.addNode(sl, "item", "보통 인부"); sl = r.sl; const man = r.node; man.ref = "LB000002";
r = M.addNode(sl, "result", "결과"); sl = r.sl; const res = r.node;
sl = E.applyOutside(sl, [{ node: pum.id, cell: "B2", value: 0.5 }, { node: man.id, cell: "B3", value: 100000 }]);
sl = link(sl, pum, "품", man, "수량");
sl = link(sl, man, "노무비", res, "노무비");
"""
# 견본 둘 — 품 · 할증률(고정값) → 계산(곱) → 석공 · 보통인부 두 항목 → 결과 노무비(두 선을 더함)
TWO = r"""
let sl = M.newLogic("시험 둘");
let r = M.addNode(sl, "input", "품"); sl = r.sl; const pum = r.node;
r = M.addNode(sl, "const", "할증률"); sl = r.sl; const rate = r.node;
r = M.addNode(sl, "calc", "할증 품"); sl = r.sl; const calc = r.node;
r = M.addNode(sl, "item", "석공"); sl = r.sl; const stone = r.node;
r = M.addNode(sl, "item", "보통인부"); sl = r.sl; const man = r.node;
r = M.addNode(sl, "result", "결과"); sl = r.sl; const res = r.node;
sl = E.applyOutside(sl, [
{ node: pum.id, cell: "B2", value: 0.2 }, { node: rate.id, cell: "B2", value: 1.3 },
{ node: stone.id, cell: "B3", value: 200000 }, { node: man.id, cell: "B3", value: 100000 },
]);
sl = link(sl, pum, "품", calc, "값1");
sl = link(sl, rate, "할증률", calc, "값2");
sl = link(sl, calc, "결과", stone, "수량");
sl = link(sl, pum, "품", man, "수량");
sl = link(sl, stone, "노무비", res, "노무비");
sl = link(sl, man, "노무비", res, "노무비");
"""
def test_입력값_항목_결과_수식과_값() -> None:
got = _run(
ONE
+ "out([sheet(sl, man.id).칸.B2, sheet(sl, res.id).칸.B2, E.evalLogic(sl), sl.edges, sl.nodes.map(n => [n.kind, n.stage]),"
" sl.workbook.시트.map(s => s.이름)]);"
)
qty, total_in, ev, edges, kinds, names = got
assert qty == {"식": "품!B2"}
assert total_in == {"식": "'보통 인부'!B5"}
assert ev["result"] == {"노무비": 50000, "재료비": None, "경비": None, "계": 50000}
assert ev["pills"]["n2"]["금액"] == 50000 and ev["pillsIn"]["n2"]["수량"] == 0.5
assert ev["errors"] == []
assert edges == [
{"id": "e1", "from": {"node": "n1", "pill": "품"}, "to": {"node": "n2", "pill": "수량"}},
{
"id": "e2",
"from": {"node": "n2", "pill": "노무비"},
"to": {"node": "n3", "pill": "노무비"},
},
]
assert kinds == [["input", 0], ["item", 3], ["result", 4]]
assert names == ["품", "보통 인부", "결과"]
def test_계산_두_항목_더함_값바뀜() -> None:
got = _run(
TWO + "const a = E.evalLogic(sl);\n"
"const sl2 = E.applyOutside(sl, [{ node: rate.id, cell: 'B2', value: 1.5 }]);\n"
"out([a.result, a.pills[calc.id], sheet(sl, res.id).칸.B2, E.evalLogic(sl2).result]);"
)
a, c, cell, b = got
# 석공 0.2×1.3×200000 = 52000 · 보통인부 0.2×100000 = 20000
assert a == {"노무비": 72000, "재료비": None, "경비": None, "계": 72000}
assert c == {"결과": 0.26}
assert cell == {"식": "석공!B5+보통인부!B5"}
assert b["계"] == 0.2 * 1.5 * 200000 + 20000
def test_선_끊기_다시_잇기() -> None:
got = _run(
TWO + "const cut = sl.edges.find(e => e.from.node === stone.id).id;\n"
"const a = M.disconnect(sl, cut);\n"
"const b = M.disconnect(M.disconnect(sl, cut), sl.edges.find(e => e.from.node === man.id && e.to.node === res.id).id);\n"
"const c = link(a, stone, '노무비', res, '경비');\n"
"out([sheet(a, res.id).칸.B2, E.evalLogic(a).result, sheet(b, res.id).칸.B2 ?? null, E.checkLogic(b),"
" E.evalLogic(c).result, a.edges.length]);"
)
cell_a, ra, cell_b, check_b, rc, n_edges = got
assert cell_a == {"식": "보통인부!B5"} and ra["계"] == 20000
assert cell_b is None
assert "결과 상자에 이어진 선이 없음" in check_b
assert not any("입력 알약" in x for x in check_b)
assert rc == {"노무비": 20000, "재료비": None, "경비": 52000, "계": 72000}
assert n_edges == 5
def test_순환_같은상자_두번_없는알약_막음() -> None:
got = _run(
TWO
+ "let r2 = M.addNode(sl, 'calc', '다른 계산'); const sl3 = r2.sl; const other = r2.node;\n"
"const s4 = link(sl3, calc, '결과', other, '값1');\n"
"const tries = [\n"
" M.connect(s4, { node: other.id, pill: '결과' }, { node: calc.id, pill: '값1' }).why,\n"
" M.connect(s4, { node: calc.id, pill: '결과' }, { node: calc.id, pill: '값2' }).why,\n"
" M.connect(s4, { node: calc.id, pill: '결과' }, { node: other.id, pill: '값1' }).why,\n"
" M.connect(s4, { node: calc.id, pill: '없음' }, { node: other.id, pill: '값2' }).why,\n"
" M.connect(s4, { node: res.id, pill: '계' }, { node: pum.id, pill: '품' }).why,\n"
"];\n"
"const same = M.connect(s4, { node: other.id, pill: '결과' }, { node: calc.id, pill: '값1' }).sl === s4;\n"
"out([tries, same]);"
)
tries, same = got
assert tries == [
"이으면 돌고 도는 선이 됨",
"같은 상자끼리는 못 이음",
"이미 이어져 있음",
"알약이 없음",
"알약이 없음",
]
assert same is True
def test_엔진_순환은_오류로_알림() -> None:
got = _run(TWO + "sheet(sl, calc.id).칸.B3 = { 식: 'B4' };\nout(E.evalLogic(sl).errors);")
assert got and all("돌고 도는 참조" in e for e in got)
assert got[0].startswith("「할증 품」 ")
def test_바깥값_칸목록과_표_덩어리() -> None:
got = _run(
ONE
+ "r = M.addNode(sl, 'table', '메쌓기 표'); sl = r.sl; const tb = r.node; tb.ref = 'QF000421';\n"
"sl.nodes.find(n => n.id === tb.id).ref = 'QF000421';\n"
"sl = M.setPill(sl, tb.id, 'E1', 'out', '표', { outside: true });\n"
"r = M.addNode(sl, 'logic', '작업량'); sl = r.sl; const lg = r.node;\n"
"sl.nodes.find(n => n.id === lg.id).ref = 'GF000114';\n"
"const cells = E.outsideCells(sl);\n"
"sl = E.applyOutside(sl, [{ node: tb.id, cell: 'E1', value: [['뒷길이', '석공'], [25, 0.15], [30, null]] },"
" { node: lg.id, cell: 'B2', value: 12.5 }]);\n"
"const s = sheet(sl, tb.id).칸;\n"
"out([cells, s.E1, s.F2, s.E3, s.F3 ?? null, E.evalLogic(sl).pills[lg.id], E.checkLogic(sl)]);"
)
cells, e1, f2, e3, f3, lg, check = got
assert cells == [
{"node": "n2", "cell": "B3", "kind": "price", "ref": "LB000002"},
{"node": "n4", "cell": "E1", "kind": "table", "ref": "QF000421"},
{"node": "n5", "cell": "B2", "kind": "logic", "ref": "GF000114"},
]
assert (e1, f2, e3, f3) == ({"값": "뒷길이"}, {"값": 0.15}, {"값": 30}, None)
assert lg == {"결과": 12.5}
assert "「메쌓기 표」의 입력 알약 「조건」이 비어 있음" in check
def test_알약_정하기_이름바꾸면_선이_따라감() -> None:
got = _run(
ONE + "let s2 = M.setPill(sl, pum.id, 'B2', 'out', '품셈 품');\n"
"const e = s2.edges[0].from;\n"
"s2 = M.setPill(s2, pum.id, 'D2', 'out', '품셈 품');\n"
"const moved = sheet(s2, man.id).칸.B2;\n"
"const s3 = M.removePill(s2, pum.id, 'out', '품셈 품');\n"
"const s4 = M.setPill(sl, man.id, 'C9', 'in', '할증');\n"
"out([e, moved, s3.edges.length, sheet(s3, man.id).칸.B2 ?? null, s4.nodes[1].pillsIn.map(p => [p.name, p.cell])]);"
)
e, moved, n_edges, cleared, ins = got
assert e == {"node": "n1", "pill": "품셈 품"}
assert moved == {"식": "품!D2"}
assert n_edges == 1 and cleared is None
assert ins == [["수량", "B2"], ["단가", "B3"], ["할증", "C9"]]
def test_상자_지우기() -> None:
got = _run(
TWO + "const s2 = M.removeNode(sl, stone.id);\n"
"out([s2.nodes.map(n => n.name), s2.workbook.시트.map(s => s.이름), s2.edges.length,"
" sheet(s2, res.id).칸.B2, E.evalLogic(s2).result.계, sl.nodes.length]);"
)
names, sheets, n_edges, cell, total, before = got
assert names == ["품", "할증률", "할증 품", "보통인부", "결과"] and sheets == names
assert n_edges == 4 and cell == {"식": "보통인부!B5"} and total == 20000
assert before == 6 # 받은 것은 안 바뀜
def test_같은_이름_상자와_새_번호() -> None:
got = _run(
"let sl = M.newLogic(); let r = M.addNode(sl, 'input', '높이'); sl = r.sl;\n"
"r = M.addNode(sl, 'input', '높이'); sl = r.sl;\n"
"sl = M.removeNode(sl, 'n1'); r = M.addNode(sl, 'const', '높이'); sl = r.sl;\n"
"out([sl.nodes.map(n => [n.id, n.name, n.sheetId]), E.checkLogic(sl)]);"
)
nodes, check = got
assert nodes == [["n2", "높이 (2)", "s2"], ["n3", "높이", "s3"]]
assert check == ["결과 상자가 없음"]
def test_화면_글에_금지_낱말_없음() -> None:
for name in ("M01_SL_Model.ts", "M01_SL_Eval.ts"):
src = (SL / name).read_text(encoding="utf-8")
block = src[src.index("const TEXT = {") : src.index("} as const;")]
korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)]
assert korean
for text in korean:
for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체"):
assert bad not in text, (name, bad, text)
+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": []}