Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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": []}
|
||||
Reference in New Issue
Block a user