diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index dc5ce134..d894891c 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -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` 면 저장까지).""" diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py index 886554d0..9b0aa0c5 100644 --- a/M01_MasterData/M01_MasterData_Store_Make.py +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -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 본뜬키: diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 236bc196..483c9728 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -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( diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Copy.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Copy.ts new file mode 100644 index 00000000..5df376fd --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Copy.ts @@ -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 = { + 중간: "중간", + 호표: "호표", + 덧줄: "덧줄", + 결과: "결과", +}; + +export interface CopyOptions { + /** 본뜰 원본 키(정본·자체 둘 다) */ + key: string; + /** 저장 뒤 새 GX 키를 알림 — 밖(LogicLab)이 목록 새로고침·그 키 열기를 함 */ + afterSave: (newKey: string) => void | Promise; +} + +/** 자리표(`호표[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[] | 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 => { + 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 => { + 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 { + 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 = {}; + let timer: number | undefined; + + const refreshCalc = (): void => { + window.clearTimeout(timer); + timer = window.setTimeout(() => void runPreview(), 300); + }; + const runPreview = async (): Promise => { + 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 => { + 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); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css index e5bdfcda..b12562b3 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css @@ -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); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts index 7c1ffbf3..14380f2a 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts @@ -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; export type LabTextKey = keyof typeof TEXT; diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index f2c06d1e..9f62a665 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -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[]; +} +export interface FormulaLine { + 자리: string; + 갈래: "중간" | "호표" | "덧줄" | "결과"; + 차례: number; + 이름: string; + 단위: string; + 비목: string; + 식: string; + 출처: string; + 종류?: string; + 요소?: unknown; +} +export interface LogicFormula { + file: string; + version: string; + key: string; + 소유: string; + prices: Record; + 로직: LogicRow; + 별칭: FormulaAlias[]; + 줄: FormulaLine[]; +} + +export const fetchLogicFormula = (key: string): Promise => + 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", { 로직, 별칭 }); diff --git a/resources/master_data/_키대장.json b/resources/master_data/_키대장.json index c96ba6f5..b1c37fea 100644 --- a/resources/master_data/_키대장.json +++ b/resources/master_data/_키대장.json @@ -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"}, diff --git a/resources/master_data/_화면_계약.md b/resources/master_data/_화면_계약.md index dfeab059..33c87f09 100644 --- a/resources/master_data/_화면_계약.md +++ b/resources/master_data/_화면_계약.md @@ -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. 식으로 만들기 diff --git a/resources/tester/test_m01_copy.py b/resources/tester/test_m01_copy.py index 1f446699..e0eff6bc 100644 --- a/resources/tester/test_m01_copy.py +++ b/resources/tester/test_m01_copy.py @@ -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)