Merge branch 'sub_laptop_2' of C:/Aislo_wt/sub_laptop_2 into main_laptop_1

This commit is contained in:
2026-11-07 12:22:18 +09:00
11 changed files with 649 additions and 40 deletions
@@ -7,6 +7,7 @@
* · 변수를 누르면 변수 창(`_EquationVar`) · 고친 목록(`edits`)은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `/equation/save`(개정 확인)
* 계산 자리 = 서버 한 벌(③) — 화면은 고침 목록만 보내고 서버가 다시 셈한 나무를 다시 그림(옛 요청 끊음)
* 시트 · 시험 쪽에 저장 안 한 고침이 있으면 수식 고침은 잠김(두 갈래가 한 줄을 따로 고치지 않게)
* 페이지 갈래(`page` · 796-2b M02 단가산출 사본) = 같은 부품 · 길 · 고침 목록(문서 `수식고침`) · 옵션 값이 페이지 것 · 저장 = 페이지 [저장]
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
@@ -47,6 +48,8 @@ const TEXT = {
Discard: ["수식 고침 버림", "Discard edits"],
Add: ["변수 더함", "Add variable"],
Edits: ["수식 고침 {n} 건 · 저장 안 함", "{n} equation edits · unsaved"],
EditsPage: ["수식 고침 {n} 건 · 사본 위", "{n} equation edits · on copy"],
DiscardPage: ["수식 원래로", "Back to copy"],
Now: ["지금", "Now"],
Before: ["고치기 전", "Before"],
Blocked: [
@@ -104,6 +107,19 @@ const loadCache = (): Record<string, EqEdit[]> => {
}
};
/** 페이지 캐시 갈래(796-2b M02 단가산출 사본) — 길 · 고침 목록 · 옵션 값이 페이지 것 · 저장 = 페이지 [저장] */
export interface EqPage {
ask: <T>(
suffix: "" | "/preview",
body: Record<string, unknown>,
signal?: AbortSignal,
) => Promise<T>;
edits: () => EqEdit[];
keep: (edits: EqEdit[]) => void;
values: () => Record<string, string>;
setValue: (name: string, value: string | null) => void;
}
export interface LabEquationOptions {
/** 시스템 관리자 — 고침 도구 */
admin: boolean;
@@ -111,6 +127,8 @@ export interface LabEquationOptions {
blocked: () => boolean;
/** 저장 뒤 — 상세를 저장본으로 다시 엶 */
onSaved: () => void;
/** 없으면 M01(길 `/m01/logic/equation` · 캐시 sessionStorage · [수식 저장]) */
page?: EqPage;
}
export function createLabEquation(opt: LabEquationOptions) {
@@ -139,9 +157,14 @@ export function createLabEquation(opt: LabEquationOptions) {
const open = new Set<string>();
const drafts = new Map<string, string>();
const canEdit = (): boolean => opt.admin && !opt.blocked();
const page = opt.page;
const vals = (): Record<string, string> => page?.values() ?? inputs;
const ask = <T>(suffix: "" | "/preview", body: Record<string, unknown>, signal?: AbortSignal) =>
page ? page.ask<T>(suffix, body, signal) : post<T>(`/logic/equation${suffix}`, body, signal);
const body0 = (): Record<string, unknown> => ({ key, inputs: inputsOf(inputs), 고침: edits });
const body0 = (): Record<string, unknown> => ({ key, inputs: inputsOf(vals()), 고침: edits });
const persist = (): void => {
if (page) return page.keep(edits);
const all = loadCache();
if (edits.length) all[key] = edits;
else delete all[key];
@@ -157,9 +180,7 @@ export function createLabEquation(opt: LabEquationOptions) {
ctl?.abort();
const mine = (ctl = new AbortController());
try {
const got = edits.length
? await post<Read>("/logic/equation/preview", body0(), mine.signal)
: await post<Read>("/logic/equation", body0(), mine.signal);
const got = await ask<Read>(edits.length ? "/preview" : "", body0(), mine.signal);
if (mine !== ctl) return;
if (got.되나 === false || got.ok === false) {
why = (got.까닭 ?? [got.reason ?? ""]).join(" · ");
@@ -178,7 +199,7 @@ export function createLabEquation(opt: LabEquationOptions) {
/** 고침 하나를 더해 보고 되면 굳힘 — 안 되면 까닭 목록 */
async function commit(list: EqEdit[]): Promise<string[]> {
const got = await post<Read>("/logic/equation/preview", {
const got = await ask<Read>("/preview", {
...body0(),
고침: [...edits, ...list],
}).catch((error: unknown): Read => ({
@@ -214,6 +235,7 @@ export function createLabEquation(opt: LabEquationOptions) {
};
const label = createText(spec.이름 + (spec.단위 ? ` (${spec.단위})` : ""), "muted", 12);
const set = (v: string | number | null): void => {
if (page) return page.setValue(spec.이름, v === null || v === "" ? null : String(v));
if (v === null || v === "") delete inputs[spec.이름];
else inputs[spec.이름] = String(v);
askAgain();
@@ -222,7 +244,7 @@ export function createLabEquation(opt: LabEquationOptions) {
if ((spec.고르기?.length ?? 0) > BIG) {
// 찾기형 — 입력 + 목록(칠수록 좁혀짐)
const box = createInputField({
value: inputs[spec.이름] ?? "",
value: vals()[spec.이름] ?? "",
compact: true,
size: 24,
placeholder: auto === null || auto === undefined ? "" : String(auto),
@@ -239,7 +261,7 @@ export function createLabEquation(opt: LabEquationOptions) {
});
control = el("div", { children: [box.root, list] });
} else {
const made = createValueControl(item, inputs[spec.이름] ?? null, (v) => set(v));
const made = createValueControl(item, vals()[spec.이름] ?? null, (v) => set(v));
const tip = (): void => {
const c = made.control;
c.title = c instanceof HTMLSelectElement ? (c.selectedOptions[0]?.text ?? "") : c.value;
@@ -283,8 +305,8 @@ export function createLabEquation(opt: LabEquationOptions) {
const check =
(line: EqLine) =>
async (text: string, signal: AbortSignal): Promise<FormulaVerdict> => {
const got = await post<Read>(
"/logic/equation/preview",
const got = await ask<Read>(
"/preview",
{ ...body0(), 고침: [...edits, formulaEdit(line, text)] },
signal,
);
@@ -369,15 +391,17 @@ export function createLabEquation(opt: LabEquationOptions) {
if (!v || !canEdit()) return;
openVarDialog({
row: v,
value: inputs[v.열쇠] ?? "",
value: vals()[v.열쇠] ?? "",
commit,
onValue: (value) => {
if (page) return page.setValue(v.열쇠, value || null);
if (value) inputs[v.열쇠] = value;
else delete inputs[v.열쇠];
drawOptions();
void refresh();
},
onDropped: (k) => {
if (page) return page.setValue(k, null);
delete inputs[k];
drawOptions();
},
@@ -403,14 +427,16 @@ export function createLabEquation(opt: LabEquationOptions) {
items.push("spacer");
if (edits.length) {
items.push(
createBadge(t("Edits", edits.length), "warning"),
createBadge(t(page ? "EditsPage" : "Edits", edits.length), "warning"),
createButton({
label: t("Discard"),
label: t(page ? "DiscardPage" : "Discard"),
variant: "ghost",
size: 24,
onClick: () => void discard(),
}),
createButton({ label: t("Save"), size: 24, onClick: () => void save() }),
...(page
? []
: [createButton({ label: t("Save"), size: 24, onClick: () => void save() })]),
);
} else items.push(createText(t("Hint"), "muted", 12));
}
@@ -488,17 +514,21 @@ export function createLabEquation(opt: LabEquationOptions) {
root,
/** 상세가 그릴 때마다 — 로직이 바뀔 때만 서버를 부름 */
draw: (nextKey: string, nextVersion: string, nextRow: LogicRow): void => {
const moved = nextVersion !== version; // 판 · 페이지 문서 · 옵션 바뀜 = 다시 셈
version = nextVersion;
row = nextRow;
if (nextKey !== key) {
[key, eq, rowNow, before, why, selected] = [nextKey, null, null, null, "", null];
inputs = {};
edits = loadCache()[nextKey] ?? [];
edits = page ? page.edits() : (loadCache()[nextKey] ?? []);
open.clear();
drafts.clear();
paint();
drawOptions();
void refresh().then(drawOptions);
} else if (moved) {
if (page) edits = page.edits();
void refresh().then(drawOptions);
} else if (opt.blocked() !== paintedBlocked) paint();
},
dirty: (): boolean => edits.length > 0,
@@ -0,0 +1,173 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_Equation.ts
* M02 단가산출(SK) 「수식」 보기(PLAN 796-2b · 요구 655 · 656 · 657 · 요구서_655 0 장) — M01 수식 탭 부품(`createLabEquation`)을 페이지 갈래로.
* 길 = `POST /layers/{층}/sangeun/equation`(+ `/preview`) · 몸 = 화면 문서(캐시) · 고침 목록 = 문서 `수식고침`(사본 위 기록) ·
* 저장 = 상세 [저장](서버가 다시 얹어 검사 · 셈) · 옵션 = 상세 옵션 값(`onInput`)
* 위 줄 = 로직 사본 — 「M01 새 판 있음」 배지 · [단가 갱신] · [재생성](`…/sangeun/copy/price|regen` → 문서 `사본할일` · [저장] 때 서버 적용)
* 가져온 식 = 「엑셀 원래」 금액 글(셈 답 `원래` = 엑셀 금액)
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { el, showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { createToolbar } from "@ui/ui_template_toolbar";
import { formatMoney } from "../common_util/common_util_money";
import type { LogicRow } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { createLabEquation } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Equation";
import type { EqEdit } from "../M01_MasterData/logic/M01_Logic_Equation_Model";
import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import type { SkAnswer, SkDoc } from "./logic/M02_Sangeun_Cover_Model";
const TEXT = {
Copy: ["로직 사본", "Logic copy"],
NewM01: ["M01 새 판 있음", "New M01 version"],
Price: ["단가 갱신", "Refresh prices"],
Regen: ["재생성", "Regenerate"],
Job: ["{n} · [저장] 때 적용", "{n} · applied on save"],
Excel: ["엑셀 원래", "Excel original"],
Lost: ["못 얹은 고침", "Not re-applied"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT, n = ""): string =>
(TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]).replace("{n}", n);
const KEY = "수식고침";
const JOB = "사본할일";
interface CopyMark {
M01새판?: boolean;
지운키?: string[];
}
type Answer = SkAnswer & {
사본?: CopyMark;
원래?: Record<string, number> | null;
가져온?: unknown;
};
interface CopyReply {
문서: SkDoc;
전: Record<string, number> | null;
후: Record<string, number> | null;
바뀐자원?: unknown[];
못얹은?: { cid: string; 까닭: string }[];
}
export interface SkEquationOptions {
scope: { layer: Layer; owner: string | null };
edit: boolean;
touch: () => void;
onInput: (name: string, value: string) => void;
}
export interface SkEquationDraw {
doc: SkDoc;
ans: SkAnswer | null;
base: LogicRow | null;
values: Record<string, string>;
}
export function createSangeunEquation(opt: SkEquationOptions) {
const { layer, owner } = opt.scope;
const q = owner && layer !== "system" ? `?owner=${encodeURIComponent(owner)}` : "";
const bar = el("div", { attrs: { "data-sk-copy": "" } });
let last: SkEquationDraw | null = null;
const doc = (): SkDoc => last?.doc ?? ({} as SkDoc);
/** 고침 = 저장되는 SK 만(일위대가 줄 모달 `줄모드` = 보기만 — 줄로 못 옮김) · 처음 그릴 때 정함 */
const make = (d: SkEquationDraw) =>
createLabEquation({
admin: opt.edit && d.doc.줄모드 !== true,
blocked: () => false,
onSaved: () => undefined,
page: {
ask: (suffix, body, signal) =>
call(`/layers/${layer}/sangeun/equation${suffix}${q}`, {
method: "POST",
body: JSON.stringify({ ...body, 문서: doc() }),
signal,
}),
edits: () => (doc()[KEY] as EqEdit[] | undefined) ?? [],
keep: (edits) => {
if (edits.length) doc()[KEY] = edits;
else delete doc()[KEY];
opt.touch();
},
values: () => last?.values ?? {},
setValue: (name, value) => opt.onInput(name, value ?? ""),
},
});
let lab: ReturnType<typeof make> | null = null;
const root = el("div", { attrs: { "data-sk-equation": "" }, children: [bar] });
async function job(how: "price" | "regen"): Promise<void> {
try {
const got = await call<CopyReply>(`/layers/${layer}/sangeun/copy/${how}${q}`, {
method: "POST",
body: JSON.stringify({ 문서: doc(), 입력값: last?.values ?? {} }),
});
doc()[JOB] = got.문서[JOB];
const sum = (v: Record<string, number> | null): string => formatMoney(v?.계 ?? 0);
const lost = got.못얹은 ?? [];
showToast(
`${t(how === "price" ? "Price" : "Regen")} ${sum(got.전)} → ${sum(got.후)}` +
(lost.length ? ` · ${t("Lost")} ${lost.map((x) => x.cid).join(" · ")}` : ""),
lost.length ? "warning" : "success",
);
opt.touch();
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
}
}
function drawBar(d: SkEquationDraw): void {
const ans = d.ans as Answer | null;
const mark = ans?.사본;
const pending = d.doc[JOB] as string | undefined;
const items: (HTMLElement | "divider" | "spacer")[] = [];
if (ans && "가져온" in ans)
items.push(createText(`${t("Excel")} ${formatMoney(ans.원래?.계 ?? 0)}`, "muted", 14));
if (mark) {
items.push(createText(t("Copy"), "muted", 14));
if (mark.M01새판) items.push(createBadge(t("NewM01"), "warning"));
if (pending) items.push(createBadge(t("Job", pending), "info"));
if (opt.edit) {
items.push(
"spacer",
createButton({
label: t("Price"),
variant: "ghost",
size: 24,
onClick: () => void job("price"),
}),
createButton({
label: t("Regen"),
variant: "ghost",
size: 24,
onClick: () => void job("regen"),
}),
);
}
}
bar.replaceChildren(
...(items.length ? [createToolbar({ items, size: 28, label: t("Copy") })] : []),
);
}
return {
root,
draw: (d: SkEquationDraw): void => {
last = d;
drawBar(d);
if (!d.base) return;
if (!lab) root.append((lab = make(d)).root);
const { [KEY]: _edits, ...rest } = d.doc;
lab.draw(String(d.doc.키 ?? ""), JSON.stringify([rest, d.values]), d.base);
},
/** [저장] 뒤 — 할일 칸은 서버가 적용 · 뗌(상세 `getDoc` 이 복사한 뒤) */
saved: (): void =>
queueMicrotask(() => {
if (!last?.doc[JOB]) return;
delete last.doc[JOB];
drawBar(last);
}),
};
}
@@ -1,7 +1,7 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_Sheet.ts
* M02 단가산출(SK) 줄 식 시트(PLAN 790-1 · E1 · 요구 650~652 · 요구서_652 1 장 4) — 상세 첫 탭 「시트」.
* 보기 고르기 = 시트 · 옛 보기 넷(단가산출 상세 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 — E7 까지 둠)
* 보기 고르기 = 수식(첫 탭 · 796-2b `_Sangeun_Equation`) · 단가산출 상세 · 시트 · 옛 보기 셋(인력·재료·경비 · 시험 계산 · 텍스트 수식 — 뒤로만)
* 시트 = 한 줄 「이름 | 식 | 값 | 비목」(줄 꼴 = `logic/M02_Sangeun_Sheet_Model`) · 안 보기 전환 「시트 ↔ 산출서」
* 고침 = 지금 되는 것만(모델 안 바꿈) — 옵션 고름 · 품 · 단가 · 율 % · 줄 빼기 · 되살림 · [+ 자원 줄] · [+ 율 줄]
* → SK 문서(덮개 · 더한줄 · 뺀줄 = `M02_Sangeun_Cover_Model`) → 상세 `touch` = 서버 셈 한 번(최신 응답만)
@@ -24,6 +24,7 @@ import { formatLineMoney, formatMoney } from "../common_util/common_util_money";
import type { LogicRow, TextAnswer } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { tl } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Text";
import { removedStrip } from "./M02_MasterTemplete_Sangeun_Cover";
import { createSangeunEquation } from "./M02_MasterTemplete_Sangeun_Equation";
import {
addTopNote,
formulaEditor,
@@ -130,14 +131,16 @@ export interface SheetOptions {
scope: { layer: Layer; owner: string | null };
}
type View = "sheet" | "ho" | "est" | "calc" | "formula";
type View = "equation" | "sheet" | "ho" | "est" | "calc" | "formula";
/** 보기 고르기(시트 + 옛 넷) · 시트 그릇 — `editor` = 옛 보기 그릇(시트일 때 숨김) */
export function createSangeunSheet(
editor: HTMLElement,
opt: SheetOptions,
): { picker: HTMLElement; host: HTMLElement; draw: (d: SheetDraw) => void; saved: () => void } {
const host = el("div", { className: "m02-sks", attrs: { "data-sk-sheet": "" } });
const sheetHost = el("div", { className: "m02-sks", attrs: { "data-sk-sheet": "" } });
const eqView = createSangeunEquation(opt); // 796-2b 수식 첫 탭
const host = el("div", { children: [eqView.root, sheetHost] });
const status = el("div", { className: "m02-sks__status", attrs: { "data-sk-status": "" } });
const body = el("div", { className: "m02-sks__body" });
let mode: "sheet" | "paper" = "sheet";
@@ -148,19 +151,21 @@ export function createSangeunSheet(
const drafts = new Map<string, string>(); // 치는 중 식 글
let focusNext = ""; // 새 글 줄 칸 — 다음 그림 뒤 초점
const show = (v: View): void => {
host.hidden = v !== "sheet";
editor.hidden = v === "sheet";
if (v !== "sheet") editor.dataset.view = v;
eqView.root.hidden = v !== "equation";
sheetHost.hidden = v !== "sheet";
editor.hidden = v === "sheet" || v === "equation";
if (!editor.hidden) editor.dataset.view = v;
};
const picker = createSegment({
options: [
{ value: "sheet", label: t("Sheet") },
{ value: "equation", label: tl("View_Equation") },
{ value: "ho", label: tl("Ho_Title") },
{ value: "sheet", label: t("Sheet") },
{ value: "est", label: tl("View_Est") },
{ value: "calc", label: tl("Calc_Title") },
{ value: "formula", label: tl("Formula_Title") },
],
value: "sheet",
value: "equation",
size: 32,
ariaLabel: tl("View_Label"),
onChange: (v) => show(v as View),
@@ -180,8 +185,11 @@ export function createSangeunSheet(
},
});
inner.root.dataset.skMode = "";
host.append(createToolbar({ items: [inner.root, "divider", status], label: t("Mode") }), body);
show("sheet");
sheetHost.append(
createToolbar({ items: [inner.root, "divider", status], label: t("Mode") }),
body,
);
show("equation");
function drawStatus(d: SheetDraw): void {
const s = statusOf(d.ans, d.bare, before);
@@ -447,6 +455,7 @@ export function createSangeunSheet(
function draw(d: SheetDraw): void {
last = d;
eqView.draw(d);
if (before === null && d.ans?.기준?.계 != null) before = d.ans.기준.계;
ctx = {
doc: d.doc as ConstDoc,
@@ -461,7 +470,8 @@ export function createSangeunSheet(
};
drawStatus(d);
const active = document.activeElement as HTMLElement | null;
const keep = focusNext || (active && host.contains(active) ? active.dataset.sheetCell : "");
const keep =
focusNext || (active && sheetHost.contains(active) ? active.dataset.sheetCell : "");
focusNext = "";
body.dataset.mode = mode;
body.replaceChildren(
@@ -479,6 +489,7 @@ export function createSangeunSheet(
host,
draw,
saved: () => {
eqView.saved(); // 796-2b 사본 할일 뗌
before = last?.ans?.기준?.계 ?? null; // 저장 = 고치기 전 새로(상태줄만 다시)
if (last) drawStatus(last);
},
+9 -9
View File
@@ -32,12 +32,12 @@ import re
from decimal import Decimal
from typing import Any
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Fixed as fixed
from M02_MasterTemplete import M02_Labor_Doc as labor, M02_Labor_Fixed as fixed
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Const as const, M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_Copy as skc, M02_Sangeun_Views as views
from M02_MasterTemplete import M02_Sangeun_Const as const, M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Sangeun_Doc as sd, M02_Sangeun_Equation as eqm
from M02_MasterTemplete import M02_Sangeun_Views as views
COSTS = labor.COSTS
Ref = dict[str, Any]
@@ -120,13 +120,13 @@ def _base_of(doc: dict, whole: Any, read: Any, trail: tuple[str, ...]) -> dict[s
"""이 SK 가 얹힐 바탕 — {줄, 고름, 자재표시, 입력값, 켜쌓기율, 짝잃음}."""
base = doc.get("바탕")
empty = {"고름": {}, "자재표시": {}, "입력값": {}, "켜쌓기율": None, "짝잃음": []}
if not base:
return {**empty, "줄": sd.empty_row(doc)}
if "로직" in base:
if not base or "로직" in base: # 사본(795-1) · 가져온 식 · 그 위 수식 고침(796-2b)
try:
return {**empty, "줄": skc.over(doc, whole).logic(base["로직"])} # 사본(795-1)
row = skc.over(doc, whole).logic(base["로직"]) if base else sd.empty_row(doc)
except Exception as e: # noqa: BLE001 — 엔진 FormulaError
raise sd.DocError(f"바탕 로직 · {e}") from e
row, lost = eqm.on(row, doc, skc.over(doc, whole))
return {**empty, "줄": row, "짝잃음": lost}
ref = base["참조"]
rid = refs.ref_id(ref)
if rid in trail:
@@ -533,7 +533,7 @@ def clean_doc(doc: Any, here_: refs.Here) -> dict[str, Any]:
try:
rough = sd.clean(doc, None, whole)
_check_refs(rough, here_)
base = _base_of(rough, whole, refs.read_ref, ())
eqm.strict(base := _base_of(rough, whole, refs.read_ref, ())) # 수식 고침(796-2b)
out = sd.clean(doc, base["줄"], whole)
out["바탕"], out["더한줄"] = rough["바탕"], rough["더한줄"]
for one in out["더한줄"]: # 처음수량(744-1) = 넣을 때 수량 · 없으면 지금 수량
+8 -3
View File
@@ -30,6 +30,7 @@ from decimal import Decimal
from typing import Any
from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_Equation as eqm
from M02_MasterTemplete import M02_Sangeun_Formula as fx
from M02_MasterTemplete import M02_Sangeun_Imported as imp
@@ -159,8 +160,11 @@ def lost(row: dict, doc: dict) -> list[dict]:
def no_flat(doc: Any) -> None:
"""펼침 막음 — 상수 · 표값 덮개 SK(펼친 줄에 못 옮김 · 옛 참조 그대로 = 값 그대로)."""
if isinstance(doc, dict) and (doc.get("상수덮개") or doc.get("표값덮개") or fx.has(doc)):
raise sd.DocError("상수 · 표값 덮개 · 식 칸 단가산출은 펼치지 않음(참조 그대로)")
keys = ("상수덮개", "표값덮개", eqm.KEY) # 수식 고침(796-2b)
if isinstance(doc, dict) and (any(doc.get(k) for k in keys) or fx.has(doc)):
raise sd.DocError(
"상수 · 표값 덮개 · 식 칸 · 수식 고침 단가산출은 펼치지 않음(참조 그대로)"
)
if imp.is_imported(doc): # 가져온 식(793-2) = M01 기초 없음
raise sd.DocError("가져온 식 단가산출은 펼치지 않음(참조 그대로)")
@@ -231,6 +235,7 @@ def clean_more(raw: Any, out: dict) -> dict:
out["까닭"] = why
if isinstance(raw.get("사본"), dict): # 로직 사본(795-1) — 셈 · 저장 첫머리 settle 이 정함
out["사본"] = raw["사본"]
out = eqm.clean(raw, out) # 수식 고침(796-2b)
return imp.clean(raw, fx.clean(raw, out)) # 식 칸(792-1) · 가져온 식(793-2)
@@ -336,7 +341,7 @@ def answer(got: dict, clean: dict, raw: Any, given: dict | None, trail: tuple) -
def _bare(doc: dict) -> dict:
out = {k: v for k, v in doc.items() if k not in (*NEW, *fx.FIELDS, "뺀더한줄")}
out = {k: v for k, v in doc.items() if k not in (*NEW, *fx.FIELDS, "뺀더한줄", eqm.KEY)}
return out | {"덮개": {}, "뺀줄": [], "더한줄": []}
+5
View File
@@ -84,6 +84,11 @@ class Copy:
def __getattr__(self, name: str) -> Any:
return getattr(self._whole, name)
def __copy__(self) -> Copy: # 수식 고침(796-2b · `master_equation_edit.swap`) — 얕은 사본
out = object.__new__(Copy)
out.__dict__.update(self.__dict__)
return out
def _rows(copy: dict) -> list[dict]:
raw = json.dumps(prices.to_json([*copy["로직"], *(copy.get("단가") or [])]), ensure_ascii=False)
+3 -1
View File
@@ -315,7 +315,9 @@ def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str
given = _given(doc.get("입력값"), "입력값")
opened = [str(x) for x in doc.get("열린입력") or []]
if base_row is not None:
keep = names | _added_names(whole, doc.get("더한줄"))
from M02_MasterTemplete import M02_Sangeun_Equation as eqm # 수식 고침 더한 입력(796-2b)
keep = names | _added_names(whole, doc.get("더한줄")) | eqm.added_inputs(doc)
given = {k: v for k, v in given.items() if k in keep}
bad = [x for x in opened if x not in names or x in given]
if bad:
+157
View File
@@ -0,0 +1,157 @@
"""M02 단가산출(SK) 수식 탭 서버 몫(PLAN 796-2b · 요구 655 · 656 · 657 · 요구서_655 0 · 2 장 1).
수식 = 그 SK 바탕 줄(로직 사본 줄 `M02_Sangeun_Copy` · 가져온 식 줄 `M02_Sangeun_Imported.row_of`) +
문서 덧칸 `수식고침` = 사본 위 고침 기록 — M01 수식 탭 고침 꼴(`master_equation_edit` 머리 ·
차례대로 · 식 · 더함 · 지움 · 변수 · 표시) + 줄마다 `까닭`?(예정가격작성기준 제2조 ②) ·
사본은 찍은 그대로(= 원래 대비 · 고친 칸 = 고침 목록).
셈 = `M02_Sangeun_Calc._base_of` 가 바탕 줄에 `on` 으로 얹음(엔진 · 검사 = M01 `edit_row` 한 벌) →
모든 셈 길(화면 · 저장 원값 · B09 · 펼침) 같은 줄 · 못 얹은 고침(재생성 뒤 자리 없음 등) =
고침 없는 바탕 + 짝잃음 cid `수식` · 저장(`strict`) = 400 · [재생성] = 새 사본에 같은 고침
다시 얹음(못 얹음 = 795-1 `못얹은`).
길(`M02_Sangeun_Router` · 저장 없음):
POST …/sangeun/equation {문서, inputs?} → {ok, 수식 | reason}(층 읽기)
POST …/sangeun/equation/preview {문서, inputs?, 고침} → {되나, 까닭, 줄, 수식, 전, 후}
(고침 = `수식고침` 통째 · 전 = 고침 없는 사본 합계 · 층 쓰기)
저장 = SK [저장](화면 캐시 `수식고침` → `clean_doc` 이 다시 얹어 검사 · 셈 ·
브라우저 값 안 받음).
바탕 참조 SK(`바탕.참조`) = 수식 없음(바탕 SK 의 고침은 그 SK 가 얹음).
"""
from __future__ import annotations
import hashlib
import json
from typing import Any
from M02_MasterTemplete import M02_Sangeun_Doc as sd
KEY = "수식고침"
MANY = 200
LONG = 500 # 까닭 길이
LOST = "수식" # 짝잃음 cid
STAND = "GX999997" # 가져온 식(SK 키) 줄을 엔진에 얹을 잠깐 키(GX999998 = 견본 · 999999 = 초안)
_MEMO: dict[tuple[str, str], tuple[dict, list[str]]] = {}
def _eqn() -> Any:
from M01_MasterData import M01_MasterData_Equation as eqn # M02 가 M01 을 부름 — 늦게
return eqn
def _hash(v: Any) -> str:
raw = json.dumps(v, ensure_ascii=False, sort_keys=True, default=str)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def clean(raw: Any, out: dict) -> dict:
"""`sd.clean` 끝 — 꼴만(목록 · 묶음 · 까닭 길이) · 뜻 검사는 셈 · 저장 때 `on`."""
edits = raw.get(KEY) if isinstance(raw, dict) else None
if not edits:
return out
if not isinstance(edits, list) or len(edits) > MANY:
raise sd.DocError(f"수식고침 · {MANY} 개까지 목록")
for at, one in enumerate(edits, 1):
why = one.get("까닭") if isinstance(one, dict) else None
if not isinstance(one, dict) or (why is not None and len(str(why)) > LONG):
raise sd.DocError(f"수식고침 {at}째 · 묶음 · 까닭 {LONG} 자까지")
out[KEY] = json.loads(json.dumps(edits, ensure_ascii=False, default=str))
return out
def added_inputs(doc: Any) -> set[str]:
"""고침으로 더한 입력 이름 — `sd.clean` 입력값 거름에서 남김."""
edits = (doc.get(KEY) if isinstance(doc, dict) else None) or []
return {
str(e.get("열쇠"))
for e in edits
if isinstance(e, dict) and e.get("할일") == "더함" and e.get("갈래") == "입력"
}
def _stand(row: dict) -> tuple[dict, str]:
"""(엔진이 로직으로 부를 줄, 본 키) — M01 로직 키 아님(가져온 식 SK) = 잠깐 키."""
key = str(row.get("키"))
return (row, key) if key[:2] in ("GC", "GF", "GX") else ({**row, "키": STAND}, key)
def export(whole: Any, row: dict, inputs: dict | None) -> dict:
stand, key = _stand(row)
return _eqn().export_row(whole, stand, inputs) | {"열쇠": key}
def apply(row: dict, edits: list, whole: Any) -> tuple[dict, list[str]]:
"""바탕 줄 + 고침 → (새 줄, 까닭) — 같은 줄 · 같은 고침 = 메모(셈 길마다 다시 검사 안 함)."""
tag = (_hash(row), _hash(edits))
got = _MEMO.get(tag)
if got is None:
if len(_MEMO) > 64: # ponytail: 통째 비움 · 쓰는 SK 가 많아 느려지면 LRU 로
_MEMO.clear()
stand, key = _stand(row)
new, why = _eqn().edit_row(whole, stand, edits)
got = _MEMO[tag] = ({**new, "키": key}, why)
return got
def on(row: dict, doc: Any, whole: Any) -> tuple[dict, list[dict]]:
"""셈 길 — 고침 얹은 줄 · 못 얹음 = 바탕 그대로 + 짝잃음 `수식`."""
edits = (doc.get(KEY) if isinstance(doc, dict) else None) or []
if not edits:
return row, []
new, why = apply(row, edits, whole)
if why:
return row, [{"cid": LOST, "까닭": "수식 고침 못 얹음 — " + " · ".join(why)}]
return new, []
def strict(base: dict) -> None:
"""저장 — 이 SK 고침이 못 얹히면 400(바탕 SK 사슬 것 = 알림만)."""
for x in base.get("짝잃음") or []:
if x.get("cid") == LOST and "바탕" not in x:
raise sd.DocError(x["까닭"])
# ── 길 ──────────────────────────────────────────────────────────────
def _plain(doc: Any) -> tuple[dict, Any, dict]:
"""(정한 문서, 사본 색인, 고침 없는 바탕 줄) — 바탕 참조 · 빈 SK = DocError."""
from M02_MasterTemplete import M02_Sangeun_Calc as calc
if not isinstance(doc, dict):
raise sd.DocError("단가산출 문서가 묶음 아님")
base = doc.get("바탕") or {}
if "참조" in base:
raise sd.DocError("바탕 참조 단가산출 — 수식은 바탕 단가산출에서")
doc = calc.skc.settle(doc)
whole = calc.skc.over(doc, calc.labor.m01.loaded()[1])
bare = {k: v for k, v in doc.items() if k != KEY}
row = calc._base_of(bare, whole, calc.refs.read_ref, ())["줄"]
if not row.get("중간") and not row.get("호표") and not row.get("결과"):
raise sd.DocError("수식 없음 — 바탕 로직 · 가져온 식이 없는 단가산출")
return doc, whole, row
def read(doc: Any, inputs: dict | None = None) -> dict[str, Any]:
"""{ok, 수식} — 저장본 · 화면 캐시 고침 얹은 수식(못 얹음 = 바탕 그대로 + 까닭)."""
eqn = _eqn()
try:
doc, whole, row = _plain(doc)
now, lost = on(row, doc, whole)
out = {"ok": True, "수식": export(whole, now, inputs)}
except (sd.DocError, *eqn._ERR) as e:
return {"ok": False, "reason": str(e) or type(e).__name__}
return out | ({"reason": lost[0]["까닭"]} if lost else {})
def preview(doc: Any, edits: list, inputs: dict | None = None) -> dict[str, Any]:
"""고침 통째 미리 셈(저장 없음) — {되나, 까닭, 줄, 수식, 전(고침 없는 사본), 후}."""
eqn = _eqn()
try:
doc, whole, row = _plain(doc)
before = export(whole, row, inputs)["합계"]
new, why = apply(row, edits, whole) if edits else (row, [])
if why:
return {"되나": False, "까닭": why}
eq = export(whole, new, inputs)
except (sd.DocError, *eqn._ERR) as e:
return {"되나": False, "까닭": [f"셈 막힘 — {e}"]}
return {"되나": True, "까닭": [], "줄": new, "수식": eq, "전": before, "후": eq["합계"]}
+33
View File
@@ -7,6 +7,8 @@
(만들 층 쓰기).
로직 사본(795-1 · `M02_Sangeun_Copy`) `…/sangeun/copy/price` [단가 갱신] · `/regen` [재생성]
(화면 캐시 문서에 할일 얹음 · 저장 없음 · 층 읽기).
수식 탭(796-2b · `M02_Sangeun_Equation`) `…/sangeun/equation`(층 읽기) · `/preview`(고침 미리 셈 ·
층 쓰기) — 저장 없음 · 고침 = 화면 캐시 `수식고침` → [저장].
저장 안 함 · 로그인 사용자 · 층 읽기 권한(`Router_Items._root`) · 화면은 최신 응답만.
몸 = `문서`(저장 전 화면 문서) 또는 `키`(그 층 저장본) + `입력값`(열린입력 · 시험 값) ·
@@ -27,6 +29,7 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Calc as calc
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_Equation as eqm
from M02_MasterTemplete import M02_Sangeun_Formula as fx
from M02_MasterTemplete import M02_Sangeun_FromHopyo as from_hopyo
from M02_MasterTemplete import M02_Sangeun_Imported as imp
@@ -215,3 +218,33 @@ def sangeun_copy(
raise HTTPException(status_code=404, detail="price · regen")
args = (body.문서, _JOBS[how], body.입력값)
return _call(_root(session, layer, owner), _sd(skc.preview), *args)
class EquationBody(BaseModel):
문서: dict[str, Any]
inputs: dict[str, Any] | None = None
고침: list[Any] | None = None
@router.post("/layers/{layer}/sangeun/equation")
def sangeun_equation(
layer: Layer,
body: EquationBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""수식 탭 읽기(796-2b) — 사본(가져온 식) 줄 + 문서 `수식고침` → {ok, 수식 | reason}."""
return _call(_root(session, layer, owner), _sd(eqm.read), body.문서, body.inputs)
@router.post("/layers/{layer}/sangeun/equation/preview")
def sangeun_equation_preview(
layer: Layer,
body: EquationBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""수식 고침 미리 셈(796-2b · 층 쓰기 · 저장 없음) — 고침 = `수식고침` 통째 →
{되나, 까닭, 줄, 수식, 전, 후}."""
folder = _root(session, layer, owner, write=True)
return _call(folder, _sd(eqm.preview), body.문서, body.고침 or [], body.inputs)
@@ -59,8 +59,9 @@ def test_계산_자리_화면은_모양만():
assert "fetch(" not in _text(path) # 부품은 그리기만
page = _text(PAGE[0])
assert page.count("fetch(") == 1 # 서버 길 한 곳 — 계산은 서버 한 벌(③)
for route in ('"/logic/equation"', '"/logic/equation/preview"', '"/logic/equation/save"'):
assert route in page, route
for route in ("`/logic/equation${suffix}`", '"/preview"', '"/logic/equation/save"'):
assert route in page, route # 796-2b 길 앞머리 = M01 · 페이지 갈래(page.ask)
assert "page ? page.ask<T>(suffix, body, signal)" in page
assert "saveRevised(" in page and "개정확인" in page # [저장] = 개정 확인 길
assert "edits" in page and "sessionStorage" in page # 고침 목록 = 캐시 → [저장] 한 번
assert "ctl?.abort()" in page # 옛 요청 끊음
@@ -0,0 +1,192 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""796-2b M02 단가산출 수식 탭 — 사본 줄 수식 · 고침 = 금액 · 저장 = 사본 위 정본(임시 storage).
① 사본 줄 수식 읽기 = SK 금액 ② 고침 미리 셈 = 금액 바뀜(저장 없음) ③ [저장] = 서버가 다시 얹어 셈 ·
틀린 고침 400 · M01 그대로 ④ 변수 더함 · 지움(쓰는 곳 막음) · 기호 · 첨자 전파 ⑤ 권한 = 층 쓰기
⑥ 가져온 식 SK 같은 길 ⑦ M01 바뀌어도 그대로 · [재생성] = 고침 다시 얹음 · 못 얹음 알림.
"""
from __future__ import annotations
import copy
import pytest
from fastapi import HTTPException
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Sangeun_Equation as eqm
from M02_MasterTemplete import M02_Sangeun_Imported as imp
from M02_MasterTemplete import M02_Sangeun_Router as router
from resources.tester.test_795_1_sk_copy import (
_calc,
_doc,
_fake,
_imported_row,
_live,
_save,
_sk,
_sum,
)
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
KEY = "GC000211" # 합판거푸집 — 중간 시공량 · 형틀목공 · 보통인부 · 호표 0 = 형틀목공
DOUBLE = [{"할일": "식", "자리": "호표:0", "식": "형틀목공 × 2", "까닭": "sub2임시 시험"}]
def _line(eq, key, kind=None):
return next(x for x in eq["줄"] if x.get("열쇠") == key and (kind is None or x["갈래"] == kind))
def _read(folder, doc):
with store.at(folder):
return eqm.read(doc)
def _preview(folder, doc, edits):
with store.at(folder):
return eqm.preview(doc, edits)
def test_read_copy_row_equals_sk_amount(world):
"""① 사본 줄 수식 — 합계 = SK 금액 = M01 셈 · 키보드 글."""
me = world["personal"]
key = _sk(me, 바탕={"로직": KEY})
got = _read(me, _doc(me, key))
assert got["ok"], got
eq = got["수식"]
assert eq["열쇠"] == KEY and eq["합계"]["계"] == _sum(me, key)
assert eq["합계"]["계"] == labor.run_line(_live(), KEY, {})["기준"]["계"]
assert _line(eq, "형틀목공", "호표")["입력글"] == "형틀목공"
def test_edit_changes_amount_save_is_canonical(world):
"""② · ③ 고침 미리 셈 = 금액 바뀜 · [저장] = 고침 목록 남음 · 서버 셈 = 미리 셈 · M01 그대로."""
me = world["personal"]
key = _sk(me, 바탕={"로직": KEY})
was = _sum(me, key)
m01_row = copy.deepcopy(_live().logic(KEY))
got = _preview(me, _doc(me, key), DOUBLE)
assert got["되나"], got["까닭"]
assert got["전"]["계"] == was and got["후"]["계"] > was
assert got["줄"]["호표"][0]["수량"] == "형틀목공 * 2"
assert _sum(me, key) == was # 미리 셈 = 저장 없음
doc = {**_doc(me, key), eqm.KEY: DOUBLE}
_save(me, key, doc)
saved = _doc(me, key)
assert saved[eqm.KEY][0]["까닭"] == "sub2임시 시험"
assert saved["사본"]["로직"][0]["호표"][0]["수량"] == m01_row["호표"][0]["수량"] # 사본 = 원래
now = _calc(me, saved)
assert now["기준"]["계"] == got["후"]["계"]
assert now["원래"]["계"] == was # 원래 대비 = 고침 없는 사본
assert _read(me, saved)["수식"]["합계"]["계"] == got["후"]["계"]
assert _live().logic(KEY) == m01_row # M01 정본 안 바뀜
bad = {**saved, eqm.KEY: [{"할일": "식", "자리": "호표:0", "식": "없는값 × 2"}]}
with pytest.raises(store.StoreError):
_save(me, key, bad)
assert _sum(me, key) == got["후"]["계"]
def test_variables_add_drop_rename(world):
"""④ 변수 더함 → 식에서 부름 · 쓰는 변수 지움 막음 · 기호 · 첨자 = 쓰는 식 모두."""
me = world["personal"]
key = _sk(me, 바탕={"로직": KEY})
doc = _doc(me, key)
add = [
{"할일": "더함", "갈래": "중간", "열쇠": "할증", "식": "1.5", "뒤": "보통인부"},
{"할일": "식", "자리": "호표:1", "식": "보통인부 × 할증"},
]
got = _preview(me, doc, add)
assert got["되나"], got["까닭"]
assert got["후"]["노무비"] > got["전"]["노무비"]
assert "할증" in [v["열쇠"] for v in got["수식"]["변수표"]]
got = _preview(me, doc, [{"할일": "지움", "열쇠": "시공량"}])
assert not got["되나"] and "형틀목공" in " ".join(got["까닭"])
sign = [{"할일": "변수", "열쇠": "시공량", "기호": "Q", "아래": "d", "이름": "하루 시공량"}]
got = _preview(me, doc, sign)
assert got["되나"] and got["후"] == got["전"] # 이름만 — 금액 그대로
assert "Q_d" in _line(got["수식"], "형틀목공", "중간")["입력글"]
assert "Q_d" in _line(got["수식"], "보통인부", "중간")["입력글"]
_save(me, key, {**doc, eqm.KEY: sign})
again = _read(me, _doc(me, key))["수식"]
assert next(v for v in again["변수표"] if v["열쇠"] == "시공량")["기호"] == "Q"
def _session(role="USER"):
return {"role": role, "company_id": 7, "user_id": 42}
def test_permission_layer_write(world, monkeypatch):
"""⑤ 미리 셈 = 그 층 쓰기 — 마스터 = 시스템 관리자 · 회사 = 회사 관리자 · 개인 = 본인."""
me = world["personal"]
key = _sk(me, 바탕={"로직": KEY})
body = router.EquationBody(문서=_doc(me, key), 고침=DOUBLE)
for layer in ("system", "company"):
with pytest.raises(HTTPException) as e:
router.sangeun_equation_preview(layer, body, None, _session())
assert e.value.status_code == 403
got = router.sangeun_equation_preview("personal", body, None, _session())
assert got["되나"] and got["후"]["계"] > got["전"]["계"]
got = router.sangeun_equation("personal", body, None, _session())
assert got["ok"] and got["수식"]["합계"]["계"] == got["수식"]["합계"]["계"]
monkeypatch.setattr(router, "_root", lambda *a, **k: me)
admin = router.sangeun_equation_preview("system", body, None, _session("SYSTEM_ADMIN"))
assert admin["되나"]
def test_imported_same_path(world):
"""⑥ 가져온 식 SK(바탕 null · 가져온.로직) — 같은 읽기 · 고침 · 저장."""
me = world["personal"]
with store.at(me):
key = imp.create(_imported_row(), None, "sub2임시 가져온")["이름"]
doc = _doc(me, key)
got = _read(me, doc)
assert got["ok"], got
assert got["수식"]["합계"]["계"] == _sum(me, key)
edit = [{"할일": "식", "자리": "호표:0", "식": "0.5 × 2"}]
got = _preview(me, doc, edit)
assert got["되나"], got["까닭"]
assert got["후"]["노무비"] == pytest.approx(got["전"]["노무비"] * 2, abs=1)
_save(me, key, {**doc, eqm.KEY: edit})
now = _calc(me, _doc(me, key))
assert now["기준"]["노무비"] == pytest.approx(got["후"]["노무비"], abs=1)
assert _doc(me, key)["가져온"]["로직"]["호표"][0]["수량"] == "0.5" # 엑셀 줄 그대로
def test_m01_change_kept_regen_reapplies(world, monkeypatch):
"""⑦ M01 바뀌어도 고친 SK 그대로 · [재생성] = 새 사본에 고침 다시 · 자리 없으면 못얹은."""
me = world["personal"]
key = _sk(me, 바탕={"로직": KEY})
_save(me, key, {**_doc(me, key), eqm.KEY: DOUBLE})
was = _sum(me, key)
row = copy.deepcopy(_live().logic(KEY))
row["호표"][1]["수량"] = f"({row['호표'][1]['수량']}) * 3"
_fake(monkeypatch, [row])
assert _sum(me, key) == was
with store.at(me):
got = skc.preview(_doc(me, key), skc.REGEN)
assert not got["못얹은"] and got["후"]["계"] > was # 새 판 + 같은 고침
gone = copy.deepcopy(row)
gone["호표"] = []
_fake(monkeypatch, [gone])
with store.at(me):
got = skc.preview(_doc(me, key), skc.REGEN)
assert eqm.LOST in [x["cid"] for x in got["못얹은"]] # 그 밖 = 없어진 줄 M01판(790-2)
assert m01.loaded()[1].logic(KEY)["호표"] == []
def test_screen_same_part_first_tab():
"""화면 — M02 수식 = M01 수식 탭 부품 다시 씀(복사 없음) · 첫 탭 · 옛 탭 뒤로 · 저장 = 페이지."""
import re
from pathlib import Path
root = Path(__file__).resolve().parents[2] / "M02_MasterTemplete"
mine = (root / "M02_MasterTemplete_Sangeun_Equation.ts").read_text(encoding="utf-8")
assert "createLabEquation(" in mine and "fetch(" not in mine and "createEqChain" not in mine
assert "/sangeun/equation${suffix}" in mine and "/sangeun/copy/${how}" in mine
sheet = (root / "M02_MasterTemplete_Sangeun_Sheet.ts").read_text(encoding="utf-8")
views = re.findall(r'\{ value: "(\w+)", label: ', sheet)[:6]
assert views == ["equation", "ho", "sheet", "est", "calc", "formula"]
assert 'show("equation");' in sheet