Merge remote-tracking branch 'origin/dev' into main_desktop_1

# Conflicts:
#	M01_MasterData/M01_MasterData_UI_LogicLab.ts
#	M01_MasterData/M01_MasterData_UI_LogicLab_Style.css
#	M01_MasterData/M01_MasterData_UI_Logic_Api.ts
#	resources/master_data/ref/_검증_별칭_복사.md
This commit is contained in:
2026-09-21 21:37:11 +09:00
18 changed files with 1300 additions and 99 deletions
+2 -2
View File
@@ -287,14 +287,14 @@ def logic_draft(
save: bool = False,
owner: str = OWNERS[0],
) -> dict:
"""줄로 적은 식 → 변수 · 검사(`_화면_계약.md` 7장).
"""줄로 적은 식 → 변수 · 검사(`_화면_계약.md` 8장).
`head`(이름 · 결과단위 …)가 오면 로직 한 줄까지 미리 보이고, `save` 면 다 정해진 때만
자체 로직으로 저장(키 GX 는 서버) · 덜 정했거나 걸리면 422 로 아무것도 안 씀.
"""
import master_draft as draft # store 가 scripts 를 길에 올린 뒤라야 함
got = draft.read(text, decided)
got = draft.read(text, decided, head)
if head is None:
return {**got, "logic": None}
row, bad = draft.옮기기(text, decided, head)
+47 -5
View File
@@ -33,9 +33,11 @@ import { buildCalc } from "./M01_MasterData_UI_Logic_Calc";
import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List";
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 { buildLabDetail, isOwnLogic } from "./M01_MasterData_UI_LogicLab_Detail";
import { openCopyScreen } from "./M01_MasterData_UI_LogicLab_Copy";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
import { buildNewLogic } from "./M01_MasterData_UI_LogicLab_New";
import { tn } from "./M01_MasterData_UI_LogicLab_New_Text";
import "./M01_MasterData_UI_Logic_Style.css";
/** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */
@@ -56,10 +58,12 @@ interface Opened extends Draft {
reasons: string[];
lines: CalcLine[] | null;
text: TextAnswer | null;
ownHost: HTMLElement;
values: Record<string, string>;
}
const cacheKey = "m01_logic_drafts_lab";
const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" });
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
function loadDrafts(): Record<string, Draft> {
@@ -82,10 +86,13 @@ export async function mountM01LogicLab(
): Promise<LogicHandle> {
let drafts = loadDrafts();
let opened: Opened | null = null;
let creating = false; // 「식으로 새로 만들기」 화면이 열려 있음
let calcInputs = "";
let recalc: number | undefined;
const editor = el("div", { className: "m01-logic__editor" });
const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } });
const calc = el("div", { className: "m01lab__calc" });
const newHost = el("div", { className: "m01lab__new", attrs: { hidden: "" } });
const filterHost = el("div");
const waiting = el("p", { className: "m01-logic__muted", text: tx("Loading") });
let loaded = false; // 첫 목록이 오기 전에는 「0 줄」 빈 표를 보이지 않음
@@ -132,6 +139,11 @@ export async function mountM01LogicLab(
persist();
const inputs = JSON.stringify(opened.row. ?? []);
if (inputs !== calcInputs) drawCalc();
if (isOwnLogic(opened.row)) {
// 자체 로직 — 고친 뒤 잠시 쉬면 다시 계산해 호표 소계와 텍스트 수식을 새로 그림
window.clearTimeout(recalc);
recalc = window.setTimeout(() => calc.querySelector("button")?.click(), 700);
}
};
const pick = (o: Opened, row: LogicRow | null): Draft => ({
@@ -167,10 +179,12 @@ export async function mountM01LogicLab(
const drawEditor = (): void => {
// 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산
const listing = !opened;
const listing = !opened && !creating;
list.root.hidden = !listing || !loaded;
waiting.hidden = loaded;
editor.hidden = back.hidden = listing;
editor.hidden = !opened;
back.hidden = listing;
newHost.hidden = !creating;
if (!opened) return;
if (!opened.row) {
editor.replaceChildren(
@@ -187,6 +201,7 @@ export async function mountM01LogicLab(
lines: current.lines,
text: current.text,
values: current.values,
ownHost: current.ownHost,
onChange: touch,
calcHost: calc,
});
@@ -194,6 +209,7 @@ export async function mountM01LogicLab(
const show = (next: Opened | null): void => {
opened = next;
creating = false;
errors.hidden = true;
drawEditor();
drawCalc();
@@ -210,6 +226,7 @@ export async function mountM01LogicLab(
reasons: [],
lines: null,
text: null,
ownHost: ownHost(),
values: {},
});
return;
@@ -228,6 +245,7 @@ export async function mountM01LogicLab(
reasons: one.reasons,
lines: null,
text: null,
ownHost: ownHost(),
values: {},
});
} catch (error) {
@@ -253,7 +271,7 @@ export async function mountM01LogicLab(
restore: true,
onPick: (sub, detail) => {
list.setPick(sub, detail);
if (opened) show(null);
if (opened || creating) show(null);
},
}),
);
@@ -337,6 +355,29 @@ export async function mountM01LogicLab(
}
};
/** 식으로 새로 만들기 — 저장하면 목록을 다시 받아 그 로직의 상세 화면을 엶 */
const startNew = (): void => {
opened = null;
creating = true;
newHost.replaceChildren(
buildNewLogic({
onSaved: (key) => {
void (async () => {
try {
await reload();
await openKeyOn(key);
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
})();
},
onClose: () => show(null),
}),
);
drawEditor();
drawCalc();
};
const saveButton = createButton({ label: tx("Bar_Save"), onClick: () => void onSave() });
const discardButton = createButton({
label: tx("Bar_Discard"),
@@ -349,6 +390,7 @@ export async function mountM01LogicLab(
back,
el("h2", { text: tx("Title") }),
pending,
createButton({ label: tn("Open"), variant: "filled", onClick: startNew }),
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
discardButton,
saveButton,
@@ -374,7 +416,7 @@ export async function mountM01LogicLab(
children: [
el("main", {
className: "m01-logic__main",
children: [bar, errors, waiting, list.root, editor],
children: [bar, errors, waiting, list.root, editor, newHost],
}),
],
}),
@@ -15,7 +15,8 @@ import type {
NamedFormula,
TextAnswer,
} from "./M01_MasterData_UI_Logic_Api";
import { formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Edit";
import { buildEditor, formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Edit";
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula";
import { openMaterialModal } from "./M01_MasterData_UI_LogicLab_Modal";
@@ -32,6 +33,8 @@ export interface DetailContext {
/** 마지막 시험 계산의 읽는 식 줄(서버가 줌) */
text: TextAnswer | null;
values: Record<string, string>;
/** 자체 로직 편집기 자리 — 다시 그려도 같은 칸을 씀(고치던 값이 안 날아감) */
ownHost: HTMLElement;
onChange: () => void;
/** 시험 계산 칸(`buildCalc` 가 채움) — 넷째 컨테이너 안에 놓음 */
calcHost: HTMLElement;
@@ -79,6 +82,20 @@ const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement =>
function infoBox(ctx: DetailContext): HTMLElement {
const row = ctx.row;
const own = isOwnLogic(row);
const field = (label: string, key: "이름" | "원문번호" | "결과단위" | "출처"): HTMLElement => {
if (!own) return cell(label, row[key] ?? "");
const box = el("input", { className: "m01-logic__input", attrs: { "data-own": key } });
box.value = row[key] ?? "";
box.addEventListener("input", () => {
row[key] = box.value;
ctx.onChange();
});
return el("label", {
className: "m01lab__cell",
children: [el("span", { className: "m01-logic__muted", text: label }), box],
});
};
const cell = (label: string, value: string): HTMLElement =>
el("div", {
className: "m01lab__cell",
@@ -106,17 +123,20 @@ function infoBox(ctx: DetailContext): HTMLElement {
]
: [];
return section(tl("Info_Title"), [
el("p", { className: "m01-logic__muted", text: tl("Info_ReadOnly") }),
el("p", {
className: "m01-logic__muted",
text: tl(own ? "Info_Own" : "Info_ReadOnly"),
}),
...reasons,
el("div", {
className: "m01lab__info",
children: [
cell(tx("Head_File"), ctx.file),
cell(tx("Head_Key"), row.),
cell(tx("Head_Number"), row.),
cell(tx("Head_Name"), row.),
cell(tx("Head_Unit"), row.),
cell(tx("Head_Source"), row.),
field(tx("Head_Number"), "원문번호"),
field(tx("Head_Name"), "이름"),
field(tx("Head_Unit"), "결과단위"),
field(tx("Head_Source"), "출처"),
cell(tx("Head_Owner"), row. ?? ""),
el("label", {
className: "m01lab__cell m01lab__cell--wide",
@@ -254,6 +274,31 @@ function groupTable(group: Group, mine: Entry[], calculated: boolean): HTMLEleme
];
}
/** 자체 로직(키 GX…) — 정본은 아님 */
export const isOwnLogic = (row: LogicRow): boolean => row..startsWith("GX");
/** 자체 로직은 정본 화면과 같은 편집기를 호표 아래에 폄(머리 칸은 기본정보에서 고치므로 숨김) */
function ownEditor(ctx: DetailContext): HTMLElement {
if (!ctx.ownHost.childElementCount)
buildEditor(ctx.ownHost, {
row: ctx.row,
file: ctx.file,
isNew: false,
files: [],
reasons: [],
prices: ctx.prices,
lines: ctx.lines,
onChange: ctx.onChange,
onFile: () => undefined,
onPick: openPicker,
});
return el("details", {
className: "m01lab__edit",
attrs: { open: "" },
children: [el("summary", { text: tl("Edit_Title") }), ctx.ownHost],
});
}
function hoBox(ctx: DetailContext): HTMLElement {
const money = !("결과" in ctx.row) && (ctx.row. ?? "").startsWith("원");
if (!money) {
@@ -264,18 +309,24 @@ function hoBox(ctx: DetailContext): HTMLElement {
}
const all = entries(ctx);
const calculated = ctx.lines !== null;
const blocks = GROUPS.flatMap((group) => {
const tables = GROUPS.flatMap((group) => {
const mine = all.filter((e) => e.group === group);
return group === "other" && !mine.length ? [] : groupTable(group, mine, calculated); // 그 밖은 있을 때만
});
const total = all.reduce((s, e) => s + (e.amount ?? 0), 0);
blocks.push(
el("p", {
className: "m01lab__total",
attrs: { "data-sum": calculated ? String(total) : "" },
text: `${tl("Total")} ${calculated ? formatNumber(total) : "—"}`,
}),
);
const totalLine = el("p", {
className: "m01lab__total",
attrs: { "data-sum": calculated ? String(total) : "" },
text: `${tl("Total")} ${calculated ? formatNumber(total) : ""}`,
});
// 자체 로직 — 고치는 자리는 편집기 하나 · 읽기용 묶음 표는 접어 둠(펼치면 소계가 보임)
const fold = el("details", {
className: "m01lab__fold",
attrs: ctx.ownHost.dataset.fold ? { open: "" } : {},
children: [el("summary", { text: tl("Ho_Fold") }), ...tables],
});
fold.addEventListener("toggle", () => (ctx.ownHost.dataset.fold = fold.open ? "1" : ""));
const blocks = isOwnLogic(ctx.row) ? [ownEditor(ctx), fold, totalLine] : [...tables, totalLine];
const hint = calculated ? tl("Click_Hint") : `${tl("Click_Hint")} · ${tl("Calc_First")}`;
return section(tl("Ho_Title"), blocks, hint);
}
@@ -284,10 +335,22 @@ function hoBox(ctx: DetailContext): HTMLElement {
export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
const formula = el("div", { className: "m01lab__formula" });
buildFormula(formula, { text: ctx.text });
// 다시 그리면 포커스가 빠짐 — 고치던 칸(편집기 · 기본정보)을 기억해 되돌림
const active = document.activeElement;
const kept =
active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement
? { node: active, from: active.selectionStart, to: active.selectionEnd }
: null;
host.replaceChildren(
infoBox(ctx),
hoBox(ctx),
section(tl("Formula_Title"), [formula]),
section(tl("Calc_Title"), [ctx.calcHost]),
);
const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음
const back = own ? host.querySelector<HTMLInputElement>(`[data-own="${own}"]`) : kept?.node;
if (kept && back && (own || ctx.ownHost.contains(back))) {
back.focus();
if (kept.from !== null) back.setSelectionRange(kept.from, kept.to);
}
}
@@ -0,0 +1,105 @@
/* M01 로직 개선 시험 — 식으로 새 로직 만들기 (좁은 화면에서도 한 칸으로 쌓임) */
.m01lab-new {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
min-width: 0;
}
.m01lab-new__step {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m01lab-new__text {
width: 100%;
min-height: 140px;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg);
color: var(--color-text);
font-family: ui-monospace, Consolas, monospace;
font-size: var(--text-body);
resize: vertical;
}
.m01lab-new__row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m01lab-new__vars {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.m01lab-new__var {
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.m01lab-new__var-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--spacing-8);
}
.m01lab-new__var-body {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-8);
margin-top: var(--spacing-8);
}
.m01lab-new__wide {
flex: 1 1 220px;
}
.m01lab-new__picked {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
min-width: 0;
}
.m01lab-new__src {
display: flex;
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border-radius: var(--radius-lg);
background: var(--color-paper);
overflow-x: auto;
}
.m01lab-new__no {
flex: 0 0 auto;
color: var(--color-text-secondary);
}
.m01lab-new__src mark {
background: var(--color-danger);
color: var(--color-bg);
border-radius: 2px;
}
.m01lab-new__bad {
color: var(--color-danger);
}
.m01lab-new__soft {
color: var(--color-text-secondary);
}
.m01lab-new__calc {
min-width: 0;
}
@@ -0,0 +1,272 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_New.ts
* 「로직 개선 시험」 — 식으로 새 로직 만들기 (PLAN 3-3 · 계약 `_화면_계약.md` 7장)
* ① 식을 줄로 적음 ② [분석] → 변수 표에서 변수마다 무엇인지·단위·비목을 고름
* ③ 이름·결과 단위 ④ 시험 계산으로 값을 보고 저장(자체 로직 · 키 GX)
* 검사에 걸린 것은 그 줄·자리를 짚어 쉬운 말로 보임 — 서버 글 그대로(`문제`)
* ========================================================================== */
import {
createButton,
createInputField,
el,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import {
ApiError,
draftLogic,
type DraftAnswer,
type DraftDecided,
type DraftHead,
type DraftProblem,
type DraftVar,
} from "./M01_MasterData_UI_Logic_Api";
import { buildCalc } from "./M01_MasterData_UI_Logic_Calc";
import { tn } from "./M01_MasterData_UI_LogicLab_New_Text";
import { varRow, type VarRow } from "./M01_MasterData_UI_LogicLab_New_Var";
import "./M01_MasterData_UI_LogicLab_New.css";
/** 보기 글 — 넓이 → 품 → 인부 한 줄 + 잡재료비 덧줄 */
const SAMPLE = `# 보기 — 분석을 누른 뒤 변수마다 무엇인지 골라 주세요
넓이 = 길이 * 폭
인부 = 넓이 * 품
잡재료비 = 노무비 * 0.05
공사비 = 인부 * 보통인부 + 잡재료비
`;
export interface NewOptions {
/** 저장 끝 — 새 자체 로직의 키 */
onSaved: (key: string) => void;
onClose: () => void;
}
export function buildNewLogic(opts: NewOptions): HTMLElement {
const decided: Record<string, DraftDecided> = {};
const rows = new Map<string, VarRow>();
const values: Record<string, string> = {};
let analyzed = ""; // 분석한 식 — 그 뒤 글이 바뀌면 다시 분석하라고 알림
let answer: DraftAnswer | null = null;
let calcSig = "";
let seq = 0;
const text = el("textarea", {
className: "m01lab-new__text",
attrs: { rows: "7", spellcheck: "false", "aria-label": tn("Step1") },
});
text.value = SAMPLE;
const stale = el("p", {
className: "m01-logic__muted",
text: tn("Stale"),
attrs: { hidden: "" },
});
const varsHost = el("div", { className: "m01lab-new__vars" });
const problemHost = el("div", { className: "m01lab-new__problems" });
const calcHost = el("div", { className: "m01lab__calc m01lab-new__calc" });
const nameBox = createInputField({
type: "text",
label: tn("Name"),
onInput: () => onEdit(),
});
nameBox.input.setAttribute("data-key", "name");
const unitBox = createInputField({
type: "text",
label: tn("ResultUnit"),
placeholder: "원/㎡",
onInput: () => onEdit(),
});
unitBox.input.setAttribute("data-key", "unit");
const ownerBox = createInputField({ type: "text", label: tn("Owner") });
const state = el("p", { className: "m01-logic__muted" });
const save = createButton({ label: tn("Save"), onClick: () => void doSave() });
save.setAttribute("data-act", "save");
save.disabled = true;
const head = (): DraftHead | undefined => {
const = nameBox.input.value.trim();
const = unitBox.input.value.trim();
return && ? { , , : "자체" } : undefined;
};
/** 문제가 걸린 줄을 그대로 보이고 자리 글자에 표시 · 아래에 쉬운 말 목록 */
const drawProblems = (problems: DraftProblem[]): void => {
if (!analyzed) return problemHost.replaceChildren();
const lines = analyzed.split("\n");
const flagged = new Map<number, DraftProblem[]>();
for (const p of problems) {
if (p.) flagged.set(p., [...(flagged.get(p.) ?? []), p]);
}
const shown = [...flagged.keys()]
.sort((a, b) => a - b)
.filter((n) => n <= lines.length)
.map((n) => {
const raw = lines[n - 1];
const at = flagged.get(n)?.find((p) => p. !== null)?. ?? -1;
return el("div", {
className: "m01lab-new__src",
children: [
el("span", { className: "m01lab-new__no", text: String(n) }),
at >= 0
? el("code", {
children: [
raw.slice(0, at),
el("mark", { text: raw.slice(at, at + 1) }),
raw.slice(at + 1),
],
})
: el("code", { text: raw }),
],
});
});
const items = problems.map((p) =>
el("li", {
className: p.갈래 === "덜정함" ? "m01lab-new__soft" : "m01lab-new__bad",
attrs: { "data-kind": p. },
text: `${p. ? `${tn("Line", { n: p.줄 })} · ` : ""}${p.}`,
}),
);
problemHost.replaceChildren(
el("h4", { text: tn("Problems") }),
...(items.length
? [...shown, el("ul", { children: items })]
: [el("p", { className: "m01-logic__muted", text: tn("AllGood") })]),
);
};
const drawCalc = (): void => {
const row = answer?.logic ?? null;
save.disabled = !row || (answer?..length ?? 1) > 0;
state.textContent = !answer
? ""
: answer..some((p) => p. !== "덜정함")
? tn("SaveBlocked")
: !head()
? tn("NeedHead")
: answer.
? ""
: tn("Undecided");
if (!row) {
calcSig = "";
calcHost.replaceChildren();
return;
}
const sig = JSON.stringify(row. ?? []);
if (sig === calcSig) return; // 입력 칸이 그대로면 넣은 값·결과를 안 지우려고 다시 안 그림
calcSig = sig;
buildCalc(calcHost, {
savedKey: null,
row: { ...row, : row.키 ?? "" }, // 아직 번호 없는 새 초안 — 키 칸은 비워 보냄
dirty: () => true,
values,
onLines: () => undefined,
});
};
const drawVars = (vars: DraftVar[]): void => {
rows.clear();
const shown = vars.filter((v) => v. !== "비목합");
for (const name of Object.keys(decided)) {
if (!shown.some((v) => v. === name)) delete decided[name];
}
const result = answer?. ?? "";
const order = [
...shown.filter((v) => v. === "미정"),
...shown.filter((v) => v. !== "미정"),
];
for (const v of order) rows.set(v., varRow(v, v. === result, { decided, onEdit }));
varsHost.replaceChildren(...[...rows.values()].map((r) => r.root));
};
const evaluate = async (rebuild: boolean): Promise<void> => {
if (!analyzed) return;
const mine = ++seq;
try {
const got = await draftLogic({ text: analyzed, decided, head: head() });
if (mine !== seq) return; // 최신 응답만
answer = got;
if (rebuild) drawVars(got.);
for (const v of got.) rows.get(v.)?.update(v);
drawProblems(got.);
drawCalc();
} catch (error) {
showToast(error instanceof Error ? error.message : tn("Failed"), "error");
}
};
let timer = 0;
function onEdit(): void {
window.clearTimeout(timer);
timer = window.setTimeout(() => void evaluate(false), 250);
}
const analyzeButton = createButton({
label: tn("Analyze"),
onClick: () => {
analyzed = text.value;
stale.hidden = true;
void evaluate(true);
},
});
analyzeButton.setAttribute("data-act", "analyze");
text.addEventListener("input", () => (stale.hidden = !analyzed || text.value === analyzed));
async function doSave(): Promise<void> {
const h = head();
if (!h) return void showToast(tn("NeedHead"), "info");
save.disabled = true;
showLoadingOverlay(); // 서버가 파일을 검사하며 저장하느라 몇 초 걸림
try {
const owner = ownerBox.input.value.trim();
const done = await draftLogic({
text: analyzed,
decided,
head: h,
save: true,
...(owner ? { owner } : {}),
});
showToast(tn("Saved", { v: done.key ?? "" }), "success");
if (done.key) opts.onSaved(done.key);
} catch (error) {
const detail = error instanceof ApiError ? (error.detail as { 문제?: DraftProblem[] }) : null;
if (detail?.) drawProblems(detail.);
else showToast(error instanceof Error ? error.message : tn("Failed"), "error");
save.disabled = false;
} finally {
hideLoadingOverlay();
}
}
const section = (title: string, ...children: HTMLElement[]): HTMLElement =>
el("section", {
className: "m01lab__box m01lab-new__step",
children: [el("h3", { text: title }), ...children],
});
return el("div", {
className: "m01lab-new",
children: [
el("div", {
className: "m01-logic__section-head",
children: [
el("h2", { text: tn("Title") }),
createButton({ label: tn("Close"), variant: "ghost", onClick: opts.onClose }),
],
}),
section(
tn("Step1"),
el("p", { className: "m01-logic__muted", text: tn("Guide") }),
text,
el("div", { className: "m01lab-new__row", children: [analyzeButton, stale] }),
),
section(tn("Step2"), varsHost, problemHost),
section(
tn("Step3"),
el("div", {
className: "m01lab-new__row",
children: [nameBox.root, unitBox.root, ownerBox.root],
}),
),
section(tn("Step4"), calcHost, state, save),
],
});
}
@@ -0,0 +1,218 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_New_Find.ts
* 새 로직 만들기의 고르기 창 둘
* ① 마스터 요소 — 종류(인력·재료·기계) → 구분 → 상세구분 → 이름 찾기 → 한 줄 고르기
* ② 표 찾기 — 표(소요량·계수)와 값 칸을 고른 뒤 표의 조건 칸마다 식을 적음
* ========================================================================== */
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import {
fetchFiles,
fetchRows,
fetchSubsAll,
type FileInfo,
type SubInfo,
} from "./M01_MasterData_Api_Fetch";
import type { DraftFind } from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { loadTable } from "./M01_MasterData_UI_Logic_Note";
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
import { tn } from "./M01_MasterData_UI_LogicLab_New_Text";
export interface PickedElement {
ref: string;
/** 이름 + 규격 */
name: string;
/** 수량에 쓰는 단위(「원/일」 → 「일」) */
unit: string;
group: string;
}
const GROUPS = ["인력", "재료", "기계"];
const SHOWN = 50;
/** 창 뼈대 — 제목 · 닫기 · 몸통 */
function shell(title: string, body: HTMLElement[]): { close: () => void } {
const close = (): void => backdrop.remove();
const dialog = el("div", {
className: "m01-logic__pick",
attrs: { role: "dialog", "aria-label": title },
children: [
el("div", {
className: "m01-logic__section-head",
children: [
el("h3", { text: title }),
createButton({ label: tn("Close"), variant: "ghost", onClick: close }),
],
}),
...body,
],
});
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);
return { close };
}
/** 그룹의 파일 — 재료는 자재품목(구분·상세구분이 있는 것) */
async function fileOf(group: string): Promise<FileInfo | undefined> {
const files = await fetchFiles(group);
return group === "재료" ? (files.find((f) => f.file.includes("자재품목")) ?? files[0]) : files[0];
}
const options = (values: string[]): { value: string; text: string }[] => [
{ value: "", text: tn("All") },
...values.map((v) => ({ value: v, text: v })),
];
export function openElementPick(done: (picked: PickedElement) => void): void {
let file: FileInfo | undefined;
let subs: SubInfo[] = [];
const groupBox = createSelectField({
options: GROUPS.map((g) => ({ value: g, text: g })),
value: GROUPS[0],
compact: true,
label: tn("Group"),
});
const subBox = createSelectField({
options: options([]),
value: "",
compact: true,
label: tn("Sub"),
});
const detailBox = createSelectField({
options: options([]),
value: "",
compact: true,
label: tn("Detail"),
});
const searchField = createInputField({ type: "search", placeholder: tn("Search") });
const count = el("span", { className: "m01-logic__muted" });
const list = el("div", { className: "m01-logic__pick-list" });
const win = shell(tn("K_master"), [
el("div", {
className: "m01-logic__pick-bar",
children: [groupBox.root, subBox.root, detailBox.root, searchField.root, count],
}),
list,
]);
let seq = 0;
const load = async (): Promise<void> => {
if (!file) return;
const mine = ++seq;
try {
const got = await fetchRows(
file.file,
1,
SHOWN,
searchField.input.value.trim(),
false,
subBox.select.value,
detailBox.select.value,
);
if (mine !== seq) return;
count.textContent = tn("Count", { n: got.total, m: Math.min(got.total, SHOWN) });
list.replaceChildren(
...got.rows.map((row) => {
const name = `${row["이름"] ?? ""} ${row["규격"] ?? ""}`.trim();
const unitText = String(row["단위"] ?? "");
const unit = groupBox.select.value === "인력" ? "인" : (unitText.split("/").pop() ?? "");
const button = el("button", {
className: "m01-logic__pick-row m01-logic__pick-row--button",
attrs: { type: "button", "data-ref": String(row["키"]) },
children: [
el("div", {
className: "m01-logic__stack",
children: [
el("strong", { text: name }),
el("span", { className: "m01-logic__muted", text: String(row["키"]) }),
],
}),
el("span", {
className: "m01-logic__money",
text: `${formatNumber(row["값"])} ${unitText}`.trim(),
}),
],
});
button.addEventListener("click", () => {
win.close();
done({ ref: String(row["키"]), name, unit, group: groupBox.select.value });
});
return button;
}),
);
} catch (error) {
showToast(error instanceof Error ? error.message : tn("Failed"), "error");
}
};
const changeGroup = async (): Promise<void> => {
file = await fileOf(groupBox.select.value);
subs = file ? (await fetchSubsAll({ file: file.file })).subs : [];
subBox.setOptions(options(subs.map((s) => s.name)), "");
detailBox.setOptions(options([]), "");
detailBox.select.disabled = true;
await load();
};
groupBox.select.addEventListener("change", () => void changeGroup());
subBox.select.addEventListener("change", () => {
const details = subs.find((s) => s.name === subBox.select.value)?.details ?? [];
detailBox.setOptions(options(details), "");
detailBox.select.disabled = details.length === 0;
void load();
});
detailBox.select.addEventListener("change", () => void load());
let timer = 0;
searchField.input.addEventListener("input", () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => void load(), 250);
});
void changeGroup();
}
/** 표 찾기 — 표·값 칸을 고른 뒤 조건 칸마다 식을 받음 · 표의 조건 칸이 없으면 바로 끝 */
export function openTablePick(done: (find: DraftFind, label: string) => void): void {
openPicker("table", "소요량", (item, column) => {
void (async () => {
const table = await loadTable(item.ref);
const conds = Object.keys(table?. ?? {});
const label = `${item. ?? item.ref} · ${item.ref}${column ?? ""}`;
const find = { : item.ref, 값칸: column ?? "", : {} as Record<string, string> };
if (!conds.length) {
done(find, label);
return;
}
const inputs = conds.map((c) => {
const box = createInputField({ type: "text", value: c });
box.input.setAttribute("data-cond", c);
return { c, box };
});
const win = shell(tn("TableTitle"), [
el("p", { className: "m01-logic__muted", text: label }),
el("p", { text: tn("Cond") }),
...inputs.map(({ c, box }) =>
el("label", {
className: "m01-logic__field",
children: [el("span", { text: c }), box.root],
}),
),
createButton({
label: tn("Ok"),
onClick: () => {
for (const { c, box } of inputs) find.[c] = box.input.value.trim() || c;
win.close();
done(find, label);
},
}),
]);
})();
});
}
@@ -0,0 +1,71 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_New_Text.ts
* 「식으로 새 로직 만들기」 글자 — [한국어, 영어]
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Open: ["식으로 새로 만들기", "New from formula"],
Title: ["식으로 새 로직 만들기", "New logic from a formula"],
Close: ["닫기", "Close"],
Step1: ["① 식 적기", "① Write the formula"],
Guide: [
"한 줄에 「이름 = 식」 · 마지막 줄 왼쪽이 이 로직의 결과 · 「#」 로 시작한 줄은 건너뜀",
"One line each as “name = formula” · the last line's left side is the result · lines starting with # are skipped",
],
Analyze: ["분석", "Analyze"],
Stale: ["식이 바뀌었음 — 다시 [분석] 을 눌러 주세요", "Formula changed — press Analyze again"],
Step2: ["② 변수 정하기", "② Decide each variable"],
Step3: ["③ 이름·단위", "③ Name and unit"],
Step4: ["④ 시험 계산 · 저장", "④ Trial calculation and save"],
Problems: ["고칠 곳", "To fix"],
Line: ["{n}번 줄", "Line {n}"],
AllGood: ["검사에 걸린 것 없음", "No problems found"],
Result: ["결과", "Result"],
Undecided: ["아직 정하지 않은 칸이 있음", "Some cells are not decided yet"],
Kind: ["무엇인가", "What is it"],
K_master: ["마스터 요소", "Master element"],
K_input: ["설계 입력", "Design input"],
K_fixed: ["고정값", "Fixed value"],
K_table: ["표 찾기", "Table lookup"],
Pick: ["고르기", "Pick"],
Repick: ["다시 고르기", "Pick again"],
NotPicked: ["아직 안 골랐음", "Not picked"],
Unit: ["단위", "Unit"],
Cost: ["비목", "Cost item"],
CostNone: ["넣지 않음", "None"],
CostExtra: ["덧줄 비목", "Extra-line cost item"],
Value: ["값", "Value"],
Note: ["설명", "Note"],
Derived: ["식으로 정해짐", "Set by the formula"],
SumName: ["비목 합 — 덧줄에서만 쓰는 이름", "Cost total — only for extra lines"],
Name: ["로직 이름", "Logic name"],
ResultUnit: ["결과 단위", "Result unit"],
Owner: ["소유", "Owner"],
Save: ["저장", "Save"],
Saved: ["저장했음 — 「{v}」", "Saved — “{v}”"],
SaveBlocked: ["위 고칠 곳을 먼저 고치세요", "Fix the problems above first"],
NeedHead: ["이름과 결과 단위를 적어야 시험 계산·저장이 됨", "Enter the name and result unit"],
Group: ["종류", "Kind"],
Sub: ["구분", "Group"],
Detail: ["상세구분", "Detail"],
All: ["(전체)", "(All)"],
Search: ["이름 찾기", "Search name"],
Count: ["{n} 개 중 앞 {m} 개", "first {m} of {n}"],
TableTitle: ["표 찾기 — 표와 조건", "Table lookup — table and conditions"],
TablePick: ["표 고르기", "Pick a table"],
TableCol: ["값 칸", "Value column"],
Cond: ["조건 — 표의 칸마다 식(설계 값 이름)", "Conditions — a formula per table column"],
Ok: ["확인", "OK"],
Cancel: ["취소", "Cancel"],
Failed: ["못 함", "Failed"],
} as const satisfies Record<string, readonly [string, string]>;
export type NewTextKey = keyof typeof TEXT;
export function tn(key: NewTextKey, fill: Record<string, string | number> = {}): string {
const entry = TEXT[key];
const text: string = entry[currentLanguageIndex as 0 | 1] ?? entry[0];
return text.replace(/\{(\w+)\}/g, (m, k: string) => (k in fill ? String(fill[k]) : m));
}
@@ -0,0 +1,178 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_New_Var.ts
* 변수 한 칸 — 무엇인지(마스터 요소 / 설계 입력 / 고정값 / 표 찾기) · 단위 · 비목을 고름
* 고른 것은 `decided[이름]` 에 쌓음(서버 계약 `_화면_계약.md` 7장의 `decided` 그대로)
* ========================================================================== */
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import type { DraftDecided, DraftVar } from "./M01_MasterData_UI_Logic_Api";
import { openElementPick, openTablePick } from "./M01_MasterData_UI_LogicLab_New_Find";
import { tn } from "./M01_MasterData_UI_LogicLab_New_Text";
export interface VarCtx {
decided: Record<string, DraftDecided>;
/** 고른 것이 바뀜 — 화면이 검사를 다시 부름 */
onEdit: () => void;
}
export interface VarRow {
root: HTMLElement;
/** 서버가 다시 준 변수(식으로 정해진 단위 등)를 반영 */
update: (v: DraftVar) => void;
}
const KINDS: [string, () => string][] = [
["마스터요소", () => tn("K_master")],
["설계입력", () => tn("K_input")],
["고정값", () => tn("K_fixed")],
["표찾기", () => tn("K_table")],
];
const COSTS = ["노무비", "재료비", "경비"];
/** 요소 종류 → 비목 첫 제안 */
const COST_OF: Record<string, string> = { : "노무비", : "재료비", : "경비" };
const costOptions = (): { value: string; text: string }[] => [
{ value: "", text: tn("CostNone") },
...COSTS.map((c) => ({ value: c, text: c })),
];
const field = (label: string, control: HTMLElement): HTMLElement =>
el("label", {
className: "m01-logic__field",
children: [el("span", { text: label }), control],
});
export function varRow(v: DraftVar, isResult: boolean, ctx: VarCtx): VarRow {
const name = v.;
const info = el("span", { className: "m01-logic__muted m01lab-new__derived" });
const body = el("div", { className: "m01lab-new__var-body" });
const root = el("div", {
className: "m01lab-new__var",
attrs: { "data-var": name },
children: [
el("div", {
className: "m01lab-new__var-head",
children: [el("strong", { text: name }), info],
}),
body,
],
});
const now = (): DraftDecided => (ctx.decided[name] ??= {});
const set = (patch: DraftDecided): void => {
ctx.decided[name] = { ...now(), ...patch };
for (const [k, val] of Object.entries(ctx.decided[name])) {
if (val === "" || val === undefined) delete (ctx.decided[name] as Record<string, unknown>)[k];
}
ctx.onEdit();
};
const text = (label: string, key: "단위" | "값" | "설명", wide = false): HTMLElement => {
const box = createInputField({
type: "text",
value: now()[key] ?? "",
onInput: (val) => set({ [key]: val.trim() }),
});
box.input.setAttribute("data-key", key);
const cell = field(label, box.root);
if (wide) cell.classList.add("m01lab-new__wide");
return cell;
};
let picked = ""; // 마스터 요소·표 찾기에서 고른 것의 글
const draw = (): void => {
const kind = now(). ?? "";
if (v. === "비목합") {
body.replaceChildren(el("span", { className: "m01-logic__muted", text: tn("SumName") }));
return;
}
if (v. === "정의됨") {
if (isResult) return body.replaceChildren();
const cost = createSelectField({
options: costOptions(),
value: now(). ?? "",
compact: true,
label: tn("CostExtra"),
onChange: (val) => set({ 비목: val }),
});
body.replaceChildren(cost.root);
return;
}
const kindBox = createSelectField({
options: [
{ value: "", text: "—" },
...KINDS.map(([value, label]) => ({ value, text: label() })),
],
value: kind,
compact: true,
label: tn("Kind"),
onChange: (val) => {
ctx.decided[name] = val ? { 무엇: val, ...(now(). ? { 단위: now(). } : {}) } : {};
picked = "";
draw();
ctx.onEdit();
},
});
const parts: HTMLElement[] = [kindBox.root];
if (kind === "마스터요소") {
const cost = createSelectField({
options: costOptions(),
value: now(). ?? "",
compact: true,
label: tn("Cost"),
onChange: (val) => set({ 비목: val }),
});
const choose = createButton({
label: now(). ? tn("Repick") : tn("Pick"),
variant: "ghost",
onClick: () =>
openElementPick((p) => {
picked = `${p.name} · ${p.ref}`;
set({ 요소: p.ref, 단위: p.unit, 비목: COST_OF[p.group] ?? now(). });
draw();
}),
});
choose.setAttribute("data-act", "pick-element");
parts.push(
el("div", {
className: "m01lab-new__picked",
children: [choose, el("span", { text: picked || now(). || tn("NotPicked") })],
}),
text(tn("Unit"), "단위"),
cost.root,
);
} else if (kind === "설계입력") {
parts.push(text(tn("Unit"), "단위"), text(tn("Note"), "설명", true));
} else if (kind === "고정값") {
parts.push(text(tn("Value"), "값"), text(tn("Unit"), "단위"));
} else if (kind === "표찾기") {
const choose = createButton({
label: now(). ? tn("Repick") : tn("TablePick"),
variant: "ghost",
onClick: () =>
openTablePick((find, label) => {
picked = label;
set({ 찾기: find });
draw();
}),
});
choose.setAttribute("data-act", "pick-table");
parts.push(
el("div", {
className: "m01lab-new__picked",
children: [choose, el("span", { text: picked || tn("NotPicked") })],
}),
text(tn("Unit"), "단위"),
);
}
body.replaceChildren(...parts);
};
draw();
const update = (next: DraftVar): void => {
v = next;
info.textContent =
next. === "정의됨"
? `${tn("Derived")}${next. ? ` · ${next.}` : ""}`
: (next. ?? "");
};
update(v);
return { root, update };
}
@@ -121,3 +121,11 @@
grid-template-columns: 2fr 1fr;
gap: var(--spacing-8);
}
.m01lab__own > :first-child {
display: none;
}
.m01lab__edit {
margin-top: var(--spacing-12);
}
@@ -11,6 +11,15 @@ const TEXT = {
"기본 로직은 품셈 원문 그대로라 여기서 고치지 못함 — 비고만 고칠 수 있음",
"Base logic follows the source book; only the note can be edited",
],
Info_Own: [
"자체 로직 — 칸을 고치면 잠시 뒤 호표 소계와 텍스트 수식이 다시 계산됨",
"Own logic — edits recalculate the subtotals and formula shortly",
],
Edit_Title: [
"고치기 (호표 · 중간 값 · 덧줄 · 끝수)",
"Edit (lines · middles · extras · rounding)",
],
Ho_Fold: ["묶음 소계 표 (읽기용 — 고치기는 위 편집기)", "Group subtotals (read-only)"],
Ho_Title: ["일위대가 호표", "Unit-cost table"],
Formula_Title: ["텍스트 수식", "Text formula"],
Formula_Slot: ["시험 계산을 누르면 식이 글로 나옴", "Run the trial calc to see the formula"],
@@ -267,3 +267,66 @@ export const previewLogicFormula = (
로직: LogicRow,
별칭: FormulaAlias[],
): Promise<{ logic: LogicRow }> => request("/logic/formula/preview", { , });
/* ── 식으로 새 로직 만들기(계약 7장) ── */
export interface DraftFind {
: string;
값칸: string;
조건: Record<string, string>;
}
/** 변수마다 정한 것 */
export interface DraftDecided {
무엇?: string;
단위?: string;
비목?: string;
요소?: string;
찾기?: DraftFind;
값?: string;
설명?: string;
}
export interface DraftVar {
이름: string;
처음: number;
: "정의됨" | "미정" | "비목합";
정의줄: number | null;
쓰임: number[];
무엇: string | null;
단위: string | null;
비목: string | null;
정할것: string[];
}
export interface DraftProblem {
: number | null;
자리: number | null;
갈래: string;
: string;
}
export interface DraftAnswer {
: { : number; 이름: string; : string; : number }[];
변수: DraftVar[];
결과: string;
문제: DraftProblem[];
다됨: boolean;
logic: LogicRow | null;
/** 저장했을 때만 */
file?: string;
version?: string;
key?: string;
}
export interface DraftHead {
이름: string;
결과단위: string;
원문번호?: string;
출처?: string;
비고?: string;
끝수?: string;
}
/** 검사·미리보기 · `save` 면 자체 로직(키 `GX`)으로 저장 — 문제가 있으면 422 */
export const draftLogic = (body: {
text: string;
decided: Record<string, DraftDecided>;
head?: DraftHead;
save?: boolean;
owner?: string;
}): Promise<DraftAnswer> => request("/logic/draft", body);
@@ -19,7 +19,8 @@ import { tx } from "./M01_MasterData_UI_Logic_Text";
export interface CalcContext {
/** 저장된 키 — 새 로직은 null */
savedKey: string | null;
file: string;
/** 저장 전 새 초안이면 비움 — 서버가 임시 자리에 얹어 셈 */
file?: string;
row: LogicRow;
dirty: () => boolean;
/** 로직마다 넣은 값 — 다시 그려도 남음 */
+6 -4
View File
@@ -142,16 +142,18 @@
`POST /logic/draft` 받음 `{text, decided, head?, save?, owner?}` — 줄로 적은 식을 받아 변수를 뽑고 검사한 뒤 로직 한 줄로 옮김(해석기 `scripts/master_draft.py`).
- `text` = 줄마다 「이름 = 식」 · 빈 줄과 `#` 는 건너뜀 · **마지막 줄 왼쪽이 결과 변수**. 식 글자는 `_틀.md` 8장 범위만.
- `text` = 줄마다 「이름 = 식」 **또는 「식 = 이름」**(`A * B = C`) · 한쪽이 값 이름 하나면 그쪽이 이름 · 양쪽이 다 식이면 그 줄의 등호 자리를 짚어 `못읽음`. 괄호 안 등호(`찾기(…, 뒷길이=높이)`)와 비교 기호(`==` `<=` `>=` `!=`)는 안 가름. 빈 줄과 `#` 는 건너뜀 · **마지막 줄의 값 이름이 결과 변수**. 식 글자는 `_틀.md` 8장 범위만.
- `decided` = 변수마다 정한 것 `{이름: {무엇, 단위, 비목, 요소 | 찾기 | 값, 설명 · 고르기 · 범위}}`.
- `무엇``마스터요소`(키 또는 재료 고르기 조건을 `요소` 에 · `비목` 도 정함) · `설계입력`(입력 칸이 됨) · `고정값`(`값` 을 중간으로) · `표찾기`(`찾기` = `{표, 값칸, 조건}``찾기(…).값칸` 을 중간으로).
- `단위` — 마스터요소는 그 줄의 단위(「인」 · 「포」)를 적음. 식에서는 단가라 `원/단위` 로 셈.
- **단위 없는 맨 수**(횟수 · 배율 · 비율)는 단위 칸에 `없음`(또는 `맨수` · `-` · `%` · 빈 글)을 골라 무차원으로 둠 — 「전용 4회」 를 「회」 로 두면 `원/회` 가 남아 덧셈 검사에 걸림. 칸 자체가 없으면 아직 안 정한 것(`정할것``단위`).
- 비목 합 이름(`노무비` · `재료비` · `경비` · `계`)은 정하지 않음 — 덧줄에서만 쓰는 이름.
- `head` = 로직 머리(`이름` · `결과단위` · `원문번호` · `출처` · `비고` · `끝수`). 없으면 검사만 함.
-`{줄, 변수, 결과, 문제, 다됨, logic}``logic` = 옮긴 로직 한 줄(`head` 가 있을 때만 · 저장 전).
- `변수` = `{이름, 처음, 갈래, 정의줄, 쓰임, 무엇, 단위, 비목, 정할것}` · `갈래` = `정의됨`(왼쪽에 나옴) · `미정`(쓰기만 함) · `비목합` · `정할것` = 아직 안 정한 칸 이름들.
- `문제` = `{줄, 자리, 갈래, 말}` · `자리` = 그 줄의 글자 자리(모르면 null) · `갈래` = `못읽음` · `이름` · `두번정의` · `돌고돎` · `안쓰임`(= 결과가 하나인지) · `단위` · `덜정함` · `항` · `비목합`.
- 단위 검사 — 곱셈은 단위를 곱하고 나눗셈은 나눔 · 덧셈·뺄셈과 `만약`·`최소`·`최대`·비교는 단위가 같아야 함. 「m \* m」 과 「㎡」 는 같게 봄. 모르는 단위는 안 따짐.
- `변수` = `{이름, 처음, 갈래, 정의줄, 쓰임, 무엇, 단위, 비목, 정할것}` · `갈래` = `정의됨`(식이 있음) · `미정`(쓰기만 함) · `비목합` · `정할것` = 아직 안 정한 칸 이름들. `단위` 는 그 값이 **식에서** 가지는 단위(무차원은 빈 글).
- `문제` = `{줄, 자리, 갈래, 말}` · `자리` = 그 줄의 글자 자리(모르면 null) · `갈래` = `못읽음` · `이름` · `두번정의` · `돌고돎` · `안쓰임`(= 결과가 하나인지) · `단위` · `덜정함` · `항` · `비목` · `비목합`.
- 단위 검사 — 곱셈은 단위를 곱하고 나눗셈은 나눔(같은 단위끼리 나누면 맨 수) · 덧셈·뺄셈과 `만약`·`최소`·`최대`는 단위가 같아야 함 · 비교는 한쪽이 맨 수면 안 따짐(`높이 <= 3`). 「m \* m」 과 「㎡」 는 같게 봄. 모르는 단위는 안 따짐.
- **덧줄이 될 값**(결과 식의 항 가운데 마스터요소가 없는 것)은 막지 않고 그 값의 `정할것``비목` 을 얹음 — 화면은 「비목을 골라 주세요」 로 보임.
- `save` = true 면 `POST /logic/new` 와 같은 자리에 저장(키 `GX` 는 서버 · 소유 `owner`) → 답에 `{file, version, key, logic}` 이 더 옴. 문제가 하나라도 있으면 422 `{detail: {errors, 문제}}` 로 아무것도 안 씀.
- 옮기는 법 — 돈 로직(`결과단위` 가 「원」 으로 시작)은 결과 식을 `+` 로 갈라 항마다: 마스터요소 변수 하나면 **호표 줄**(나머지 곱이 `수량`) · 비목만 정한 변수면 **덧줄** · 그 밖은 문제 `항`. 나머지 정의 줄은 `중간`(쓰는 차례대로). 돈 아닌 로직은 호표 없이 `중간` + `결과` 식.
- 표 찾기는 글자로 치지 않음 — 변수 하나로 두고 `무엇``표찾기` 로 정할 때 모달이 `찾기` 묶음을 채움.
+41 -39
View File
@@ -4,6 +4,8 @@
판정: 걷음 = 부르는 곳 없음 · 바꿈 = 부르는 곳 있음(새 마스터 읽기로 바꾼 뒤 걷음).
「새 마스터 후보」= 이름만 대응 확인 · 구조 비교 안 함 · 실제 코드는 안 고침.
## 걷음 (부르는 곳 없음)
| 파일 | 읽는 것 | 부르는 곳 | 판정 |
@@ -14,54 +16,54 @@
## 바꿈 — Z01
| 파일 | old/ref 에서 읽는 것 | 부르는 곳 |
|---|---|---|
| Z01_MasterData/Z01_MasterData_Tables.py | old/ 화면 이름표 | Z01 BasePrices · BasePrices_Machine · Router · WorkItems · test_z01_master_data |
| Z01_MasterData/Z01_MasterData_BasePrices_Machine.py | old/ 기계 파일 | Z01 BasePrices |
| Z01_MasterData/Z01_MasterData_WorkItems.py | old/ 공종 마스터 · 연결 | Z01 BasePrices · Router · test_z01_work_items · test_z01_base_tables |
| 파일 | old/ref 에서 읽는 것 | 부르는 곳 | 새 마스터 후보 |
|---|---|---|---|
| Z01_MasterData/Z01_MasterData_Tables.py | old/ 화면 이름표(4_화면_이름표) | Z01 BasePrices · BasePrices_Machine · Router · WorkItems · test_z01_master_data | 없음(공종 계열) |
| Z01_MasterData/Z01_MasterData_BasePrices_Machine.py | old/ 기계 파일(B09 MachineCost·MachineOperating 통해) | Z01 BasePrices | B09 MachineCost·MachineOperating 판단 따름 |
| Z01_MasterData/Z01_MasterData_WorkItems.py | old/ 공종 마스터 · 열쇠대장 · 연결 | Z01 BasePrices · Router · test_z01_work_items · test_z01_base_tables | 없음(공종 마스터 계열 자체가 새 마스터에 아직 없음) |
## 바꿈 — B08
| 파일 | old/ref 에서 읽는 것 | 부르는 곳 |
|---|---|---|
| B08_Quantity_Build_WorkItemMaster.py | old/3_품셈_산림_표전체 (읽고 old/ 에 씀) | WorkItemMaster_Const · _Table · 시험 6 |
| B08_Quantity_Build_WorkItemMaster_Const.py | old/3_품셈_건설_표전체 · work_item_keys_const.json | WorkItemMaster · test_work_item_master_const |
| B08_Quantity_Build_WorkItemMaster_Keys.py | old/ 열쇠대장 | WorkItemMaster · _Const · Z01 WorkItems · 시험 3 |
| B08_Quantity_Engine_BasisUnit.py | old/ 기준 원단위 | Handoff · Handoff_Rows · test_b08_basis_unit |
| B08_Quantity_Engine_Formwork.py | old/ 거푸집 사용횟수 | Handoff_Mapping · UnitQuantity · 시험 3 |
| B08_Quantity_Engine_Handoff_Mapping.py | ref/ 수량 연결 · old/ 철근 · 돌쌓기 · 목재 갈래 | Handoff 계열 7 · RockSplit · Provenance |
| B08_Quantity_Engine_MaterialSummary.py | ref/ 재료 할증 | Handoff_Boundaries · Provenance · Router_Material · 시험 다수 |
| B08_Quantity_Engine_ObservedUnit.py | ref/ 구조물 원단위 | StructureFigure · UnitQuantity · UnitQuantity_Base · 시험 다수 |
| B08_Quantity_Engine_Pipe.py | ref/ 사방 기슭막이 치수 | StructureFigure · StructureSummary · Router_Material · B06_Section_Structure_Layouts.ts · 시험 다수 |
| B08_Quantity_Engine_UnitQuantity_StoneSpec.py | ref/ 돌 종류 · old/ 돌쌓기 경사 | ObservedUnit · StructureTemplate · UnitQuantity · _Masonry · common_util_masonry_slope.ts · 시험 3 |
| 파일 | old/ref 에서 읽는 것 | 부르는 곳 | 새 마스터 후보 |
|---|---|---|---|
| B08_Quantity_Build_WorkItemMaster.py | old/3_품셈_산림_표전체 (읽고 old/ 에 씀) | WorkItemMaster_Const · _Table · 시험 6 | 로직_산림품셈_*·계수_산림품셈_*·소요량_산림품셈_* 전 장 — 짜맞추기 새로 필요, 단순 대응 아님 |
| B08_Quantity_Build_WorkItemMaster_Const.py | old/3_품셈_건설_표전체 · work_item_keys_const.json | WorkItemMaster · test_work_item_master_const | 로직_건설품셈_*·계수_건설품셈_*·소요량_건설품셈_* 전 장 — 짜맞추기 새로 필요, 단순 대응 아님 |
| B08_Quantity_Build_WorkItemMaster_Keys.py | old/ 열쇠대장 | WorkItemMaster · _Const · Z01 WorkItems · 시험 3 | 없음(공종 마스터 계열) |
| B08_Quantity_Engine_BasisUnit.py | old/ 기준 원단위 | Handoff · Handoff_Rows · test_b08_basis_unit | 후보 낱개 확인 필요(소요량_*.json 장 미확정) |
| B08_Quantity_Engine_Formwork.py | old/ 거푸집 사용횟수(1-7-1) | Handoff_Mapping · UnitQuantity · 시험 3 | 계수_산림품셈_01장_적용기준.json |
| B08_Quantity_Engine_Handoff_Mapping.py | ref/ 수량 연결 · old/ 철근 · 돌쌓기 · 목재 갈래 | Handoff 계열 7 · RockSplit · Provenance | old 부분: 계수_산림품셈_12·13장·로직_산림품셈_12·13장 / ref 수량연결은 공종_수량연결_실무 그대로 |
| B08_Quantity_Engine_MaterialSummary.py | ref/ 재료 할증 | Handoff_Boundaries · Provenance · Router_Material · 시험 다수 | 없음(재료_품셈재료.json 구조 확인 안 됨) |
| B08_Quantity_Engine_ObservedUnit.py | ref/ 구조물 원단위 | StructureFigure · UnitQuantity · UnitQuantity_Base · 시험 다수 | 없음(기술기준 원자료 — 별도 마스터 미확보) |
| B08_Quantity_Engine_Pipe.py | ref/ 사방 기슭막이 치수 | StructureFigure · StructureSummary · Router_Material · B06_Section_Structure_Layouts.ts · 시험 다수 | 없음(사방기술교본 원자료 — 별도 마스터 미확보) |
| B08_Quantity_Engine_UnitQuantity_StoneSpec.py | ref/ 돌 종류 · old/ 돌쌓기 경사 | ObservedUnit · StructureTemplate · UnitQuantity · _Masonry · common_util_masonry_slope.ts · 시험 3 | old 부분: 계수_산림품셈_13장_구조물.json / 돌 종류(ref)는 후보 없음 |
## 바꿈 — B09
| 파일 | old/ref 에서 읽는 것 | 부르는 곳 |
|---|---|---|
| B09_Estimation_BasisSheet.py | old/_manifest.json · 마스터 | Provenance_Sources · Router · 시험 3 |
| B09_Estimation_LaborSurcharge.py | old/3_품셈_산림_계수 | BasisSheet · Router · Router_Factors · UnitPrice · 시험 2 |
| B09_Estimation_MachineCost.py | old/ 기계 카탈로그 | Lists · Lists_Sources · MachineExpenseSheet · MachineOperating · MachineProductivity 계열 |
| B09_Estimation_MachineExpenseSheet.py | old/ 기계 카탈로그 | Provenance · Router · 시험 4 |
| B09_Estimation_MachineOperating.py | old/ 기계 카탈로그 (읽고 old/ 에 씀) | Consumables · Lists_Sources · UnitPrice · UnitPrice_View · WoodChipping · 시험 다수 |
| B09_Estimation_MachineProductivity_Data.py | old/ 작업량 | MachineProductivity_Dozer · _Dump |
| B09_Estimation_MaterialCatalog.py | old/ 재료 카탈로그 | MaterialSheet · ResourceAxis_Sources · UnitPrice_View |
| B09_Estimation_MaterialPrices.py | old/재료_나라장터자재.json | BillOfQuantities 계열 · Router 계열 · UnitPrice · test_b09_material_prices |
| B09_Estimation_Rates.py | old/ 제비율 | CostSheet · Engine_Cost · Provenance · RateOverride · RateTable · Router 계열 |
| B09_Estimation_ResourceAxis.py | old/ 공종 마스터 · 카탈로그 | B08 Variants · BillOfQuantities · 공종 계산 모듈 다수 |
| B09_Estimation_ResourceAxis_Join.py | old/ 자원줄 | MaterialPrices · ResourceAxis · _ChooseOne · _JudgedTable · _Sources · 시험 2 |
| B09_Estimation_ResourceAxis_Sources.py | old/ 마스터 · 카탈로그 | B08 Variants · ResourceAxis · _Join · 시험 2 |
| B09_Estimation_UnitPrice.py | old/ 각종 (파일명 인자) | B08 Variants · StructurePriceLink · Router 계열 · BillOfQuantities 계열 |
| 파일 | old/ref 에서 읽는 것 | 부르는 곳 | 새 마스터 후보 |
|---|---|---|---|
| B09_Estimation_BasisSheet.py | old/_manifest.json · 마스터 | Provenance_Sources · Router · 시험 3 | 후보 낱개 확인 필요(_원문목록.json·_키대장.json 구조 다름) |
| B09_Estimation_LaborSurcharge.py | old/3_품셈_산림_계수 | BasisSheet · Router · Router_Factors · UnitPrice · 시험 2 | 계수_산림품셈_*.json(장별) |
| B09_Estimation_MachineCost.py | old/ 기계경비기준(3_품셈_건설_기계경비기준_8장) | Lists · Lists_Sources · MachineExpenseSheet · MachineOperating · MachineProductivity 계열 | 계수_건설품셈_08장_건설기계.json |
| B09_Estimation_MachineExpenseSheet.py | old/ 기계 카탈로그(MachineCost 통해) | Provenance · Router · 시험 4 | MachineCost 판단 따름 |
| B09_Estimation_MachineOperating.py | old/ 기계운전경비(3_품셈_건설_기계운전경비_8-4, 읽고 old/ 에 씀) | Consumables · Lists_Sources · UnitPrice · UnitPrice_View · WoodChipping · 시험 다수 | 로직_건설품셈_08장_건설기계.json |
| B09_Estimation_MachineProductivity_Data.py | old/ 작업량(3_품셈_산림건설_기계작업량) | MachineProductivity_Dozer · _Dump | 후보 없음(장별 미확정) |
| B09_Estimation_MaterialCatalog.py | old/ 재료 카탈로그 | MaterialSheet · ResourceAxis_Sources · UnitPrice_View | 재료_자재품목.json·재료_품셈재료.json |
| B09_Estimation_MaterialPrices.py | old/재료_나라장터자재.json | BillOfQuantities 계열 · Router 계열 · UnitPrice · test_b09_material_prices | 재료_자재품목.json(이름 직접 대응) |
| B09_Estimation_Rates.py | old/ 제비율 | CostSheet · Engine_Cost · Provenance · RateOverride · RateTable · Router 계열 | 요율_조달청제비율.json(이름 직접 대응) |
| B09_Estimation_ResourceAxis.py | old/ 공종 마스터 · 카탈로그 | B08 Variants · BillOfQuantities · 공종 계산 모듈 다수 | 공종 부분 후보 없음 — 절반 블록 |
| B09_Estimation_ResourceAxis_Join.py | old/ 자원줄 | MaterialPrices · ResourceAxis · _ChooseOne · _JudgedTable · _Sources · 시험 2 | 없음(공종 마스터 계열) |
| B09_Estimation_ResourceAxis_Sources.py | old/ 마스터 · 카탈로그 | B08 Variants · ResourceAxis · _Join · 시험 2 | ResourceAxis.py 판단 따름 |
| B09_Estimation_UnitPrice.py | old/ 각종 (파일명 인자) | B08 Variants · StructurePriceLink · Router 계열 · BillOfQuantities 계열 | 공용 로더 — 낱개 판단 필요(특정 불가) |
## 바꿈 — common_util · 화면
| 파일 | old/ref 에서 읽는 것 | 부르는 곳 |
|---|---|---|
| common_util/common_util_node_bundle.py | old/ 통째(SOURCE_DATA_DIRS) | B05 Corridor_Prebuild · B06 Router_HaulPlan · B06 Server_Calc_Prebuild · B08 Engine_Formula |
| common_util/common_util_work_item_key.py | old/ 열쇠대장 | B08 Handoff · B09 BillOfQuantities_Input · 시험 2 |
| common_util/common_util_work_item_link.py | old/ 연결 | B09 BillOfQuantities · _Input · test_work_item_link_choice |
| B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts | ref/수량_사방기슭막이치수 (import) | B05 화면 |
| B06_Section/B06_Section_UI_Cross_Lean.ts | old/3_품셈_산림_돌쌓기경사 (import) | B06 화면 |
| 파일 | old/ref 에서 읽는 것 | 부르는 곳 | 새 마스터 후보 |
|---|---|---|---|
| common_util/common_util_node_bundle.py | old/ 통째(SOURCE_DATA_DIRS) | B05 Corridor_Prebuild · B06 Router_HaulPlan · B06 Server_Calc_Prebuild · B08 Engine_Formula | 없음 — 전체 재설계 필요(개별 대응 불가) |
| common_util/common_util_work_item_key.py | old/ 열쇠대장 | B08 Handoff · B09 BillOfQuantities_Input · 시험 2 | 없음(공종 마스터 계열) |
| common_util/common_util_work_item_link.py | old/ 연결 | B09 BillOfQuantities · _Input · test_work_item_link_choice | 없음(공종 마스터 계열) |
| B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts | ref/수량_사방기슭막이치수 (import) | B05 화면 | B08 Engine_Pipe.py 판단 따름(후보 없음) |
| B06_Section/B06_Section_UI_Cross_Lean.ts | old/3_품셈_산림_돌쌓기경사 (import) | B06 화면 | 계수_산림품셈_13장_구조물.json(StoneSpec.py 와 같음) |
## 바꿈 — 시험
@@ -0,0 +1,35 @@
# 로직 개선 시험 — 사용자 경로 검증 (PLAN 3 마감)
ORCA · 포트 8006/5179 · 넓은 화면 928px · 좁은 화면 390px. 고치지 않고 적기만 함.
## ① 정본 로직 다섯
컨테이너 넷(기본정보 · 호표 · 텍스트 수식 · 시험 계산) 모두 있음. 기본정보는 읽기 전용, 비고만 고쳐짐. 호표에 [줄 더하기] 없음.
| 로직 | 호표 소계 합 | 계 | 모달 |
|---|---|---|---|
| 13-4-1 GF000219 (뒷길이 45 · 호박돌 · 켜쌓기 · 높이 2) | 인력 100,672.2 | 100,672.2 | 표 2개 |
| 12-4 GF000160 | 인력 82,948.3992 + 자재 36,163.252 | 119,111.6512 | 표 + 노란 줄 |
| 12-17-1 GF000182 | 6,615.989 + 438.8951 + 6,400.8296 | 13,455.7136 (합 13,455.7137) | 표 |
| 9-16-1 GF000119 | 그 밖 4,522.9569 | 4,522.9569 | 「다른 로직을 부름」 글만 · 표 없음 |
| 10-4 GF000137 | 그 밖 85,970.6407 | 85,970.6407 | 「다른 로직을 부름」 글만 · 표 없음 |
## ② 만들기
- [식으로 새로 만들기] : 보기 글 → [분석] 8개 변수 → 보통인부 마스터 요소 · 길이·폭 설계 입력 · 품 고정값 · 잡재료비 덧줄 비목 → 이름·단위 → 시험 계산 길이 2 · 폭 3 → 계 162,604.26 → 저장 → 자체 로직 GX000001 상세가 열림(저장 뒤 약 10초). 지움까지 됨.
- 막힘 셋 걸림 : 「길이 + 넓이」 단위 · 물고 돎 · 「가」 두 번 정의. 자리 표시 · 저장 단추 막힘.
- [새로 만들기] · [본떠 만들기] : 「로직 개선 시험」 화면에 [식으로 새로 만들기]만 있고 [본떠 만들기] 단추가 없음(글 `Edit_Copy` 만 있고 단추 코드 없음) → 정본을 본떠 한 줄 고쳐 저장하는 길은 못 걸어 봄.
## ③ 막히거나 어색한 자리
1. 본떠 만들기 단추 없음(PLAN 3-4 미착수) — 정본에서 자체 로직으로 가는 길이 화면에 없음.
2. 13-4-1 시험 계산 기본 선택(25 · 견치돌 · 골쌓기)이 「맞는 줄 0개」로 멈춤. 비고에 「원문에 없는 조합」 안내가 있으나 계산 화면에서는 안 보임. 첫 번째 선택으로 바로 계산되는 조합을 기본으로 두거나 멈춘 글에 안내 필요.
3. 13-4-1 모달 : 시험 계산 뒤에도 「걸린 줄을 아직 못 맞힘」 안내가 뜨고 강조 줄이 0개(석공 줄 · 계산 후 눌렀을 때).
4. 호표 줄 「표 찾기 찾기(QF000421 …」 — 「찾기」가 겹쳐 보임.
5. 호표 묶음 소계와 텍스트 수식 소계가 다름 : 12-17-1 호표 인력 6,615.989 · 경비 438.8951 ↔ 텍스트 노무비 소계 8,777.9014 · 경비 3,098.2357(호표는 줄 종류별 · 텍스트는 비목별). 합(계)은 같음. 같은 「소계」 이름이라 헷갈림.
6. 호표 묶음이 인력·자재·경비에 「그 밖」이 더해져 네 묶음 : 12-17-1 · 9-16-1 · 10-4. 9-16-1 · 10-4 는 인력·자재·경비 세 묶음이 모두 「줄 없음」이고 기계 줄이 「그 밖」 한 줄.
7. 자체 로직(GX) 상세는 옛 편집 꼴(설계에서 받을 값 · 호표 · 중간 값 · 덧줄 · [줄 더하기])이고 네 컨테이너 꼴이 아님 — 정본과 화면이 다름.
8. 새로 만들기 보기 글은 변수 8개를 다 골라야 저장됨(첫 사용자에게 많음). 저장에 약 10초 걸림 · 불러오는 표시만 뜸.
9. 좁은 화면 390px : 상세 화면 폭 안(가로 스크롤 없음). 모달은 iframe 조작으로 열리지 않아 폭을 못 잼.
시험 흔적 : GX000001 지움 · 키 대장 되돌림 · 자체 로직 파일 지움.
+96 -29
View File
@@ -1,10 +1,10 @@
# -*- coding: utf-8 -*-
"""줄로 적은 식 → 변수 뽑기 · 검사 · 단위 맞춤 · 로직 틀로 옮기기 (`_화면_계약.md` 7장).
"""줄로 적은 식 → 변수 뽑기 · 검사 · 단위 맞춤 · 로직 틀로 옮기기 (`_화면_계약.md` 8장).
수량 = 길이 * 폭
자재비 = 수량 * 시멘트
· 줄마다 「이름 = 식」 · 빈 줄과 `#` 는 건너뜀 · 마지막 줄 왼쪽이 결과 변수
· 줄마다 「이름 = 식」 또는 「식 = 이름」 · 빈 줄과 `#` 는 건너뜀 · 마지막 줄의 값 이름이 결과 변수
· 식 글자는 `_틀.md` 8장 범위만 — 읽기는 `master_formula` 가 함
· 표 찾기는 글자로 치지 않음 — 변수 하나로 두고 「무엇」 을 `표찾기` 로 정할 때 이음
· 값은 안 씀 — 검사만 하고 `옮기기` 가 로직 한 줄을 만듦(저장은 `Store_Make.logic_new`)
@@ -32,8 +32,9 @@ ALIAS = {
"M": ("m", 1),
}
SQUARED = {2: "", 3: ""}
DIMLESS = ("%", "") # 비율은 단위가 없음 — 「1 + 증가율 / 100」 이 안 걸리게
_ASSIGN = re.compile(r"^\s*([\w가-힣]+)\s*=(?!=)\s*(.*)$")
# 단위가 없는 맨 수 — 횟수 · 배율 · 비율. 빈 글도 같음(「없음」 을 고른 것과 같게 봄)
DIMLESS = ("%", "", "없음", "맨수", "맨 수", "-")
_NAME_ONLY = re.compile(r"^[\w가-힣]+$")
# ── 자리를 적어 두는 읽기 ──────────────────────────────────────────────
@@ -224,18 +225,51 @@ def find_text(pick: dict) -> str:
# ── 읽기 · 검사 ───────────────────────────────────────────────────────
def _is_name(text: str) -> bool:
"""값 이름 하나 — 맨 수는 이름이 아님."""
return bool(_NAME_ONLY.fullmatch(text)) and not mf._NUMBER.fullmatch(text)
def _split(raw: str) -> int:
"""줄을 가를 등호 자리 — 괄호 안과 비교 기호(`==` `<=` `>=` `!=`)는 건너뜀 · 없으면 -1."""
depth = 0
for i, ch in enumerate(raw):
if ch in "([{":
depth += 1
elif ch in ")]}":
depth -= 1
elif ch == "=" and depth == 0:
if raw[i - 1 : i] in ("<", ">", "!", "=") or raw[i + 1 : i + 2] == "=":
continue
return i
return -1
def _cut(text: str):
"""(줄 번호, 왼쪽 이름, 식, 식이 시작하는 자리) — 빈 줄과 `#` 는 건너뜀."""
"""(줄 번호, 이름, 식, 식이 시작하는 자리, 까닭) — 빈 줄과 `#` 는 건너뜀.
「이름 = 식」 과 「식 = 이름」 을 다 받음 — 한쪽이 값 이름 하나면 그쪽이 이름.
"""
for at, raw in enumerate(str(text).split("\n"), 1):
if not raw.strip() or raw.lstrip().startswith("#"):
continue
m = _ASSIGN.match(raw)
yield (
at,
(m.group(1) if m else ""),
(m.group(2).strip() if m else raw.strip()),
(m.start(2) if m else len(raw) - len(raw.lstrip())),
)
cut = _split(raw)
if cut < 0:
yield (
at,
"",
raw.strip(),
len(raw) - len(raw.lstrip()),
"「이름 = 식」 이 아님 — 등호가 없음",
)
continue
left, right = raw[:cut], raw[cut + 1 :]
if _is_name(left.strip()):
yield at, left.strip(), right.strip(), cut + 1 + len(right) - len(right.lstrip()), ""
elif _is_name(right.strip()): # 「A * B = C」 — 오른쪽이 이름
yield at, right.strip(), left.strip(), len(left) - len(left.lstrip()), ""
else:
yield at, "", raw.strip(), cut, "양쪽이 다 식 — 한쪽은 값 이름 하나여야 함"
def _order(defined: dict, deps: dict) -> list[str]:
@@ -255,14 +289,14 @@ def _order(defined: dict, deps: dict) -> list[str]:
return out
def _scan(text: str, 정함: dict | None = None) -> dict:
def _scan(text: str, 정함: dict | None = None, 머리: dict | None = None) -> dict:
"""줄 가르기 · 변수 · 검사 · 단위 — `read` 와 `옮기기` 가 같이 씀(나무를 들고 있음)."""
정함 = 정함 or {}
lines, 문제 = [], []
for at, name, body, head in _cut(text):
for at, name, body, head, why in _cut(text):
one = {"": at, "이름": name, "": body, "": head}
if not name:
문제.append(_trouble(at, head, "못읽음", "「이름 = 식」 이 아님"))
if why:
문제.append(_trouble(at, head, "못읽음", why))
elif not body:
문제.append(_trouble(at, head, "못읽음", f"{name}」 오른쪽이 빔"))
else:
@@ -343,6 +377,7 @@ def _scan(text: str, 정함: dict | None = None) -> dict:
got = _unit(one["나무"], units, one[""], one[""], 문제)
units[name] = pinned if pinned is not None else got
덧줄후보 = _extra_names(by, 결과, 정함, 머리) if 결과 in by and 결과 not in loops else set()
변수 = []
for name in sorted(
set(쓰임) | set(정의), key=lambda n: (min([쓰임.get(n, [10**9])[0], 정의.get(n, 10**9)]), n)
@@ -359,16 +394,18 @@ def _scan(text: str, 정함: dict | None = None) -> dict:
"무엇": spec.get("무엇") if 갈래 == "미정" else None,
"단위": unit_text(units.get(name)),
"비목": spec.get("비목"),
"정할것": _todo(갈래, spec, units.get(name)),
"정할것": _todo(갈래, spec, units.get(name), name in 덧줄후보),
}
)
return {"": lines, "변수": 변수, "결과": 결과, "문제": 문제, "차례": order, "정의": 정의}
def _todo(갈래: str, spec: dict, unit) -> list[str]:
def _todo(갈래: str, spec: dict, unit, 덧줄: bool = False) -> list[str]:
"""그 변수에 아직 안 정한 것 — 무엇 · 요소·찾기·값 · 단위 · 비목."""
if 갈래 != "미정":
if 갈래 == "비목합":
return []
if 갈래 == "정의됨": # 식이 있는 값 — 덧줄이 될 항이면 비목만 고르면 됨
return ["비목"] if 덧줄 and spec.get("비목") not in COST_ITEMS else []
what, need = spec.get("무엇"), []
if what not in WHAT:
need.append("무엇")
@@ -386,9 +423,9 @@ def _todo(갈래: str, spec: dict, unit) -> list[str]:
return need
def read(text: str, 정함: dict | None = None) -> dict:
def read(text: str, 정함: dict | None = None, 머리: dict | None = None) -> dict:
"""줄로 적은 식 → `{줄, 변수, 결과, 문제, 다됨}` — 화면이 그대로 받는 모양(나무는 뺌)."""
got = _scan(text, 정함)
got = _scan(text, 정함, 머리)
다됨 = not got["문제"] and not any(v["정할것"] for v in got["변수"])
return {
"": [{k: v for k, v in one.items() if k != "나무"} for one in got[""]],
@@ -408,22 +445,40 @@ def _factors(node) -> list:
return _factors(node[2]) + _factors(node[3]) if node[0] == "bin" and node[1] == "*" else [node]
def _spread(node, by: dict, 정함: dict, : set) -> list:
"""결과 항 — 항이 이름 하나이고 비목을 안 정했으면 그 식으로 펼침.
def _spread(node, by: dict, 정함: dict, : set, 뿌리: str = "") -> list:
"""결과 항 — (항, 펼치기 전 이름). 항이 이름 하나이고 비목을 안 정했으면 그 식으로 펼침.
호표 줄을 항 안에서 찾으려는 것.
호표 줄을 항 안에서 찾으려는 것 · 못 펼친 항의 `뿌리` 가 덧줄이 될 값 이름.
"""
out = []
for term in _terms(node):
name = term[1] if term[0] == "name" else ""
if name in by and name not in and not (정함.get(name) or {}).get("비목"):
.add(name)
out += _spread(by[name]["나무"], by, 정함, )
out += _spread(by[name]["나무"], by, 정함, , name)
else:
out.append(term)
out.append((term, 뿌리))
return out
def _has_element(term, 정함: dict) -> bool:
return any(
f[0] == "name" and (정함.get(f[1]) or {}).get("무엇") == "마스터요소"
for f in _factors(term)
)
def _extra_names(by: dict, 결과: str, 정함: dict, 머리: dict | None) -> set[str]:
"""결과 식의 항 가운데 마스터요소가 없어 덧줄이 될 값 이름 — 비목을 골라야 함."""
if 머리 is not None and not str(머리.get("결과단위") or "").startswith(""):
return set() # 돈 아닌 로직은 덧줄이 없음
return {
뿌리
for term, 뿌리 in _spread(by[결과]["나무"], by, 정함, set())
if 뿌리 and not _has_element(term, 정함)
}
def _kind_of(요소) -> str:
if isinstance(요소, dict):
return "재료"
@@ -447,13 +502,16 @@ def 옮기기(text: str, 정함: dict | None = None, 머리: dict | None = None)
돈 아닌 로직은 호표 없이 `중간` + `결과` 식.
"""
정함, 머리 = 정함 or {}, dict(머리 or {})
got = _scan(text, 정함)
got = _scan(text, 정함, 머리)
문제 = list(got["문제"])
for v in got["변수"]:
if v["정할것"]:
문제.append(
_trouble(
v["처음"], 0, "덜정함", f"{v['이름']}」 에 {' · '.join(v['정할것'])} 안 정함"
v["처음"],
0,
"덜정함",
f"{v['이름']}」 에 아직 안 정한 것 — {' · '.join(v['정할것'])}",
)
)
if 문제:
@@ -475,7 +533,7 @@ def 옮기기(text: str, 정함: dict | None = None, 머리: dict | None = None)
덧줄이름, = set(), set()
if :
for term in _spread(by[결과]["나무"], by, 정함, ):
for term, 뿌리 in _spread(by[결과]["나무"], by, 정함, ):
facts = _factors(term)
picks = [
f
@@ -520,6 +578,15 @@ def 옮기기(text: str, 정함: dict | None = None, 머리: dict | None = None)
{"이름": name, "": _expr(by[name]["나무"]), "비목": 정함[name]["비목"]}
)
덧줄이름.add(name)
elif 뿌리 in by: # 덧줄이 될 값 — 막지 않고 비목만 짚음
문제.append(
_trouble(
by[뿌리][""],
0,
"비목",
f"덧줄 「{뿌리}」 의 비목을 골라 주세요 — {' · '.join(COST_ITEMS)}",
)
)
else:
문제.append(
_trouble(
+6 -5
View File
@@ -375,11 +375,12 @@ def test_못_이은_줄만_거름(client: TestClient) -> None:
assert 0 < cut["total"] < every["total"] and not any(r["연결"] for r in cut["rows"])
def test_품셈재료_단가는_연결의_낮은_값과_출처(client: TestClient) -> None:
key = key_of("GC000628")
one = _get(client, "/api/m01/logic", key=key)
cement = one["prices"][key_of("MP:시멘트")]
assert cement[""] > 0 and cement["출처"].startswith("MT004176" + ".")
def test_재료_고르기_줄_단가는_대표_자재품목의_낮은_값(client: TestClient) -> None:
"""옛 「MP:시멘트」 줄이 고르기 조건+대표로 바뀜 — 값은 대표 자재품목 줄의 낮은 값."""
one = _get(client, "/api/m01/logic", key=key_of("GC000628"))
cond = {"구분": "시멘트·골재", "상세구분": "시멘트", "대표": "MT004176"}
cement = one["prices"][store.cm.mf.cond_text(cond)]
assert cement["ref"] == "MT004176" and cement[""] > 0 and cement["후보"] >= 1
def test_전력은_계약종별을_골라야_값이_섬(client: TestClient) -> None:
+64
View File
@@ -133,6 +133,31 @@ def test_만약과_최소는_갈래마다_단위가_같아야_한다() -> None:
assert md.read("값 = 최소(길이, 길이 * 2)", 정함)["문제"] == []
def test_단위_없음을_골라_맨_수로_다룬다() -> None:
"""횟수 · 배율 — 단위 칸에 「없음」 을 고르면 무차원(3-3 화면 흠 ①)."""
= {"금액": {"무엇": "설계입력", "단위": ""}, "전용": {"무엇": "설계입력", "단위": ""}}
got = md.read("몫 = 금액 / 전용\n계금액 = 몫 + 금액", )
assert _var(got, "")["단위"] == "원/회" # 「회」 로 두면 덧셈에서 걸림
assert [x["갈래"] for x in got["문제"]] == ["단위"]
맨수 = dict(, 전용={"무엇": "설계입력", "단위": "없음"})
깨끗 = md.read("몫 = 금액 / 전용\n계금액 = 몫 + 금액", 맨수)
assert _var(깨끗, "")["단위"] == "" and 깨끗["문제"] == []
assert _var(깨끗, "전용")["단위"] == "" and _var(깨끗, "전용")["정할것"] == []
for in ("", "맨수", "-", "%"):
= dict(, 전용={"무엇": "설계입력", "단위": })
assert md.read("몫 = 금액 / 전용\n계금액 = 몫 + 금액", )["문제"] == [],
없으면 = dict(, 전용={"무엇": "설계입력"}) # 단위 칸 자체가 없으면 아직 안 정한 것
assert _var(md.read("몫 = 금액 / 전용", 없으면), "전용")["정할것"] == ["단위"]
def test_나눗셈으로_단위가_없어진다() -> None:
정함 = {"길이": {"무엇": "설계입력", "단위": "m"}, "넓이": {"무엇": "설계입력", "단위": ""}}
assert _var(md.read("폭 = 넓이 / 길이", 정함), "")["단위"] == "m"
assert _var(md.read("비 = 길이 / 길이", 정함), "")["단위"] == "" # 같은 단위끼리 나누면 맨 수
assert md.read("합 = 길이 / 길이 + 2", 정함)["문제"] == []
# ── ③ 그 밖 검사 ──────────────────────────────────────────────────────
def test_서로_물고_도는_식을_잡는다() -> None:
kinds = [x["갈래"] for x in md.read(LOOP)["문제"]]
@@ -152,6 +177,29 @@ def test_두_번_정의_안_쓰임_결과_하나() -> None:
assert md.read("이건 식이 아님")["문제"][0]["갈래"] == "못읽음"
def test_식이_왼쪽에_와도_받는다() -> None:
"""「A * B = C」 로 적는 사용자도 있음(3-3 화면 흠 ②)."""
정함 = {"길이": {"무엇": "설계입력", "단위": "m"}, "": {"무엇": "설계입력", "단위": "m"}}
got = md.read("길이 * 폭 = 넓이", 정함)
assert got["문제"] == [] and got["결과"] == "넓이"
assert _var(got, "넓이")["갈래"] == "정의됨" and _var(got, "넓이")["단위"] == ""
assert got[""][0][""] == "길이 * 폭"
assert md.read("넓이 = 길이 * 폭", 정함)["변수"] == got["변수"] # 어느 쪽으로 적든 같음
양쪽 = md.read("길이 * 폭 = 넓이 * 2", 정함)["문제"]
assert 양쪽[0]["갈래"] == "못읽음" and "양쪽이 다 식" in 양쪽[0][""]
assert 양쪽[0]["자리"] == "길이 * 폭 = 넓이 * 2".index("=")
assert md.read("길이 * 폭 = 4", 정함)["문제"][0]["갈래"] == "못읽음" # 맨 수는 이름이 아님
def test_비교_기호와_찾기_안_등호는_안_가른다() -> None:
정함 = {"높이": {"무엇": "설계입력", "단위": "m"}}
assert md.read("값 = 만약(높이 <= 3, 1, 2)", 정함)["문제"] == []
assert md.read("값 = 만약(높이 == 3, 1, 2)", 정함)["문제"] == []
안쪽 = md.read("품 = 찾기(QF000421, 뒷길이=높이).석공", 정함)
assert 안쪽["문제"] == [] and 안쪽[""][0]["이름"] == ""
# ── ⑤ 로직 틀로 옮기기 ────────────────────────────────────────────────
def test_다_정하면_로직_틀로_옮긴다() -> None:
row, bad = md.옮기기(GOOD, GOOD_정함, GOOD_머리)
@@ -192,6 +240,22 @@ def test_덜_정했으면_안_옮긴다() -> None:
assert row == {} and any(x["갈래"] == "덜정함" for x in bad)
def test_비목_안_고른_덧줄은_막지_않고_짚어_준다() -> None:
"""「못 옮김」 으로 막지 않고 그 값에 「비목」 을 정할 것으로 얹음(3-3 화면 흠 ③)."""
빼기 = {k: v for k, v in GOOD_정함.items() if k != "잡재료비"}
got = md.read(GOOD, 빼기, GOOD_머리)
assert _var(got, "잡재료비")["정할것"] == ["비목"] and got["다됨"] is False
assert [x["갈래"] for x in got["문제"]] == [] # 검사에는 안 걸림 — 고를 것이 남았을 뿐
row, bad = md.옮기기(GOOD, 빼기, GOOD_머리)
assert row == {} and [x["갈래"] for x in bad] == ["덜정함"]
assert "잡재료비" in bad[0][""] and "비목" in bad[0][""]
assert _var(md.read(GOOD, GOOD_정함, GOOD_머리), "잡재료비")["정할것"] == []
안돈 = md.read(GOOD, 빼기, {"이름": "수량만", "결과단위": ""}) # 돈 아닌 로직은 덧줄이 없음
assert _var(안돈, "잡재료비")["정할것"] == []
# ── 서버 길 ───────────────────────────────────────────────────────────
def test_서버_길이_변수와_문제를_돌려_준다(client: TestClient) -> None:
got = _post(client, {"text": GOOD, "decided": GOOD_정함})