feat(M01): PLAN 3-4 복사해서 만들기 화면 — 별칭 꼴 식 편집·표 바꾸기·로직 부르기 갈래·시험 계산·저장

- 새 M01_MasterData_UI_LogicLab_Copy.ts — [본떠 만들기] → GET /logic/formula(별칭 폼) 편집
  → 표 바꾸기 모달(소요량·계수 검색) · 로직 부르기 그대로/같이 복사 갈래 · 식 줄 고치기·덧줄 더하기
  → POST /logic/formula/preview 로 되돌린 실제 식을 buildCalc 로 재사용해 시험 계산
  → POST /logic/formula/save 로 자체 로직(GX) 저장 · afterSave 로 LogicLab 이 새 키를 바로 엶
- 서버: POST /logic/formula/preview 새로 더함(저장 없이 별칭만 원문으로 되돌림) · 계약 7장 반영
- LogicLab.ts 는 「본떠 만들기」 단추 한 줄만 붙임(정본 로직 화면·New 화면은 안 건드림)
- ORCA 로 13-4-1 을 본떠 저장 — 안 고치면 79,279.668 로 원본과 같음 · 석공 줄 곱 2배로 고치면
  131,716.728 로 바뀜 확인 · 시험 흔적(GX000001·로직_자체.json·키대장 GX 번호) 지움·되돌림
