Merge remote-tracking branch 'origin/dev' into sub_laptop_4
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CalcAnswer, LogicRow } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { lbCalcLater, type LbTables } from "./M01_LB_Api";
|
||||
import { lbCalcNow, type LbTables } from "./M01_LB_Api";
|
||||
import { writeLogic, type LbLogic, type LbLookup } from "./M01_LB_Blocks";
|
||||
|
||||
type Cell = string | number | boolean | null | [number | null, number | null];
|
||||
@@ -212,20 +212,20 @@ type CalcFn = (
|
||||
tables: LbTables,
|
||||
) => Promise<CalcAnswer>;
|
||||
|
||||
/** LB Api 계산을 한 번 기다림 — 차례로 부르므로 「최신만」 에 안 걸림 */
|
||||
const calcOnce: CalcFn = (row, inputs, tables) =>
|
||||
new Promise((resolve, reject) =>
|
||||
lbCalcLater(row, inputs, tables, (ans, plain) =>
|
||||
ans ? resolve(ans) : reject(new Error(plain ?? "")),
|
||||
),
|
||||
);
|
||||
/** LB Api 바로 계산 — 화면 예시값 계산(0.3초 · 최신만)과 안 부딪힘 · 멈춤 까닭은 쉬운 말로 */
|
||||
const calcNow: CalcFn = async (row, inputs, tables) => {
|
||||
let plain = "";
|
||||
const ans = await lbCalcNow(row, inputs, tables, (p) => (plain = p));
|
||||
if (!ans) throw new Error(plain);
|
||||
return ans.ok ? ans : { ok: false, reason: plain || ans.reason };
|
||||
};
|
||||
|
||||
/** 줄마다 시험 계산(차례로 · 하나씩) — 로직은 블록을 글로 되돌린 줄 · `calc` 는 시험에서 바꿔 끼움 */
|
||||
export async function runExpect(
|
||||
lb: LbLogic,
|
||||
tables: LbTables,
|
||||
rows: ExpectRow[],
|
||||
calc: CalcFn = calcOnce,
|
||||
calc: CalcFn = calcNow,
|
||||
): Promise<ExpectResult[]> {
|
||||
const row = writeLogic(lb);
|
||||
const out: ExpectResult[] = [];
|
||||
|
||||
@@ -201,3 +201,42 @@
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* 결과 칸 — 빈틈 검사 · 기대값 시험표 */
|
||||
.m01-lb__check {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding-top: var(--spacing-8);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01-lb__check-tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-lb__check-out ul {
|
||||
margin: 0;
|
||||
padding-left: var(--spacing-16);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-lb__expect-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 96px auto auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-lb__expect-row .is-ok {
|
||||
color: var(--color-success, var(--color-text));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01-lb__expect-row .is-bad {
|
||||
color: var(--color-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/* =============================================================================
|
||||
* M01_LB_Entry.ts
|
||||
* 로직 만들기 판(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 세 칸 틀(원문 · 산출근거 · 결과) · 위 띠
|
||||
* 로직 만들기 판(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 세 칸(원문 · 산출근거 · 결과) · 위 띠 · 잇기
|
||||
* 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄).
|
||||
* 1걸음 = 틀 — 원문 · 산출근거 칸은 자리만 · 결과 칸은 EasyCalc 계산 · 일위대가표로 바로 보임.
|
||||
* 2걸음에 Blocks · Api · Source · Steps · Grid · Wizard 를 이음.
|
||||
* 잇기: 열기(openEasy) → readLogic → 원문(renderSource · 걸린 줄 lookupHits) · 산출근거(renderSteps / renderGrid)
|
||||
* · 결과(합계 · 예시값 · 일위대가표 · 빈틈 · 기대값 시험표) → 바뀔 때마다 기록 · lbCalcLater(고친 표 사본)
|
||||
* → 저장(현장 = newEasy · editEasy · writeLogic / 표준은 복사 편집 뒤) · 실행 취소 · Ctrl+Z · Ctrl+S · 단계 안내.
|
||||
* 표 칸 고침은 메모리 사본만 — 저장 안 함(정함 5).
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
@@ -21,14 +23,24 @@ import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
|
||||
import { formatNumber } from "../M01_MasterData_UI_Logic_Edit";
|
||||
import {
|
||||
copyEasy,
|
||||
deleteEasy,
|
||||
editEasy,
|
||||
listEasy,
|
||||
newEasy,
|
||||
openEasy,
|
||||
plainError,
|
||||
usesOf,
|
||||
type EasyListItem,
|
||||
} from "../EasyCalc/M01_EasyCalc_Api";
|
||||
import { renderSheet } from "../EasyCalc/M01_EasyCalc_Sheet";
|
||||
import { lbCalcLater, type LbTables } from "./M01_LB_Api";
|
||||
import { readLogic, type LbLogic } from "./M01_LB_Blocks";
|
||||
import { fetchLbTable, lbCalcLater, type LbTables } from "./M01_LB_Api";
|
||||
import { readLogic, setCond, writeLogic, type LbLogic, type TableEdit } from "./M01_LB_Blocks";
|
||||
import { lookupHits } from "./M01_LB_Check";
|
||||
import { drawExamples, renderChecks } from "./M01_LB_Entry_Result";
|
||||
import { renderGrid } from "./M01_LB_Grid";
|
||||
import { renderSource, type LbRole } from "./M01_LB_Source";
|
||||
import { renderSteps } from "./M01_LB_Steps";
|
||||
import { openWizard } from "./M01_LB_Wizard";
|
||||
import "./M01_LB_Entry.css";
|
||||
|
||||
const TEXT = {
|
||||
@@ -68,14 +80,23 @@ const TEXT = {
|
||||
View_Grid: ["격자", "Grid"],
|
||||
Money: ["{u} 만드는 데 {v} 원", "{v} won to make {u}"],
|
||||
Value: ["결과 {v} {u}", "Result {v} {u}"],
|
||||
Examples: ["예시값", "Example values"],
|
||||
Examples_Note: [
|
||||
"예시값은 계산을 보려는 값 — 저장 안 됨",
|
||||
"Example values are only for trying — not saved",
|
||||
],
|
||||
Pick: ["선택", "Choose"],
|
||||
Sheet: ["일위대가표", "Unit-cost sheet"],
|
||||
NotYet: ["준비 중", "Coming soon"],
|
||||
NoBlocks: ["이 로직은 블록으로 못 읽음 — 일위대가표만 봄", "This logic can't be shown as blocks"],
|
||||
Blocked: ["못 읽은 곳:", "Not readable:"],
|
||||
Role_Result: ["결과 칸 바꾸기는 아직 안 됨", "Changing the result column is not ready yet"],
|
||||
Role_NoInput: [
|
||||
"「{v}」 을(를) 조건으로 쓰려면 같은 이름 입력값이 있어야 함",
|
||||
"To use “{v}” as a condition, add an input with the same name",
|
||||
],
|
||||
NoName: ["이름을 넣어 주세요", "Enter a name"],
|
||||
Saving: ["저장 중", "Saving"],
|
||||
Saved: ["저장했음", "Saved"],
|
||||
SaveFailed: ["저장 못 함", "Not saved"],
|
||||
Delete: ["삭제", "Delete"],
|
||||
Delete_Ask: ["「{v}」 을(를) 삭제할까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"],
|
||||
Deleted: ["삭제했음", "Deleted"],
|
||||
Uses_Wait: ["사용처를 찾는 중 — 몇 초 걸림", "Looking for places that use it"],
|
||||
Uses_Block: ["사용처가 있어 삭제 못 함 — {v}", "Cannot delete — used by {v}"],
|
||||
Leave_Ask: [
|
||||
"저장 안 한 것이 있음 — 저장하지 않고 닫을까요?",
|
||||
"There are unsaved changes — close without saving?",
|
||||
@@ -105,19 +126,32 @@ const amountOf = (resultUnit: string): string => {
|
||||
};
|
||||
const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/");
|
||||
|
||||
/** 고치는 화면이 쥔 것 — row = 저장할 로직 줄 · lb = 블록(못 읽으면 null) · tables = 고친 표 사본(저장 안 함) */
|
||||
/** 세 칸 화면이 쥔 것 — row = 연 로직 줄 · lb = 블록(못 읽으면 null) · tables = 원문 표 사본 · changed = 고친 표 */
|
||||
interface Paper {
|
||||
key: string;
|
||||
version: string;
|
||||
key: string | null;
|
||||
version: string | null;
|
||||
isBase: boolean;
|
||||
row: LogicRow;
|
||||
trial: Record<string, unknown>;
|
||||
lb: LbLogic | null;
|
||||
tables: LbTables;
|
||||
changed: Set<string>;
|
||||
}
|
||||
/** 실행 취소 한 칸 — 객체를 그대로 쥠(JSON 복사 안 함 · 안 바뀐 조각은 원래 글 보존) */
|
||||
interface Snap {
|
||||
lb: LbLogic | null;
|
||||
tables: LbTables;
|
||||
changed: Set<string>;
|
||||
}
|
||||
type View = "sentence" | "grid";
|
||||
|
||||
/** 블록 읽기 — 못 읽는 꼴이면 null(세 칸은 원래 줄로 보임) */
|
||||
const blocksOf = (row: LogicRow, key: string, version: string, isBase: boolean): LbLogic | null => {
|
||||
/** 블록 읽기 — 못 읽는 꼴이면 null(일위대가표만 보임) */
|
||||
const blocksOf = (
|
||||
row: LogicRow,
|
||||
key: string | null,
|
||||
version: string | null,
|
||||
isBase: boolean,
|
||||
): LbLogic | null => {
|
||||
try {
|
||||
return readLogic(row, { key, version, isBase });
|
||||
} catch {
|
||||
@@ -132,16 +166,27 @@ export function createLogicBuilderButton(): HTMLButtonElement {
|
||||
|
||||
function openLogicBuilder(): void {
|
||||
let body: HTMLElement;
|
||||
let paper: Paper | null = null;
|
||||
let dirty = false;
|
||||
/** 세 칸 화면이 열려 있을 때만 */
|
||||
let paper: { dirty(): boolean; save(): Promise<void>; undo(): void } | null = null;
|
||||
let logics: EasyListItem[] | null = null;
|
||||
let query = "";
|
||||
let view: "sentence" | "grid" = "sentence";
|
||||
let view: View = "sentence";
|
||||
|
||||
const mayLeave = async (): Promise<boolean> =>
|
||||
!paper || !dirty || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
|
||||
!paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
|
||||
|
||||
const notYet = (): void => showToast(te("NotYet"), "info");
|
||||
/** Ctrl+Z · Ctrl+S — 이 창 안에서만(단계 안내 등 다른 창은 제 몫) */
|
||||
const onKey = (ev: KeyboardEvent): void => {
|
||||
if (!paper || !(ev.ctrlKey || ev.metaKey)) return;
|
||||
const key = ev.key.toLowerCase();
|
||||
if (key === "z" && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
paper.undo();
|
||||
} else if (key === "s") {
|
||||
ev.preventDefault();
|
||||
void paper.save();
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------- 시작 화면 ---------- */
|
||||
|
||||
@@ -166,7 +211,6 @@ function openLogicBuilder(): void {
|
||||
|
||||
const showStart = async (): Promise<void> => {
|
||||
paper = null;
|
||||
dirty = false;
|
||||
const status = el("p", { className: "m01-lb__muted", text: te("Loading") });
|
||||
const trialHost = el("div", { className: "m01-lb__list" });
|
||||
const fieldHost = el("div", { className: "m01-lb__list" });
|
||||
@@ -215,7 +259,7 @@ function openLogicBuilder(): void {
|
||||
el("div", {
|
||||
className: "m01-lb__start-col",
|
||||
children: [
|
||||
bigButton(te("Start_Wizard"), notYet),
|
||||
bigButton(te("Start_Wizard"), () => void openNew()),
|
||||
el("h4", { text: te("Start_Field") }),
|
||||
fieldHost,
|
||||
],
|
||||
@@ -240,9 +284,7 @@ function openLogicBuilder(): void {
|
||||
);
|
||||
const field = logics.filter((x) => !x.isBase);
|
||||
fieldHost.replaceChildren(
|
||||
...(field.length
|
||||
? field.map(listRow)
|
||||
: [el("p", { className: "m01-lb__muted", text: te("Start_FieldNone") })]),
|
||||
...(field.length ? field.map(listRow) : [muted(te("Start_FieldNone"))]),
|
||||
);
|
||||
drawFound();
|
||||
};
|
||||
@@ -250,76 +292,232 @@ function openLogicBuilder(): void {
|
||||
/* ---------- 세 칸 화면 ---------- */
|
||||
|
||||
const openPaper = async (key: string): Promise<void> => {
|
||||
body.replaceChildren(el("p", { className: "m01-lb__muted", text: te("Loading") }));
|
||||
body.replaceChildren(muted(te("Loading")));
|
||||
try {
|
||||
const opened = await openEasy(key);
|
||||
const lb = blocksOf(opened.row, key, opened.version, opened.isBase);
|
||||
paper = { key, ...opened, lb, tables: {} };
|
||||
dirty = false;
|
||||
showPaper();
|
||||
const o = await openEasy(key);
|
||||
const lb = blocksOf(o.row, key, o.version, o.isBase);
|
||||
showPaper({ key, version: o.version, isBase: o.isBase, row: o.row, lb, trial: o.trial });
|
||||
} catch (error) {
|
||||
showToast(plainError(error), "error");
|
||||
void showStart();
|
||||
}
|
||||
};
|
||||
|
||||
const showPaper = (): void => {
|
||||
const p = paper;
|
||||
if (!p) return;
|
||||
const openNew = async (): Promise<void> => {
|
||||
const lb = await openWizard();
|
||||
if (!lb) return;
|
||||
const row = writeLogic(lb);
|
||||
showPaper({ key: null, version: null, isBase: false, row, lb, trial: autoTrial(lb.inputs) });
|
||||
};
|
||||
|
||||
const showPaper = (first: Omit<Paper, "tables" | "changed">): void => {
|
||||
const p: Paper = { ...first, tables: {}, changed: new Set() };
|
||||
let ans: CalcAnswer | null = null;
|
||||
let busy = false;
|
||||
const savedLb = p.key ? p.lb : null;
|
||||
let stack: Snap[] = [];
|
||||
const readOnly = p.isBase || !p.lb;
|
||||
paper = { dirty: () => p.lb !== savedLb, save: () => doSave(), undo: () => doUndo() };
|
||||
|
||||
const unsaved = createTag(te("Unsaved"), "warning");
|
||||
unsaved.hidden = !dirty;
|
||||
const total = el("div", { className: "m01-lb__total" });
|
||||
const note = el("p", { className: "m01-lb__note", attrs: { role: "status" } });
|
||||
const sheetHost = el("div");
|
||||
const examplesHost = el("div", { className: "m01-lb__examples" });
|
||||
const checksHost = el("div");
|
||||
const sourceHost = el("div", { className: "m01-lb__slot" });
|
||||
const basisHost = el("div", { className: "m01-lb__slot" });
|
||||
const blockedNote = el("p", { className: "m01-lb__note" });
|
||||
|
||||
const name = createInputField({ label: te("Name"), value: p.row.이름 });
|
||||
name.input.disabled = true; // ponytail: 이름 고치기는 2걸음(Blocks · writeLogic) 에 이음
|
||||
const rowNow = (): LogicRow => (p.lb ? writeLogic(p.lb) : p.row);
|
||||
const unitNow = (): string => p.lb?.doc.resultUnit ?? p.row.결과단위 ?? "";
|
||||
const tableName = (key: string): string | null =>
|
||||
(p.tables[key] as { 이름?: string } | undefined)?.이름 ?? null;
|
||||
/** 서버에 넘길 표 — 고친 표만 */
|
||||
const edited = (): LbTables => Object.fromEntries([...p.changed].map((k) => [k, p.tables[k]]));
|
||||
|
||||
const name = createInputField({
|
||||
label: te("Name"),
|
||||
value: p.lb?.doc.name ?? p.row.이름,
|
||||
onInput: (v) => {
|
||||
if (p.lb) edit({ ...p.lb, doc: { ...p.lb.doc, name: v } });
|
||||
},
|
||||
});
|
||||
name.input.disabled = readOnly;
|
||||
name.root.classList.add("m01-lb__name");
|
||||
|
||||
const drawResult = (pending: boolean): void => {
|
||||
const unit = p.row.결과단위 ?? "";
|
||||
const value = ans?.ok ? (ans.result ?? ans.sums?.["계"]) : undefined;
|
||||
/** 계산 답만 왔을 때 글을 넣는 중인 칸이 있으면 그 칸을 떠난 뒤 그림(넣던 글이 안 날아가게) */
|
||||
const later = (host: HTMLElement, draw: () => void, answer: boolean): void => {
|
||||
const a = document.activeElement;
|
||||
const typing = a instanceof HTMLInputElement || a instanceof HTMLSelectElement;
|
||||
if (answer && typing && host.contains(a)) {
|
||||
host.addEventListener("focusout", () => setTimeout(draw), { once: true });
|
||||
return;
|
||||
}
|
||||
draw();
|
||||
};
|
||||
|
||||
const drawSource = (): void => {
|
||||
if (!p.lb) return void sourceHost.replaceChildren(muted(te("NoBlocks")));
|
||||
renderSource(sourceHost, p.lb, p.tables, lookupHits(p.lb, p.tables, p.trial), {
|
||||
edit: onTableEdit,
|
||||
role: onRole,
|
||||
});
|
||||
};
|
||||
const drawBasis = (): void => {
|
||||
if (!p.lb) return void basisHost.replaceChildren(muted(te("NoBlocks")));
|
||||
const on = { change: (lb: LbLogic) => edit(lb) };
|
||||
if (view === "sentence") renderSteps(basisHost, p.lb, ans, { ...on, tableName });
|
||||
else renderGrid(basisHost, p.lb, ans, on, { trial: p.trial });
|
||||
};
|
||||
|
||||
/** 합계 · 일위대가표 · 원문 · 산출근거 — 바뀔 때(pending) · 계산 답이 올 때(answer) */
|
||||
const draw = (pending: boolean, answer = false): void => {
|
||||
const unit = unitNow();
|
||||
const v = ans?.ok ? (ans.result ?? ans.sums?.["계"]) : undefined;
|
||||
total.textContent = isMoney(unit)
|
||||
? te("Money", { u: amountOf(unit), v: value === undefined ? "—" : formatMoney(value) })
|
||||
: te("Value", { u: unit, v: value === undefined ? "—" : formatNumber(value) });
|
||||
? te("Money", { u: amountOf(unit), v: v === undefined ? "—" : formatMoney(v) })
|
||||
: te("Value", { u: unit, v: v === undefined ? "—" : formatNumber(v) });
|
||||
total.classList.toggle("is-pending", pending);
|
||||
sheetHost.classList.toggle("is-pending", pending);
|
||||
renderSheet(sheetHost, { name: p.row.이름, resultUnit: unit }, ans);
|
||||
unsaved.hidden = !paper?.dirty();
|
||||
undo.disabled = !stack.length;
|
||||
blockedNote.textContent = p.lb?.blocked.length
|
||||
? `${te("Blocked")} ${p.lb.blocked.join(" · ")}`
|
||||
: "";
|
||||
renderSheet(sheetHost, { name: p.lb?.doc.name ?? p.row.이름, resultUnit: unit }, ans);
|
||||
later(sourceHost, drawSource, answer);
|
||||
later(basisHost, drawBasis, answer);
|
||||
};
|
||||
|
||||
const calc = (): void => {
|
||||
drawResult(true);
|
||||
lbCalcLater(p.row, p.trial, p.tables, (next, plain) => {
|
||||
draw(true);
|
||||
lbCalcLater(rowNow(), p.trial, edited(), (next, plain) => {
|
||||
ans = next;
|
||||
note.textContent = plain ?? "";
|
||||
drawResult(false);
|
||||
draw(false, true);
|
||||
});
|
||||
};
|
||||
|
||||
const drawExamplesNow = (): void =>
|
||||
drawExamples(examplesHost, p.lb?.inputs ?? p.row.입력 ?? [], p.trial, (next) => {
|
||||
p.trial = next;
|
||||
calc();
|
||||
});
|
||||
|
||||
/** 바뀐 조각만 새 객체로 온 lb 를 그대로 쌓음 */
|
||||
const edit = (next: LbLogic): void => {
|
||||
if (readOnly || next === p.lb) return;
|
||||
stack = [...stack, { lb: p.lb, tables: p.tables, changed: p.changed }];
|
||||
const inputsChanged = next.inputs !== p.lb?.inputs;
|
||||
p.lb = next;
|
||||
if (inputsChanged) {
|
||||
p.trial = { ...autoTrial(next.inputs), ...p.trial };
|
||||
drawExamplesNow();
|
||||
}
|
||||
void loadTables();
|
||||
calc();
|
||||
};
|
||||
|
||||
/** 표 칸 고침 — 메모리 사본만 · 저장 안 함(정함 5) */
|
||||
const onTableEdit = (e: TableEdit): void => {
|
||||
const t = p.tables[e.table] as { 줄: Record<string, unknown>[] } | undefined;
|
||||
if (!t) return;
|
||||
stack = [...stack, { lb: p.lb, tables: p.tables, changed: p.changed }];
|
||||
const 줄 = t.줄.map((r, i) => (i === e.row ? { ...r, [e.col]: e.value } : r));
|
||||
p.tables = { ...p.tables, [e.table]: { ...t, 줄 } };
|
||||
p.changed = new Set([...p.changed, e.table]);
|
||||
calc();
|
||||
};
|
||||
|
||||
/** 칸 역할 — 조건 = 같은 이름 입력값 · 고정 = 걸린 줄(없으면 첫 줄) 값 · 결과 칸 바꾸기는 아직 */
|
||||
const onRole = (table: string, col: string, role: LbRole): void => {
|
||||
const lb = p.lb;
|
||||
if (!lb || readOnly) return;
|
||||
if (role === "결과") return void (note.textContent = te("Role_Result"));
|
||||
const hits = lookupHits(lb, p.tables, p.trial);
|
||||
const rows = (p.tables[table] as { 줄?: Record<string, unknown>[] } | undefined)?.줄;
|
||||
let next = lb;
|
||||
lb.lookups.forEach((lk, i) => {
|
||||
if (lk.table !== table || lk.col === col) return;
|
||||
const id = next.lookups[i]?.id ?? lk.id;
|
||||
if (role === "조건") {
|
||||
if (lb.inputs.some((x) => x.이름 === col))
|
||||
next = setCond(next, id, col, { k: "input", name: col });
|
||||
return;
|
||||
}
|
||||
const hit = hits[lk.id];
|
||||
const cell = rows?.[typeof hit === "number" ? hit : 0]?.[col];
|
||||
const value = Array.isArray(cell) ? (cell[0] ?? cell[1]) : cell;
|
||||
if (typeof value === "number" || typeof value === "string")
|
||||
next = setCond(next, id, col, { k: "fixed", value });
|
||||
});
|
||||
if (next === lb) note.textContent = te("Role_NoInput", { v: col });
|
||||
else edit(next);
|
||||
};
|
||||
|
||||
/** 로직이 보는 원문 표 — 처음 보는 표만 받아 옴 */
|
||||
const loadTables = async (): Promise<void> => {
|
||||
const keys = [...new Set(p.lb?.lookups.map((lk) => lk.table) ?? [])];
|
||||
const want = keys.filter((k) => !(k in p.tables));
|
||||
if (!want.length) return;
|
||||
const got = await Promise.all(
|
||||
want.map((k) =>
|
||||
fetchLbTable(k).then(
|
||||
(t) => [k, t.table] as const,
|
||||
() => [k, undefined] as const,
|
||||
),
|
||||
),
|
||||
);
|
||||
p.tables = { ...p.tables, ...Object.fromEntries(got) };
|
||||
drawSource();
|
||||
drawBasis();
|
||||
};
|
||||
|
||||
const doUndo = (): void => {
|
||||
const back = stack[stack.length - 1];
|
||||
if (!back) return;
|
||||
stack = stack.slice(0, -1);
|
||||
({ lb: p.lb, tables: p.tables, changed: p.changed } = back);
|
||||
name.input.value = p.lb?.doc.name ?? p.row.이름;
|
||||
drawExamplesNow();
|
||||
calc();
|
||||
};
|
||||
|
||||
/** 저장 뒤 · 복사 편집 뒤 — 서버가 준 줄로 다시 엶 · 실행 취소 기록은 새로 */
|
||||
const reopen = (row: LogicRow, key: string, version: string): void => {
|
||||
const lb = blocksOf(row, key, version, false);
|
||||
showPaper({ key, version, isBase: false, row, lb, trial: p.trial });
|
||||
};
|
||||
|
||||
const doSave = async (): Promise<void> => {
|
||||
if (readOnly || busy || !p.lb) return;
|
||||
const row = writeLogic(p.lb);
|
||||
if (!row.이름.trim()) return void (note.textContent = te("NoName"));
|
||||
busy = true;
|
||||
note.textContent = te("Saving");
|
||||
try {
|
||||
const saved =
|
||||
p.key && p.version ? await editEasy(p.key, p.version, row) : await newEasy(row);
|
||||
logics = null;
|
||||
showToast(te("Saved"), "success");
|
||||
reopen(saved.row, saved.key, saved.version);
|
||||
} catch (error) {
|
||||
note.textContent = `${te("SaveFailed")} — ${plainError(error)}`;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
|
||||
const doCopy = async (): Promise<void> => {
|
||||
if (busy) return;
|
||||
if (!p.key || busy) return;
|
||||
busy = true;
|
||||
note.textContent = te("Copying");
|
||||
try {
|
||||
const saved = await copyEasy(p.key);
|
||||
paper = {
|
||||
key: saved.key,
|
||||
version: saved.version,
|
||||
isBase: false,
|
||||
row: saved.row,
|
||||
trial: p.trial,
|
||||
lb: blocksOf(saved.row, saved.key, saved.version, false),
|
||||
tables: {},
|
||||
};
|
||||
logics = null;
|
||||
showToast(te("Copied"), "success");
|
||||
showPaper();
|
||||
reopen(saved.row, saved.key, saved.version);
|
||||
} catch (error) {
|
||||
note.textContent = plainError(error);
|
||||
} finally {
|
||||
@@ -327,44 +525,80 @@ function openLogicBuilder(): void {
|
||||
}
|
||||
};
|
||||
|
||||
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet });
|
||||
undo.disabled = true;
|
||||
const doDelete = async (): Promise<void> => {
|
||||
if (!p.key || !p.version || p.isBase || busy) return;
|
||||
busy = true;
|
||||
note.textContent = te("Uses_Wait");
|
||||
try {
|
||||
const uses = (await usesOf(p.key)).filter((u) => u.key);
|
||||
if (uses.length) {
|
||||
note.textContent = te("Uses_Block", { v: uses.map((u) => u.name).join(", ") });
|
||||
return;
|
||||
}
|
||||
note.textContent = "";
|
||||
const ask = te("Delete_Ask", { v: rowNow().이름 });
|
||||
if (!(await showConfirmDialog(ask, te("Delete")))) return;
|
||||
await deleteEasy(p.key, p.version);
|
||||
logics = null;
|
||||
paper = null;
|
||||
showToast(te("Deleted"), "success");
|
||||
void showStart();
|
||||
} catch (error) {
|
||||
note.textContent = plainError(error);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
|
||||
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: doUndo });
|
||||
const close = createButton({
|
||||
label: te("Close"),
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
void (async () => {
|
||||
if (await mayLeave()) void showStart();
|
||||
})(),
|
||||
});
|
||||
const fieldActions = [
|
||||
undo,
|
||||
createButton({ label: te("Save"), onClick: () => void doSave() }),
|
||||
...(p.key
|
||||
? [
|
||||
createButton({
|
||||
label: te("Delete"),
|
||||
variant: "danger",
|
||||
onClick: () => void doDelete(),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
close,
|
||||
];
|
||||
const actions = el("div", {
|
||||
className: "m01-lb__actions",
|
||||
children: [
|
||||
...(p.isBase
|
||||
? [createButton({ label: te("CopyEdit"), onClick: () => void doCopy() })]
|
||||
: [undo, createButton({ label: te("Save"), onClick: notYet })]),
|
||||
createButton({
|
||||
label: te("Close"),
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
void (async () => {
|
||||
if (await mayLeave()) void showStart();
|
||||
})(),
|
||||
}),
|
||||
],
|
||||
children: p.isBase
|
||||
? [createButton({ label: te("CopyEdit"), onClick: () => void doCopy() }), close]
|
||||
: fieldActions,
|
||||
});
|
||||
|
||||
const viewButton = (which: "sentence" | "grid", label: string): HTMLButtonElement => {
|
||||
const viewButtons = (["sentence", "grid"] as View[]).map((which) => {
|
||||
const b = createButton({
|
||||
label,
|
||||
label: te(which === "sentence" ? "View_Sentence" : "View_Grid"),
|
||||
variant: view === which ? "filled" : "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
view = which;
|
||||
showPaper();
|
||||
for (const x of viewButtons) {
|
||||
const on = x.dataset.view === view;
|
||||
x.classList.toggle("ui-btn--filled", on);
|
||||
x.classList.toggle("ui-btn--ghost", !on);
|
||||
x.setAttribute("aria-pressed", String(on));
|
||||
}
|
||||
drawBasis();
|
||||
},
|
||||
});
|
||||
b.dataset.view = which;
|
||||
b.setAttribute("aria-pressed", String(view === which));
|
||||
return b;
|
||||
};
|
||||
|
||||
sourceHost.replaceChildren(el("p", { className: "m01-lb__muted", text: te("NotYet") }));
|
||||
basisHost.replaceChildren(el("p", { className: "m01-lb__muted", text: te("NotYet") }));
|
||||
drawExamples(examplesHost, p.row.입력 ?? [], p.trial, (next) => {
|
||||
p.trial = next;
|
||||
calc();
|
||||
});
|
||||
|
||||
body.replaceChildren(
|
||||
@@ -382,25 +616,33 @@ function openLogicBuilder(): void {
|
||||
],
|
||||
}),
|
||||
note,
|
||||
...(p.isBase ? [el("p", { className: "m01-lb__muted", text: te("StandardNote") })] : []),
|
||||
...(p.isBase ? [muted(te("StandardNote"))] : []),
|
||||
el("div", {
|
||||
className: "m01-lb__cols",
|
||||
children: [
|
||||
column(te("Col_Source"), [sourceHost]),
|
||||
column(
|
||||
te("Col_Basis"),
|
||||
[basisHost],
|
||||
[viewButton("sentence", te("View_Sentence")), viewButton("grid", te("View_Grid"))],
|
||||
),
|
||||
column(te("Col_Basis"), [blockedNote, basisHost], viewButtons),
|
||||
column(te("Col_Result"), [
|
||||
total,
|
||||
examplesHost,
|
||||
el("h5", { text: te("Sheet") }),
|
||||
sheetHost,
|
||||
checksHost,
|
||||
]),
|
||||
],
|
||||
}),
|
||||
);
|
||||
drawExamplesNow();
|
||||
if (p.lb) {
|
||||
renderChecks(checksHost, {
|
||||
lb: () => p.lb as LbLogic,
|
||||
tables: () => p.tables,
|
||||
trial: () => p.trial,
|
||||
ans: () => ans,
|
||||
draftKey: p.key ?? "새",
|
||||
});
|
||||
}
|
||||
void loadTables();
|
||||
calc();
|
||||
};
|
||||
|
||||
@@ -412,11 +654,24 @@ function openLogicBuilder(): void {
|
||||
beforeClose: mayLeave,
|
||||
mount: (host) => {
|
||||
body = host;
|
||||
(host.parentElement ?? host).addEventListener("keydown", onKey);
|
||||
void showStart();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 단계 안내 · 새 입력값의 예시값 — 서버 자동값과 같은 규칙(선택 = 첫째 · 범위 = 낮은 끝 · 숫자 0 · 가격 값은 비움) */
|
||||
function autoTrial(inputs: LogicInput[]): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const spec of inputs) {
|
||||
if (String(spec.단위 ?? "").includes("원")) continue;
|
||||
out[spec.이름] = spec.고르기?.length ? spec.고르기[0] : (spec.범위?.[0] ?? 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const muted = (text: string): HTMLElement => el("p", { className: "m01-lb__muted", text });
|
||||
|
||||
/** 세 칸 가운데 하나 — 머리(제목 · 단추) + 몸 */
|
||||
function column(title: string, children: HTMLElement[], tools: HTMLElement[] = []): HTMLElement {
|
||||
return el("section", {
|
||||
@@ -431,62 +686,6 @@ function column(title: string, children: HTMLElement[], tools: HTMLElement[] = [
|
||||
});
|
||||
}
|
||||
|
||||
/** 예시값 띠 — 입력값마다 선택 또는 숫자 · 바꾸면 onChange(새 예시값) · 저장 안 함 */
|
||||
function drawExamples(
|
||||
host: HTMLElement,
|
||||
inputs: LogicInput[],
|
||||
trial: Record<string, unknown>,
|
||||
onChange: (trial: Record<string, unknown>) => void,
|
||||
): void {
|
||||
host.hidden = !inputs.length;
|
||||
if (!inputs.length) return void host.replaceChildren();
|
||||
const state = { ...trial };
|
||||
const field = (spec: LogicInput): HTMLElement => {
|
||||
const label = spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름;
|
||||
let box: HTMLInputElement | HTMLSelectElement;
|
||||
if (spec.고르기?.length) {
|
||||
box = el("select", {
|
||||
className: "ui-select ui-select--sm",
|
||||
children: [
|
||||
el("option", { attrs: { value: "" }, text: te("Pick") }),
|
||||
...spec.고르기.map((v) => el("option", { attrs: { value: String(v) }, text: String(v) })),
|
||||
],
|
||||
});
|
||||
box.value = state[spec.이름] === undefined ? "" : String(state[spec.이름]);
|
||||
box.addEventListener("change", () => {
|
||||
const picked = spec.고르기?.find((v) => String(v) === box.value);
|
||||
if (picked === undefined) delete state[spec.이름];
|
||||
else state[spec.이름] = picked;
|
||||
onChange({ ...state });
|
||||
});
|
||||
} else {
|
||||
box = el("input", {
|
||||
className: "ui-input ui-input--sm",
|
||||
attrs: {
|
||||
type: "number",
|
||||
...(spec.범위 ? { min: String(spec.범위[0]), max: String(spec.범위[1]) } : {}),
|
||||
},
|
||||
});
|
||||
box.value = state[spec.이름] == null ? "" : String(state[spec.이름]);
|
||||
box.addEventListener("change", () => {
|
||||
if (box.value === "") delete state[spec.이름];
|
||||
else state[spec.이름] = Number(box.value);
|
||||
onChange({ ...state });
|
||||
});
|
||||
}
|
||||
box.setAttribute("aria-label", spec.이름);
|
||||
return el("label", {
|
||||
className: "m01-lb__example",
|
||||
children: [el("span", { text: label }), box],
|
||||
});
|
||||
};
|
||||
host.replaceChildren(
|
||||
el("strong", { text: te("Examples") }),
|
||||
el("div", { className: "m01-lb__example-list", children: inputs.map(field) }),
|
||||
el("p", { className: "m01-lb__muted", text: te("Examples_Note") }),
|
||||
);
|
||||
}
|
||||
|
||||
function bigButton(label: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = createButton({ label, variant: "filled", onClick });
|
||||
button.classList.add("m01-lb__big");
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
/* =============================================================================
|
||||
* M01_LB_Entry_Result.ts
|
||||
* 로직 만들기 판 오른쪽 결과 칸의 도구 — 예시값 띠 · 빈틈 검사 · 기대값 시험표(Entry 가 부름)
|
||||
* 빈틈 · 시험표 셈은 Check(sub8) 것 · 시험표 줄은 화면 초안(sessionStorage)만 · 저장 안 함(정함 7)
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import type { CalcAnswer, LogicInput } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatNumber } from "../M01_MasterData_UI_Logic_Edit";
|
||||
import type { LbTables } from "./M01_LB_Api";
|
||||
import type { LbLogic } from "./M01_LB_Blocks";
|
||||
import {
|
||||
gapSkipped,
|
||||
gaps,
|
||||
loadExpectRows,
|
||||
pickValues,
|
||||
runExpect,
|
||||
saveExpectRows,
|
||||
type ExpectRow,
|
||||
} from "./M01_LB_Check";
|
||||
|
||||
const TEXT = {
|
||||
Examples: ["예시값", "Example values"],
|
||||
Examples_Note: [
|
||||
"예시값은 계산을 보려는 값 — 저장 안 됨",
|
||||
"Example values are only for trying — not saved",
|
||||
],
|
||||
Pick: ["선택", "Choose"],
|
||||
Gaps: ["빈틈 검사", "Check for gaps"],
|
||||
Gaps_None: [
|
||||
"빈틈 없음 — 모든 선택 조합에 맞는 줄이 하나씩 있음",
|
||||
"No gaps — every combination finds one row",
|
||||
],
|
||||
Gaps_No: ["해당 없음", "No match"],
|
||||
Gaps_Dup: ["중복", "Duplicate"],
|
||||
Gaps_Rows: ["줄 {v}", "rows {v}"],
|
||||
Gaps_Skip: [
|
||||
"못 따진 표 조회 {n} 개 — 입력값이 숫자라 조합을 셀 수 없음",
|
||||
"{n} lookups not checked — numeric inputs",
|
||||
],
|
||||
Gaps_More: ["그 밖 {n} 건", "{n} more"],
|
||||
Expect: ["기대값 시험표", "Expected-value table"],
|
||||
Expect_Add: ["지금 예시값으로 한 줄 더하기", "Add a row from the current examples"],
|
||||
Expect_Run: ["모두 돌리기", "Run all"],
|
||||
Expect_Total: ["합계", "Total"],
|
||||
Expect_Ok: ["맞음", "Match"],
|
||||
Expect_Bad: ["다름 — 나온 값 {v}", "Differs — got {v}"],
|
||||
Expect_Del: ["삭제", "Delete"],
|
||||
Expect_None: [
|
||||
"줄 없음 — 예시값을 정한 뒤 한 줄 더하기",
|
||||
"No rows — set examples, then add a row",
|
||||
],
|
||||
Expect_Note: [
|
||||
"시험표는 이 화면에만 남음 — 저장 안 됨",
|
||||
"The table stays on this screen only — not saved",
|
||||
],
|
||||
} as const;
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
|
||||
const tr = (key: TextKey, vars: Record<string, string | number> = {}): string =>
|
||||
Object.entries(vars).reduce(
|
||||
(text, [k, v]) => text.replace(`{${k}}`, String(v)),
|
||||
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string,
|
||||
);
|
||||
|
||||
/** 볼 값 이름 — 합계는 「계」 · 값 결과는 「결과」 */
|
||||
const totalKey = (ans: CalcAnswer | null): "계" | "결과" =>
|
||||
ans?.ok && ans.result !== undefined ? "결과" : "계";
|
||||
|
||||
/** 예시값 띠 — 입력값마다 선택 또는 숫자 · 바꾸면 onChange(새 예시값) · 저장 안 함 */
|
||||
export function drawExamples(
|
||||
host: HTMLElement,
|
||||
inputs: LogicInput[],
|
||||
trial: Record<string, unknown>,
|
||||
onChange: (trial: Record<string, unknown>) => void,
|
||||
): void {
|
||||
host.hidden = !inputs.length;
|
||||
if (!inputs.length) return void host.replaceChildren();
|
||||
const state = { ...trial };
|
||||
const field = (spec: LogicInput): HTMLElement => {
|
||||
const label = spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름;
|
||||
let box: HTMLInputElement | HTMLSelectElement;
|
||||
if (spec.고르기?.length) {
|
||||
box = el("select", {
|
||||
className: "ui-select ui-select--sm",
|
||||
children: [
|
||||
el("option", { attrs: { value: "" }, text: tr("Pick") }),
|
||||
...spec.고르기.map((v) => el("option", { attrs: { value: String(v) }, text: String(v) })),
|
||||
],
|
||||
});
|
||||
box.value = state[spec.이름] === undefined ? "" : String(state[spec.이름]);
|
||||
box.addEventListener("change", () => {
|
||||
const picked = spec.고르기?.find((v) => String(v) === box.value);
|
||||
if (picked === undefined) delete state[spec.이름];
|
||||
else state[spec.이름] = picked;
|
||||
onChange({ ...state });
|
||||
});
|
||||
} else {
|
||||
box = el("input", {
|
||||
className: "ui-input ui-input--sm",
|
||||
attrs: {
|
||||
type: "number",
|
||||
...(spec.범위 ? { min: String(spec.범위[0]), max: String(spec.범위[1]) } : {}),
|
||||
},
|
||||
});
|
||||
box.value = state[spec.이름] == null ? "" : String(state[spec.이름]);
|
||||
box.addEventListener("change", () => {
|
||||
if (box.value === "") delete state[spec.이름];
|
||||
else state[spec.이름] = Number(box.value);
|
||||
onChange({ ...state });
|
||||
});
|
||||
}
|
||||
box.setAttribute("aria-label", spec.이름);
|
||||
return el("label", {
|
||||
className: "m01-lb__example",
|
||||
children: [el("span", { text: label }), box],
|
||||
});
|
||||
};
|
||||
host.replaceChildren(
|
||||
el("strong", { text: tr("Examples") }),
|
||||
el("div", { className: "m01-lb__example-list", children: inputs.map(field) }),
|
||||
el("p", { className: "m01-lb__muted", text: tr("Examples_Note") }),
|
||||
);
|
||||
}
|
||||
|
||||
const condText = (conds: Record<string, unknown>): string =>
|
||||
Object.entries(conds)
|
||||
.map(([k, v]) => `${k} ${Array.isArray(v) ? v.join("~") : String(v)}`)
|
||||
.join(" · ");
|
||||
|
||||
/** 빈틈 검사 · 기대값 시험표 — 화면 초안 열쇠 = 로직 번호(새 로직은 「새」) */
|
||||
export function renderChecks(
|
||||
host: HTMLElement,
|
||||
s: {
|
||||
lb(): LbLogic;
|
||||
tables(): LbTables;
|
||||
trial(): Record<string, unknown>;
|
||||
ans(): CalcAnswer | null;
|
||||
draftKey: string;
|
||||
},
|
||||
): void {
|
||||
const gapHost = el("div", { className: "m01-lb__check-out" });
|
||||
const rowsHost = el("div", { className: "m01-lb__check-out" });
|
||||
let rows: ExpectRow[] = loadExpectRows(s.draftKey);
|
||||
let results: (boolean | number | null)[] = [];
|
||||
const keep = (): void => saveExpectRows(s.draftKey, rows);
|
||||
|
||||
const runGaps = (): void => {
|
||||
const found = gaps(s.lb(), s.tables());
|
||||
const skipped = gapSkipped(s.lb(), s.tables()).length;
|
||||
const lines = found.slice(0, 20).map((g) =>
|
||||
el("li", {
|
||||
text: [
|
||||
g.kind === "없음" ? tr("Gaps_No") : tr("Gaps_Dup"),
|
||||
condText(g.conds),
|
||||
g.kind === "중복" ? tr("Gaps_Rows", { v: g.rows.map((r) => r + 1).join(", ") }) : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" — "),
|
||||
}),
|
||||
);
|
||||
gapHost.replaceChildren(
|
||||
found.length
|
||||
? el("ul", { children: lines })
|
||||
: el("p", { className: "m01-lb__muted", text: tr("Gaps_None") }),
|
||||
...(found.length > 20 ? [el("p", { text: tr("Gaps_More", { n: found.length - 20 }) })] : []),
|
||||
...(skipped
|
||||
? [el("p", { className: "m01-lb__muted", text: tr("Gaps_Skip", { n: skipped }) })]
|
||||
: []),
|
||||
);
|
||||
};
|
||||
|
||||
const drawRows = (): void => {
|
||||
if (!rows.length) {
|
||||
rowsHost.replaceChildren(el("p", { className: "m01-lb__muted", text: tr("Expect_None") }));
|
||||
return;
|
||||
}
|
||||
rowsHost.replaceChildren(
|
||||
...rows.map((row, i) => {
|
||||
const key = Object.keys(row.expect)[0] ?? "계";
|
||||
const box = el("input", {
|
||||
className: "ui-input ui-input--sm",
|
||||
attrs: { type: "number", "aria-label": tr("Expect_Total") },
|
||||
});
|
||||
box.value = String(row.expect[key] ?? "");
|
||||
box.addEventListener("change", () => {
|
||||
row.expect = { [key]: Number(box.value) };
|
||||
results[i] = null;
|
||||
keep();
|
||||
});
|
||||
const r = results[i];
|
||||
return el("div", {
|
||||
className: "m01-lb__expect-row",
|
||||
children: [
|
||||
el("span", { text: condText(row.inputs) || "—" }),
|
||||
box,
|
||||
el("span", {
|
||||
className: r === true ? "is-ok" : typeof r === "number" ? "is-bad" : "",
|
||||
text:
|
||||
r === true
|
||||
? tr("Expect_Ok")
|
||||
: typeof r === "number"
|
||||
? tr("Expect_Bad", { v: formatNumber(r) })
|
||||
: "",
|
||||
}),
|
||||
createButton({
|
||||
label: tr("Expect_Del"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
rows = rows.filter((_, j) => j !== i);
|
||||
results = results.filter((_, j) => j !== i);
|
||||
keep();
|
||||
drawRows();
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const addRow = (): void => {
|
||||
const ans = s.ans();
|
||||
const key = totalKey(ans);
|
||||
const now = ans?.ok ? pickValues(ans)[key] : undefined;
|
||||
rows = [...rows, { inputs: { ...s.trial() }, expect: { [key]: now ?? 0 } }];
|
||||
keep();
|
||||
drawRows();
|
||||
};
|
||||
|
||||
const runRows = async (): Promise<void> => {
|
||||
const got = await runExpect(s.lb(), s.tables(), rows);
|
||||
results = got.map((g, i) => {
|
||||
const key = Object.keys(rows[i].expect)[0] ?? "계";
|
||||
return g.ok ? true : (g.got[key] ?? null);
|
||||
});
|
||||
drawRows();
|
||||
};
|
||||
|
||||
host.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-lb__check",
|
||||
children: [
|
||||
createButton({ label: tr("Gaps"), variant: "ghost", size: "sm", onClick: runGaps }),
|
||||
gapHost,
|
||||
el("strong", { text: tr("Expect") }),
|
||||
el("div", {
|
||||
className: "m01-lb__check-tools",
|
||||
children: [
|
||||
createButton({
|
||||
label: tr("Expect_Add"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: addRow,
|
||||
}),
|
||||
createButton({
|
||||
label: tr("Expect_Run"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => void runRows(),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
rowsHost,
|
||||
el("p", { className: "m01-lb__muted", text: tr("Expect_Note") }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
drawRows();
|
||||
}
|
||||
@@ -6,7 +6,12 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import type { CalcAnswer, CalcLine, LogicInput } from "../M01_MasterData_UI_Logic_Api";
|
||||
import {
|
||||
fetchLogics,
|
||||
type CalcAnswer,
|
||||
type CalcLine,
|
||||
type LogicInput,
|
||||
} from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
|
||||
import {
|
||||
addCard,
|
||||
@@ -23,6 +28,7 @@ import { setCond, type LbLogic, type LbLookup, type LbRef } from "./M01_LB_Block
|
||||
import { calcBody, calcView } from "./M01_LB_Steps_Calc";
|
||||
import {
|
||||
chip,
|
||||
logicNames,
|
||||
lookupValue,
|
||||
note,
|
||||
numBox,
|
||||
@@ -42,6 +48,7 @@ import "./M01_LB_Steps.css";
|
||||
type Kind = "person" | "material" | "machine" | "logic" | "percent" | "locked";
|
||||
|
||||
let picking = false;
|
||||
let namesLoading = false;
|
||||
|
||||
/* ------------------------------ ① 입력값 ------------------------------ */
|
||||
|
||||
@@ -367,4 +374,19 @@ export function renderSteps(
|
||||
),
|
||||
section(tx("S5"), [el("li", { className: "m01-lbsteps-row", children: [resultView(ctx)] })]),
|
||||
);
|
||||
// 다른 일위대가 이름 — 목록을 한 번 받은 뒤 다시 그림
|
||||
if (
|
||||
!logicNames.size &&
|
||||
!namesLoading &&
|
||||
JSON.stringify([lb.calcs, lb.lookups]).includes('"k":"logic"')
|
||||
) {
|
||||
namesLoading = true;
|
||||
fetchLogics()
|
||||
.then((all) => {
|
||||
for (const l of all) logicNames.set(l.키, l.이름);
|
||||
renderSteps(host, lb, ans, on);
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => (namesLoading = false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export const TEXT = {
|
||||
SrcCalc: ["계산 · ", "calc · "],
|
||||
SrcFixed: ["고정값", "fixed value"],
|
||||
SrcAny: ["무관(조건에서 뺌)", "any (remove condition)"],
|
||||
Other: ["다른 일위대가 결과", "Other unit price result"],
|
||||
Other: ["다른 일위대가", "Other unit price"],
|
||||
Hard: ["이 화면에서 못 고치는 값", "Value not editable here"],
|
||||
Mul: ["×", "×"],
|
||||
Div: ["÷", "÷"],
|
||||
@@ -197,6 +197,9 @@ export const isRef = (r: Ref, k: "lookup", id: string): boolean => r.k === k &&
|
||||
export const lookupNo = (ctx: Ctx, id: string): number =>
|
||||
ctx.lb.lookups.findIndex((l) => l.id === id) + 1;
|
||||
|
||||
/** 로직 이름 — 목록을 한 번 받아 채움(키 → 이름) · 못 받으면 「다른 일위대가」 */
|
||||
export const logicNames = new Map<string, string>();
|
||||
|
||||
export function refText(ctx: Ctx, r: Ref): string {
|
||||
switch (r.k) {
|
||||
case "input":
|
||||
@@ -210,7 +213,7 @@ export function refText(ctx: Ctx, r: Ref): string {
|
||||
return `${tx("LkNum")}${lookupNo(ctx, r.id)}${lk ? ` · ${lk.col}` : ""}`;
|
||||
}
|
||||
case "logic":
|
||||
return `${tx("Other")} ${r.key}${r.field ? ` · ${r.field}` : ""}`;
|
||||
return `${logicNames.get(r.key) ?? tx("Other")}${r.field ? ` · ${r.field}` : ""}`;
|
||||
case "cost":
|
||||
return tx(`Base_${r.name}`);
|
||||
default:
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
* 글 [한, 영] 짝은 아래 TEXT · 용어는 정함 9 · 이 폴더째 지워도 됨
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
@@ -16,7 +15,7 @@ import {
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { ApiError, type CalcAnswer } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { type CalcAnswer } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { calcLater, plainError } from "../EasyCalc/M01_EasyCalc_Api";
|
||||
import { readLogic, type LbLogic } from "./M01_LB_Blocks";
|
||||
import { autoLinks, stepCalc, stepLink } from "./M01_LB_Wizard_Calc";
|
||||
@@ -30,6 +29,13 @@ import {
|
||||
type WCtx,
|
||||
type WTable,
|
||||
} from "./M01_LB_Wizard_Build";
|
||||
import {
|
||||
get,
|
||||
tablePreview,
|
||||
toTable,
|
||||
type SectionItem,
|
||||
type TableHead,
|
||||
} from "./M01_LB_Wizard_Parts";
|
||||
import "./M01_LB_Wizard.css";
|
||||
|
||||
const TEXT = {
|
||||
@@ -67,6 +73,7 @@ const TEXT = {
|
||||
Unused: ["쓰지 않음", "Unused"],
|
||||
Choices: ["선택 목록", "Choices"],
|
||||
Free: ["숫자 입력", "Number input"],
|
||||
PickTable: ["표를 하나 골라 주세요", "Pick a table"],
|
||||
NeedResult: ["결과 칸이 하나는 있어야 함", "At least one result column"],
|
||||
FixedVal: ["고정값", "Fixed value"],
|
||||
BaseUnit: ["표 기준 「{b}」", "Table base “{b}”"],
|
||||
@@ -95,32 +102,7 @@ const t = (key: TKey, v: Record<string, string | number> = {}): string =>
|
||||
|
||||
const fmt = (n: number): string => Number(n.toPrecision(6)).toLocaleString("ko-KR");
|
||||
|
||||
async function get<T>(path: string, params: Record<string, string>): Promise<T> {
|
||||
const res = await fetch(`${API_BASE_URL}/m01${path}?${new URLSearchParams(params)}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & T;
|
||||
if (!res.ok) throw new ApiError(res.status, data.detail ?? res.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
interface SectionItem {
|
||||
section: string;
|
||||
title: string;
|
||||
chapter: string;
|
||||
tables: number;
|
||||
logics: number;
|
||||
}
|
||||
interface TableHead {
|
||||
file: string;
|
||||
키: string;
|
||||
이름: string;
|
||||
기준: string;
|
||||
조건: Record<string, string>;
|
||||
값칸: Record<string, string>;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const LBL = { preview: t("Preview"), notes: t("Notes") };
|
||||
const BOOKS = ["산림품셈", "건설품셈"];
|
||||
const UNITS = ["㎡", "㎥", "m", "km", "ha", "개소", "개", "ton", "본", "100㎡", "100m", "10m"];
|
||||
|
||||
@@ -233,7 +215,7 @@ export function openWizard(): Promise<LbLogic | null> {
|
||||
const per = a.resultUnit.replace(/^원\//, "");
|
||||
const v = money ? (ans.sums?.["계"] ?? Number(ans.result ?? 0)) : Number(ans.result);
|
||||
result.textContent = money
|
||||
? t("AmountPer", { p: per, m: fmt(v) })
|
||||
? t("AmountPer", { p: per, m: Math.round(v).toLocaleString("ko-KR") })
|
||||
: t("ValueIs", { c: col, v: fmt(v), u: a.table.값칸[col] ?? "" });
|
||||
});
|
||||
};
|
||||
@@ -369,7 +351,7 @@ export function openWizard(): Promise<LbLogic | null> {
|
||||
tables
|
||||
.querySelectorAll(".m01-lbw__row")
|
||||
.forEach((n, i) => n.classList.toggle("is-on", heads[i]?.키 === h.키));
|
||||
preview.replaceChildren(tablePreview(a.table));
|
||||
preview.replaceChildren(tablePreview(a.table, LBL));
|
||||
renderNav();
|
||||
};
|
||||
let timer = 0;
|
||||
@@ -379,8 +361,8 @@ export function openWizard(): Promise<LbLogic | null> {
|
||||
);
|
||||
main.replaceChildren(bookBox.root, q.root, list, tables, preview);
|
||||
void load();
|
||||
if (a.table.key) preview.replaceChildren(tablePreview(a.table));
|
||||
return () => (a.table.key ? null : t("Tables"));
|
||||
if (a.table.key) preview.replaceChildren(tablePreview(a.table, LBL));
|
||||
return () => (a.table.key ? null : t("PickTable"));
|
||||
};
|
||||
|
||||
/* ── 2단계 칸 역할 ── */
|
||||
@@ -471,7 +453,7 @@ export function openWizard(): Promise<LbLogic | null> {
|
||||
box.replaceChildren(
|
||||
el("p", { className: "m01-lbw__muted", text: t("RoleHint") }),
|
||||
...chips,
|
||||
tablePreview(a.table, a.roles),
|
||||
tablePreview(a.table, LBL, a.roles),
|
||||
);
|
||||
renderNav();
|
||||
};
|
||||
@@ -580,9 +562,12 @@ export function openWizard(): Promise<LbLogic | null> {
|
||||
stepCalc(ctx, main);
|
||||
valid = () => null;
|
||||
} else if (step === 5) {
|
||||
stepLink(ctx, main);
|
||||
if (!linked)
|
||||
((linked = true), void autoLinks(ctx).then(() => step === 5 && stepLink(ctx, main)));
|
||||
if (linked) stepLink(ctx, main);
|
||||
else {
|
||||
linked = true;
|
||||
main.replaceChildren(el("p", { className: "m01-lbw__muted", text: t("Loading") }));
|
||||
void autoLinks(ctx).then(() => step === 5 && stepLink(ctx, main));
|
||||
}
|
||||
valid = () => null;
|
||||
} else valid = stepName();
|
||||
side.hidden = step === 1;
|
||||
@@ -641,58 +626,3 @@ export function openWizard(): Promise<LbLogic | null> {
|
||||
draw();
|
||||
});
|
||||
}
|
||||
|
||||
/** 서버 표 답 → WTable */
|
||||
function toTable(raw: WTable & { 키: string }, file: string): WTable {
|
||||
return {
|
||||
key: raw.키,
|
||||
file,
|
||||
이름: raw.이름,
|
||||
기준: raw.기준 ?? "",
|
||||
출처: raw.출처 ?? "",
|
||||
조건: raw.조건,
|
||||
값칸: raw.값칸,
|
||||
범위규칙: raw.범위규칙 ?? null,
|
||||
줄: raw.줄,
|
||||
주: raw.주 ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
const cellText = (v: unknown): string =>
|
||||
Array.isArray(v)
|
||||
? v.map((x) => x ?? "").join("~")
|
||||
: v === null || v === undefined
|
||||
? ""
|
||||
: String(v);
|
||||
|
||||
/** 원문 표 앞 몇 줄 + 주 — 칸 머리 색 = 역할 */
|
||||
function tablePreview(tb: WTable, roles?: Record<string, Role>): HTMLElement {
|
||||
const cols = [...Object.keys(tb.조건), ...Object.keys(tb.값칸)];
|
||||
const head = el("tr", {
|
||||
children: cols.map((c) =>
|
||||
el("th", { className: `is-${roles?.[c] ?? (c in tb.조건 ? "조건" : "결과")}`, text: c }),
|
||||
),
|
||||
});
|
||||
const rows = tb.줄
|
||||
.slice(0, 6)
|
||||
.map((r) => el("tr", { children: cols.map((c) => el("td", { text: cellText(r[c]) })) }));
|
||||
return el("div", {
|
||||
className: "m01-lbw__table",
|
||||
children: [
|
||||
el("h5", { text: `${t("Preview")} — ${tb.이름} (${tb.기준 || "—"})` }),
|
||||
el("div", {
|
||||
className: "m01-lbw__scroll",
|
||||
children: [el("table", { children: [head, ...rows] })],
|
||||
}),
|
||||
...(tb.줄.length > 6
|
||||
? [el("p", { className: "m01-lbw__muted", text: `… ${tb.줄.length} 줄` })]
|
||||
: []),
|
||||
...(tb.주.length
|
||||
? [
|
||||
el("h5", { text: t("Notes") }),
|
||||
...tb.주.map((n) => el("p", { className: "m01-lbw__note-line", text: n })),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -184,12 +184,25 @@ function inputsOf(a: WAnswers): LogicInput[] {
|
||||
if (m.k === "할증" && m.rate.t === "cond") add({ 이름: m.rate.input });
|
||||
if (m.k === "나누기" && m.by.t === "input") add({ 이름: m.by.name });
|
||||
}
|
||||
for (const l of a.links) for (const n of l.needInputs ?? []) add(n);
|
||||
if (a.kind === "금액")
|
||||
for (const l of a.links)
|
||||
if (l.kind !== "없음" && a.roles[l.col] === "결과")
|
||||
for (const n of l.needInputs ?? []) add(n);
|
||||
return out;
|
||||
}
|
||||
|
||||
const COST: Record<string, string> = { 노무: "노무비", 재료: "재료비" };
|
||||
|
||||
/** 사람 줄 노임 합 글 — 「(수량 * 노임 키 + …)」 · 사람 줄이 없으면 0 */
|
||||
const personSum = (ho: HoLine[]): string => {
|
||||
const terms = ho
|
||||
.filter((h) => h.종류 === "인력")
|
||||
.map(
|
||||
(h) => `${/^[\p{L}\p{N}_.]+$/u.test(h.수량) ? h.수량 : `(${h.수량})`} * ${String(h.요소)}`,
|
||||
);
|
||||
return `(${terms.length ? terms.join(" + ") : "0"})`;
|
||||
};
|
||||
|
||||
/** 답 → 로직 줄 */
|
||||
export function buildRow(a: WAnswers): LogicRow {
|
||||
const mids: { 이름: string; 식: string; 출처?: string }[] = [];
|
||||
@@ -222,7 +235,11 @@ export function buildRow(a: WAnswers): LogicRow {
|
||||
}));
|
||||
const 덧줄 = a.extras.map((e) =>
|
||||
e.k === "비율"
|
||||
? { 이름: e.name, 식: `${e.base} * ${num(e.rate)} / 100`, 비목: e.cost }
|
||||
? {
|
||||
이름: e.name,
|
||||
식: `${e.base === "사람줄합" ? personSum(호표) : e.base} * ${num(e.rate)} / 100`,
|
||||
비목: e.cost,
|
||||
}
|
||||
: { 이름: e.name, 식: num(e.amount), 비목: e.cost },
|
||||
);
|
||||
return { ...base, 입력: inputsOf(a), 중간: mids, 호표, 덧줄, 끝수: null };
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { fetchPick } from "../M01_MasterData_Api_Fetch";
|
||||
import { openEasyPick } from "../EasyCalc/M01_EasyCalc_Pick";
|
||||
import { plainError } from "../EasyCalc/M01_EasyCalc_Api";
|
||||
@@ -50,9 +56,11 @@ const TEXT = {
|
||||
DSurchargeNum: ["{p} % 를 더함", "add {p} %"],
|
||||
DSurchargeIn: ["「{n}」 % 를 더함(입력값)", "add “{n}” % (input)"],
|
||||
DSurchargeCond: [
|
||||
"「{i}」 가 {l} {o} 이면 0 · 넘으면 「{t}」 의 {c} % 를 더함",
|
||||
"if “{i}” {o} {l} add 0, else “{t}” {c} %",
|
||||
"「{i}」 가 {l} {o}이면 0 · 아니면 「{t}」 의 {c} % 를 더함",
|
||||
"if “{i}” is {l} {o} add 0, else “{t}” {c} %",
|
||||
],
|
||||
OpLe: ["이하", "or less"],
|
||||
OpLt: ["미만", "under"],
|
||||
DDivNum: ["{n} 로 나눔", "divide by {n}"],
|
||||
DDivIn: ["「{n}」 로 나눔(입력값)", "divide by “{n}” (input)"],
|
||||
DMin: ["{n} 보다 작은 값", "smaller of value and {n}"],
|
||||
@@ -127,7 +135,7 @@ const describe = (m: Mod): string => {
|
||||
: t("DSurchargeCond", {
|
||||
i: r.input,
|
||||
l: r.limit,
|
||||
o: r.op === "<=" ? "≤" : "<",
|
||||
o: t(r.op === "<=" ? "OpLe" : "OpLt"),
|
||||
t: r.table.이름,
|
||||
c: r.col,
|
||||
});
|
||||
@@ -371,7 +379,7 @@ export function stepCalc(ctx: WCtx, host: HTMLElement): void {
|
||||
export async function autoLinks(ctx: WCtx): Promise<void> {
|
||||
const { a } = ctx;
|
||||
for (const col of colsOf(a, "결과")) {
|
||||
if (a.links.some((l) => l.col === col)) continue;
|
||||
if (a.links.some((l) => l.col === col && l.kind !== "없음")) continue;
|
||||
const unit = a.table.값칸[col] ?? "";
|
||||
const link: Link = { col, kind: "없음", name: col, spec: "", unit, element: null };
|
||||
try {
|
||||
@@ -393,6 +401,7 @@ export async function autoLinks(ctx: WCtx): Promise<void> {
|
||||
} catch {
|
||||
/* 못 찾으면 연결 안 함으로 둠 */
|
||||
}
|
||||
a.links = a.links.filter((l) => l.col !== col);
|
||||
a.links.push(link);
|
||||
}
|
||||
ctx.changed();
|
||||
@@ -441,7 +450,10 @@ export function stepLink(ctx: WCtx, host: HTMLElement): void {
|
||||
attrs: { type: "button" },
|
||||
text: t(label),
|
||||
});
|
||||
b.addEventListener("click", () => void pick(kind, pk).catch((e) => alert(plainError(e))));
|
||||
b.addEventListener(
|
||||
"click",
|
||||
() => void pick(kind, pk).catch((e) => showToast(plainError(e), "error")),
|
||||
);
|
||||
return b;
|
||||
});
|
||||
const none = el("button", {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/* =============================================================================
|
||||
* M01_LB_Wizard_Parts.ts
|
||||
* 단계 안내 곁부품 — 서버 읽기(절 · 표) · 표 → WTable · 원문 표 미리 보기 · `M01_LB_Wizard.ts` 가 부름
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { ApiError } from "../M01_MasterData_UI_Logic_Api";
|
||||
import type { Role, WTable } from "./M01_LB_Wizard_Build";
|
||||
|
||||
export async function get<T>(path: string, params: Record<string, string>): Promise<T> {
|
||||
const res = await fetch(`${API_BASE_URL}/m01${path}?${new URLSearchParams(params)}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & T;
|
||||
if (!res.ok) throw new ApiError(res.status, data.detail ?? res.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface SectionItem {
|
||||
section: string;
|
||||
title: string;
|
||||
chapter: string;
|
||||
tables: number;
|
||||
logics: number;
|
||||
}
|
||||
export interface TableHead {
|
||||
file: string;
|
||||
키: string;
|
||||
이름: string;
|
||||
기준: string;
|
||||
조건: Record<string, string>;
|
||||
값칸: Record<string, string>;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** 서버 표 답 → WTable */
|
||||
export function toTable(raw: WTable & { 키: string }, file: string): WTable {
|
||||
return {
|
||||
key: raw.키,
|
||||
file,
|
||||
이름: raw.이름,
|
||||
기준: raw.기준 ?? "",
|
||||
출처: raw.출처 ?? "",
|
||||
조건: raw.조건,
|
||||
값칸: raw.값칸,
|
||||
범위규칙: raw.범위규칙 ?? null,
|
||||
줄: raw.줄,
|
||||
주: raw.주 ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
const cellText = (v: unknown): string =>
|
||||
Array.isArray(v)
|
||||
? v.map((x) => x ?? "").join("~")
|
||||
: v === null || v === undefined
|
||||
? ""
|
||||
: String(v);
|
||||
|
||||
/** 원문 표 앞 몇 줄 + 주 — 칸 머리 색 = 역할 */
|
||||
export function tablePreview(
|
||||
tb: WTable,
|
||||
labels: { preview: string; notes: string },
|
||||
roles?: Record<string, Role>,
|
||||
): HTMLElement {
|
||||
const cols = [...Object.keys(tb.조건), ...Object.keys(tb.값칸)];
|
||||
const head = el("tr", {
|
||||
children: cols.map((c) =>
|
||||
el("th", { className: `is-${roles?.[c] ?? (c in tb.조건 ? "조건" : "결과")}`, text: c }),
|
||||
),
|
||||
});
|
||||
const rows = tb.줄
|
||||
.slice(0, 6)
|
||||
.map((r) => el("tr", { children: cols.map((c) => el("td", { text: cellText(r[c]) })) }));
|
||||
return el("div", {
|
||||
className: "m01-lbw__table",
|
||||
children: [
|
||||
el("h5", { text: `${labels.preview} — ${tb.이름} (${tb.기준 || "—"})` }),
|
||||
el("div", {
|
||||
className: "m01-lbw__scroll",
|
||||
children: [el("table", { children: [head, ...rows] })],
|
||||
}),
|
||||
...(tb.줄.length > 6
|
||||
? [el("p", { className: "m01-lbw__muted", text: `… ${tb.줄.length} 줄` })]
|
||||
: []),
|
||||
...(tb.주.length
|
||||
? [
|
||||
el("h5", { text: labels.notes }),
|
||||
...tb.주.map((n) => el("p", { className: "m01-lbw__note-line", text: n })),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"다음": {"LB": 409, "EQ": 655, "RT": 20, "MN": 7000, "MT": 30719, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 331, "CC": 341, "GC": 1140, "QC": 2060, "GX": 1, "UA": 38},
|
||||
"다음": {"LB": 409, "EQ": 655, "RT": 20, "MN": 7000, "MT": 30719, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 331, "CC": 341, "GC": 1140, "QC": 2060, "GX": 4, "UA": 38},
|
||||
"폐기": {"MT000001": "자재품목 휘발유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000002": "자재품목 경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000003": "자재품목 선박용경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000004": "자재품목 중유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000005": "자재품목 등유 — 값이 비고 글뿐 · 유가 테이블로 감", "LB000399": "이름 「〃」 — 직종이 아니라 되풀이 기호 · 소요량 13-6-9 에서 윗줄 직종으로 바로잡음", "MN": "재료_나라장터자재 — old 로 옮김 · 로직은 자재품목·품셈재료로 갈아 끼움", "LB000372": "미확보 고급기능사 — 측량노임 항공사진고급기능사 옛이름으로 옮김", "LB000393": "미확보 리베팅공 — 건설노임 철골공 옛이름으로 옮김(2010 통합표 연번10)", "LB000405": "미확보 Belt Conveyor 설치공 — 품셈 13-10-1 주② 배분으로 다섯 직종에 나눔", "MT000006": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "MT000007": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "LB000368": "미확보 기술사 — 기술사(건설) 옛이름으로 옮김", "LB000369": "미확보 특급기술자 — 특급기술자(건설) 옛이름으로 옮김", "LB000370": "미확보 고급기술자 — 고급기술자(건설) 옛이름으로 옮김", "LB000361": "미확보 중급기술자 — 중급기술자(건설) 옛이름으로 옮김", "LB000364": "미확보 초급기술자 — 초급기술자(건설) 옛이름으로 옮김", "LB000365": "미확보 고급숙련기술자 — 고급숙련기술자(건설) 옛이름으로 옮김", "LB000366": "미확보 중급숙련기술자 — 중급숙련기술자(건설) 옛이름으로 옮김", "LB000367": "미확보 초급숙련기술자 — 초급숙련기술자(건설) 옛이름으로 옮김", "LB000389": "미확보 기계기사 — 초급기술자(기계·설비) 옛이름으로 옮김", "LB000388": "미확보 기계산업기사 — 초급숙련기술자(기계·설비) 옛이름으로 옮김", "MO000055": {"원문번호": "주택용저압:기본_200이하", "파일": "재료_유가전력.json"}, "MO000056": {"원문번호": "주택용저압:기본_201~400", "파일": "재료_유가전력.json"}, "MO000057": {"원문번호": "주택용저압:기본_400초과", "파일": "재료_유가전력.json"}, "MO000059": {"원문번호": "주택용저압:전력량_다음200", "파일": "재료_유가전력.json"}, "MO000060": {"원문번호": "주택용저압:전력량_400초과", "파일": "재료_유가전력.json"}, "MO000061": {"원문번호": "주택용저압:월간최저요금", "파일": "재료_유가전력.json"}, "MO000062": {"원문번호": "일반용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000063": {"원문번호": "일반용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000064": {"원문번호": "일반용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000065": {"원문번호": "일반용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000066": {"원문번호": "산업용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000067": {"원문번호": "산업용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000068": {"원문번호": "산업용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000069": {"원문번호": "산업용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000070": {"원문번호": "임시전력갑:기본요금적용", "파일": "재료_유가전력.json"}, "MO000071": {"원문번호": "임시전력갑:전력량요금적용", "파일": "재료_유가전력.json"}, "MO000072": {"원문번호": "임시전력갑:월간최저요금", "파일": "재료_유가전력.json"}},
|
||||
"키": {
|
||||
"CC000001": {"원문번호": "공통 1-3-1 콘크리트 및 포장용 재료", "파일": "계수_건설품셈_01장_적용기준.json"},
|
||||
@@ -2628,6 +2628,9 @@
|
||||
"GF000328": {"원문번호": "13-11-1 원형 돌망태", "파일": "로직_산림품셈_13장_구조물.json"},
|
||||
"GF000329": {"원문번호": "13-11-2 타원형 돌망태", "파일": "로직_산림품셈_13장_구조물.json"},
|
||||
"GF000330": {"원문번호": "13-15-1 목재 껍질벗기기", "파일": "로직_산림품셈_13장_구조물.json"},
|
||||
"GX000001": {"원문번호": "13-4-1 돌쌓기 메쌓기(인력)", "파일": "로직_자체.json"},
|
||||
"GX000002": {"원문번호": "12-34-4 암거이음받침 채움재(T=20㎜)", "파일": "로직_자체.json"},
|
||||
"GX000003": {"원문번호": "13-4-1 돌쌓기 메쌓기(인력) (2)", "파일": "로직_자체.json"},
|
||||
"LB000001": {"원문번호": "1001", "파일": "인력.json"},
|
||||
"LB000002": {"원문번호": "1002", "파일": "인력.json"},
|
||||
"LB000003": {"원문번호": "1003", "파일": "인력.json"},
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"그룹": "로직",
|
||||
"원문": "자체",
|
||||
"판": "2026",
|
||||
"줄": [
|
||||
{
|
||||
"키": "GX000001",
|
||||
"원문번호": "13-4-1",
|
||||
"구분": "자체",
|
||||
"상세구분": "13장 구조물",
|
||||
"이름": "돌쌓기 메쌓기(인력)",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "산림품셈 13-4-1",
|
||||
"소유": "현장",
|
||||
"비고": "고르기 첫 값 = 원문에 있는 조합(뒷길이 25 · 깬잡석 · 골쌓기) · 「원문에 없는 조합: 뒷길이=25 · 돌=견치돌 · 쌓기=골쌓기 / 뒷길이=25 · 돌=견치돌 · 쌓기=켜쌓기 / 뒷길이=25 · 돌=깬돌 · 쌓기=골쌓기 / 뒷길이=25 · 돌=깬돌 · 쌓기=켜쌓기 / 뒷길이=30 · 돌=견치돌 · 쌓기=골쌓기 외 5」 · GF000219 를 본뜸",
|
||||
"입력": [
|
||||
{ "이름": "뒷길이", "설명": "쌓을 돌의 뒤쪽 길이", "단위": "㎝", "고르기": [25, 30, 35, 45, 55, 60, 75] },
|
||||
{ "이름": "돌", "설명": "쌓을 돌 종류", "고르기": ["깬잡석", "견치돌", "깬돌", "호박돌 및 야면석"] },
|
||||
{ "이름": "쌓기", "설명": "돌을 어긋나게 쌓는지 줄 맞춰 쌓는지", "고르기": ["골쌓기", "켜쌓기"] },
|
||||
{ "이름": "높이", "설명": "쌓는 벽 높이", "단위": "m" },
|
||||
{ "이름": "초과증가율", "설명": "기준 높이를 넘어 더 드는 품 비율", "단위": "%", "범위": [80, 100] }
|
||||
],
|
||||
"중간": [{ "이름": "증가율", "식": "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 초과증가율))", "출처": "산림품셈 13-4-1 주③" }],
|
||||
"호표": [
|
||||
{ "종류": "인력", "요소": "LB000033", "이름": "석공", "단위": "인", "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)", "비목": "노무비" },
|
||||
{ "종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)", "비목": "노무비" }
|
||||
],
|
||||
"덧줄": [],
|
||||
"끝수": null,
|
||||
"시험입력": { "높이": 4.0, "돌": "깬잡석" }
|
||||
},
|
||||
{
|
||||
"키": "GX000002",
|
||||
"원문번호": "12-34-4",
|
||||
"구분": "자체",
|
||||
"상세구분": "12장 철근콘크리트",
|
||||
"이름": "암거이음받침 채움재(T=20㎜)",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "산림품셈 12-34-4",
|
||||
"소유": "현장",
|
||||
"비고": "해석: 원문 재료비 「JOINT FILLER」 뿐 — ㎡당 1㎡ · 설치 품 원문 없음 · GF000208 를 본뜸",
|
||||
"입력": [],
|
||||
"중간": [],
|
||||
"호표": [{ "종류": "재료", "요소": { "구분": "도료·방수·접착", "상세구분": "방수·실링", "검색어": "JOINT FILLER" }, "이름": "JOINT FILLER(T=20㎜)", "단위": "㎡", "수량": "1", "비목": "재료비" }],
|
||||
"덧줄": [],
|
||||
"끝수": null
|
||||
},
|
||||
{
|
||||
"키": "GX000003",
|
||||
"원문번호": "13-4-1",
|
||||
"구분": "자체",
|
||||
"상세구분": "13장 구조물",
|
||||
"이름": "돌쌓기 메쌓기(인력)",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "산림품셈 13-4-1",
|
||||
"소유": "현장",
|
||||
"비고": "고르기 첫 값 = 원문에 있는 조합(뒷길이 25 · 깬잡석 · 골쌓기) · 「원문에 없는 조합: 뒷길이=25 · 돌=견치돌 · 쌓기=골쌓기 / 뒷길이=25 · 돌=견치돌 · 쌓기=켜쌓기 / 뒷길이=25 · 돌=깬돌 · 쌓기=골쌓기 / 뒷길이=25 · 돌=깬돌 · 쌓기=켜쌓기 / 뒷길이=30 · 돌=견치돌 · 쌓기=골쌓기 외 5」 · GF000219 를 본뜸",
|
||||
"입력": [
|
||||
{ "이름": "뒷길이", "설명": "쌓을 돌의 뒤쪽 길이", "단위": "㎝", "고르기": [25, 30, 35, 45, 55, 60, 75] },
|
||||
{ "이름": "돌", "설명": "쌓을 돌 종류", "고르기": ["깬잡석", "견치돌", "깬돌", "호박돌 및 야면석"] },
|
||||
{ "이름": "쌓기", "설명": "돌을 어긋나게 쌓는지 줄 맞춰 쌓는지", "고르기": ["골쌓기", "켜쌓기"] },
|
||||
{ "이름": "높이", "설명": "쌓는 벽 높이", "단위": "m" },
|
||||
{ "이름": "초과증가율", "설명": "기준 높이를 넘어 더 드는 품 비율", "단위": "%", "범위": [80, 100] }
|
||||
],
|
||||
"중간": [{ "이름": "증가율", "식": "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 초과증가율))", "출처": "산림품셈 13-4-1 주③" }],
|
||||
"호표": [
|
||||
{ "종류": "인력", "요소": "LB000033", "이름": "석공", "단위": "인", "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)", "비목": "노무비" },
|
||||
{ "종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)", "비목": "노무비" }
|
||||
],
|
||||
"덧줄": [],
|
||||
"끝수": null,
|
||||
"시험입력": { "높이": 4.0, "돌": "깬잡석" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"""로직 만들기 판 단계 안내 — 답 → 로직 줄 짜기(`M01_LB_Wizard_Build.ts`).
|
||||
|
||||
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 순수 함수만 부름.
|
||||
견본 표 · 답은 이 시험 안에 둠(진짜 마스터 내용에 기대지 않음).
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Wizard_Build.ts"
|
||||
|
||||
RUNNER = r"""
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const [root, file] = process.argv.slice(1);
|
||||
const ts = require(path.join(root, "config", "node_modules", "typescript"));
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, () => ({}));
|
||||
const m = box.exports;
|
||||
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const out = jobs.map((job) => {
|
||||
if (job.op === "unit") return m.unitOfBase(job.base);
|
||||
if (job.op === "row") return m.buildRow(job.a);
|
||||
if (job.op === "trial") return m.trialOf(job.a, m.buildRow(job.a));
|
||||
return null;
|
||||
});
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
TABLE = {
|
||||
"key": "QF900001",
|
||||
"이름": "견본 쌓기",
|
||||
"기준": "1㎡",
|
||||
"출처": "산림품셈 9-9-9",
|
||||
"조건": {"뒷길이": "수", "돌": "고르기"},
|
||||
"값칸": {"석공": "인", "보통인부": "인"},
|
||||
"줄": [
|
||||
{"뒷길이": 25, "돌": "깬돌", "석공": 0.15, "보통인부": 0.12},
|
||||
{"뒷길이": 30, "돌": "깬돌", "석공": 0.26, "보통인부": 0.21},
|
||||
],
|
||||
"주": ["③ 높이 3m 초과 시 할증"],
|
||||
}
|
||||
RATE_TABLE = {
|
||||
"key": "QF900002",
|
||||
"이름": "견본 증가율표",
|
||||
"기준": "",
|
||||
"출처": "산림품셈 9-9-9 주③",
|
||||
"조건": {"높이": "범위"},
|
||||
"값칸": {"증가율": "%"},
|
||||
"줄": [{"높이": [3, 4], "증가율": 30}],
|
||||
"주": [],
|
||||
}
|
||||
|
||||
|
||||
def answers(**more) -> dict:
|
||||
base = {
|
||||
"book": "산림품셈",
|
||||
"section": "9-9-9",
|
||||
"table": TABLE,
|
||||
"roles": {"뒷길이": "조건", "돌": "조건", "석공": "결과", "보통인부": "결과"},
|
||||
"fixed": {},
|
||||
"options": {"뒷길이": [25, 30], "돌": ["깬돌"]},
|
||||
"resultUnit": "원/㎡",
|
||||
"mods": [],
|
||||
"links": [
|
||||
{
|
||||
"col": "석공",
|
||||
"kind": "노무",
|
||||
"name": "석공",
|
||||
"spec": "",
|
||||
"unit": "인",
|
||||
"element": "LB000033",
|
||||
},
|
||||
{
|
||||
"col": "보통인부",
|
||||
"kind": "노무",
|
||||
"name": "보통인부",
|
||||
"spec": "",
|
||||
"unit": "인",
|
||||
"element": "LB000002",
|
||||
},
|
||||
],
|
||||
"extras": [],
|
||||
"name": "견본",
|
||||
"kind": "금액",
|
||||
"valueCol": "",
|
||||
}
|
||||
base.update(more)
|
||||
return base
|
||||
|
||||
|
||||
def run(jobs: list[dict]) -> list:
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node 가 없음")
|
||||
done = subprocess.run(
|
||||
["node", "-e", RUNNER, str(ROOT), str(SOURCE)],
|
||||
input=json.dumps(jobs, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def test_결과단위는_표_기준에서():
|
||||
out = run([{"op": "unit", "base": b} for b in ("1㎡", "100㎡ 당", "㎡당", "m당", "")])
|
||||
assert out == ["원/㎡", "원/100㎡", "원/㎡", "원/m", "원/㎡"]
|
||||
|
||||
|
||||
def test_기본_두_줄():
|
||||
row = run([{"op": "row", "a": answers()}])[0]
|
||||
assert [i["이름"] for i in row["입력"]] == ["뒷길이", "돌"]
|
||||
assert row["입력"][0]["고르기"] == [25, 30]
|
||||
assert row["중간"] == []
|
||||
assert [h["수량"] for h in row["호표"]] == [
|
||||
"찾기(QF900001, 뒷길이=뒷길이, 돌=돌).석공",
|
||||
"찾기(QF900001, 뒷길이=뒷길이, 돌=돌).보통인부",
|
||||
]
|
||||
assert row["호표"][0]["종류"] == "인력" and row["호표"][0]["비목"] == "노무비"
|
||||
assert row["결과단위"] == "원/㎡" and row["출처"] == "산림품셈 9-9-9"
|
||||
|
||||
|
||||
def test_고정칸은_값으로():
|
||||
a = answers(
|
||||
roles={"뒷길이": "고정", "돌": "조건", "석공": "결과", "보통인부": "쓰지 않음"},
|
||||
fixed={"뒷길이": 25},
|
||||
)
|
||||
row = run([{"op": "row", "a": a}])[0]
|
||||
assert [i["이름"] for i in row["입력"]] == ["돌"]
|
||||
assert [h["수량"] for h in row["호표"]] == ["찾기(QF900001, 돌=돌, 뒷길이=25).석공"]
|
||||
|
||||
|
||||
def test_문장틀_차례대로_씌움():
|
||||
cond = {
|
||||
"t": "cond",
|
||||
"input": "높이",
|
||||
"op": "<=",
|
||||
"limit": 3,
|
||||
"table": RATE_TABLE,
|
||||
"col": "증가율",
|
||||
}
|
||||
a = answers(
|
||||
mods=[
|
||||
{"k": "할증", "rate": cond, "src": "산림품셈 9-9-9 주③"},
|
||||
{"k": "나누기", "by": {"t": "input", "name": "시공량"}},
|
||||
{"k": "올림", "fn": "반올림", "places": 3},
|
||||
]
|
||||
)
|
||||
row = run([{"op": "row", "a": a}])[0]
|
||||
assert row["중간"] == [
|
||||
{
|
||||
"이름": "증가율",
|
||||
"식": "만약(높이 <= 3, 0, 찾기(QF900002, 높이=높이).증가율)",
|
||||
"출처": "산림품셈 9-9-9 주③",
|
||||
}
|
||||
]
|
||||
assert row["호표"][0]["수량"] == (
|
||||
"반올림((찾기(QF900001, 뒷길이=뒷길이, 돌=돌).석공 * (1 + 증가율 / 100)) / 시공량, 3)"
|
||||
)
|
||||
assert [i["이름"] for i in row["입력"]] == ["뒷길이", "돌", "높이", "시공량"]
|
||||
|
||||
|
||||
def test_비율_덧줄은_사람줄합():
|
||||
extras = [{"k": "비율", "name": "공구손료", "base": "사람줄합", "rate": 3, "cost": "재료비"}]
|
||||
row = run([{"op": "row", "a": answers(extras=extras)}])[0]
|
||||
assert row["덧줄"] == [
|
||||
{
|
||||
"이름": "공구손료",
|
||||
"식": "((찾기(QF900001, 뒷길이=뒷길이, 돌=돌).석공) * LB000033 + (찾기(QF900001, 뒷길이=뒷길이, 돌=돌).보통인부) * LB000002) * 3 / 100",
|
||||
"비목": "재료비",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_값_종류는_결과_식_하나():
|
||||
a = answers(kind="값", valueCol="석공", resultUnit="인/㎡")
|
||||
row = run([{"op": "row", "a": a}])[0]
|
||||
assert row["결과"] == {"식": "찾기(QF900001, 뒷길이=뒷길이, 돌=돌).석공"}
|
||||
assert "호표" not in row and row["결과단위"] == "인/㎡"
|
||||
|
||||
|
||||
def test_예시값은_첫_줄에_걸림():
|
||||
out = run([{"op": "trial", "a": answers()}])[0]
|
||||
assert out == {"뒷길이": 25, "돌": "깬돌"}
|
||||
Reference in New Issue
Block a user