feat(794-1): M01 관리자 줄 식 시트 — 로직 상세 첫 보기 「시트」 · 식 칸 칠 때마다 검사 · 기존 [저장] 길

- 로직 상세 보기 첫 칸 「시트」(기본) — 이름 | 식 | 값 | 비목 · 근거 · 옵션(시험 값) · 변수 · 계산 · 자원 · 율 · 덧줄 · 단계 · 끝수 · 소계 · 합계 · 상태줄 지금 · 고치기 전
- 식 글 = 이름 꼴(M02 단가산출 시트와 같은 번역 · 새 서버 M01_MasterData_Sheet.py · POST /api/m01/logic/sheet · /logic/sheet/check · 저장 때와 같은 엔진 검사 · 쓰기 없음)
- 식 고침 = 시스템 관리자만 · 되는 식만 줄 사본에 · 캐시 → 기존 [저장](개정 확인 · 서버 검사 422) · [원래로]
- 식 칸 몸 = 공용 A00_Common/sheet_formula_cell.ts — M02 단가산출 시트도 같은 것을 씀(동작 그대로)
- 자체 로직(GX) 편집기 식 날 입력칸 → 읽기만(시트 식 칸으로 갈음)
- 시험: test_794_1_m01_sheet 새로 · test_790_1 식 칸 문구 자리를 공용 파일로 옮김(식 칸 몸이 공용으로 감) · test_717_2b · test_722 LogicLab.ts 줄 상한 627 → 637(시트 이음 10 줄 · 셈 · 고침은 시트 파일로 뺌 · 700 아래)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 18:45:48 +09:00
co-authored by Claude Opus 5.5
parent b989fe1724
commit 2e7328d3a5
17 changed files with 970 additions and 79 deletions
+101
View File
@@ -0,0 +1,101 @@
/* =============================================================================
* A00_Common/sheet_formula_cell.ts
* 줄 식 시트 식 칸 한 벌(PLAN 792 · 794-1) — M02 단가산출 시트 · M01 로직 시트가 같이 씀.
* 칠 때마다 검사(`check` · 150 ms 쉼 · 앞 요청 끊음) → 틀리면 빨간 칸 + 까닭 · 알림 한 줄
* 바꿀 때(Enter · 떠남) — 빈 칸 · 원래 식 그대로 = `put(null)` · 되면 `put(엔진 식)` · 틀리면 안 넣음
* 치는 중 글 = `drafts`(다시 그려도 안 잃음) · 부품 = 입력 · 글 · 서버 검사 길은 부르는 쪽 몫
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
const TEXT = {
Formula: ["식", "Formula"],
Hint: ["예) 무게 / 운반량 × (1 + 할증율 / 100)", "e.g. weight / load × 1.2"],
Bad: ["식이 틀려 안 넣음", "Invalid formula — not applied"],
Checking: ["검사 중", "Checking…"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
/** 검사 답 — 되나 · 까닭 · 알림(없으면 빈) · 엔진 식 */
export interface FormulaVerdict {
되나: boolean;
까닭: string[];
알림?: string[];
식: string | null;
}
export interface FormulaCellOptions {
/** 시트 칸 열쇠(초점 되찾기 · 치는 중 글) */
key: string;
/** 칸 그릇 이름표 값(`data-sheet-fx`) */
spot: string;
shown: string;
/** 원래 식 글 — 그대로 두면 `put(null)` */
origin: string;
drafts: Map<string, string>;
check: (text: string, signal: AbortSignal) => Promise<FormulaVerdict>;
put: (engine: string | null) => void;
className?: string;
}
export function createFormulaCell(o: FormulaCellOptions): HTMLElement {
const box = createInputField({
value: o.drafts.get(o.key) ?? o.shown,
compact: true,
size: 24,
placeholder: t("Hint"),
});
box.input.setAttribute("aria-label", t("Formula"));
box.input.dataset.sheetCell = o.key;
box.input.spellcheck = false;
const note = createText("", "muted", 12);
note.dataset.sheetCheck = o.key;
let timer = 0;
let ctl: AbortController | null = null;
const run = (text: string): Promise<FormulaVerdict | null> => {
ctl?.abort();
const mine = (ctl = new AbortController());
note.textContent = t("Checking");
return o.check(text, mine.signal).then(
(got) => {
if (mine !== ctl) return got;
box.setError(got.되나 ? undefined : got.까닭.join(" · "));
note.textContent = (got.알림 ?? []).join(" · ");
return got;
},
(error: unknown) => {
if (mine !== ctl || mine.signal.aborted) return null;
box.setError(error instanceof Error ? error.message : String(error));
note.textContent = "";
return null;
},
);
};
box.input.addEventListener("input", () => {
o.drafts.set(o.key, box.input.value);
window.clearTimeout(timer);
const text = box.input.value.trim();
if (!text) return void (ctl?.abort(), box.setError(undefined), (note.textContent = ""));
timer = window.setTimeout(() => void run(text), 150);
});
box.input.addEventListener("change", async () => {
window.clearTimeout(timer);
const text = box.input.value.trim();
if (!text || text === o.origin) {
o.drafts.delete(o.key);
return o.put(null);
}
const got = await run(text);
if (!got?.되나 || !got.식) return void (note.textContent = t("Bad"));
o.drafts.delete(o.key);
o.put(got.식);
});
return el("div", {
className: o.className ?? "m02-sks__fx",
attrs: { "data-sheet-fx": o.spot },
children: [box.root, note],
});
}
+24
View File
@@ -12,6 +12,7 @@ from typing import Any, Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from M01_MasterData import M01_MasterData_Sheet as line_sheet
from M01_MasterData import M01_MasterData_Store as store
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M01_MasterData import M01_MasterData_Store_Flow as flow
@@ -347,6 +348,29 @@ def post_logic_draft(body: DraftLogic) -> dict:
return _call(make.logic_draft, body.text, body.decided, body.head, body.save, body.owner)
class SheetRow(BaseModel):
row: dict[str, Any]
class SheetCheck(BaseModel):
row: dict[str, Any]
갈래: Literal["중간", "호표", "덧줄"]
차례: int
식: str
@router.post("/logic/sheet")
def post_logic_sheet(body: SheetRow) -> dict:
"""줄 식 시트(794-1) — 줄마다 이름 꼴 식 글 · 식 칸 이름표(셈 · 쓰기 없음)."""
return _call(line_sheet.names, body.row)
@router.post("/logic/sheet/check")
def post_logic_sheet_check(body: SheetCheck) -> dict:
"""시트 식 칸 칠 때마다 — {되나, 까닭, 식(엔진), 글(이름 꼴)} · 저장 때와 같은 엔진 검사."""
return _call(line_sheet.check, body.row, body.갈래, body.차례, body.식)
# ── 일위대가 조합 — 조합 길은 M02 층 길(`/api/m02/layers/{layer}/combos` · PLAN 27-2a) ──
@router.get("/logic/combos")
def get_logic_combos(key: str) -> dict:
+70
View File
@@ -0,0 +1,70 @@
"""M01 로직 줄 식 시트(PLAN 794-1 · 요구 650~652 · 요구서_652 1 장 6) — 관리자 시트 식 칸 서버 몫.
이름 꼴 ↔ 엔진 식 · 상한 = M02 단가산출 식 칸과 한 벌(`M02_Sangeun_Formula` 불러 씀 · 고치지 않음) ·
검사 = 저장 때와 같은 엔진 검사(`check_logic`) 중 그 자리 까닭만 · 셈 · 쓰기 없음(③ 서버 단독).
자리 = (갈래 중간 | 호표 | 덧줄, 차례 0 부터) — 저장은 화면 캐시 줄 → 기존 M01 [저장](`/save`).
짝 = `logic/M01_Logic_Sheet_Model.ts`(줄 고치기 — 서버 짝 함수 없음 · 자리 꼴만 같음) ·
시험 `resources/tester/test_794_1_m01_sheet.py`.
"""
from __future__ import annotations
import copy
from typing import Any
from M01_MasterData import M01_MasterData_Store as store
FIELD = {"중간": "식", "호표": "수량", "덧줄": "식"}
def _fx() -> Any:
from M02_MasterTemplete import M02_Sangeun_Formula as fx # M02 가 M01 을 부름 — 늦게
return fx
def _rows(row: dict, part: str) -> list:
got = row.get(part)
return got if isinstance(got, list) else []
def names(row: dict) -> dict[str, Any]:
"""줄마다 이름 꼴 식 글 {중간: [글], 호표: [글], 덧줄: [글], 이름표: [{글, 갈래}]}."""
fx, out = _fx(), {}
for part, field in FIELD.items():
out[part] = [fx.to_name(str(x.get(field) or ""), row) for x in _rows(row, part)]
try:
out["이름표"] = fx.name_table(row, {})
except (KeyError, TypeError, ValueError):
out["이름표"] = []
return out
def _where(row: dict, part: str, at: int) -> str:
"""`check_logic` 까닭 머리(그 자리)."""
key = f"{row.get('키')} {row.get('원문번호')}"
name = _rows(row, part)[at].get("이름", "")
return f"{key} {part} {name}" + (" 수량" if part == "호표" else "")
def check(row: dict, part: str, at: int, text: str) -> dict[str, Any]:
"""식 글 하나 → {되나, 까닭, 식(엔진), 글(이름 꼴)} — 그 자리에 넣은 사본을 엔진 검사."""
fx = _fx()
text = str(text or "").strip()
out: dict[str, Any] = {"되나": False, "까닭": [], "식": None, "글": None}
if part not in FIELD or not 0 <= at < len(_rows(row, part)):
return out | {"까닭": [f"없는 자리 「{part} {at}」"]}
if not text:
return out | {"까닭": ["식이 빔"]}
if bad := fx.limits(text):
return out | {"까닭": bad}
got, why = fx.to_engine(text, row)
if why:
return out | {"까닭": why}
out |= {"식": got, "글": fx.to_name(got, row)}
trial = copy.deepcopy(row)
trial[part][at][FIELD[part]] = got
head = _where(trial, part, at)
found = store.cm.mf.check_logic(store.loaded()[1], trial)[0]
mine = [w[len(head) :].lstrip(" ·") for w in found if w.startswith(head)]
return out | {"되나": not mine, "까닭": mine}
+11 -1
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab.ts
* 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 단가산출 상세(호표) · 시험 계산 / 오른쪽 흐름도
* 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 시트(794-1 · 식 고침 = 관리자) · 단가산출 상세 · 시험 계산 / 오른쪽 흐름도
* 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「단가산출 로직」 컨테이너(`listHost`) 안
*
* 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`.
@@ -54,6 +54,7 @@ import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic";
import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise";
import { buildLogicSide } from "./M01_MasterData_UI_LogicLab_Side";
import { createViewPicker } from "./M01_MasterData_UI_LogicLab_View";
import { createLabSheet } from "./M01_MasterData_UI_LogicLab_Sheet";
import { buildHeadBar } from "./M01_MasterData_UI_Head";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model";
@@ -128,6 +129,12 @@ export async function mountM01LogicLab(
.then((u) => u?.role === "SYSTEM_ADMIN")
.catch(() => false);
let recalc: number | undefined;
const sheet = createLabSheet({
admin,
onInput: (name, v) => opened && syncLeft(opened, { [name]: v }, true),
edited: () => (touch(), drawEditor(), drawCalc()), // 줄 사본 → 캐시 → [저장](기존 길)
run: () => calc.querySelector<HTMLButtonElement>("[data-calc-run]")?.click(),
});
const editor = el("div", { className: "m01-logic__editor" });
// 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 둘(흐름도 · 원문 · 하나만 열림 · 폭 공유)
const panes = buildLogicSide(editor);
@@ -225,6 +232,7 @@ export async function mountM01LogicLab(
if (text.ok) current.lastText = text;
drawEditor(); // [계산] 전 미리보기 응답도 왼쪽 이름 식 칸에 바로 비치게 — onLines 와 같은 자리
},
onAnswer: sheet.answer,
onLines: (lines) => {
current.lines = lines;
drawEditor();
@@ -260,7 +268,9 @@ export async function mountM01LogicLab(
calcHost: calc,
flowHost: flowOf(current.row) ? current.flowHost : undefined,
admin,
sheet: sheet.root,
});
sheet.draw(current.row as LogicRow, current.original, current.values);
flowNow?.redrawLeft();
};
@@ -39,6 +39,8 @@ export interface DetailContext extends HoContext {
onTrial?: () => void;
/** 717-2b M02 단가산출 — 단가산출 상세 · 인력·재료·경비 덩이를 이 칸으로(SK 셈 답 · 고침 표) */
cover?: { ho: HTMLElement; est: HTMLElement; open?: ReturnType<typeof lineOpener> };
/** 794-1 M01 줄 식 시트(`_LogicLab_Sheet`) 그릇 — 있으면 「시트」 덩이(M02 는 안 넘김) */
sheet?: HTMLElement;
}
const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement =>
@@ -166,7 +168,11 @@ function ownEditor(ctx: DetailContext): HTMLElement {
return el("details", {
className: "m01lab__edit",
attrs: { open: "" },
children: [el("summary", { text: tl("Edit_Title") }), ctx.ownHost],
children: [
el("summary", { text: tl("Edit_Title") }),
el("p", { className: "ui-text ui-text--muted", text: tl("Edit_SheetHint") }),
ctx.ownHost,
],
});
}
@@ -230,6 +236,7 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
// 보기 고르기(`_View.ts`) — 덩이마다 이름표 · 고른 덩이만 보임 · 시험 계산은 숨어도 그려 둠
host.replaceChildren(
...infoBox(ctx),
...(ctx.sheet ? [block(ctx.sheet, "sheet")] : []),
block(section(tl("Calc_Title"), [ctx.calcHost]), "calc"),
block(
section(tl("View_Est"), [
@@ -0,0 +1,324 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Sheet.ts
* M01 단가산출 로직 상세 「시트」 보기(PLAN 794-1 · 요구 650~652 · 요구서_652 1 장 6) — M02 단가산출 시트와 같은 꼴.
* 한 줄 「이름 | 식 | 값 | 비목」(줄 꼴 = `logic/M01_Logic_Sheet_Model`) · 상태줄 = 지금 · 고치기 전 + [계산]
* 식 글 = 서버 이름 꼴(`POST /api/m01/logic/sheet` · 줄이 바뀔 때만 · 옛 요청 끊음)
* 고침 = 시스템 관리자만 — [식 고침] → 식 칸(공용 `sheet_formula_cell` · 칠 때마다 `/logic/sheet/check`)
* → 되면 엔진 식을 줄 사본에(`onEdit`) → 상세 캐시(drafts) → 기존 M01 [저장](서버 검사 · 개정 확인)
* 옵션 칸 = 시험 값(저장 안 함 · 고치면 시험 계산) · 자동저장 없음 · 부품 = calc_row · 입력 · 고르기 · 단추 · 배지
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { createCalcFormula, createCalcSheetRow } from "@ui/ui_template_calc_row";
import { el } from "@ui/ui_template_elements";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { createToolbar } from "@ui/ui_template_toolbar";
import { createFormulaCell, type FormulaVerdict } from "../A00_Common/sheet_formula_cell";
import { formatLineMoney, formatMoney } from "../common_util/common_util_money";
import type { CalcAnswer, LogicRow } from "./M01_MasterData_UI_Logic_Api";
import {
formulaAt,
labItems,
setLabFormula,
spotKey,
type LabItem,
type LabKind,
type LabNames,
type LabPart,
} from "./logic/M01_Logic_Sheet_Model";
import "../M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.css";
const TEXT = {
Sheet: ["시트", "Sheet"],
Now: ["지금", "Now"],
Before: ["고치기 전", "Before edit"],
Run: ["계산", "Calculate"],
근거: ["근거", "Basis"],
옵션: ["옵션 · 시험 값", "Options · trial values"],
계산: ["변수 · 계산", "Variables"],
자원: ["자원", "Resources"],
율: ["율 · 덧줄", "Rates"],
단계: ["단계", "Stages"],
소계: ["소계 · 합계", "Subtotals"],
Edit: ["식 고침", "Edit formula"],
Close: ["식 닫기", "Close formula"],
Back: ["원래로", "Revert"],
Wait: ["[계산] 을 누르면 값이 채워짐", "Press Calculate to fill values"],
Flow: ["흐름 로직 — 시트로 못 봄", "Flow logic — no sheet"],
Address: ["번지", "Address"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
const SECTION: Record<LabKind, keyof typeof TEXT> = {
근거: "근거",
옵션: "옵션",
계산: "계산",
자원: "자원",
율: "율",
단계: "단계",
소계: "소계",
합계: "소계",
};
const ORDER: (keyof typeof TEXT)[] = ["근거", "옵션", "계산", "자원", "율", "단계", "소계"];
const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 });
export interface LabSheetDraw {
row: LogicRow;
/** 저장본 줄(새 로직 = null) */
original: LogicRow | null;
values: Record<string, string>;
answer: CalcAnswer | null;
}
export interface LabSheetOptions {
/** 시스템 관리자 — 식 칸 · [식 고침] · [원래로] */
admin: boolean;
onInput: (name: string, value: string) => void;
/** 줄 식 칸을 고친 뒤(줄 객체는 여기서 고침) — 상세가 캐시(touch) · 다시 그림 */
edited: () => void;
/** 시험 계산 [계산] 누름 */
run: () => void;
}
async function post<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
const r = await fetch(`${API_BASE_URL}/m01${path}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown };
if (!r.ok) throw new Error(typeof data.detail === "string" ? data.detail : r.statusText);
return data;
}
export function createLabSheet(opt: LabSheetOptions) {
const status = el("div", { className: "m02-sks__status", attrs: { "data-lab-status": "" } });
const body = el("div", { className: "m02-sks__body" });
const run = createButton({ label: t("Run"), variant: "ghost", size: 28, onClick: opt.run });
const root = el("div", {
className: "m02-sks",
attrs: { "data-lab-sheet": "" },
children: [createToolbar({ items: [run, "divider", status], label: t("Sheet") }), body],
});
let last: LabSheetDraw | null = null;
let names: LabNames | null = null;
let asked = "";
let ctl: AbortController | null = null;
let before: number | null = null;
let beforeAt = ""; // 고치기 전 값을 잰 시험 값(옵션) — 옵션이 바뀌면 고친 줄의 「고치기 전」 은 모름
const open = new Set<string>();
const drafts = new Map<string, string>();
/** 줄이 바뀌면 이름 꼴 식 글을 다시 받음(옛 요청 끊음) */
const askNames = (row: LogicRow): void => {
const text = JSON.stringify(row);
if (text === asked) return;
asked = text;
ctl?.abort();
const mine = (ctl = new AbortController());
post<LabNames>("/logic/sheet", { row }, mine.signal).then(
(got) => {
if (mine !== ctl) return;
names = got;
if (last) paint(last);
},
() => undefined, // 못 받으면 엔진 식 그대로 보임
);
};
const check =
(row: LogicRow, part: LabPart, at: number) =>
(text: string, signal: AbortSignal): Promise<FormulaVerdict> =>
post<FormulaVerdict>("/logic/sheet/check", { row, 갈래: part, 차례: at, 식: text }, signal);
const money = (it: LabItem): string => {
const v = it.값;
if (typeof v !== "number") return it.kind === "옵션" ? (it.input?.단위 ?? "") : String(v ?? "");
if (it.kind === "자원" || it.kind === "율") return formatLineMoney(v);
return it.kind === "소계" || it.kind === "합계" ? formatMoney(v) : NUM.format(v);
};
function optionCell(d: LabSheetDraw, it: LabItem): HTMLElement {
const spec = it.input!;
const value = d.values[spec.이름] ?? "";
if (spec.고르기?.length) {
const sel = createSelectField({
options: ["", ...spec.고르기.map(String)].map((o) => ({ value: o, text: o })),
value,
compact: true,
size: 24,
ariaLabel: spec.이름,
onChange: (v) => opt.onInput(spec.이름, v),
});
sel.select.dataset.sheetCell = `opt|${spec.이름}`;
return sel.root;
}
const box = createInputField({ value, compact: true, size: 24 });
box.input.setAttribute("aria-label", spec.이름);
box.input.dataset.sheetCell = `opt|${spec.이름}`;
box.input.addEventListener("change", () => opt.onInput(spec.이름, box.input.value.trim()));
return box.root;
}
function tail(d: LabSheetDraw, it: LabItem): HTMLElement {
const kids: HTMLElement[] = it.비목 ? [createBadge(it.비목, "neutral")] : [];
if (it.번지) kids.push(createBadge(`${t("Address")} ${it.번지}`, "info"));
if (opt.admin && it.part && it.at !== undefined) {
const key = spotKey(it.part, it.at);
const on = open.has(key);
const toggle = createButton({
label: on ? t("Close") : t("Edit"),
variant: "ghost",
size: 22,
onClick: () => {
if (on) open.delete(key);
else open.add(key);
drafts.delete(key);
paint(d);
},
});
toggle.dataset.sheetFxToggle = key;
kids.push(toggle);
if (it.changed && d.original) {
const { part, at } = it;
const back = createButton({
label: t("Back"),
variant: "ghost",
size: 22,
onClick: () => edit(d, part, at, null),
});
back.dataset.sheetRevert = key;
kids.push(back);
}
}
return el("span", { children: kids });
}
function rowOf(d: LabSheetDraw, it: LabItem): HTMLElement {
const key = it.part && it.at !== undefined ? spotKey(it.part, it.at) : "";
const editing = opt.admin && key && open.has(key) && it.part && it.at !== undefined;
const middle =
it.kind === "옵션"
? optionCell(d, it)
: el("div", {
className: "m02-sks__mid",
children: [
createCalcFormula(it.식 || (["근거", "소계", "합계"].includes(it.kind) ? "" : "—")),
...(editing
? [
createFormulaCell({
key,
spot: key,
shown: it.식,
origin: it.식,
drafts,
check: check(d.row, it.part!, it.at!),
put: (engine) => {
if (engine === null) return;
open.delete(key);
edit(d, it.part!, it.at!, engine);
},
}),
]
: []),
],
});
const value = el("span", {
className: `ui-calc-row__out${it.kind === "소계" || it.kind === "합계" ? " is-key" : ""}`,
text: money(it),
});
const row = createCalcSheetRow(it.이름, middle, value, tail(d, it));
row.dataset.sheetKind = it.kind;
if (key) row.dataset.sheetSpot = key;
if (it.changed) row.classList.add("is-changed");
return row;
}
function drawStatus(d: LabSheetDraw): void {
const ok = d.answer?.ok ? d.answer : null;
const now = ok?.sums?.계 ?? null;
const at = JSON.stringify(d.values);
const same = !d.original || JSON.stringify(d.row) === JSON.stringify(d.original);
if (same && now !== null)
[before, beforeAt] = [now, at]; // 저장본 그대로 = 지금이 고치기 전
else if (!same && at !== beforeAt) before = null;
const part = (k: string, label: string, v: number | null): HTMLElement =>
el("span", {
className: "m02-sks__amount",
attrs: { "data-lab-amount": k },
children: [createText(`${label} ${v == null ? "—" : formatMoney(v)}`, "muted", 14)],
});
const diff = now !== null && before !== null && now !== before ? now - before : 0;
status.replaceChildren(
part("now", t("Now"), now),
part("before", t("Before"), before),
...(diff
? [createBadge(`${diff > 0 ? "+" : "−"}${NUM.format(Math.abs(diff))}`, "warning")]
: []),
);
}
function paint(d: LabSheetDraw): void {
last = d;
drawStatus(d);
if (d.row.흐름) return void body.replaceChildren(createText(t("Flow"), "muted"));
const items = labItems({ ...d, names });
const active = document.activeElement as HTMLElement | null;
const keep = active && body.contains(active) ? active.dataset.sheetCell : "";
const typed = active instanceof HTMLInputElement && keep ? active.value : null; // 치던 글 지킴
const out: HTMLElement[] = [];
for (const sec of ORDER) {
const mine = items.filter((it) => SECTION[it.kind] === sec);
if (!mine.length) continue;
out.push(
el("div", {
className: "m02-sks__head",
attrs: { "data-sheet-section": sec },
children: [createText(t(sec), "muted", 14)],
}),
...mine.map((it) => rowOf(d, it)),
);
}
if (!d.answer) out.push(createText(t("Wait"), "muted"));
body.replaceChildren(...out);
const back = keep
? body.querySelector<HTMLElement>(`[data-sheet-cell="${CSS.escape(keep)}"]`)
: null;
if (back instanceof HTMLInputElement && typed !== null) back.value = typed;
back?.focus();
}
/** 식 칸 고침(엔진 식) · null = 저장본 식 → 상세 캐시 · 시험 계산 다시 */
function edit(d: LabSheetDraw, part: LabPart, at: number, engine: string | null): void {
const text = engine ?? (d.original ? formulaAt(d.original, part, at) : null);
if (text === null || !setLabFormula(d.row, part, at, text)) return;
opt.edited();
opt.run();
}
let answer: CalcAnswer | null = null;
let answerOf: Record<string, string> | null = null; // 답을 받은 로직(시험 값 그릇 = 연 로직마다 하나)
let base: { src: string | null; row: LogicRow | null } = { src: null, row: null };
return {
root,
/** 시험 계산 답 — 상세 `buildCalc` onAnswer */
answer: (a: CalcAnswer) => void (answer = a),
/** 상세가 그릴 때마다 — 줄 · 저장본 글(`original`) · 시험 값 그릇 */
draw: (row: LogicRow, original: string | null, values: Record<string, string>) => {
if (answerOf !== values) [answer, answerOf] = [null, values]; // 다른 로직 · 저장 뒤
if (base.src !== original) {
base = { src: original, row: original ? (JSON.parse(original) as LogicRow) : null };
open.clear(); // 연 식 칸 · 치던 글 버림
drafts.clear();
}
askNames(row);
paint({ row, original: base.row, values, answer });
},
};
}
@@ -199,6 +199,7 @@
}
/* 보기 고르기(710-2 · `_View.ts`) — 그릇 data-view 와 다른 이름표 덩이는 숨김(시험 계산은 숨어도 돎) */
.m01-logic__editor[data-view="sheet"] [data-block]:not([data-block="sheet"]),
.m01-logic__editor[data-view="ho"] [data-block]:not([data-block="ho"]),
.m01-logic__editor[data-view="est"] [data-block]:not([data-block="est"]),
.m01-logic__editor[data-view="calc"] [data-block]:not([data-block="calc"]),
@@ -33,6 +33,8 @@ const TEXT = {
],
Calc_Title: ["시험 계산", "Trial calculation"],
View_Est: ["인력·재료·경비", "Labor·material·expense"],
View_Sheet: ["시트", "Sheet"],
Edit_SheetHint: ["식은 「시트」 보기에서 고침", "Edit formulas in the Sheet view"],
View_Label: ["보기", "View"],
Now_Inputs: ["지금 입력", "Current inputs"],
Now_None: ["없음", "none"],
@@ -1,10 +1,10 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_View.ts
* 「단가산출 로직」 상세 보기 고르기 — 머리줄 [신규 로직] 왼쪽 「분할 고르기 | 세로 나눔선」
* 보기 넷 = 단가산출 상세(기본) · 인력·재료·경비 · 시험 계산 · 텍스트 수식 — 고른 덩이만 보임
* 보기 다섯 = 시트(기본 · 794-1 줄 식 시트) · 단가산출 상세 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 — 고른 덩이만 보임
* 덩이 = `data-block` 이름표 · 숨김 = 그릇 `data-view` + CSS(`LogicLab_Style.css`) — 다시 그려도 고름 유지
* 시험 계산은 숨겨도 그려 둠(자동 계산이 숨은 칸에서도 돎) · 기본정보는 이름표 없음(늘 맨 위)
* 기억 = 이 화면 안에서만(로직을 바꿔도 유지 · 새로 들어오면 단가산출 상세)
* 기억 = 이 화면 안에서만(로직을 바꿔도 유지 · 새로 들어오면 시트)
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
@@ -12,7 +12,7 @@ import { createSegment } from "@ui/ui_template_segment";
import { createToolbar } from "@ui/ui_template_toolbar";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
export type LabView = "ho" | "est" | "calc" | "formula";
export type LabView = "sheet" | "ho" | "est" | "calc" | "formula";
/** 덩이에 보기 이름표를 붙여 돌려줌 */
export const block = <T extends HTMLElement>(node: T, view: LabView): T => {
@@ -22,15 +22,16 @@ export const block = <T extends HTMLElement>(node: T, view: LabView): T => {
/** 보기 고르기 — `host`(상세 그릇)에 `data-view` 를 둠 · `root` 는 머리줄에 놓음 */
export function createViewPicker(host: HTMLElement): { root: HTMLElement } {
host.dataset.view = "ho";
host.dataset.view = "sheet";
const segment = createSegment({
options: [
{ value: "sheet", label: tl("View_Sheet") },
{ value: "ho", label: tl("Ho_Title") },
{ value: "est", label: tl("View_Est") },
{ value: "calc", label: tl("Calc_Title") },
{ value: "formula", label: tl("Formula_Title") },
],
value: "ho",
value: "sheet",
size: 32,
ariaLabel: tl("View_Label"),
onChange: (v) => (host.dataset.view = v),
@@ -70,6 +70,8 @@ export interface CalcContext {
/** 로직마다 넣은 값 — 다시 그려도 남음 */
values: Record<string, string>;
onLines?: (lines: CalcLine[] | null) => void;
/** 794-1 셈 답 통째(중간 값 · 소계) — M01 줄 식 시트 값 */
onAnswer?: (answer: CalcAnswer) => void;
/** 읽는 식 줄(`/text`)도 같이 받고 싶을 때 — 호출이 `onLines` 보다 먼저 */
onText?: (answer: TextAnswer) => void;
/** (M02 750-2) 있으면 [계산] = 이 셈(SK 셈 답 하나) · `/calc` · `/text` · 미리보기 안 부름 */
@@ -215,6 +217,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
};
const answer = await runCalc(body);
if (ctx.onText) ctx.onText(await runText(body));
ctx.onAnswer?.(answer);
ctx.onLines?.(answer.ok ? (answer.lines ?? null) : null);
if (answer.ok && answer.lines) showItems(host, ctx.values, answer.lines);
out.replaceChildren(...answerView(answer, draft, money));
+10 -5
View File
@@ -18,6 +18,7 @@ import type {
} from "./M01_MasterData_UI_Logic_Api";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import { createBadge } from "@ui/ui_template_badge";
import { createCalcFormula } from "@ui/ui_template_calc_row";
import { createText } from "@ui/ui_template_text";
import { createIconButton } from "@ui/ui_template_button";
import { formatLineMoney, formatNumber, formatPrice } from "../common_util/common_util_money";
@@ -107,6 +108,13 @@ function grid(headers: string[], rows: HTMLElement[][], className: string): HTML
return el("div", { className: "m01-logic__scroll", children: [table] });
}
/** 식 칸 — 읽기만(고침은 「시트」 보기 식 칸 · 칠 때마다 검사 · 794-1) */
const sheetOnly = (formula: string): HTMLElement => {
const box = createCalcFormula(formula || "—");
box.dataset.sheetOnly = "";
return box;
};
export function qtyKind(qty: string): string {
const text = qty.trim();
if (/^-?\d+(\.\d+)?$/.test(text)) return tx("Ho_QtyNumber");
@@ -308,10 +316,7 @@ function hoSection(
});
const brief = ctx.prices[item.요소];
const spec = item.규격 ?? brief?.규격 ?? "";
const qty = textBox(item.수량, (v) => {
set(() => (item.수량 = v));
tag.textContent = qtyKind(v);
});
const qty = sheetOnly(item.수량); // 794-1 식 = 「시트」 보기 식 칸(날 입력칸 갈음)
const tag = createBadge(qtyKind(item.수량), "accent");
const table = createButton({
label: tx("Ho_PickTable"),
@@ -387,7 +392,7 @@ function formulaRows(
return list.map((item, i) => {
const cells: HTMLElement[] = [
textBox(item.이름, (v) => set(() => (item.이름 = v))),
textBox(item.식, (v) => set(() => (item.식 = v)), "m01-logic__formula"),
sheetOnly(item.식),
];
if (withCost) {
cells.push(
@@ -0,0 +1,158 @@
/* =============================================================================
* M01_MasterData/logic/M01_Logic_Sheet_Model.ts
* M01 로직 줄 식 시트 꼴(PLAN 794-1 · 요구 650~652 · 요구서_652 1 장 6) — M02 단가산출 시트와 같은 꼴
* 「이름 | 식 | 값 | 비목」 · 줄 갈래 = 근거(출처 · 비고 · 끝수) · 옵션(입력) · 계산(중간) · 자원(호표) ·
* 율(덧줄) · 단계 · 소계 · 합계 · 식 글 = 서버 이름 꼴(`/logic/sheet` · 없으면 엔진 식 「*」 → 「×」)
* 값 = 시험 계산 답(줄 금액 = 호표 → 덧줄 차례 · 중간 값 · 비목 소계)
* 고침 = 줄 사본 식 칸 하나(`setLabFormula`) — 저장은 화면이 기존 M01 [저장] 길로
* 짝 = 서버 `M01_MasterData_Sheet.py`(자리 = 갈래 · 차례 꼴만 같음) · 화면 · fetch · sessionStorage 없음
* ========================================================================== */
import type {
CalcAnswer,
CalcLine,
LogicInput,
LogicRow,
NamedFormula,
} from "../M01_MasterData_UI_Logic_Api";
export type LabPart = "중간" | "호표" | "덧줄";
export type LabKind = "근거" | "옵션" | "계산" | "자원" | "율" | "단계" | "소계" | "합계";
/** 줄마다 식 칸 이름(갈래 → 칸) — 서버 `FIELD` 와 같음 */
export const FIELD: Record<LabPart, "식" | "수량"> = { 중간: "식", 호표: "수량", 덧줄: "식" };
export interface LabNames {
중간: string[];
호표: string[];
덧줄: string[];
이름표?: { 글: string; 갈래: string }[];
}
export interface LabItem {
kind: LabKind;
이름: string;
/** 이름 꼴 식 글 · 빈 글 = 식 없음 */
식: string;
값: number | string | null;
/** 비목 줄임(노 · 재 · 경) */
비목: string;
/** 식 칸 자리(중간 · 호표 · 덧줄) */
part?: LabPart;
at?: number;
input?: LogicInput;
/** 금액번지(791-1) */
번지?: string;
/** 저장본과 식이 다름 */
changed: boolean;
}
const SHORT: Record<string, string> = { 노무비: "노", 재료비: "재", 경비: "경" };
const short = (v: unknown): string => (typeof v === "string" ? (SHORT[v] ?? v) : "");
/** 엔진 식 → 보기 글(이름 꼴이 아직 없을 때) */
export const plain = (s: string): string => s.replace(/\s*\*\s*/g, " × ");
export const spotKey = (part: LabPart, at: number): string => `${part}#${at}`;
/** 줄 식 칸 값(엔진 식) */
export function formulaAt(row: LogicRow, part: LabPart, at: number): string {
const list = (row[part] ?? []) as unknown as Record<string, unknown>[];
return String(list[at]?.[FIELD[part]] ?? "");
}
/** 줄 식 칸 하나를 엔진 식으로 바꿈(줄 객체 그대로 고침) · 없는 자리 = false */
export function setLabFormula(row: LogicRow, part: LabPart, at: number, engine: string): boolean {
const list = row[part] as unknown as Record<string, unknown>[] | undefined;
if (!list || at < 0 || at >= list.length) return false;
list[at][FIELD[part]] = engine;
return true;
}
/** 끝수 글 — 글자면 그대로 · 묶음이면 「대상 방법 자리」 */
export function cutText(rule: unknown): string {
if (rule == null || rule === "") return "";
if (typeof rule === "string") return rule;
const r = rule as Record<string, unknown>;
return [r.대상, r.방법, r.자리 != null ? `${String(r.자리)} 자리` : ""]
.filter((x) => x !== undefined && x !== "")
.join(" · ");
}
const valueOf = (v: unknown): number | string | null =>
typeof v === "number" || typeof v === "string" ? v : null;
/** 시트 줄 — 근거 · 옵션 · 계산 · 자원 · 율 · 단계 · 소계 · 합계 */
export function labItems(o: {
row: LogicRow;
/** 저장본 줄(새 로직 = null) — 식이 다르면 고친 줄 */
original: LogicRow | null;
values: Record<string, string>;
names: LabNames | null;
answer: CalcAnswer | null;
}): LabItem[] {
const { row, original } = o;
const out: LabItem[] = [];
const item = (x: Partial<LabItem> & { kind: LabKind; 이름: string }): void =>
void out.push({ 식: "", 값: null, 비목: "", changed: false, ...x });
if (row.출처) item({ kind: "근거", 이름: row.원문번호 || "", 식: row.출처 });
for (const line of (row.비고 ?? "").split(/\r?\n/).filter((x) => x.trim()))
item({ kind: "근거", 이름: "", 식: line.trim() });
for (const input of row.입력 ?? [])
item({ kind: "옵션", 이름: input.이름, 값: o.values[input.이름] ?? "", input });
const ok = o.answer?.ok ? o.answer : null;
const mid = ok?.middle ?? {};
const shown = (part: LabPart, at: number): string =>
o.names?.[part]?.[at] ?? plain(formulaAt(row, part, at));
const changed = (part: LabPart, at: number): boolean =>
!!original && formulaAt(original, part, at) !== formulaAt(row, part, at);
const spot = (part: LabPart, at: number) => ({
part,
at,
식: shown(part, at),
changed: changed(part, at),
});
(row.중간 ?? []).forEach((f: NamedFormula, at) =>
item({ kind: "계산", 이름: f.이름, 값: valueOf(mid[f.이름]), ...spot("중간", at) }),
);
const ho = row.호표 ?? [];
const extra = row.덧줄 ?? [];
const lines: (CalcLine | undefined)[] =
ok?.lines && ok.lines.length === ho.length + extra.length ? ok.lines : [];
ho.forEach((h, at) => {
const b = (h as { 번지?: string }).번지;
item({
kind: "자원",
이름: h.단위 ? `${h.이름 ?? ""} (${h.단위})` : (h.이름 ?? ""),
값: lines[at]?.금액 ?? null,
비목: short(h.비목),
...(b ? { 번지: b } : {}),
...spot("호표", at),
});
});
extra.forEach((x, at) => {
const b = (x as { 번지?: string }).번지;
item({
kind: "율",
이름: x.이름,
값: lines[ho.length + at]?.금액 ?? null,
비목: short(x.비목),
...(b ? { 번지: b } : {}),
...spot("덧줄", at),
});
});
const steps = (row.단계 ?? []) as { 이름?: string; 줄?: unknown }[];
for (const s of Array.isArray(steps) ? steps : [])
item({
kind: "단계",
이름: String(s.이름 ?? ""),
식: Array.isArray(s.줄) ? s.줄.map(String).join(" + ") : "",
});
const cut = cutText(row.끝수);
if (cut) item({ kind: "근거", 이름: "끝수", 식: cut });
const sums = ok?.sums ?? null;
for (const k of Object.keys(SHORT))
if (sums && k in sums) item({ kind: "소계", 이름: k, 값: sums[k], 비목: SHORT[k] });
if (sums && "계" in sums) item({ kind: "합계", 이름: "계", 값: sums.계 });
return out;
}
@@ -14,7 +14,7 @@ import { createCalcSheetRow } from "@ui/ui_template_calc_row";
import { el } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { createFormulaCell } from "../A00_Common/sheet_formula_cell";
import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import type { EditCtx } from "./M02_MasterTemplete_Sangeun_SheetEdit";
import {
@@ -28,16 +28,12 @@ import {
import { num, type SpotInfo } from "./logic/M02_Sangeun_Sheet_Model";
const TEXT = {
Formula: ["식", "Formula"],
Edit: ["식 고침", "Edit formula"],
Close: ["식 닫기", "Close formula"],
Hint: ["예) 무게 / 운반량 × (1 + 할증율 / 100)", "e.g. weight / load × 1.2"],
Var: ["변수", "Variable"],
VarName: ["변수 이름", "Variable name"],
AddVar: ["+ 변수 줄", "+ Variable"],
DropVar: ["변수 빼기", "Remove variable"],
Bad: ["식이 틀려 안 넣음", "Invalid formula — not applied"],
Checking: ["검사 중", "Checking…"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
@@ -97,7 +93,7 @@ export function clearNums<T extends FormulaDoc>(doc: T, spot: string): T {
return out as T;
}
/** 식 칸 — 칠 때마다 검사(앞 요청 끊음) · 바꿀 때 넣음 · `put(엔진 식 | null)` */
/** 식 칸 — 칠 때마다 검사(앞 요청 끊음) · 바꿀 때 넣음 · `put(엔진 식 | null)` · 몸 = 공용 `sheet_formula_cell` */
function formulaBox(
c: FormulaCtx,
spot: string,
@@ -106,63 +102,14 @@ function formulaBox(
origin: string,
put: (engine: string | null) => void,
): HTMLElement {
const box = createInputField({
value: c.drafts.get(key) ?? shown,
compact: true,
size: 24,
placeholder: t("Hint"),
});
box.input.setAttribute("aria-label", t("Formula"));
box.input.dataset.sheetCell = key;
box.input.spellcheck = false;
const note = createText("", "muted", 12);
note.dataset.sheetCheck = key;
let timer = 0;
let ctl: AbortController | null = null;
let last: Promise<CheckAnswer | null> | null = null;
const run = (text: string): Promise<CheckAnswer | null> => {
ctl?.abort();
const mine = (ctl = new AbortController());
note.textContent = t("Checking");
last = checkFormula(c, spot, text, mine.signal).then(
(got) => {
if (mine !== ctl) return got;
box.setError(got.되나 ? undefined : got.까닭.join(" · "));
note.textContent = got.알림.join(" · ");
return got;
},
(error: unknown) => {
if (mine !== ctl || mine.signal.aborted) return null;
box.setError(error instanceof Error ? error.message : String(error));
note.textContent = "";
return null;
},
);
return last;
};
box.input.addEventListener("input", () => {
c.drafts.set(key, box.input.value);
window.clearTimeout(timer);
const text = box.input.value.trim();
if (!text) return void (ctl?.abort(), box.setError(undefined), (note.textContent = ""));
timer = window.setTimeout(() => void run(text), 150);
});
box.input.addEventListener("change", async () => {
window.clearTimeout(timer);
const text = box.input.value.trim();
if (!text || text === origin) {
c.drafts.delete(key);
return put(null);
}
const got = await run(text);
if (!got?.되나) return void (note.textContent = t("Bad"));
c.drafts.delete(key);
put(got.식);
});
return el("div", {
className: "m02-sks__fx",
attrs: { "data-sheet-fx": spot },
children: [box.root, note],
return createFormulaCell({
key,
spot,
shown,
origin,
drafts: c.drafts,
check: (text, signal) => checkFormula(c, spot, text, signal),
put,
});
}
@@ -192,7 +192,9 @@ def test_상세_부품_길_부품상자():
side = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_Side.ts")
modal = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Modal.ts")
assert "export const hasMasterJump" in side and "go.hidden = !hasMasterJump()" in modal
assert len(_text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab.ts").splitlines()) <= 627
assert (
len(_text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab.ts").splitlines()) <= 637
) # 794-1 시트 이음 10 줄
def test_화면_문서_꼴_서버_저장_셈(world): # noqa: F811 — 가져온 fixture
+1 -1
View File
@@ -56,7 +56,7 @@ def test_상세가_공용_부품을_씀():
assert "export function varRow(" in _text(ROOT / "A00_Common" / "calc_sheet.ts")
for p in (HO, COVER, M01 / "M01_MasterData_UI_LogicLab.ts"):
assert len(_text(p).splitlines()) <= (
627 if p.name == "M01_MasterData_UI_LogicLab.ts" else 700
637 if p.name == "M01_MasterData_UI_LogicLab.ts" else 700 # 794-1 시트 이음 10 줄
)
+4 -2
View File
@@ -276,26 +276,28 @@ def test_790_3_식_조각_글줄_산출서_열쇠_밂():
# ── 792 식 칸 화면(줄 식 고침 · 칠 때마다 검사 · 변수 줄) ────────────────────────────
FX = M02 / "M02_MasterTemplete_Sangeun_SheetFormula.ts"
CELL = ROOT / "A00_Common" / "sheet_formula_cell.ts" # 794-1 식 칸 몸 = 공용(M01 시트와 한 벌)
def test_792_식_칸_정적():
s = _text(FX)
assert "<button" not in s and "<input" not in s and "document.createElement" not in s
assert "sessionStorage." not in s and len(s.splitlines()) < 700
both = s + _text(CELL)
for call in (
"/sangeun/formula-check", # 검사 길(셈 · 저장 없음)
"ctl?.abort();", # 칠 때마다 · 옛 요청 끊기
"signal,",
'box.input.addEventListener("input"',
'box.setError(got.되나 ? undefined : got.까닭.join(" · "))', # 빨간 칸 + 까닭
"if (!got?.되나) return", # 틀린 식은 문서에 안 넣음
"if (!got?.되나 || !got.식) return", # 틀린 식은 문서에 안 넣음
"setFormula(doc, spot, engine)", # 엔진 식을 문서에
"clearNums(c.doc as FormulaDoc, spot)", # 같은 자리 상수 · 표값 · 품 덮개 걷음
"addVar(c.doc as FormulaDoc,",
"editVar(c.doc as FormulaDoc, v.이름, { 이름: to })",
"dropVar(c.doc as FormulaDoc, v.이름)",
):
assert call in s, call
assert call in both, call
sheet = _text(SHEET)
for call in (
"rowFormula(ctx, it.자리,",
+234
View File
@@ -0,0 +1,234 @@
# ruff: noqa: E501
"""794-1 M01 관리자 줄 식 시트 — 로직 상세 첫 보기 「시트」 · 식 칸 = M02 단가산출 시트와 한 벌.
- 정적: 시트 화면 = 부품 상자만(날 button · input 0) · 식 칸 몸 = 공용 `sheet_formula_cell`(M02 도 같은 것) ·
고침 = 관리자만 · 줄 사본 → 캐시(touch) → 기존 [저장] 길(시트는 `/save` 안 부름) · 보기 첫 칸 = 시트 ·
자체 로직(GX) 편집기 식 칸 = 읽기만(날 입력칸 갈음)
- 서버(사본 폴더): 이름 꼴 식 글 · 칠 때마다 검사(없는 이름 · 아래 줄 · 괄호 · 빈 식 · 길이 · 없는 자리) ·
검사 = 쓰기 없음 · 공구손료 식 1 → 2 % 저장(개정 확인 409 → 200) 뒤 셈 51,802 · 틀린 식 저장 422 · 시스템 관리자가 아니면 403
- 모델(node strip-types): 줄 갈래 · 줄 금액 = 호표 → 덧줄 차례 · 고친 줄 · 끝수 · 단계 · 식 칸 고침
"""
from __future__ import annotations
import copy
import hashlib
import json
import re
import shutil
import subprocess
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 m01_router
from M01_MasterData import M01_MasterData_Sheet as line_sheet
from M01_MasterData import M01_MasterData_Store as store
ROOT = Path(__file__).resolve().parents[2]
M01 = ROOT / "M01_MasterData"
SHEET = M01 / "M01_MasterData_UI_LogicLab_Sheet.ts"
MODEL = M01 / "logic" / "M01_Logic_Sheet_Model.ts"
LAB = M01 / "M01_MasterData_UI_LogicLab.ts"
CELL = ROOT / "A00_Common" / "sheet_formula_cell.ts"
REAL = store.FOLDER
KEY = "GC000211" # 합판거푸집 설치 및 해체
INPUTS = {"자재지역": "포항", "유형": "보통", "사용횟수": "6회", "높이감률": 0, "지붕슬래브": "아님",
"폼타이": "아님", "폼타이규격": "12.7㎜", "측압": "3t/㎡", "구멍땜": 0} # fmt: skip
TOOL_1 = "(형틀목공 × [노임 형틀목공] + 보통인부 × [노임 보통인부]) × 1 / 100"
TOOL_2 = TOOL_1.replace("× 1 / 100", "× 2 / 100")
IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE)
def _text(p: Path) -> str:
return p.read_text("utf-8")
def _hashes(folder: Path) -> dict[str, str]:
return {p.name: hashlib.md5(p.read_bytes()).hexdigest() for p in sorted(folder.glob("*.json"))}
# ── 정적 ─────────────────────────────────────────────────────────────
def test_정적_부품_상자만_관리자만_저장은_기존_길() -> None:
for p in (SHEET, MODEL, CELL):
s = _text(p)
assert "<button" not in s and "<input" not in s and "document.createElement" not in s
assert "sessionStorage." not in s and len(s.splitlines()) < 700
assert "fetch(" not in _text(MODEL) and "document." not in _text(MODEL)
sheet = _text(SHEET)
for part in (
"createCalcSheetRow",
"createFormulaCell",
'"/logic/sheet"',
'"/logic/sheet/check"',
):
assert part in sheet, part
assert "/save" not in sheet # 저장 = 상세 [저장](기존 길)
assert sheet.count("opt.admin &&") >= 2 # [식 고침] · 식 칸 = 관리자만
lab = _text(LAB)
assert "const sheet = createLabSheet({\n admin," in lab.replace("\r\n", "\n")
assert "sheet: sheet.root," in lab and "onAnswer: sheet.answer," in lab
assert "edited: () => (touch(), drawEditor(), drawCalc())" in lab # 캐시(touch) → [저장]
assert "setLabFormula(d.row, part, at, text)" in sheet and "opt.edited();" in sheet
assert len(lab.splitlines()) <= 637
view = _text(M01 / "M01_MasterData_UI_LogicLab_View.ts")
assert (
'host.dataset.view = "sheet";' in view
and '{ value: "sheet", label: tl("View_Sheet") }' in view
)
css = _text(M01 / "M01_MasterData_UI_LogicLab_Style.css")
assert '[data-view="sheet"] [data-block]:not([data-block="sheet"])' in css
edit = _text(M01 / "M01_MasterData_UI_Logic_Edit.ts")
assert "textBox(item.수량" not in edit and "textBox(item.식" not in edit # GX 날 식 칸 갈음
m02 = _text(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Sangeun_SheetFormula.ts")
assert "return createFormulaCell({" in m02 # M02 시트도 같은 식 칸
main = _text(ROOT / "main.py")
assert "app.include_router(m01_master_data_router, dependencies=system_admin_only)" in main
# ── 서버 ─────────────────────────────────────────────────────────────
@pytest.fixture
def folder(tmp_path: Path, monkeypatch) -> Path:
master = tmp_path / "master"
master.mkdir()
for path in REAL.glob("*.json"):
shutil.copy(path, master / path.name)
monkeypatch.setattr(store, "FOLDER", master)
return master
@pytest.fixture
def client(folder: Path) -> TestClient:
app = FastAPI()
app.include_router(m01_router.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 test_이름_꼴_검사_쓰기_없음(client: TestClient, folder: Path) -> None:
before, real = _hashes(folder), _hashes(REAL)
one = client.get("/api/m01/logic", params={"key": KEY}).json()
row = one["logic"]
names = _post(client, "/api/m01/logic/sheet", {"row": row})
assert names["덧줄"][0] == TOOL_1
assert names["중간"][3] == "[합판거푸집설치및해체일당].형틀목공 / 시공량"
assert (
len(names["호표"]) == len(row["호표"])
and {"글": "시공량", "갈래": "중간"} in names["이름표"]
)
ask = lambda part, at, text: _post( # noqa: E731
client, "/api/m01/logic/sheet/check", {"row": row, "갈래": part, "차례": at, "식": text}
)
good = ask("덧줄", 0, TOOL_2)
assert good["되나"] and good["식"] == "(형틀목공 * LB000007 + 보통인부 * LB000002) * 2 / 100"
assert good["글"] == TOOL_2
assert ask("덧줄", 0, "형틀목공 × [노임 없음]")["까닭"] == ["없는 이름 「[노임 없음]」"]
below = ask("중간", 0, "시공량 × 2") # 시공량 = 아래 줄
assert not below["되나"] and "시공량" in below["까닭"][0]
assert not ask("중간", 2, "(1 +")["되나"]
assert ask("호표", 0, " ")["까닭"] == ["식이 빔"]
assert not ask("호표", 0, "1" + " + 1" * 200)["되나"] # 길이 상한
assert ask("덧줄", 9, "1")["까닭"] == ["없는 자리 「덧줄 9」"]
assert ask("호표", 0, "형틀목공 × 2")["되나"]
assert row == client.get("/api/m01/logic", params={"key": KEY}).json()["logic"] # 줄 안 바뀜
assert _hashes(folder) == before and _hashes(REAL) == real
def test_저장_길_개정_확인_다시_셈_틀린_식_422(client: TestClient, folder: Path) -> None:
real = _hashes(REAL)
one = client.get("/api/m01/logic", params={"key": KEY}).json()
row = copy.deepcopy(one["logic"])
got = line_sheet.check(row, "덧줄", 0, TOOL_2)
row["덧줄"][0]["식"] = got["식"]
body = {"files": [{"file": one["file"], "version": one["version"],
"changes": [{"op": "edit", "key": KEY, "row": row}]}]} # fmt: skip
assert client.post("/api/m01/save", json=body).status_code == 409 # 정본 = 개정 확인
_post(client, "/api/m01/save", {**body, "개정확인": True})
saved = client.get("/api/m01/logic", params={"key": KEY}).json()
assert saved["logic"]["덧줄"][0]["식"] == got["식"]
sums = _post(client, "/api/m01/calc", {"key": KEY, "inputs": INPUTS})["sums"]
assert sums["계"] == 51802 and sums["경비"] == 765 # _분석_650-H2 4 장 공구손료 2 %
bad = copy.deepcopy(saved["logic"])
bad["덧줄"][0]["식"] = "형틀목공 * 없는이름"
body = {"files": [{"file": saved["file"], "version": saved["version"],
"changes": [{"op": "edit", "key": KEY, "row": bad}]}], "개정확인": True} # fmt: skip
before = _hashes(folder)
assert client.post("/api/m01/save", json=body).status_code == 422
assert _hashes(folder) == before and _hashes(REAL) == real
def test_시스템_관리자가_아니면_403(folder: Path) -> None:
app = FastAPI()
app.include_router(
m01_router.router, dependencies=[Depends(verify_session), Depends(require_system_admin)]
)
app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1}
client = TestClient(app)
for url, body in (("/api/m01/logic/sheet", {"row": {}}),
("/api/m01/logic/sheet/check", {"row": {}, "갈래": "중간", "차례": 0, "식": "1"})): # fmt: skip
assert client.post(url, json=body).status_code == 403
# ── 모델 ─────────────────────────────────────────────────────────────
ROW = {
"키": "GX1", "원문번호": "1-1", "이름": "견본", "결과단위": "원/㎡", "출처": "견본 1-1", "비고": "한 줄\n둘째 줄",
"입력": [{"이름": "폭", "단위": "m"}],
"중간": [{"이름": "넓이", "식": "폭 * 2"}],
"호표": [{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": "넓이 * 0.1", "비목": "노무비", "번지": "A01"}],
"덧줄": [{"이름": "공구손료", "식": "노무비 * 1 / 100", "비목": "경비"}],
"단계": [{"이름": "노무 소계", "단": 1, "줄": ["A01"]}],
"끝수": {"대상": "비목", "방법": "버림", "자리": 0},
} # fmt: skip
def _run() -> dict:
src = IMPORTS.sub("", _text(MODEL))
body = (
f"\nconst R = {json.dumps(ROW, ensure_ascii=False)};"
"\nconst now = structuredClone(R);"
"\nconst put = setLabFormula(now, '호표', 0, '넓이 * 0.2');"
"\nconst miss = setLabFormula(now, '덧줄', 5, '1');"
"\nconst answer = { ok: true, middle: { 넓이: 6 }, sums: { 노무비: 1000, 재료비: 0, 경비: 10, 계: 1010 },"
" lines: [{ 금액: 1000 }, { 금액: 10.5 }] };"
"\nconst items = labItems({ row: now, original: R, values: { 폭: '3' },"
" names: { 중간: ['폭 × 2'], 호표: ['넓이 × 0.2'], 덧줄: ['노무비 × 1 / 100'] }, answer });"
"\nconst bare = labItems({ row: R, original: null, values: {}, names: null, answer: { ok: false, reason: 'x' } });"
"\nconsole.log(JSON.stringify({ put, miss, items, bare, cut: cutText('글자 끝수') }));"
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-"],
input=src + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_모델_줄_갈래_값_고친_줄() -> None:
got = _run()
assert got["put"] is True and got["miss"] is False
kinds = [(x["kind"], x["이름"]) for x in got["items"]]
assert kinds == [("근거", "1-1"), ("근거", ""), ("근거", ""), ("옵션", "폭"), ("계산", "넓이"),
("자원", "보통인부 (인)"), ("율", "공구손료"), ("단계", "노무 소계"), ("근거", "끝수"),
("소계", "노무비"), ("소계", "재료비"), ("소계", "경비"), ("합계", "계")] # fmt: skip
by = {(x["kind"], x["이름"]): x for x in got["items"]}
assert by[("옵션", "폭")]["값"] == "3"
assert by[("계산", "넓이")]["값"] == 6 and not by[("계산", "넓이")]["changed"]
res = by[("자원", "보통인부 (인)")]
assert res["값"] == 1000 and res["비목"] == "노" and res["번지"] == "A01" and res["changed"]
assert res["식"] == "넓이 × 0.2" and res["part"] == "호표" and res["at"] == 0
assert by[("율", "공구손료")]["값"] == 10.5 and by[("율", "공구손료")]["part"] == "덧줄"
assert by[("단계", "노무 소계")]["식"] == "A01"
assert by[("근거", "끝수")]["식"] == "비목 · 버림 · 0 자리"
assert by[("합계", "계")]["값"] == 1010
bare = {(x["kind"], x["이름"]): x for x in got["bare"]}
assert bare[("계산", "넓이")]["식"] == "폭 × 2" # 이름 꼴 전 = 엔진 식 「*」 → 「×」
assert bare[("자원", "보통인부 (인)")]["값"] is None and ("합계", "계") not in bare
assert got["cut"] == "글자 끝수"