- 시험 `test_m01_copy.py` 에 미리보기 시험 둘 더함 · M01 시험 130 통과 · typecheck 통과
This commit is contained in:
2026-09-21 21:12:43 +09:00
parent 7379379bd7
commit e73ef5d3a0
10 changed files with 563 additions and 9 deletions
+13
View File
@@ -73,6 +73,13 @@ class FormulaLogic(BaseModel):
본뜬키: str = ""
class FormulaPreview(BaseModel):
"""저장 전 미리보기 — 별칭을 원문으로 되돌린 로직 줄만 받고 싶을 때(시험 계산 전)."""
로직: dict[str, Any]
별칭: list[dict[str, Any]] = []
class DraftLogic(BaseModel):
text: str # 줄로 적은 식 — 줄마다 「이름 = 식」
decided: dict[str, Any] = {} # 변수마다 정한 것 {이름: {무엇, 단위, 비목, 요소·찾기·값}}
@@ -246,6 +253,12 @@ def post_logic_formula_save(body: FormulaLogic) -> dict:
return _call(make.logic_from_formula, body.로직, body.별칭, body.owner, body.이름, body.본뜬키)
@router.post("/logic/formula/preview")
def post_logic_formula_preview(body: FormulaPreview) -> dict:
"""저장 전 시험 계산용 — 별칭을 원문으로 되돌린 로직 줄만(저장 안 함)."""
return _call(make.logic_formula_preview, body.로직, body.별칭)
@router.post("/logic/draft")
def post_logic_draft(body: DraftLogic) -> dict:
"""식으로 새 로직 만들기 — 변수 뽑기 · 검사 · 로직 줄로 옮기기(`save` 면 저장까지)."""
+13 -4
View File
@@ -247,14 +247,23 @@ def logic_formula(key: str) -> dict:
}
def _gather(logic: dict, marks: list[dict]) -> dict:
try:
return mcp.gather(logic, marks)
except cm.mf.FormulaError as e:
raise store.StoreError(400, str(e)) from e
def logic_formula_preview(logic: dict, marks: list[dict]) -> dict:
"""별칭을 원문으로 되돌린 로직 줄만 — 저장 안 함(시험 계산 전 미리보기)."""
return {"logic": _gather(logic, marks)}
def logic_from_formula(
logic: dict, marks: list[dict], owner: str = OWNERS[0], 이름: str = "", 본뜬키: str = ""
) -> dict:
"""고친 식 묶음을 자체 로직으로 저장 — 별칭을 원문으로 되돌린 뒤 `logic_new` 를 그대로 씀."""
try:
row = mcp.gather(logic, marks)
except cm.mf.FormulaError as e:
raise store.StoreError(400, str(e)) from e
row = _gather(logic, marks)
if 이름:
row["이름"] = 이름
if 본뜬키:
@@ -34,6 +34,8 @@ import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterDa
import type { SideHandle } from "./M01_MasterData_UI_Side";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import { buildLabDetail } from "./M01_MasterData_UI_LogicLab_Detail";
import { openCopyScreen } from "./M01_MasterData_UI_LogicLab_Copy";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
import "./M01_MasterData_UI_Logic_Style.css";
/** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */
@@ -350,6 +352,20 @@ export async function mountM01LogicLab(
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
discardButton,
saveButton,
createButton({
label: tl("Copy_Open"),
variant: "ghost",
onClick: () =>
opened?.origKey
? void openCopyScreen({
key: opened.origKey,
afterSave: async (newKey) => {
await reload();
await open(logicId("자체", newKey), "자체", newKey);
},
})
: showToast(tl("Copy_NeedOpen"), "info"),
}),
],
});
host.replaceChildren(
@@ -0,0 +1,391 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Copy.ts
* 복사해서 만들기(PLAN 3-4 · 계약 7장) — 정본·자체 로직을 별칭 꼴 식으로 펴 보이고
* 줄을 고치거나 더한 뒤 자체 로직(GX)으로 저장.
*
* [본떠 만들기] → GET /logic/formula(별칭 폼) → 별칭 표 바꾸기·로직 부르기 갈래 고르기·
* 식 줄 고치기·더하기 → [시험 계산](POST /logic/formula/preview 로 원문 되돌린 뒤 /calc 재사용)
* → [저장](POST /logic/formula/save) → afterSave 로 새 키를 알림(정본은 손대지 않음).
* ========================================================================== */
import {
createButton,
createInputField,
createSelectField,
el,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import {
copyLogic,
fetchLogicFormula,
previewLogicFormula,
saveLogicFormula,
type FormulaAlias,
type FormulaLine,
type LogicRow,
} from "./M01_MasterData_UI_Logic_Api";
import { buildCalc } from "./M01_MasterData_UI_Logic_Calc";
import { fetchTables, type TableHead } from "./M01_MasterData_Api_Fetch";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
import "./M01_MasterData_UI_LogicLab_Style.css";
const OWNERS = ["현장", "공용"] as const;
const TABLE_GROUPS = ["소요량", "계수"] as const;
const GROUP_LABEL: Record<FormulaLine["갈래"], string> = {
: "중간",
: "호표",
: "덧줄",
: "결과",
};
export interface CopyOptions {
/** 본뜰 원본 키(정본·자체 둘 다) */
key: string;
/** 저장 뒤 새 GX 키를 알림 — 밖(LogicLab)이 목록 새로고침·그 키 열기를 함 */
afterSave: (newKey: string) => void | Promise<void>;
}
/** 자리표(`호표[0].수량` 등)로 로직 안 그 칸을 읽고 씀 — `master_copy.slots()` 와 같은 자리 */
function field(row: LogicRow, place: string): { get: () => string; set: (v: string) => void } {
if (place === "결과.식") {
return {
get: () => row.?. ?? "",
set: (v) => {
if (row.) row.. = v;
},
};
}
const m = /^(호표|중간|덧줄)\[(\d+)\]\.(.+)$/.exec(place);
if (!m) return { get: () => "", set: () => {} };
const [, group, idxStr, col] = m;
const idx = Number(idxStr);
const arr = row[group] as Record<string, unknown>[] | undefined;
return {
get: () => (arr?.[idx] ? String(arr[idx][col] ?? "") : ""),
set: (v) => {
if (arr?.[idx]) arr[idx][col] = v;
},
};
}
/** 별칭 원문의 키만 갈아 끼움 — 「찾기(QF000421, …)」 → 「찾기(QF999999, …)」(인자는 그대로) */
function swapKey(raw: string, newKey: string): string {
return raw.replace(/^(찾기|로직)\(\s*[A-Za-z0-9]+/, `$1(${newKey}`);
}
/** 표 바꾸기 모달 — 소요량·계수 두 그룹을 함께 찾음 */
function openTablePick(onPick: (table: TableHead) => void): void {
const close = (): void => backdrop.remove();
const search = createInputField({ type: "search", placeholder: tl("Copy_PickSearch") });
const list = el("div", { className: "m01-logic__pick-list" });
const dialog = el("div", {
className: "m01-logic__pick",
attrs: { role: "dialog", "aria-label": tl("Copy_PickTitle") },
children: [
el("div", {
className: "m01lab__modal-top",
children: [
el("h3", { text: tl("Copy_PickTitle") }),
createButton({ label: tl("Modal_Close"), variant: "ghost", onClick: close }),
],
}),
search.root,
list,
],
});
const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] });
backdrop.addEventListener("click", (ev) => ev.target === backdrop && close());
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
document.body.append(backdrop);
search.input.focus();
let timer: number | undefined;
const run = async (): Promise<void> => {
const q = search.input.value.trim();
const pages = await Promise.all(
TABLE_GROUPS.map((group) => fetchTables(group, "", "", q, 1, 20)),
);
const hits = pages.flatMap((p) => p.tables);
list.replaceChildren(
...(hits.length
? hits.map((t) => {
const b = el("button", {
className: "m01-logic__pick-row m01-logic__pick-row--button",
attrs: { type: "button" },
children: [
el("span", { text: `${t. || t.} · ${t. || ""}` }),
el("span", { className: "m01-logic__muted", text: t.키 }),
],
});
b.addEventListener("click", () => {
onPick(t);
close();
});
return b;
})
: [el("p", { className: "m01-logic__muted", text: tl("Copy_PickNone") })]),
);
};
search.input.addEventListener("input", () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => void run(), 250);
});
void run();
}
function aliasRow(a: FormulaAlias, onSwap: (raw: string) => void, sub: HTMLElement): HTMLElement {
const about =
a. === "찾기"
? [a. || a., Object.keys(a.).join("·")].filter(Boolean).join(" · ")
: [a. || a., a.].filter(Boolean).join(" · ");
return el("div", {
className: "m01lab__box",
children: [
el("div", {
className: "m01-logic__section-head",
children: [
el("strong", { text: a.이름 }),
el("span", { className: "m01-logic__muted", text: about }),
],
}),
el("pre", { className: "m01lab__expr", text: a.원문 }),
sub,
...(a. === "찾기"
? [
createButton({
label: tl("Copy_Alias_Swap"),
variant: "ghost",
onClick: () => openTablePick((t) => onSwap(swapKey(a., t.))),
}),
]
: []),
],
});
}
function logicAliasControls(
a: FormulaAlias,
onForked: (raw: string) => void,
): { root: HTMLElement } {
let forked = false;
const original = a.;
const status = el("span", { className: "m01-logic__muted" });
const refBtn = createButton({
label: tl("Copy_Alias_Ref"),
variant: "ghost",
onClick: () => {
forked = false;
a. = original;
refresh();
},
});
const forkBtn = createButton({
label: tl("Copy_Alias_Fork"),
variant: "ghost",
onClick: () => void doFork(),
});
const refresh = (): void => {
refBtn.disabled = !forked;
forkBtn.disabled = forked;
status.textContent = forked ? tl("Copy_Alias_Forked") : "";
};
const doFork = async (): Promise<void> => {
try {
const got = await copyLogic(a.);
forked = true;
a. = swapKey(original, got.key);
onForked(a.);
refresh();
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
};
refresh();
return { root: el("div", { children: [refBtn, forkBtn, status] }) };
}
function lineBox(row: LogicRow, line: FormulaLine, onChange: () => void): HTMLElement {
const f = field(row, line.);
const box = el("textarea", { className: "m01-logic__input m01lab__qty" });
box.value = f.get();
box.rows = Math.max(1, Math.ceil(box.value.length / 60));
box.addEventListener("input", () => {
f.set(box.value);
onChange();
});
const tag = [GROUP_LABEL[line.], line.].filter(Boolean).join(" · ");
return el("div", {
className: "m01lab__box",
children: [
el("div", {
className: "m01-logic__section-head",
children: [
el("strong", { text: line.이름 || line. }),
el("span", { className: "m01-logic__tag", text: tag }),
...(line. ? [el("span", { className: "m01-logic__muted", text: line.단위 })] : []),
],
}),
box,
],
});
}
function addExtra(row: LogicRow, onChange: () => void, redraw: () => void): void {
row. = row. ?? [];
row..push({ 이름: tl("Copy_ExtraName"), : "0", : "재료비" });
onChange();
redraw();
}
/** 복사 화면을 전체 덮는 시트로 엶 */
export async function openCopyScreen(opt: CopyOptions): Promise<void> {
showLoadingOverlay();
let got;
try {
got = await fetchLogicFormula(opt.key);
} catch (error) {
hideLoadingOverlay();
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
return;
}
hideLoadingOverlay();
const row: LogicRow = got.;
const marks: FormulaAlias[] = got.;
const nameField = createInputField({ label: tl("Copy_Name"), value: `${row.} 복사본` });
const ownerField = createSelectField({
label: tl("Copy_Owner"),
options: OWNERS.map((o) => ({
value: o,
text: o === "현장" ? tl("Copy_Site") : tl("Copy_Shared"),
})),
value: "현장",
});
const aliasHost = el("div", { className: "m01-logic__scroll" });
const linesHost = el("div", { className: "m01-logic__scroll" });
const calcHost = el("div", { className: "m01lab__calc" });
const values: Record<string, string> = {};
let timer: number | undefined;
const refreshCalc = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(() => void runPreview(), 300);
};
const runPreview = async (): Promise<void> => {
try {
const preview = await previewLogicFormula(row, marks);
buildCalc(calcHost, {
savedKey: null,
file: got.file,
row: preview.logic,
dirty: () => true,
values,
onLines: () => undefined,
});
} catch (error) {
calcHost.replaceChildren(
el("p", { className: "m01-logic__bad", text: error instanceof Error ? error.message : "" }),
);
}
};
const drawLines = (): void => {
linesHost.replaceChildren(...got..map((line) => lineBox(row, line, refreshCalc)));
};
const drawAliases = (): void => {
aliasHost.replaceChildren(
...marks.map((a) => {
const sub =
a. === "로직" ? logicAliasControls(a, refreshCalc).root : el("span", { text: "" });
return aliasRow(
a,
(raw) => {
a. = raw;
refreshCalc();
},
sub,
);
}),
);
};
drawLines();
drawAliases();
refreshCalc();
const close = (): void => backdrop.remove();
const onSave = async (): Promise<void> => {
showLoadingOverlay();
try {
const saved = await saveLogicFormula({
로직: row,
별칭: marks,
owner: ownerField.select.value,
이름: nameField.input.value.trim() || row.,
본뜬키: opt.key,
});
showToast(tl("Copy_Saved"), "success");
close();
await opt.afterSave(saved.key);
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Save_Failed"), "error");
} finally {
hideLoadingOverlay();
}
};
const sheet = el("div", {
className: "m01-logic__pick m01copy__sheet",
attrs: { role: "dialog", "aria-label": tl("Copy_Title") },
children: [
el("div", {
className: "m01lab__modal-top",
children: [
el("h3", { text: `${tl("Copy_Title")}${row.}(${opt.key})` }),
createButton({ label: tl("Copy_Cancel"), variant: "ghost", onClick: close }),
],
}),
el("div", {
className: "m01copy__head",
children: [nameField.root, ownerField.root],
}),
el("section", {
className: "m01-logic__section",
children: [el("h3", { text: tl("Copy_Aliases") }), aliasHost],
}),
el("section", {
className: "m01-logic__section",
children: [
el("div", {
className: "m01-logic__section-head",
children: [
el("h3", { text: tl("Copy_Lines") }),
createButton({
label: tl("Copy_AddExtra"),
variant: "ghost",
onClick: () => addExtra(row, refreshCalc, drawLines),
}),
],
}),
linesHost,
],
}),
el("section", { className: "m01-logic__section", children: [calcHost] }),
el("div", {
className: "m01-logic__pick-bar",
children: [
el("span", {}),
el("span", {}),
createButton({ label: tl("Copy_Save"), onClick: () => void onSave() }),
],
}),
],
});
const backdrop = el("div", { className: "m01-logic__backdrop", children: [sheet] });
backdrop.addEventListener("click", (ev) => ev.target === backdrop && close());
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
document.body.append(backdrop);
}
@@ -109,3 +109,15 @@
font-weight: 600;
text-align: right;
}
/* 복사해서 만들기(3-4) — 별칭 꼴 식 시트 */
.m01copy__sheet {
width: min(1040px, 94vw);
max-height: 90vh;
}
.m01copy__head {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--spacing-8);
}
@@ -47,6 +47,27 @@ const TEXT = {
"걸린 줄을 아직 못 맞힘 — 「시험 계산」 에 값을 넣고 다시 누르세요",
"No matching row yet — enter trial values and reopen",
],
Copy_Open: ["본떠 만들기", "Copy as mine"],
Copy_NeedOpen: ["먼저 로직을 고르세요", "Pick a logic first"],
Copy_Title: ["복사해서 만들기", "Copy to edit"],
Copy_Name: ["새 이름", "New name"],
Copy_Owner: ["소유", "Owner"],
Copy_Site: ["현장", "Site"],
Copy_Shared: ["공용", "Shared"],
Copy_Aliases: ["부르는 표·로직", "Tables & logics used"],
Copy_Alias_Swap: ["표 바꾸기", "Change table"],
Copy_Alias_Ref: ["그대로 부르기", "Keep reference"],
Copy_Alias_Fork: ["같이 복사하기", "Copy along"],
Copy_Alias_Forked: ["복사됨", "Copied"],
Copy_Lines: ["식 줄 — 고치거나 더함", "Formula lines — edit or add"],
Copy_AddExtra: ["덧줄 더하기", "Add extra line"],
Copy_ExtraName: ["새 덧줄", "New extra line"],
Copy_Save: ["저장(자체 로직)", "Save as mine"],
Copy_Cancel: ["취소", "Cancel"],
Copy_Saved: ["자체 로직으로 저장함", "Saved as your own logic"],
Copy_PickTitle: ["바꿀 표 찾기", "Find a table"],
Copy_PickSearch: ["표 이름·키로 찾기", "Search by table name or key"],
Copy_PickNone: ["찾는 표가 없음", "No matching tables"],
} as const satisfies Record<string, readonly [string, string]>;
export type LabTextKey = keyof typeof TEXT;
@@ -214,3 +214,56 @@ export const copyLogic = (
key: string,
): Promise<{ file: string; version: string; key: string; logic: LogicRow }> =>
request("/logic/copy", { key });
/** 복사해서 만들기(계약 7장) — 찾기·로직 부르기 한 덩이를 이름 하나(별칭)로 보임 */
export interface FormulaAlias {
이름: string;
: "찾기" | "로직";
참조: string;
/** 되돌릴 원문 부르기 글 — 저장 때 이 글로 갈아 끼움 */
원문: string;
밑이름: string;
값칸: Record<string, string>;
결과단위: string;
자리: string[];
}
export interface FormulaLine {
자리: string;
: "중간" | "호표" | "덧줄" | "결과";
차례: number;
이름: string;
단위: string;
비목: string;
: string;
출처: string;
종류?: string;
요소?: unknown;
}
export interface LogicFormula {
file: string;
version: string;
key: string;
소유: string;
prices: Record<string, ElementBrief | null>;
로직: LogicRow;
별칭: FormulaAlias[];
: FormulaLine[];
}
export const fetchLogicFormula = (key: string): Promise<LogicFormula> =>
request(`/logic/formula?${query({ key })}`);
export const saveLogicFormula = (body: {
로직: LogicRow;
별칭: FormulaAlias[];
owner: string;
이름?: string;
본뜬키?: string;
}): Promise<{ file: string; version: string; key: string; logic: LogicRow }> =>
request("/logic/formula/save", body);
/** 저장 전 시험 계산용 — 별칭을 원문으로 되돌린 로직 줄만(저장 안 함) */
export const previewLogicFormula = (
로직: LogicRow,
별칭: FormulaAlias[],
): Promise<{ logic: LogicRow }> => request("/logic/formula/preview", { , });
+1 -1
View File
@@ -1,5 +1,5 @@
{
"다음": {"LB": 409, "EQ": 655, "RT": 20, "MN": 7000, "MT": 30600, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 252, "CC": 338, "GC": 1104, "QC": 2060},
"다음": {"LB": 409, "EQ": 655, "RT": 20, "MN": 7000, "MT": 30600, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 252, "CC": 338, "GC": 1104, "QC": 2060, "GX": 1},
"폐기": {"MT000001": "자재품목 휘발유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000002": "자재품목 경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000003": "자재품목 선박용경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000004": "자재품목 중유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000005": "자재품목 등유 — 값이 비고 글뿐 · 유가 테이블로 감", "LB000399": "이름 「〃」 — 직종이 아니라 되풀이 기호 · 소요량 13-6-9 에서 윗줄 직종으로 바로잡음", "MN": "재료_나라장터자재 — old 로 옮김 · 로직은 자재품목·품셈재료로 갈아 끼움", "LB000372": "미확보 고급기능사 — 측량노임 항공사진고급기능사 옛이름으로 옮김", "LB000393": "미확보 리베팅공 — 건설노임 철골공 옛이름으로 옮김(2010 통합표 연번10)", "LB000405": "미확보 Belt Conveyor 설치공 — 품셈 13-10-1 주② 배분으로 다섯 직종에 나눔", "MT000006": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "MT000007": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "LB000368": "미확보 기술사 — 기술사(건설) 옛이름으로 옮김", "LB000369": "미확보 특급기술자 — 특급기술자(건설) 옛이름으로 옮김", "LB000370": "미확보 고급기술자 — 고급기술자(건설) 옛이름으로 옮김", "LB000361": "미확보 중급기술자 — 중급기술자(건설) 옛이름으로 옮김", "LB000364": "미확보 초급기술자 — 초급기술자(건설) 옛이름으로 옮김", "LB000365": "미확보 고급숙련기술자 — 고급숙련기술자(건설) 옛이름으로 옮김", "LB000366": "미확보 중급숙련기술자 — 중급숙련기술자(건설) 옛이름으로 옮김", "LB000367": "미확보 초급숙련기술자 — 초급숙련기술자(건설) 옛이름으로 옮김", "LB000389": "미확보 기계기사 — 초급기술자(기계·설비) 옛이름으로 옮김", "LB000388": "미확보 기계산업기사 — 초급숙련기술자(기계·설비) 옛이름으로 옮김", "MO000055": {"원문번호": "주택용저압:기본_200이하", "파일": "재료_유가전력.json"}, "MO000056": {"원문번호": "주택용저압:기본_201~400", "파일": "재료_유가전력.json"}, "MO000057": {"원문번호": "주택용저압:기본_400초과", "파일": "재료_유가전력.json"}, "MO000059": {"원문번호": "주택용저압:전력량_다음200", "파일": "재료_유가전력.json"}, "MO000060": {"원문번호": "주택용저압:전력량_400초과", "파일": "재료_유가전력.json"}, "MO000061": {"원문번호": "주택용저압:월간최저요금", "파일": "재료_유가전력.json"}, "MO000062": {"원문번호": "일반용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000063": {"원문번호": "일반용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000064": {"원문번호": "일반용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000065": {"원문번호": "일반용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000066": {"원문번호": "산업용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000067": {"원문번호": "산업용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000068": {"원문번호": "산업용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000069": {"원문번호": "산업용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000070": {"원문번호": "임시전력갑:기본요금적용", "파일": "재료_유가전력.json"}, "MO000071": {"원문번호": "임시전력갑:전력량요금적용", "파일": "재료_유가전력.json"}, "MO000072": {"원문번호": "임시전력갑:월간최저요금", "파일": "재료_유가전력.json"}},
"키": {
"CC000001": {"원문번호": "공통 1-3-1 콘크리트 및 포장용 재료", "파일": "계수_건설품셈_01장_적용기준.json"},
+6 -4
View File
@@ -119,10 +119,11 @@
정본 로직을 식 줄로 펴 보이고, 고친 식을 자체 로직(`GX`)으로 저장하는 길. 규칙은 `ref/_설계_식_복사.md` · 엔진은 `scripts/master_copy.py`.
| 길 | 받음 | 줌 |
| --------------------------- | -------------------------------------------- | ------------------------------------------------------------- |
| `GET /logic/formula` | `key` | `{file, version, key, 소유, prices, 로직, 별칭: […], 줄: […]}` |
| `POST /logic/formula/save` | `{로직, 별칭, owner?, 이름?, 본뜬키?}` | `/logic/new` 와 같음 — `{file, version, key, logic}` |
| 길 | 받음 | 줌 |
| ----------------------------- | -------------------------------------- | -------------------------------------------------------------- |
| `GET /logic/formula` | `key` | `{file, version, key, 소유, prices, 로직, 별칭: […], 줄: […]}` |
| `POST /logic/formula/save` | `{로직, 별칭, owner?, 이름?, 본뜬키?}` | `/logic/new` 와 같음 — `{file, version, key, logic}` |
| `POST /logic/formula/preview` | `{로직, 별칭}` | `{logic}` — 별칭을 원문으로 되돌린 로직 줄만(저장 안 함) |
- `로직` = 로직 한 줄 통째(`_틀.md` 7장) · **식 칸만** 별칭 꼴(`찾기(…)`·`로직(…)` 한 덩이가 이름 하나로). 식이 든 칸 = `호표[].수량` · `호표[].요소`(글자일 때) · `중간[].식` · `덧줄[].식` · `결과.식`.
- `별칭` = `{이름, 종류(찾기·로직), 참조, 원문, 밑이름, 값칸, 결과단위, 자리: […]}``원문` 이 되돌릴 부르기 글 그대로 · `자리` 는 그 별칭이 쓰인 자리표 목록.
@@ -135,6 +136,7 @@
- `본뜬키` 를 주면 비고 끝에 「`<키>` 를 본뜸」 을 붙임(5장 `logic/copy` 와 같은 말) · `이름` 을 주면 그 이름으로.
- 답 — 200 · 400(별칭·몸이 틀림) · 404(없는 키) · 422(검사 걸림 — `/save` 와 같은 검사).
- 복사본은 원본을 따라가지 않음(값을 그대로 박은 스냅숏) · 식 안 `로직(…)` 부르기는 참조 그대로라 그 하위 로직은 계속 정본을 봄.
- `formula/preview` 는 저장 전 시험 계산용 — 되돌린 로직 줄을 `POST /calc`·`/text``row`(`file``GET /logic/formula` 가 준 `file`)로 그대로 넣어 씀(3장과 같은 길).
## 8. 식으로 만들기
+37
View File
@@ -182,6 +182,43 @@ def test_별칭_원문이_부르기가_아니면_막는다(client: TestClient) -
assert "찾기" in str(got["detail"])
def test_미리보기는_저장_없이_원문으로_되돌린_로직만_준다(client: TestClient) -> None:
"""3-4 화면의 [시험 계산] 이 쓰는 길 — `/calc` 의 `row`+`file` 로 그대로 넣어 씀."""
inputs = _inputs(STACK)
was = _sums(client, STACK, inputs)
view = _get(client, "/api/m01/logic/formula", key=STACK)
before_keys = _get(client, "/api/m01/logics")["logics"]
preview = _post(
client, "/api/m01/logic/formula/preview", {"로직": view["로직"], "별칭": view["별칭"]}
)
resolved = preview["logic"]["호표"][0]["수량"]
assert resolved == "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)"
after_keys = _get(client, "/api/m01/logics")["logics"]
assert after_keys == before_keys # 아무것도 저장 안 함
calc = _post(
client,
"/api/m01/calc",
{"key": STACK, "inputs": inputs, "row": preview["logic"], "file": view["file"]},
)
assert calc["ok"], calc
assert Decimal(str(calc["sums"][""])) == Decimal(str(was[""]))
def test_미리보기도_별칭_원문이_부르기가_아니면_막는다(client: TestClient) -> None:
view = _get(client, "/api/m01/logic/formula", key=STACK)
marks = [{**one} for one in view["별칭"]]
marks[0]["원문"] = "아무거나"
got = _post(
client,
"/api/m01/logic/formula/preview",
{"로직": view["로직"], "별칭": marks},
want=400,
)
assert "찾기" in str(got["detail"])
def test_정본_로직은_이_길로도_안_바뀐다(client: TestClient) -> None:
"""저장은 늘 새 자체 로직(`GX`) — 본뜬 원본 파일은 판본까지 그대로."""
before = _get(client, "/api/m01/logic", key=STACK)