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:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
/** 로직마다 넣은 값 — 다시 그려도 남음 */
|
||||
|
||||
@@ -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, 문제}}` 로 아무것도 안 씀.
|
||||
- 옮기는 법 — 돈 로직(`결과단위` 가 「원」 으로 시작)은 결과 식을 `+` 로 갈라 항마다: 마스터요소 변수 하나면 **호표 줄**(나머지 곱이 `수량`) · 비목만 정한 변수면 **덧줄** · 그 밖은 문제 `항`. 나머지 정의 줄은 `중간`(쓰는 차례대로). 돈 아닌 로직은 호표 없이 `중간` + `결과` 식.
|
||||
- 표 찾기는 글자로 치지 않음 — 변수 하나로 두고 `무엇` 을 `표찾기` 로 정할 때 모달이 `찾기` 묶음을 채움.
|
||||
|
||||
@@ -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 지움 · 키 대장 되돌림 · 자체 로직 파일 지움.
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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_정함})
|
||||
|
||||
Reference in New Issue
Block a user