Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3
This commit is contained in:
@@ -84,6 +84,24 @@ export function lbCalcLater(
|
||||
}, CALC_WAIT_MS);
|
||||
}
|
||||
|
||||
/** 기다림 없이 바로 셈(기대값 시험표처럼 여러 줄을 차례로) — `lbCalcLater` 의 최신만과 안 걸림.
|
||||
* 서버에 못 닿으면 null · 멈춤(ok false)은 답 그대로 · 둘 다 `onPlain` 에 쉬운 말 */
|
||||
export async function lbCalcNow(
|
||||
row: LogicRow,
|
||||
inputs: Record<string, unknown>,
|
||||
tables: LbTables,
|
||||
onPlain?: (plain: string) => void,
|
||||
): Promise<CalcAnswer | null> {
|
||||
try {
|
||||
const ans = await send<CalcAnswer>("/calc", { row, inputs: inputsFor(row, inputs), tables });
|
||||
if (!ans.ok) onPlain?.(plainStop(ans.reason));
|
||||
return ans;
|
||||
} catch (e: unknown) {
|
||||
onPlain?.(plainError(e));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 사용처 — 그 표를 쓰는 로직(표 「용도」 + 로직 글 대조) */
|
||||
export async function tableUsers(key: string): Promise<LbTableUser[]> {
|
||||
const got = await send<{ users: LbTableUser[] }>(
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
/* =============================================================================
|
||||
* M01_LB_Check.ts
|
||||
* 로직 만들기 판 — 검사(정함 7 · 설계초안 4절)
|
||||
* ① gaps(lb, tables) — 표 조건 칸에 묶인 입력값 선택 목록 조합 × 표 줄 → 해당 없음 · 중복 목록(브라우저 셈)
|
||||
* ② lookupHits(lb, tables, values) — 예시값으로 표마다 걸린 줄(노랑) · 해당 없음 · 중복
|
||||
* ③ runExpect(lb, tables, rows) — 기대값 시험표 · 예시값마다 시험 계산 · 통과 / 실패
|
||||
* 시험표 줄은 sessionStorage 초안만(저장 안 함)
|
||||
*
|
||||
* 줄 찾기 = 엔진 `master_formula._match · find` 와 같은 규칙(범위규칙 넷 · 빈 칸은 무관 ·
|
||||
* 여러 줄이면 위 끝 가장 작은 줄 · 결과 칸 있는 줄) — 엔진을 바꾸면 이 파일도 같이 바꿈.
|
||||
* DOM 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CalcAnswer, LogicRow } from "../M01_MasterData_UI_Logic_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];
|
||||
export interface CheckTable {
|
||||
키?: string;
|
||||
범위규칙?: string;
|
||||
조건?: Record<string, string>;
|
||||
값칸?: Record<string, string>;
|
||||
줄: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/* ── 엔진과 같은 줄 맞추기 ─────────────────────────────────────────── */
|
||||
|
||||
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
||||
|
||||
function match(
|
||||
table: CheckTable,
|
||||
row: Record<string, unknown>,
|
||||
cond: string,
|
||||
want: unknown,
|
||||
): boolean {
|
||||
if (!(cond in row)) return true; // 빈 조건 칸 = 무관
|
||||
const have = row[cond] as Cell;
|
||||
if (table.조건?.[cond] === "범위") {
|
||||
if (!Array.isArray(have) || !isNum(want)) return false;
|
||||
const [low, high] = have;
|
||||
const [head, ...more] = (table.범위규칙 ?? "초과·이하").split(" · ");
|
||||
const rule = head.split(" (")[0];
|
||||
const above = (strict: boolean): boolean => low === null || (strict ? want > low : want >= low);
|
||||
if (rule === "이상·이하") return above(false) && (high === null || want <= high);
|
||||
if (rule === "초과·이하" || rule === "이하")
|
||||
return above(true) && (high === null || want <= high);
|
||||
if (rule === "이상·미만") {
|
||||
const tops = table.줄
|
||||
.map((r) => r[cond])
|
||||
.filter((c): c is [number | null, number] => Array.isArray(c) && c[1] !== null)
|
||||
.map((c) => c[1]);
|
||||
const closed =
|
||||
more.some((x) => x.includes("이하 포함")) && tops.length > 0 && high === Math.max(...tops);
|
||||
return above(false) && (high === null || want < high || (closed && want === high));
|
||||
}
|
||||
return false; // 모르는 범위규칙 — 엔진은 멈춤
|
||||
}
|
||||
if (isNum(have) && isNum(want)) return have === want;
|
||||
return typeof have === "string" && typeof want === "string" && have === want;
|
||||
}
|
||||
|
||||
/** 엔진 `find` 처럼 맞는 줄 차례 — 하나면 그 줄 · 0 · 2+ 는 그대로 돌려줌 */
|
||||
export function findRows(table: CheckTable, conds: Record<string, unknown>, want = ""): number[] {
|
||||
let hits = table.줄
|
||||
.map((row, i) => ({ row, i }))
|
||||
.filter(({ row }) => Object.entries(conds).every(([c, v]) => match(table, row, c, v)));
|
||||
const first = (table.범위규칙 ?? "").split(" · ")[0];
|
||||
if (hits.length > 1 && (first === "이하" || first === "이상·이하")) {
|
||||
for (const c of Object.keys(conds)) {
|
||||
if (table.조건?.[c] !== "범위") continue;
|
||||
const ups = hits.filter(({ row }) => row[c]).map(({ row }) => (row[c] as Cell[])[1]);
|
||||
if (!ups.length || ups.includes(null)) continue;
|
||||
const low = Math.min(...(ups as number[]));
|
||||
hits = hits.filter(({ row }) => !row[c] || (row[c] as Cell[])[1] === low);
|
||||
}
|
||||
}
|
||||
if (hits.length > 1 && want) hits = hits.filter(({ row }) => want in row);
|
||||
return hits.map((h) => h.i);
|
||||
}
|
||||
|
||||
/* ── ① 빈틈 · 중복 ──────────────────────────────────────────────────── */
|
||||
|
||||
export interface Gap {
|
||||
table: string;
|
||||
/** 어느 표 조회에서 나왔나(화면 번호) */
|
||||
lookup: string;
|
||||
conds: Record<string, unknown>;
|
||||
kind: "없음" | "중복";
|
||||
/** 중복이면 걸린 줄 차례 */
|
||||
rows: number[];
|
||||
}
|
||||
|
||||
/** 조합마다 조건 값 — 입력값은 선택 목록 · 고정값은 그 값 · 그 밖(계산값 등)은 셀 수 없어 null */
|
||||
function combos(lb: LbLogic, lk: LbLookup): Record<string, unknown>[] | null {
|
||||
const lists: [string, unknown[]][] = [];
|
||||
for (const { col, from } of lk.conds) {
|
||||
if (from.k === "fixed") lists.push([col, [from.value]]);
|
||||
else if (from.k === "input") {
|
||||
const spec = lb.inputs.find((x) => x.이름 === from.name);
|
||||
if (!spec?.고르기?.length) return null;
|
||||
lists.push([col, spec.고르기]);
|
||||
} else return null;
|
||||
}
|
||||
let out: Record<string, unknown>[] = [{}];
|
||||
for (const [col, values] of lists)
|
||||
out = out.flatMap((o) => values.map((v) => ({ ...o, [col]: v })));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 입력값 선택 목록 조합마다 표 줄 대조 — 해당 없음 · 중복만 돌려줌(셀 수 없는 표 조회는 건너뜀) */
|
||||
export function gaps(lb: LbLogic, tables: Record<string, unknown>): Gap[] {
|
||||
const out: Gap[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const lk of lb.lookups) {
|
||||
const table = tables[lk.table] as CheckTable | undefined;
|
||||
const sets = table?.줄 ? combos(lb, lk) : null;
|
||||
if (!table || !sets) continue;
|
||||
for (const conds of sets) {
|
||||
const rows = findRows(table, conds, lk.col);
|
||||
if (rows.length === 1) continue;
|
||||
const tag = `${lk.table}|${JSON.stringify(conds)}|${rows.join(",")}`;
|
||||
if (seen.has(tag)) continue;
|
||||
seen.add(tag);
|
||||
out.push({
|
||||
table: lk.table,
|
||||
lookup: lk.id,
|
||||
conds,
|
||||
kind: rows.length ? "중복" : "없음",
|
||||
rows,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 셀 수 없어 빈틈 검사를 건너뛴 표 조회(계산값 · 숫자 입력에 묶인 조건) */
|
||||
export function gapSkipped(lb: LbLogic, tables: Record<string, unknown>): string[] {
|
||||
return lb.lookups.filter((lk) => !tables[lk.table] || !combos(lb, lk)).map((lk) => lk.id);
|
||||
}
|
||||
|
||||
/* ── ② 예시값으로 걸린 줄 ─────────────────────────────────────────── */
|
||||
|
||||
/** 표 조회 번호 → 걸린 줄 차례 · 「없음」 · 「중복」 · null(계산값에 묶여 화면이 모름) */
|
||||
export type LbHit = number | null | "없음" | "중복";
|
||||
|
||||
export function lookupHits(
|
||||
lb: LbLogic,
|
||||
tables: Record<string, unknown>,
|
||||
values: Record<string, unknown>,
|
||||
): Record<string, LbHit> {
|
||||
const out: Record<string, LbHit> = {};
|
||||
for (const lk of lb.lookups) {
|
||||
const table = tables[lk.table] as CheckTable | undefined;
|
||||
const conds: Record<string, unknown> = {};
|
||||
let known = !!table?.줄;
|
||||
for (const { col, from } of lk.conds) {
|
||||
if (from.k === "fixed") conds[col] = from.value;
|
||||
else if (from.k === "input" && values[from.name] !== undefined)
|
||||
conds[col] = values[from.name];
|
||||
else known = false;
|
||||
}
|
||||
if (!known || !table) {
|
||||
out[lk.id] = null;
|
||||
continue;
|
||||
}
|
||||
const rows = findRows(table, conds, lk.col);
|
||||
out[lk.id] = rows.length === 1 ? rows[0] : rows.length ? "중복" : "없음";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── ③ 기대값 시험표 ───────────────────────────────────────────────── */
|
||||
|
||||
export interface ExpectRow {
|
||||
/** 예시값 */
|
||||
inputs: Record<string, unknown>;
|
||||
/** 볼 값 — 「계」(합계) · 「결과」 · 항목 이름(수량) · 「항목 이름 금액」 */
|
||||
expect: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ExpectResult {
|
||||
ok: boolean;
|
||||
got: Record<string, number>;
|
||||
/** 계산 멈춤 · 서버 오류 까닭(쉬운 말은 부른 쪽이 다듬음) */
|
||||
why: string | null;
|
||||
}
|
||||
|
||||
/** 계산 답 → 볼 값 모음 */
|
||||
export function pickValues(ans: CalcAnswer): Record<string, number> {
|
||||
const got: Record<string, number> = {};
|
||||
if (!ans.ok) return got;
|
||||
if (ans.sums && isNum(ans.sums["계"])) got["계"] = ans.sums["계"];
|
||||
if (isNum(ans.result)) got["결과"] = ans.result;
|
||||
for (const line of ans.lines ?? []) {
|
||||
if (isNum(line.수량)) got[line.이름] = line.수량;
|
||||
if (isNum(line.금액)) got[`${line.이름} 금액`] = line.금액;
|
||||
}
|
||||
return got;
|
||||
}
|
||||
|
||||
/** 기대값과 같은가 — 기대값에 적은 자릿수의 반 올림 안쪽이면 같음(52,437 ↔ 52,437.06) */
|
||||
export function sameAsExpect(got: number | undefined, expect: number): boolean {
|
||||
if (!isNum(got)) return false;
|
||||
const places = (String(expect).split(".")[1] ?? "").length;
|
||||
return Math.abs(got - expect) <= 0.5 * 10 ** -places + 1e-9;
|
||||
}
|
||||
|
||||
type CalcFn = (
|
||||
row: LogicRow,
|
||||
inputs: Record<string, unknown>,
|
||||
tables: LbTables,
|
||||
) => Promise<CalcAnswer>;
|
||||
|
||||
/** 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 = calcNow,
|
||||
): Promise<ExpectResult[]> {
|
||||
const row = writeLogic(lb);
|
||||
const out: ExpectResult[] = [];
|
||||
for (const r of rows) {
|
||||
try {
|
||||
const ans = await calc(row, r.inputs, tables);
|
||||
const got = pickValues(ans);
|
||||
const ok = ans.ok && Object.entries(r.expect).every(([k, v]) => sameAsExpect(got[k], v));
|
||||
out.push({ ok, got, why: ans.ok ? null : ans.reason });
|
||||
} catch (e) {
|
||||
out.push({ ok: false, got: {}, why: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── 시험표 초안(sessionStorage · 로직마다) ───────────────────────────── */
|
||||
|
||||
const draftKey = (logic: string): string => `m01.lb.expect.${logic || "새"}`;
|
||||
|
||||
export function loadExpectRows(logic: string): ExpectRow[] {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(draftKey(logic));
|
||||
const rows = raw ? (JSON.parse(raw) as ExpectRow[]) : [];
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveExpectRows(logic: string, rows: ExpectRow[]): void {
|
||||
try {
|
||||
if (rows.length) sessionStorage.setItem(draftKey(logic), JSON.stringify(rows));
|
||||
else sessionStorage.removeItem(draftKey(logic));
|
||||
} catch {
|
||||
/* 저장 칸이 막힌 브라우저 — 이번 화면에서만 씀 */
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -20,16 +22,25 @@ import type { CalcAnswer, LogicInput, LogicRow } from "../M01_MasterData_UI_Logi
|
||||
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
|
||||
import { formatNumber } from "../M01_MasterData_UI_Logic_Edit";
|
||||
import {
|
||||
calcLater,
|
||||
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 = {
|
||||
@@ -69,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?",
|
||||
@@ -106,35 +126,38 @@ 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";
|
||||
|
||||
/* ponytail: Blocks(sub7) · LB Api(sub4) 속이 「준비 중」 인 동안만 대신함 — 채워지면 두 catch 를 지움 */
|
||||
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 {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const calcWith = (
|
||||
p: Paper,
|
||||
onDone: (ans: CalcAnswer | null, plain: string | null) => void,
|
||||
): void => {
|
||||
try {
|
||||
lbCalcLater(p.row, p.trial, p.tables, onDone);
|
||||
} catch {
|
||||
calcLater(p.row, p.trial, onDone);
|
||||
}
|
||||
};
|
||||
|
||||
/** 「쉬운 단가산출(시험)」 옆에 놓는 단추 — 누르면 큰 창 */
|
||||
export function createLogicBuilderButton(): HTMLButtonElement {
|
||||
@@ -143,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();
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------- 시작 화면 ---------- */
|
||||
|
||||
@@ -177,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" });
|
||||
@@ -226,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,
|
||||
],
|
||||
@@ -251,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();
|
||||
};
|
||||
@@ -261,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);
|
||||
calcWith(p, (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 {
|
||||
@@ -338,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(
|
||||
@@ -393,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();
|
||||
};
|
||||
|
||||
@@ -423,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", {
|
||||
@@ -442,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();
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
.m01-lbgrid__scroll {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.m01-lbgrid__table {
|
||||
width: 100%;
|
||||
min-width: 720px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-lbgrid__table th,
|
||||
.m01-lbgrid__table td {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-right: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.m01-lbgrid__table th:first-child,
|
||||
.m01-lbgrid__table td:first-child {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01-lbgrid__table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-lbgrid__row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-lbgrid__row--first td {
|
||||
border-top: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01-lbgrid__row--sub .m01-lbgrid__name {
|
||||
padding-left: var(--spacing-16);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01-lbgrid__step {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-lbgrid__table td.m01-lbgrid__num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m01-lbgrid__src {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 고른 줄 = 굵은 테두리 · 앞 줄 = 연한 칠 + 왼쪽 막대(색만으로 뜻을 안 전함) */
|
||||
.m01-lbgrid__row--on td {
|
||||
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01-lbgrid__row--pre td {
|
||||
background: color-mix(in srgb, var(--color-warning) 28%, transparent);
|
||||
}
|
||||
|
||||
.m01-lbgrid__row--pre td:first-child {
|
||||
box-shadow: inset 4px 0 0 var(--color-warning);
|
||||
}
|
||||
|
||||
.m01-lbgrid__input {
|
||||
box-sizing: border-box;
|
||||
width: 96px;
|
||||
height: var(--control-height-sm);
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
font-size: var(--text-body-sm);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.m01-lbgrid__msg {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m01-lbgrid__note,
|
||||
.m01-lbgrid__hint {
|
||||
margin: var(--spacing-4) 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
/* =============================================================================
|
||||
* M01_LB_Grid.ts
|
||||
* 로직 만들기 판 — 가운데 격자 보기(엑셀처럼 줄 · 칸). 문장 보기(Steps)와 같은 LbLogic · 같은 on.change
|
||||
* 열 = 단계 · 이름 · 무엇으로 · 값 · 단위 · 출처 / 줄 = 입력값 · 표 조회(+조건) · 계산(+조각) · 항목 · 결과값
|
||||
* 칸은 고르기 · 숫자 칸만 · 줄을 누르면 그 줄이 쓰는 줄(앞 줄)을 칠함 · 표준은 읽기만
|
||||
* 모양 = docs/raw/M01_쉬운단가/로직판_A_정함.md · 로직판_A_설계초안.md 7-1. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createSelectField, el } from "@ui/ui_template_elements";
|
||||
import type { CalcAnswer } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
|
||||
import { safeReason } from "../M01_MasterData_UI_Logic_Text";
|
||||
import { updateCard, type EasyCard } from "../EasyCalc/M01_EasyCalc_Model";
|
||||
import {
|
||||
setCalc,
|
||||
setCond,
|
||||
type LbCalc,
|
||||
type LbExpr,
|
||||
type LbLogic,
|
||||
type LbRef,
|
||||
} from "./M01_LB_Blocks";
|
||||
import "./M01_LB_Grid.css";
|
||||
|
||||
const TEXT = {
|
||||
Step1: ["① 입력값", "① Input"],
|
||||
Step2: ["② 표 조회", "② Table lookup"],
|
||||
Step3: ["③ 계산", "③ Calculation"],
|
||||
Step4: ["④ 항목 연결", "④ Items"],
|
||||
Step5: ["⑤ 결과값", "⑤ Result"],
|
||||
HStep: ["단계", "Step"],
|
||||
HName: ["이름", "Name"],
|
||||
HHow: ["무엇으로", "From"],
|
||||
HValue: ["값", "Value"],
|
||||
HUnit: ["단위", "Unit"],
|
||||
HSource: ["출처", "Source"],
|
||||
Busy: ["계산 중", "Calculating"],
|
||||
Stopped: ["계산하지 못했어요", "Could not calculate"],
|
||||
ReadOnly: [
|
||||
"표준은 읽기만 — 「복사 편집」 뒤에 고칠 수 있어요",
|
||||
"Standard is read-only — use “Copy and edit” first",
|
||||
],
|
||||
Hint: ["줄을 누르면 그 줄이 쓰는 줄이 칠해져요", "Click a row to paint the rows it uses"],
|
||||
Bad: ["숫자를 넣어 주세요", "Enter a number"],
|
||||
Cant: ["지금은 이 칸을 고칠 수 없어요", "This cell cannot be changed now"],
|
||||
Typed: ["숫자 칸", "Number"],
|
||||
Choice: ["고르기", "Choice"],
|
||||
Range: ["범위", "Range"],
|
||||
FromInput: ["입력값", "Input"],
|
||||
FromCalc: ["계산", "Calc"],
|
||||
FromLookup: ["표 조회", "Lookup"],
|
||||
FromFixed: ["고정값", "Fixed"],
|
||||
FromLogic: ["다른 일위대가 결과", "Other unit-price result"],
|
||||
FromPeople: ["사람 품값 합", "People cost total"],
|
||||
FromNone: ["무관", "Any"],
|
||||
Own: ["직접 쓴 계산", "Written by hand"],
|
||||
Cond: ["조건", "Condition"],
|
||||
Table: ["표", "Table"],
|
||||
Number: ["숫자", "Number"],
|
||||
Calc: ["계산", "Calculation"],
|
||||
Sum: ["항목 합계", "Item total"],
|
||||
UseName: ["계산 이름", "Calc name"],
|
||||
UseQty: ["항목 수량", "Item amount"],
|
||||
UseRate: ["항목 비율", "Item rate"],
|
||||
UseResult: ["결과값", "Result"],
|
||||
Won: ["원", "KRW"],
|
||||
Unnamed: ["수량 계산", "Amount calc"],
|
||||
Price: ["단가", "Unit price"],
|
||||
Result: ["결과", "Result"],
|
||||
KValue: ["값", "Value"],
|
||||
KMul: ["곱하기 · 나누기", "Multiply / divide"],
|
||||
KRaise: ["할증", "Surcharge"],
|
||||
KAdd: ["더하기", "Add"],
|
||||
KCond: ["조건 목록", "Conditions"],
|
||||
KMinMax: ["최소 · 최대", "Min / max"],
|
||||
KRound: ["올림 · 버림", "Round"],
|
||||
KSteps: ["여러 단계", "Several steps"],
|
||||
Person: ["사람", "Person"],
|
||||
Material: ["재료", "Material"],
|
||||
Machine: ["기계", "Machine"],
|
||||
Logic: ["다른 일위대가", "Other unit price"],
|
||||
Percent: ["덧붙임", "Add-on %"],
|
||||
Locked: ["그 밖 줄", "Other line"],
|
||||
Base: ["바탕", "Base"],
|
||||
Rate: ["율(%)", "Rate (%)"],
|
||||
First: ["처음", "First"],
|
||||
Times: ["곱하기", "times"],
|
||||
Divide: ["나누기", "divided by"],
|
||||
Plus: ["더하기", "plus"],
|
||||
Minus: ["빼기", "minus"],
|
||||
} as const;
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
const tg = (key: TextKey): string => TEXT[key][currentLanguageIndex];
|
||||
|
||||
type Part = Exclude<LbCalc["at"]["part"], never>;
|
||||
type Ref = LbRef | LbExpr;
|
||||
type Cell = string | HTMLElement;
|
||||
|
||||
interface GridRow {
|
||||
id: string;
|
||||
step: string;
|
||||
sub: boolean;
|
||||
name: Cell;
|
||||
how: Cell;
|
||||
value: Cell;
|
||||
unit: string;
|
||||
source: string;
|
||||
/** 이 줄이 쓰는 줄 번호 */
|
||||
uses: string[];
|
||||
}
|
||||
|
||||
const KIND_KEY: Record<LbCalc["kind"], TextKey> = {
|
||||
값: "KValue",
|
||||
곱나눔: "KMul",
|
||||
할증: "KRaise",
|
||||
더하기: "KAdd",
|
||||
조건목록: "KCond",
|
||||
최소최대: "KMinMax",
|
||||
올림: "KRound",
|
||||
단계: "KSteps",
|
||||
};
|
||||
const CARD_KEY: Record<EasyCard["kind"], TextKey> = {
|
||||
person: "Person",
|
||||
material: "Material",
|
||||
machine: "Machine",
|
||||
logic: "Logic",
|
||||
percent: "Percent",
|
||||
locked: "Locked",
|
||||
};
|
||||
const USE_KEY: Record<Part, TextKey> = {
|
||||
중간: "UseName",
|
||||
수량: "UseQty",
|
||||
비율: "UseRate",
|
||||
결과: "UseResult",
|
||||
};
|
||||
|
||||
const num = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
const show = (v: unknown): string =>
|
||||
typeof v === "number"
|
||||
? num(v)
|
||||
: v === undefined || v === null || typeof v === "object"
|
||||
? ""
|
||||
: String(v);
|
||||
|
||||
/** 선택된 줄 — 다시 그려도 남도록 칸(host)마다 기억 */
|
||||
const picked = new WeakMap<HTMLElement, string>();
|
||||
|
||||
/** 이름이 글 안에 따로 있는지 — 영문 이름은 낱말 경계로 */
|
||||
function mentions(text: string, name: string): boolean {
|
||||
if (!name) return false;
|
||||
if (!/^\w+$/.test(name)) return text.includes(name);
|
||||
return new RegExp(`(^|[^A-Za-z0-9_])${name}($|[^A-Za-z0-9_])`).test(text);
|
||||
}
|
||||
|
||||
export interface GridOptions {
|
||||
/** 입력값 칸에 보일 예시값 — 없으면 계산 답에서 찾음 */
|
||||
trial?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 격자 보기 — on.change 로 고친 LbLogic 을 알림(읽기만이면 안 알림) */
|
||||
export function renderGrid(
|
||||
host: HTMLElement,
|
||||
lb: LbLogic,
|
||||
ans: CalcAnswer | null,
|
||||
on: { change(lb: LbLogic): void },
|
||||
opts: GridOptions = {},
|
||||
): void {
|
||||
const readOnly = lb.doc.isBase;
|
||||
const okAns = ans && ans.ok ? ans : null;
|
||||
const middle = okAns?.middle ?? {};
|
||||
const lines = okAns?.lines ?? [];
|
||||
const msg = el("p", { className: "m01-lbgrid__msg" });
|
||||
const calcIndex = (name: string): number => lb.calcs.findIndex((c) => c.name === name);
|
||||
const apply = (make: () => LbLogic): void => {
|
||||
if (readOnly) return;
|
||||
msg.textContent = "";
|
||||
try {
|
||||
on.change(make());
|
||||
} catch {
|
||||
msg.textContent = tg("Cant");
|
||||
}
|
||||
};
|
||||
|
||||
/* ---- 값 · 줄 번호 ---- */
|
||||
const rowOf = (r: Ref): string[] => {
|
||||
if (r.k === "input") return [`in:${r.name}`];
|
||||
if (r.k === "lookup") return [`lk:${r.id}`];
|
||||
if (r.k === "calc") return calcIndex(r.name) >= 0 ? [`ca:${calcIndex(r.name)}`] : [];
|
||||
if (r.k === "expr") return textRows(r.text);
|
||||
return [];
|
||||
};
|
||||
const textRows = (text: string, self = -1): string[] => [
|
||||
...lb.inputs.filter((i) => mentions(text, i.이름)).map((i) => `in:${i.이름}`),
|
||||
...lb.calcs.flatMap((c, i) =>
|
||||
i !== self && c.name && mentions(text, c.name) ? [`ca:${i}`] : [],
|
||||
),
|
||||
];
|
||||
const lookupsAt = (part: Part | "요소", index: number): string[] =>
|
||||
lb.lookups
|
||||
.filter((l) => l.at.some((a) => a.part === part && a.index === index))
|
||||
.map((l) => `lk:${l.id}`);
|
||||
const lineOf = (card: EasyCard) =>
|
||||
lines.find((l) => l.이름 === card.name && l.단위 === card.unit);
|
||||
const lookupValue = (id: string): unknown => {
|
||||
const solo = lb.calcs.find(
|
||||
(c) => c.parts.length === 1 && c.parts[0].k === "lookup" && c.parts[0].id === id,
|
||||
);
|
||||
if (solo?.name && middle[solo.name] !== undefined) return middle[solo.name];
|
||||
const at = lb.lookups
|
||||
.find((l) => l.id === id)
|
||||
?.at.find((a) => a.part === "수량" && a.start === 0);
|
||||
const card = lb.doc.cards.find(
|
||||
(c) => c.source?.part === "호표" && c.source.index === at?.index,
|
||||
);
|
||||
const line = card && lineOf(card);
|
||||
return line && card?.qtyFormula && at && at.end === card.qtyFormula.length ? line.수량 : "";
|
||||
};
|
||||
const valueOf = (r: Ref): unknown => {
|
||||
if (r.k === "input") return opts.trial?.[r.name] ?? middle[r.name];
|
||||
if (r.k === "fixed") return r.value;
|
||||
if (r.k === "calc") return middle[r.name];
|
||||
if (r.k === "lookup") return lookupValue(r.id);
|
||||
if (r.k === "cost") return okAns?.sums?.[r.name === "사람줄합" ? "노무비" : r.name];
|
||||
return "";
|
||||
};
|
||||
const unitOf = (r: Ref): string =>
|
||||
r.k === "input" ? (lb.inputs.find((i) => i.이름 === r.name)?.단위 ?? "") : "";
|
||||
|
||||
/* ---- 칸 만들기 ---- */
|
||||
const numberCell = (value: unknown, make: (n: number) => LbLogic): HTMLElement => {
|
||||
const box = el("input", {
|
||||
className: "m01-lbgrid__input",
|
||||
attrs: { type: "text", inputmode: "decimal", "aria-label": tg("Number") },
|
||||
});
|
||||
box.value = show(value);
|
||||
box.readOnly = readOnly;
|
||||
box.addEventListener("click", (e) => e.stopPropagation());
|
||||
box.addEventListener("change", () => {
|
||||
const n = Number(box.value.trim());
|
||||
if (box.value.trim() === "" || Number.isNaN(n)) {
|
||||
msg.textContent = tg("Bad");
|
||||
box.value = show(value);
|
||||
return;
|
||||
}
|
||||
apply(() => make(n));
|
||||
});
|
||||
return box;
|
||||
};
|
||||
|
||||
const refLabel = (r: Ref): string => {
|
||||
if (r.k === "input") return `${tg("FromInput")} · ${r.name}`;
|
||||
if (r.k === "calc") return `${tg("FromCalc")} · ${r.name}`;
|
||||
if (r.k === "lookup") {
|
||||
const l = lb.lookups.find((x) => x.id === r.id);
|
||||
return `${tg("FromLookup")} · ${l ? `${l.table} ${l.col}` : r.id}`;
|
||||
}
|
||||
if (r.k === "fixed") return tg("FromFixed");
|
||||
if (r.k === "logic") return tg("FromLogic");
|
||||
if (r.k === "cost") return r.name === "사람줄합" ? tg("FromPeople") : r.name;
|
||||
return tg("Own");
|
||||
};
|
||||
const refKey = (r: Ref): string =>
|
||||
r.k === "input"
|
||||
? `input:${r.name}`
|
||||
: r.k === "calc"
|
||||
? `calc:${r.name}`
|
||||
: r.k === "lookup"
|
||||
? `lookup:${r.id}`
|
||||
: r.k === "fixed"
|
||||
? "fixed"
|
||||
: r.k === "logic"
|
||||
? `logic:${r.key}`
|
||||
: r.k === "cost"
|
||||
? `cost:${r.name}`
|
||||
: "expr";
|
||||
|
||||
/** 고를 수 있는 곳 — 입력값 · (앞선) 계산 · 표 조회 · 지금 값 */
|
||||
const refSelect = (
|
||||
cur: Ref,
|
||||
pool: Ref[],
|
||||
none: boolean,
|
||||
pick: (r: Ref | null) => LbLogic,
|
||||
): HTMLElement => {
|
||||
const all = [...pool];
|
||||
if (!all.some((r) => refKey(r) === refKey(cur))) all.unshift(cur);
|
||||
const options = [
|
||||
...(none ? [{ value: "none", text: tg("FromNone") }] : []),
|
||||
...all.map((r) => ({ value: refKey(r), text: refLabel(r) })),
|
||||
];
|
||||
const field = createSelectField({
|
||||
options,
|
||||
value: refKey(cur),
|
||||
compact: true,
|
||||
ariaLabel: refLabel(cur),
|
||||
disabled: readOnly || cur.k === "expr",
|
||||
onChange: (v) => {
|
||||
if (v === refKey(cur)) return;
|
||||
apply(() => pick(v === "none" ? null : (all.find((r) => refKey(r) === v) ?? cur)));
|
||||
},
|
||||
});
|
||||
field.root.addEventListener("click", (e) => e.stopPropagation());
|
||||
return field.root;
|
||||
};
|
||||
const inputRefs: Ref[] = lb.inputs.map((i) => ({ k: "input", name: i.이름 }));
|
||||
const calcRefs = (before: number): Ref[] =>
|
||||
lb.calcs.flatMap((c, i): Ref[] => (c.name && i < before ? [{ k: "calc", name: c.name }] : []));
|
||||
const lookupRefs: Ref[] = lb.lookups.map((l) => ({ k: "lookup", id: l.id }));
|
||||
|
||||
/* ---- 줄 모으기 ---- */
|
||||
const rows: GridRow[] = [];
|
||||
const add = (r: Partial<GridRow> & { id: string; name: Cell }): GridRow => {
|
||||
const row: GridRow = {
|
||||
step: "",
|
||||
sub: false,
|
||||
how: "",
|
||||
value: "",
|
||||
unit: "",
|
||||
source: "",
|
||||
uses: [],
|
||||
...r,
|
||||
};
|
||||
rows.push(row);
|
||||
return row;
|
||||
};
|
||||
|
||||
lb.inputs.forEach((inp, i) => {
|
||||
add({
|
||||
id: `in:${inp.이름}`,
|
||||
step: i === 0 ? tg("Step1") : "",
|
||||
name: inp.이름,
|
||||
how: inp.고르기?.length ? tg("Choice") : tg("Typed"),
|
||||
value: show(valueOf({ k: "input", name: inp.이름 })),
|
||||
unit: inp.단위 ?? "",
|
||||
source: inp.고르기?.length
|
||||
? inp.고르기.join(" · ")
|
||||
: inp.범위
|
||||
? `${tg("Range")} ${inp.범위[0]}∼${inp.범위[1]}`
|
||||
: "",
|
||||
});
|
||||
});
|
||||
|
||||
lb.lookups.forEach((lk, i) => {
|
||||
const head = add({
|
||||
id: `lk:${lk.id}`,
|
||||
step: i === 0 ? tg("Step2") : "",
|
||||
name: lk.col,
|
||||
how: tg("FromLookup"),
|
||||
value: show(lookupValue(lk.id)),
|
||||
source: `${tg("Table")} ${lk.table}`,
|
||||
});
|
||||
lk.conds.forEach((cd) => {
|
||||
const from = cd.from;
|
||||
const fixedNum = from.k === "fixed" && typeof from.value === "number";
|
||||
const subRow = add({
|
||||
id: `lc:${lk.id}:${cd.col}`,
|
||||
sub: true,
|
||||
name: `${tg("Cond")} · ${cd.col}`,
|
||||
how: refSelect(
|
||||
from,
|
||||
[
|
||||
...inputRefs,
|
||||
...calcRefs(lb.calcs.length).filter((r) => {
|
||||
const c = lb.calcs.find((x) => x.name === (r as { name: string }).name);
|
||||
return c && !c.parts.some((p) => p.k === "lookup") && !c.text.includes("찾기(");
|
||||
}),
|
||||
],
|
||||
true,
|
||||
(r) => setCond(lb, lk.id, cd.col, r),
|
||||
),
|
||||
value:
|
||||
fixedNum && from.k === "fixed"
|
||||
? numberCell(from.value, (n) => setCond(lb, lk.id, cd.col, { k: "fixed", value: n }))
|
||||
: show(valueOf(from)),
|
||||
unit: unitOf(from),
|
||||
uses: rowOf(from),
|
||||
});
|
||||
head.uses.push(...subRow.uses);
|
||||
});
|
||||
});
|
||||
|
||||
lb.calcs.forEach((c, i) => {
|
||||
const first = i === 0;
|
||||
const own = lookupsAt(c.at.part, c.at.index);
|
||||
const head = add({
|
||||
id: `ca:${i}`,
|
||||
step: first ? tg("Step3") : "",
|
||||
name: c.name ?? `${tg("Unnamed")} ${i + 1}`,
|
||||
how: tg(KIND_KEY[c.kind]),
|
||||
value: show(c.name ? middle[c.name] : c.at.part === "수량" ? lines[c.at.index]?.수량 : ""),
|
||||
source: tg(USE_KEY[c.at.part]),
|
||||
uses: [...textRows(c.text, i), ...own],
|
||||
});
|
||||
if (!["값", "곱나눔", "할증", "더하기"].includes(c.kind)) return;
|
||||
c.parts.forEach((p, pi) => {
|
||||
const label =
|
||||
c.kind === "할증"
|
||||
? tg(pi === 0 ? "Base" : "Rate")
|
||||
: c.kind === "값"
|
||||
? tg("KValue")
|
||||
: pi === 0
|
||||
? tg("First")
|
||||
: tg(
|
||||
({ "*": "Times", "/": "Divide", "+": "Plus", "-": "Minus" } as const)[
|
||||
c.ops?.[pi - 1] ?? (c.kind === "더하기" ? "+" : "*")
|
||||
],
|
||||
);
|
||||
const fixedNum = p.k === "fixed" && typeof p.value === "number";
|
||||
const setPart = (r: Ref | null): LbLogic => {
|
||||
const parts = c.parts.slice();
|
||||
if (r) parts[pi] = r;
|
||||
return setCalc(lb, c, { parts });
|
||||
};
|
||||
const sub = add({
|
||||
id: `cp:${i}:${pi}`,
|
||||
sub: true,
|
||||
name: label,
|
||||
how: refSelect(p, [...inputRefs, ...calcRefs(i), ...lookupRefs], false, setPart),
|
||||
value:
|
||||
fixedNum && p.k === "fixed"
|
||||
? numberCell(p.value, (n) => setPart({ k: "fixed", value: n }))
|
||||
: show(valueOf(p)),
|
||||
unit: unitOf(p),
|
||||
uses: rowOf(p),
|
||||
});
|
||||
head.uses.push(...sub.uses);
|
||||
});
|
||||
});
|
||||
|
||||
lb.doc.cards.forEach((card, i) => {
|
||||
const sp = card.source;
|
||||
const parts: Part[] = sp?.part === "덧줄" ? ["비율"] : ["수량"];
|
||||
const viaLookup = sp
|
||||
? parts
|
||||
.flatMap((p) => lookupsAt(p, sp.index))
|
||||
.concat(sp.part === "호표" ? lookupsAt("요소", sp.index) : [])
|
||||
: [];
|
||||
const viaCalc = sp
|
||||
? lb.calcs.flatMap((c, ci) =>
|
||||
parts.includes(c.at.part) && c.at.index === sp.index ? [`ca:${ci}`] : [],
|
||||
)
|
||||
: [];
|
||||
const line = lineOf(card);
|
||||
const how = card.qty !== null ? tg("Number") : viaLookup.length ? tg("FromLookup") : tg("Calc");
|
||||
add({
|
||||
id: `it:${card.id}`,
|
||||
step: i === 0 ? tg("Step4") : "",
|
||||
name: card.spec ? `${card.name} ${card.spec}` : card.name,
|
||||
how: tg(CARD_KEY[card.kind]),
|
||||
value:
|
||||
card.qty !== null
|
||||
? numberCell(card.qty, (n) => ({ ...lb, doc: updateCard(lb.doc, card.id, { qty: n }) }))
|
||||
: show(line?.수량),
|
||||
unit: card.unit,
|
||||
source: `${how}${line ? ` · ${formatMoney(line.금액)} ${tg("Won")}` : ""}`,
|
||||
uses: [...viaLookup, ...viaCalc, ...(card.qtyFormula ? textRows(card.qtyFormula) : [])],
|
||||
});
|
||||
});
|
||||
|
||||
const res = lb.result;
|
||||
add({
|
||||
id: "res",
|
||||
step: tg("Step5"),
|
||||
name: tg(res.kind === "금액" ? "Price" : "Result"),
|
||||
how: res.kind,
|
||||
value:
|
||||
res.kind === "금액"
|
||||
? okAns
|
||||
? formatMoney(okAns.sums?.["계"] ?? 0)
|
||||
: ""
|
||||
: show(okAns?.result),
|
||||
unit: res.unit,
|
||||
source: res.kind === "금액" ? tg("Sum") : tg("Calc"),
|
||||
uses:
|
||||
res.kind === "금액"
|
||||
? lb.doc.cards.map((c) => `it:${c.id}`)
|
||||
: [
|
||||
...textRows(res.calc.text),
|
||||
...lb.calcs.flatMap((c, i) => (c.at.part === "결과" ? [`ca:${i}`] : [])),
|
||||
],
|
||||
});
|
||||
|
||||
/* ---- 그리기 ---- */
|
||||
const tbody = el("tbody");
|
||||
const paint = (): void => {
|
||||
const sel = picked.get(host);
|
||||
const uses = new Set(rows.find((r) => r.id === sel)?.uses ?? []);
|
||||
tbody.querySelectorAll<HTMLElement>("tr").forEach((tr) => {
|
||||
const id = tr.dataset.row ?? "";
|
||||
tr.classList.toggle("m01-lbgrid__row--on", id === sel);
|
||||
tr.classList.toggle("m01-lbgrid__row--pre", uses.has(id));
|
||||
});
|
||||
};
|
||||
const td = (c: Cell, className = ""): HTMLElement =>
|
||||
el("td", { className, ...(typeof c === "string" ? { text: c } : { children: [c] }) });
|
||||
rows.forEach((r) => {
|
||||
const tr = el("tr", {
|
||||
className: `m01-lbgrid__row${r.sub ? " m01-lbgrid__row--sub" : ""}${r.step ? " m01-lbgrid__row--first" : ""}`,
|
||||
attrs: { "data-row": r.id },
|
||||
children: [
|
||||
td(r.step, "m01-lbgrid__step"),
|
||||
td(r.name, "m01-lbgrid__name"),
|
||||
td(r.how),
|
||||
td(r.value, "m01-lbgrid__num"),
|
||||
td(r.unit),
|
||||
td(r.source, "m01-lbgrid__src"),
|
||||
],
|
||||
});
|
||||
tr.addEventListener("click", () => {
|
||||
if (picked.get(host) === r.id) picked.delete(host);
|
||||
else picked.set(host, r.id);
|
||||
paint();
|
||||
});
|
||||
tbody.append(tr);
|
||||
});
|
||||
const heads: TextKey[] = ["HStep", "HName", "HHow", "HValue", "HUnit", "HSource"];
|
||||
const status =
|
||||
ans && !ans.ok ? `${tg("Stopped")} — ${safeReason(ans.reason)}` : ans ? "" : tg("Busy");
|
||||
host.replaceChildren(
|
||||
...(readOnly ? [el("p", { className: "m01-lbgrid__note", text: tg("ReadOnly") })] : []),
|
||||
...(status ? [el("p", { className: "m01-lbgrid__note", text: status })] : []),
|
||||
el("div", {
|
||||
className: "m01-lbgrid__scroll",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "m01-lbgrid__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((h) => el("th", { text: tg(h) })) })],
|
||||
}),
|
||||
tbody,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
msg,
|
||||
el("p", { className: "m01-lbgrid__hint", text: tg("Hint") }),
|
||||
);
|
||||
paint();
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/* 로직 만들기 판 — 왼쪽 원문 */
|
||||
.m01-lbsrc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
min-width: 0;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-lbsrc p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.m01-lbsrc__muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m01-lbsrc__muted:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m01-lbsrc__preview {
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in srgb, var(--color-warning) 14%, var(--color-canvas));
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.m01-lbsrc__table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-lbsrc__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-lbsrc__users {
|
||||
margin-left: auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--color-accent);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.m01-lbsrc__warn {
|
||||
color: var(--color-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 엑셀 같은 격자 */
|
||||
.m01-lbsrc__grid {
|
||||
max-height: 22rem;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid th,
|
||||
.m01-lbsrc__grid td {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--color-paper);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid thead th span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid .m01-lbsrc__no {
|
||||
width: 2.5rem;
|
||||
background: var(--color-paper);
|
||||
color: var(--color-text-muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid td {
|
||||
cursor: cell;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid td.is-value {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid td.is-col {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid tr.is-hit td {
|
||||
background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid td.is-edited {
|
||||
box-shadow: inset 0 0 0 2px var(--color-accent);
|
||||
}
|
||||
|
||||
.m01-lbsrc__grid td.is-bad {
|
||||
box-shadow: inset 0 0 0 2px var(--color-danger);
|
||||
}
|
||||
|
||||
.m01-lbsrc__input {
|
||||
width: 100%;
|
||||
min-width: 4rem;
|
||||
padding: 0 var(--spacing-4);
|
||||
border: 1px solid var(--color-accent);
|
||||
font: inherit;
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 칸 역할 칩 — 조건 파랑 · 고정 회색 · 결과 초록 · 무관 점선 */
|
||||
.m01-lbsrc__chip {
|
||||
margin-top: var(--spacing-4);
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: var(--text-caption);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-lbsrc__chip.is-cond {
|
||||
color: var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.m01-lbsrc__chip.is-fixed {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01-lbsrc__chip.is-result {
|
||||
color: var(--color-success, #2f6b4f);
|
||||
background: color-mix(in srgb, var(--color-success, #2f6b4f) 12%, transparent);
|
||||
}
|
||||
|
||||
.m01-lbsrc__chip.is-free {
|
||||
color: var(--color-text-muted);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.m01-lbsrc__notes summary {
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01-lbsrc__notes ol {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
padding-left: var(--spacing-16);
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01-lbsrc__notes li.is-used {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01-lbsrc__tag {
|
||||
margin-left: var(--spacing-8);
|
||||
padding: 0 var(--spacing-4);
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||
color: var(--color-accent);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
/* =============================================================================
|
||||
* M01_LB_Source.ts
|
||||
* 로직 만들기 판 — 왼쪽 원문(정함 5 · 7 · 설계초안 6절)
|
||||
* 로직이 쓰는 원문 표마다: 표 이름 · 원문번호 · 기준 · 엑셀 같은 격자(칸 머리 역할 칩 조건 / 고정 / 결과)
|
||||
* · 예시값으로 걸린 줄 노랑 · 해당 없음 / 중복 한 줄 · 칸 눌러 고침(메모리 사본 — on.edit) · 주 목록 · 사용처.
|
||||
* 표 고침은 미리 보기 — 저장하지 않음(정함 5). 화면 글에 키 · 식 안 씀(정함 9).
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { tableUsers, type LbTables } from "./M01_LB_Api";
|
||||
import type { LbLogic, LbLookup, TableEdit } from "./M01_LB_Blocks";
|
||||
import type { LbHit } from "./M01_LB_Check";
|
||||
import "./M01_LB_Source.css";
|
||||
|
||||
const TEXT = {
|
||||
Preview: [
|
||||
"표 고침은 미리 보기 — 저장은 결정 뒤",
|
||||
"Table edits are a preview — saving comes later",
|
||||
],
|
||||
Users: ["사용처", "Used by"],
|
||||
UsersN: ["이 표를 쓰는 로직 {n} 개", "{n} logics use this table"],
|
||||
UsersNone: ["이 표를 쓰는 로직 없음", "No logic uses this table"],
|
||||
UsersFail: ["사용처를 불러오지 못함", "Couldn't load users"],
|
||||
Loading: ["불러오는 중", "Loading"],
|
||||
Missing: ["표를 불러오지 못함", "Couldn't load the table"],
|
||||
NoRow: ["해당 없음 — 예시값에 맞는 줄이 없음", "No match — no row fits the sample values"],
|
||||
DupRow: [
|
||||
"중복 — 예시값에 맞는 줄이 둘 이상",
|
||||
"Duplicate — more than one row fits the sample values",
|
||||
],
|
||||
Notes: ["주", "Notes"],
|
||||
NoteUsed: ["계산에 쓰임", "Used in the calculation"],
|
||||
Cond: ["조건", "Condition"],
|
||||
Fixed: ["고정", "Fixed"],
|
||||
Result: ["결과", "Result"],
|
||||
Free: ["무관", "Any"],
|
||||
RoleHint: ["눌러 역할 바꿈", "Tap to change the role"],
|
||||
EditHint: [
|
||||
"칸을 눌러 고침 · Enter 로 끝 · Esc 로 취소",
|
||||
"Tap a cell to edit · Enter to finish · Esc to cancel",
|
||||
],
|
||||
Edited: ["고친 칸", "Edited cell"],
|
||||
} as const;
|
||||
|
||||
const ts = (key: keyof typeof TEXT, n?: number): string =>
|
||||
TEXT[key][currentLanguageIndex as 0 | 1].replace("{n}", String(n ?? ""));
|
||||
|
||||
export type LbRole = "조건" | "고정" | "결과";
|
||||
type Cell = string | number | boolean | null | [number | null, number | null] | undefined;
|
||||
|
||||
interface SourceTable {
|
||||
키?: string;
|
||||
이름?: string;
|
||||
원문번호?: string;
|
||||
기준?: string;
|
||||
범위규칙?: string;
|
||||
조건?: Record<string, string>;
|
||||
값칸?: Record<string, string>;
|
||||
주?: string[];
|
||||
용도?: { 로직키?: string[] };
|
||||
줄: Record<string, Cell>[];
|
||||
}
|
||||
|
||||
export interface SourceOn {
|
||||
edit(e: TableEdit): void;
|
||||
role(table: string, col: string, role: LbRole): void;
|
||||
}
|
||||
|
||||
/* ── 칸 보이기 · 읽기 ─────────────────────────────────────────────── */
|
||||
|
||||
const isRange = (v: unknown): v is [number | null, number | null] =>
|
||||
Array.isArray(v) && v.length === 2;
|
||||
|
||||
/** 칸 글 — 범위 [a, b] 는 「a~b」 · 끝이 없으면 「~b」 · 「a~」 */
|
||||
export function showCell(v: Cell): string {
|
||||
if (v === null || v === undefined) return "";
|
||||
if (isRange(v)) return `${v[0] ?? ""}~${v[1] ?? ""}`;
|
||||
return String(v);
|
||||
}
|
||||
|
||||
/** 고친 글 → 칸 값 — 원래 칸이 범위면 범위 · 수면 수 · 아니면 글 · 못 읽으면 null */
|
||||
export function parseCell(text: string, before: Cell): TableEdit["value"] | null {
|
||||
const t = text.trim();
|
||||
const num = (s: string): number | null | undefined => {
|
||||
const c = s.replace(/,/g, "").trim();
|
||||
if (!c) return null;
|
||||
return /^-?\d+(\.\d+)?$/.test(c) ? Number(c) : undefined;
|
||||
};
|
||||
if (isRange(before) || /^[^~~]*[~~][^~~]*$/.test(t)) {
|
||||
const parts = t.split(/[~~]/);
|
||||
if (parts.length !== 2) return null;
|
||||
const [a, b] = parts.map(num);
|
||||
if (a === undefined || b === undefined || (a === null && b === null)) return null;
|
||||
if (a !== null && b !== null && a > b) return null;
|
||||
return [a, b];
|
||||
}
|
||||
if (!t) return null;
|
||||
const n = num(t);
|
||||
if (typeof before === "number" || (typeof n === "number" && typeof before !== "string")) {
|
||||
return typeof n === "number" ? n : null;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/* ── 역할 · 주 · 걸린 줄 ─────────────────────────────────────────── */
|
||||
|
||||
/** 표 칸 하나의 역할 — 이 로직의 표 조회가 그 칸을 어떻게 쓰나(첫 조회 기준) · 안 쓰면 null */
|
||||
export function roleOf(lookups: LbLookup[], col: string): LbRole | null {
|
||||
for (const lk of lookups) {
|
||||
if (lk.col === col) return "결과";
|
||||
const c = lk.conds.find((x) => x.col === col);
|
||||
if (c) return c.from.k === "fixed" ? "고정" : "조건";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const NEXT: Record<LbRole, LbRole> = { 조건: "고정", 고정: "결과", 결과: "조건" };
|
||||
|
||||
/** 로직 글에 적힌 주 — 「13-4-1 주③」 은 그 원문번호 표의 ③ · 번호 없는 「주③」 은 모든 표 */
|
||||
export function usedNotes(row: unknown): Map<string, Set<string>> {
|
||||
const out = new Map<string, Set<string>>();
|
||||
const add = (where: string, mark: string): void => {
|
||||
const set = out.get(where) ?? new Set<string>();
|
||||
set.add(mark);
|
||||
out.set(where, set);
|
||||
};
|
||||
const walk = (v: unknown): void => {
|
||||
if (typeof v === "string") {
|
||||
for (const m of v.matchAll(/(\d+(?:-\d+)+)?\s*주\s*([①-⑳])/g)) add(m[1] ?? "*", m[2]);
|
||||
} else if (Array.isArray(v)) v.forEach(walk);
|
||||
else if (v && typeof v === "object") Object.values(v).forEach(walk);
|
||||
};
|
||||
walk(row);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 표에 걸린 줄들 · 해당 없음 · 중복 — 표 조회 번호로 찾고 없으면 표 키로 */
|
||||
function hitsOf(
|
||||
key: string,
|
||||
lookups: LbLookup[],
|
||||
hits: Record<string, LbHit | undefined>,
|
||||
): { rows: Set<number>; none: boolean; dup: boolean } {
|
||||
const got = lookups.map((lk) => hits[lk.id] ?? hits[key] ?? null);
|
||||
return {
|
||||
rows: new Set(got.filter((h): h is number => typeof h === "number")),
|
||||
none: got.includes("없음"),
|
||||
dup: got.includes("중복"),
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 그리기 ─────────────────────────────────────────────────────── */
|
||||
|
||||
/** 처음 본 표 값 — 고친 칸 표시용(화면이 열려 있는 동안) */
|
||||
const originals = new Map<string, string[][]>();
|
||||
|
||||
function snapshot(key: string, table: SourceTable, cols: string[]): string[][] {
|
||||
const had = originals.get(key);
|
||||
if (had && had.length === table.줄.length) return had;
|
||||
const shot = table.줄.map((r) => cols.map((c) => showCell(r[c])));
|
||||
originals.set(key, shot);
|
||||
return shot;
|
||||
}
|
||||
|
||||
function roleChip(
|
||||
key: string,
|
||||
col: string,
|
||||
role: LbRole | null,
|
||||
isCond: boolean,
|
||||
on: SourceOn,
|
||||
): HTMLElement {
|
||||
const shown = role ?? (isCond ? null : undefined);
|
||||
if (shown === undefined) return el("span");
|
||||
const label = shown
|
||||
? ts(shown === "조건" ? "Cond" : shown === "고정" ? "Fixed" : "Result")
|
||||
: ts("Free");
|
||||
const chip = el("button", {
|
||||
className: `m01-lbsrc__chip is-${shown ? { 조건: "cond", 고정: "fixed", 결과: "result" }[shown] : "free"}`,
|
||||
text: label,
|
||||
attrs: { type: "button", title: ts("RoleHint") },
|
||||
});
|
||||
chip.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
on.role(key, col, shown ? NEXT[shown] : "조건");
|
||||
});
|
||||
return chip;
|
||||
}
|
||||
|
||||
function editCell(
|
||||
td: HTMLTableCellElement,
|
||||
before: Cell,
|
||||
done: (v: TableEdit["value"]) => void,
|
||||
): void {
|
||||
if (td.querySelector("input")) return;
|
||||
const input = el("input", { className: "m01-lbsrc__input", attrs: { type: "text" } });
|
||||
input.value = showCell(before);
|
||||
const text = td.textContent ?? "";
|
||||
td.replaceChildren(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
let closed = false;
|
||||
const close = (save: boolean): void => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
const value = save ? parseCell(input.value, before) : null;
|
||||
const same = value !== null && showCell(value) === showCell(before);
|
||||
td.textContent = value === null || same ? text : showCell(value);
|
||||
td.classList.toggle("is-bad", save && value === null && input.value.trim() !== text);
|
||||
if (value !== null && !same) done(value);
|
||||
};
|
||||
input.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter") close(true);
|
||||
if (ev.key === "Escape") close(false);
|
||||
ev.stopPropagation();
|
||||
});
|
||||
input.addEventListener("blur", () => close(true));
|
||||
}
|
||||
|
||||
function renderTable(
|
||||
key: string,
|
||||
table: SourceTable | undefined,
|
||||
lb: LbLogic,
|
||||
hits: Record<string, LbHit | undefined>,
|
||||
notes: Map<string, Set<string>>,
|
||||
on: SourceOn,
|
||||
): HTMLElement {
|
||||
const lookups = lb.lookups.filter((lk) => lk.table === key);
|
||||
if (!table?.줄) {
|
||||
return el("section", {
|
||||
className: "m01-lbsrc__table",
|
||||
children: [el("p", { className: "m01-lbsrc__muted", text: ts("Missing") })],
|
||||
});
|
||||
}
|
||||
const conds = Object.keys(table.조건 ?? {});
|
||||
const vals = Object.keys(table.값칸 ?? {});
|
||||
const cols = [...conds, ...vals];
|
||||
const orig = snapshot(key, table, cols);
|
||||
const hit = hitsOf(key, lookups, hits);
|
||||
|
||||
const users = (table.용도?.로직키 ?? []).length;
|
||||
const userBtn = el("button", {
|
||||
className: "m01-lbsrc__users",
|
||||
text: users ? ts("UsersN", users) : ts("UsersNone"),
|
||||
attrs: { type: "button" },
|
||||
});
|
||||
const userList = el("p", { className: "m01-lbsrc__muted" });
|
||||
userBtn.addEventListener("click", () => {
|
||||
userList.textContent = ts("Loading");
|
||||
tableUsers(key)
|
||||
.then((list) => {
|
||||
userBtn.textContent = list.length ? ts("UsersN", list.length) : ts("UsersNone");
|
||||
userList.textContent = list.map((u) => u.이름).join(" · ");
|
||||
})
|
||||
.catch(() => (userList.textContent = ts("UsersFail")));
|
||||
});
|
||||
|
||||
const head = el("tr", {
|
||||
children: [
|
||||
el("th", { className: "m01-lbsrc__no", text: "" }),
|
||||
...cols.map((c) => {
|
||||
const unit = table.값칸?.[c];
|
||||
return el("th", {
|
||||
className: vals.includes(c) ? "is-value" : "",
|
||||
children: [
|
||||
el("span", { text: unit ? `${c} (${unit})` : c }),
|
||||
roleChip(key, c, roleOf(lookups, c), conds.includes(c), on),
|
||||
],
|
||||
});
|
||||
}),
|
||||
],
|
||||
});
|
||||
const body = table.줄.map((row, i) => {
|
||||
const tr = el("tr", {
|
||||
className: hit.rows.has(i) ? "is-hit" : "",
|
||||
children: [
|
||||
el("td", { className: "m01-lbsrc__no", text: String(i + 1) }),
|
||||
...cols.map((c, j) => {
|
||||
const td = el("td", {
|
||||
className: [
|
||||
vals.includes(c) ? "is-value" : "",
|
||||
lookups.some((lk) => lk.col === c) ? "is-col" : "",
|
||||
showCell(row[c]) !== orig[i]?.[j] ? "is-edited" : "",
|
||||
].join(" "),
|
||||
text: showCell(row[c]),
|
||||
});
|
||||
td.addEventListener("click", () =>
|
||||
editCell(td, row[c], (value) => {
|
||||
td.classList.toggle("is-edited", showCell(value) !== orig[i]?.[j]);
|
||||
on.edit({ table: key, row: i, col: c, value });
|
||||
}),
|
||||
);
|
||||
return td;
|
||||
}),
|
||||
],
|
||||
});
|
||||
return tr;
|
||||
});
|
||||
|
||||
const marks = new Set([...(notes.get(table.원문번호 ?? "") ?? []), ...(notes.get("*") ?? [])]);
|
||||
const noteList = (table.주 ?? []).map((n) => {
|
||||
const used = marks.has(n.trim().charAt(0));
|
||||
return el("li", {
|
||||
className: used ? "is-used" : "",
|
||||
children: [
|
||||
el("span", { text: n }),
|
||||
...(used ? [el("span", { className: "m01-lbsrc__tag", text: ts("NoteUsed") })] : []),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
return el("section", {
|
||||
className: "m01-lbsrc__table",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-lbsrc__head",
|
||||
children: [
|
||||
el("strong", { text: table.이름 ?? "" }),
|
||||
el("span", {
|
||||
className: "m01-lbsrc__muted",
|
||||
text: [table.원문번호, table.기준].filter(Boolean).join(" · "),
|
||||
}),
|
||||
userBtn,
|
||||
],
|
||||
}),
|
||||
userList,
|
||||
...(hit.none ? [el("p", { className: "m01-lbsrc__warn", text: ts("NoRow") })] : []),
|
||||
...(hit.dup ? [el("p", { className: "m01-lbsrc__warn", text: ts("DupRow") })] : []),
|
||||
el("div", {
|
||||
className: "m01-lbsrc__grid",
|
||||
children: [
|
||||
el("table", {
|
||||
children: [el("thead", { children: [head] }), el("tbody", { children: body })],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
...(noteList.length
|
||||
? [
|
||||
el("details", {
|
||||
className: "m01-lbsrc__notes",
|
||||
attrs: marks.size ? { open: "" } : {},
|
||||
children: [el("summary", { text: ts("Notes") }), el("ol", { children: noteList })],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 왼쪽 원문 — 표 조회가 쓰는 표마다 한 덩어리 · hits = 표 조회 번호(또는 표 키) → 걸린 줄 */
|
||||
export function renderSource(
|
||||
host: HTMLElement,
|
||||
lb: LbLogic,
|
||||
tables: LbTables,
|
||||
hits: Record<string, LbHit | undefined>,
|
||||
on: SourceOn,
|
||||
): void {
|
||||
const keys = [...new Set(lb.lookups.map((lk) => lk.table))];
|
||||
const notes = usedNotes(lb.doc?.row);
|
||||
host.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-lbsrc",
|
||||
children: [
|
||||
el("p", { className: "m01-lbsrc__preview", text: ts("Preview") }),
|
||||
el("p", { className: "m01-lbsrc__muted", text: ts("EditHint") }),
|
||||
...keys.map((k) =>
|
||||
renderTable(k, tables[k] as SourceTable | undefined, lb, hits, notes, on),
|
||||
),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
.m01-lbsteps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-16);
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m01-lbsteps-banner {
|
||||
margin: 0;
|
||||
padding: var(--spacing-8) var(--spacing-16);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.m01-lbsteps-banner.is-warn {
|
||||
border-left: 4px solid var(--color-warning);
|
||||
}
|
||||
|
||||
.m01-lbsteps-banner p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.m01-lbsteps-why {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
.m01-lbsteps-sec {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
padding: var(--spacing-8) var(--spacing-16) var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-lbsteps-title {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
font-size: var(--text-subheading);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m01-lbsteps-list,
|
||||
.m01-lbsteps-branches {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.m01-lbsteps-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding-top: var(--spacing-8);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01-lbsteps-row:first-child {
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.m01-lbsteps-sentence {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4) var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-lbsteps-ref,
|
||||
.m01-lbsteps-cond,
|
||||
.m01-lbsteps-chips {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01-lbsteps-shapes,
|
||||
.m01-lbsteps-shape {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding-left: var(--spacing-16);
|
||||
border-left: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01-lbsteps-branch {
|
||||
padding-left: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-lbsteps-order {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.6em;
|
||||
height: 1.6em;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pills);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m01-lbsteps-name,
|
||||
.m01-lbsteps-tag {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m01-lbsteps-chip {
|
||||
display: inline-block;
|
||||
padding: 2px var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pills);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.m01-lbsteps-note {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-lbsteps-value,
|
||||
.m01-lbsteps-sum {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-lbsteps-amount,
|
||||
.m01-lbsteps-total {
|
||||
margin-left: auto;
|
||||
font-weight: var(--font-weight-bold);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-lbsteps-amount.is-wait {
|
||||
opacity: 0.5;
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
.m01-lbsteps-pick {
|
||||
min-height: 44px;
|
||||
padding: 0 var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-inputs);
|
||||
background: var(--color-surface);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-lbsteps-pick:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
}
|
||||
|
||||
.m01-lbsteps .ui-input,
|
||||
.m01-lbsteps .ui-select,
|
||||
.m01-lbsteps .ui-btn {
|
||||
min-height: 44px;
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-lbsteps-num {
|
||||
flex: none;
|
||||
width: 6em;
|
||||
}
|
||||
|
||||
.m01-lbsteps-text {
|
||||
flex: none;
|
||||
width: 9em;
|
||||
}
|
||||
|
||||
.m01-lbsteps-select {
|
||||
flex: none;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.m01-lbsteps .ui-input.is-bad {
|
||||
border-color: var(--color-danger);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.m01-lbsteps-add {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.m01-lbsteps-amount,
|
||||
.m01-lbsteps-total {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
/* =============================================================================
|
||||
* M01_LB_Steps.ts
|
||||
* 로직 만들기 판 가운데 문장 보기 — ① 입력값 ② 표 조회 ③ 계산 ④ 항목 연결 ⑤ 결과값
|
||||
* 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · `로직판_A_설계초안.md` 7-1 그대로 · 모두 고르기 · 숫자 칸 · 바뀐 lb 는 on.change 로만 알림
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
fetchLogics,
|
||||
type CalcAnswer,
|
||||
type CalcLine,
|
||||
type LogicInput,
|
||||
} from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
|
||||
import {
|
||||
addCard,
|
||||
addPercent,
|
||||
removeCard,
|
||||
updateCard,
|
||||
withInputs,
|
||||
type CostKind,
|
||||
type EasyCard,
|
||||
type PickResult,
|
||||
} from "../EasyCalc/M01_EasyCalc_Model";
|
||||
import { openEasyPick } from "../EasyCalc/M01_EasyCalc_Pick";
|
||||
import { setCond, type LbLogic, type LbLookup, type LbRef } from "./M01_LB_Blocks";
|
||||
import { calcBody, calcView } from "./M01_LB_Steps_Calc";
|
||||
import {
|
||||
chip,
|
||||
logicNames,
|
||||
lookupValue,
|
||||
note,
|
||||
numBox,
|
||||
parseNum,
|
||||
refText,
|
||||
selectBox,
|
||||
sentence,
|
||||
span,
|
||||
textBox,
|
||||
tx,
|
||||
valueNote,
|
||||
type Ctx,
|
||||
type TEXT,
|
||||
} from "./M01_LB_Steps_Cell";
|
||||
import "./M01_LB_Steps.css";
|
||||
|
||||
type Kind = "person" | "material" | "machine" | "logic" | "percent" | "locked";
|
||||
|
||||
let picking = false;
|
||||
let namesLoading = false;
|
||||
|
||||
/* ------------------------------ ① 입력값 ------------------------------ */
|
||||
|
||||
function inputView(ctx: Ctx, input: LogicInput, at: number): HTMLElement {
|
||||
const put = (patch: Partial<LogicInput>): void =>
|
||||
ctx.commit(() => {
|
||||
const inputs = ctx.lb.inputs.map((x, i) => (i === at ? { ...x, ...patch } : x));
|
||||
return { ...ctx.lb, inputs, doc: { ...ctx.lb.doc, inputs } };
|
||||
});
|
||||
const parts: (HTMLElement | string)[] = [span("m01-lbsteps-name", input.이름)];
|
||||
if (input.고르기) {
|
||||
parts.push(
|
||||
note(tx("PickList")),
|
||||
el("span", {
|
||||
className: "m01-lbsteps-chips",
|
||||
children: input.고르기.map((v) => chip(String(v))),
|
||||
}),
|
||||
);
|
||||
} else if (input.범위) {
|
||||
const [lo, hi] = input.범위;
|
||||
parts.push(
|
||||
note(tx("Range")),
|
||||
numBox(ctx, lo, (n) => put({ 범위: [n, hi] })),
|
||||
"~",
|
||||
numBox(ctx, hi, (n) => put({ 범위: [lo, n] })),
|
||||
);
|
||||
} else parts.push(note(tx("Number")));
|
||||
parts.push(
|
||||
note(tx("Unit")),
|
||||
textBox(ctx, input.단위 ?? "", (v) => put({ 단위: v })),
|
||||
);
|
||||
return el("li", { className: "m01-lbsteps-row", children: [sentence(...parts)] });
|
||||
}
|
||||
|
||||
/* ------------------------------ ② 표 조회 ------------------------------ */
|
||||
|
||||
function condView(ctx: Ctx, lk: LbLookup, cond: LbLookup["conds"][number]): HTMLElement {
|
||||
const from = cond.from;
|
||||
const set = (next: LbRef | null): void =>
|
||||
ctx.commit(() => setCond(ctx.lb, lk.id, cond.col, next));
|
||||
const parts: (HTMLElement | string)[] = [chip(cond.col), tx("LkIs")];
|
||||
if (from.k === "input" || from.k === "fixed") {
|
||||
const options = [
|
||||
...ctx.lb.inputs.map((i) => ({ value: `i:${i.이름}`, text: `${tx("SrcInput")}${i.이름}` })),
|
||||
{ value: "f", text: tx("SrcFixed") },
|
||||
{ value: "n", text: tx("SrcAny") },
|
||||
];
|
||||
const current = from.k === "input" ? `i:${from.name}` : "f";
|
||||
parts.push(
|
||||
selectBox(
|
||||
ctx,
|
||||
options,
|
||||
current,
|
||||
(v) =>
|
||||
set(
|
||||
v === "n"
|
||||
? null
|
||||
: v === "f"
|
||||
? { k: "fixed", value: "" }
|
||||
: { k: "input", name: v.slice(2) },
|
||||
),
|
||||
cond.col,
|
||||
),
|
||||
);
|
||||
if (from.k === "fixed") {
|
||||
parts.push(
|
||||
textBox(ctx, String(from.value), (v) => set({ k: "fixed", value: parseNum(v) ?? v })),
|
||||
);
|
||||
}
|
||||
} else parts.push(chip(refText(ctx, from)));
|
||||
return el("span", { className: "m01-lbsteps-cond", children: parts });
|
||||
}
|
||||
|
||||
function lookupView(ctx: Ctx, lk: LbLookup, n: number): HTMLElement {
|
||||
const name = ctx.on.tableName?.(lk.table) ?? lk.table;
|
||||
return el("li", {
|
||||
className: "m01-lbsteps-row",
|
||||
children: [
|
||||
sentence(
|
||||
span("m01-lbsteps-name", `${tx("LkNum")}${n}`),
|
||||
chip(name),
|
||||
tx("LkFrom"),
|
||||
...lk.conds.map((c) => condView(ctx, lk, c)),
|
||||
tx("LkRow"),
|
||||
chip(lk.col),
|
||||
valueNote(lookupValue(ctx, lk)),
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------ ④ 항목 연결 ------------------------------ */
|
||||
|
||||
const BASES: (CostKind | "사람줄합")[] = ["노무비", "재료비", "경비", "사람줄합"];
|
||||
|
||||
function lineMap(ctx: Ctx): Map<string, CalcLine> {
|
||||
const out = new Map<string, CalcLine>();
|
||||
const ans = ctx.ans;
|
||||
if (!ans || !ans.ok || !ans.lines) return out;
|
||||
const cards = ctx.lb.doc.cards;
|
||||
const part = (c: EasyCard): string =>
|
||||
c.kind === "percent" ? "덧줄" : c.kind === "locked" ? (c.source?.part ?? "덧줄") : "호표";
|
||||
const ordered = [
|
||||
...cards.filter((c) => part(c) === "호표"),
|
||||
...cards.filter((c) => part(c) === "덧줄"),
|
||||
];
|
||||
if (ordered.length === ans.lines.length) ordered.forEach((c, i) => out.set(c.id, ans.lines![i]));
|
||||
return out;
|
||||
}
|
||||
|
||||
const patchItem = (ctx: Ctx, id: string, patch: Partial<EasyCard>): void =>
|
||||
ctx.commit(() => ({ ...ctx.lb, doc: updateCard(ctx.lb.doc, id, patch) }));
|
||||
|
||||
async function pickItem(ctx: Ctx, kind: PickResult["kind"], card?: EasyCard): Promise<void> {
|
||||
if (picking) return;
|
||||
picking = true;
|
||||
try {
|
||||
const pick = await openEasyPick(kind);
|
||||
if (!pick) return;
|
||||
ctx.commit(() => {
|
||||
if (!card) return { ...ctx.lb, doc: addCard(ctx.lb.doc, pick) };
|
||||
const next = updateCard(ctx.lb.doc, card.id, {
|
||||
name: pick.name,
|
||||
spec: pick.spec,
|
||||
unit: pick.unit,
|
||||
element: pick.element,
|
||||
});
|
||||
return { ...ctx.lb, doc: withInputs(next, pick.needInputs ?? []) };
|
||||
});
|
||||
} finally {
|
||||
picking = false;
|
||||
}
|
||||
}
|
||||
|
||||
function itemView(ctx: Ctx, card: EasyCard): HTMLElement {
|
||||
const kind = card.kind as Kind;
|
||||
const percent = kind === "percent";
|
||||
const part = card.source?.part === "호표" ? "수량" : "비율";
|
||||
const calc = card.source
|
||||
? ctx.lb.calcs.find((c) => c.at.part === part && c.at.index === card.source?.index)
|
||||
: undefined;
|
||||
const line = ctx.lines.get(card.id);
|
||||
const qty: HTMLElement = calc
|
||||
? el("span", {
|
||||
className: "m01-lbsteps-ref",
|
||||
children: [chip(`${tx("Calc")}${ctx.lb.calcs.indexOf(calc) + 1}`), valueNote(line?.수량)],
|
||||
})
|
||||
: card.qtyFormula === null
|
||||
? numBox(ctx, card.qty, (n) => patchItem(ctx, card.id, { qty: n }))
|
||||
: chip(tx("Hard"));
|
||||
const name =
|
||||
ctx.ro || kind === "locked" || percent
|
||||
? span("m01-lbsteps-name", [card.name, card.spec].filter(Boolean).join(" "))
|
||||
: (() => {
|
||||
const b = el("button", {
|
||||
className: "m01-lbsteps-pick",
|
||||
text: `${[card.name, card.spec].filter(Boolean).join(" ")} ▾`,
|
||||
attrs: { type: "button", "aria-label": tx("Repick") },
|
||||
});
|
||||
b.addEventListener("click", () => void pickItem(ctx, kind as PickResult["kind"], card));
|
||||
return b;
|
||||
})();
|
||||
const head: (HTMLElement | string)[] = [span("m01-lbsteps-tag", `【${tx(`Tag_${kind}`)}】`)];
|
||||
if (percent) {
|
||||
const options = BASES.map((v) => ({ value: v, text: tx(`Base_${v}`) }));
|
||||
head.push(
|
||||
selectBox(
|
||||
ctx,
|
||||
options,
|
||||
card.percentBase ?? "노무비",
|
||||
(v) => patchItem(ctx, card.id, { percentBase: v as EasyCard["percentBase"] }),
|
||||
tx("S4"),
|
||||
),
|
||||
tx("Pre_percent"),
|
||||
qty,
|
||||
tx("Post_percent"),
|
||||
name,
|
||||
);
|
||||
} else {
|
||||
head.push(
|
||||
name,
|
||||
kind === "person" ? tx("Pre_person") : tx("Pre_pick"),
|
||||
qty,
|
||||
span("m01-lbsteps-unit", ` ${card.unit}`),
|
||||
);
|
||||
}
|
||||
head.push(
|
||||
line
|
||||
? span("m01-lbsteps-amount", `= ${formatMoney(line.금액)} ${tx("Won")}`)
|
||||
: span("m01-lbsteps-amount is-wait", `= ${tx("Pending")}`),
|
||||
);
|
||||
if (!ctx.ro) {
|
||||
head.push(
|
||||
createButton({
|
||||
label: tx("Remove"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => ctx.commit(() => ({ ...ctx.lb, doc: removeCard(ctx.lb.doc, card.id) })),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return el("li", { className: "m01-lbsteps-row", children: [sentence(...head)] });
|
||||
}
|
||||
|
||||
function addBar(ctx: Ctx): HTMLElement {
|
||||
const add = (key: keyof typeof TEXT, run: () => void): HTMLButtonElement =>
|
||||
createButton({ label: tx(key), variant: "ghost", size: "sm", onClick: run });
|
||||
const pick = (kind: PickResult["kind"]) => () => void pickItem(ctx, kind);
|
||||
return el("div", {
|
||||
className: "m01-lbsteps-add",
|
||||
children: [
|
||||
add("Add_person", pick("person")),
|
||||
add("Add_material", pick("material")),
|
||||
add("Add_machine", pick("machine")),
|
||||
add("Add_logic", pick("logic")),
|
||||
add("Add_percent", () =>
|
||||
ctx.commit(() => ({ ...ctx.lb, doc: addPercent(ctx.lb.doc, "", "노무비", null, "경비") })),
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------ ⑤ 결과값 ------------------------------ */
|
||||
|
||||
function resultView(ctx: Ctx): HTMLElement {
|
||||
const result = ctx.lb.result;
|
||||
const ans = ctx.ans;
|
||||
if (result.kind === "값") {
|
||||
const value = ans?.ok ? ans.result : undefined;
|
||||
return el("div", {
|
||||
children: [
|
||||
sentence(note(tx("ResultValue")), chip(result.unit), valueNote(value)),
|
||||
calcBody(ctx, result.calc),
|
||||
],
|
||||
});
|
||||
}
|
||||
const sums = ans?.ok ? ans.sums : undefined;
|
||||
const parts: (HTMLElement | string)[] = [note(tx("ResultMoney")), chip(result.unit)];
|
||||
if (sums) {
|
||||
for (const k of ["노무비", "재료비", "경비"]) {
|
||||
if (k in sums)
|
||||
parts.push(
|
||||
span("m01-lbsteps-sum", `${tx(`Base_${k as CostKind}`)} ${formatMoney(sums[k])}`),
|
||||
);
|
||||
}
|
||||
parts.push(
|
||||
span("m01-lbsteps-total", `${tx("Total")} ${formatMoney(sums["계"] ?? 0)} ${tx("Won")}`),
|
||||
);
|
||||
} else parts.push(span("m01-lbsteps-amount is-wait", `= ${tx("Pending")}`));
|
||||
return sentence(...parts);
|
||||
}
|
||||
|
||||
/* ------------------------------ 전체 ------------------------------ */
|
||||
|
||||
function section(title: string, items: HTMLElement[], extra?: HTMLElement): HTMLElement {
|
||||
const body = items.length
|
||||
? el("ol", { className: "m01-lbsteps-list", children: items })
|
||||
: note(tx("None"));
|
||||
return el("section", {
|
||||
className: "m01-lbsteps-sec",
|
||||
children: [
|
||||
el("h3", { className: "m01-lbsteps-title", text: title }),
|
||||
body,
|
||||
...(extra ? [extra] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 가운데 문장 보기 그리기 — ans 는 칸마다 곁에 보이는 예시값 · null 이면 「계산 중」 */
|
||||
export function renderSteps(
|
||||
host: HTMLElement,
|
||||
lb: LbLogic,
|
||||
ans: CalcAnswer | null,
|
||||
on: { change(lb: LbLogic): void; tableName?(table: string): string | null },
|
||||
): void {
|
||||
const ctx: Ctx = {
|
||||
lb,
|
||||
ans,
|
||||
ro: lb.doc.isBase,
|
||||
on,
|
||||
lines: new Map(),
|
||||
commit: (make) => {
|
||||
try {
|
||||
on.change(make());
|
||||
} catch {
|
||||
showToast(tx("Cannot"), "error");
|
||||
}
|
||||
},
|
||||
};
|
||||
ctx.lines = lineMap(ctx);
|
||||
host.classList.add("m01-lbsteps");
|
||||
const top: HTMLElement[] = [];
|
||||
if (ctx.ro) top.push(el("p", { className: "m01-lbsteps-banner", text: tx("BaseNote") }));
|
||||
if (lb.blocked.length) {
|
||||
top.push(
|
||||
el("div", {
|
||||
className: "m01-lbsteps-banner is-warn",
|
||||
children: [
|
||||
el("p", { text: `⚠ ${tx("Blocked")}` }),
|
||||
...lb.blocked.map((b) => el("p", { className: "m01-lbsteps-why", text: b })),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
host.replaceChildren(
|
||||
...top,
|
||||
section(
|
||||
tx("S1"),
|
||||
lb.inputs.map((x, i) => inputView(ctx, x, i)),
|
||||
),
|
||||
section(
|
||||
tx("S2"),
|
||||
lb.lookups.map((l, i) => lookupView(ctx, l, i + 1)),
|
||||
),
|
||||
section(
|
||||
tx("S3"),
|
||||
lb.calcs.map((c, i) => calcView(ctx, c, i + 1)),
|
||||
),
|
||||
section(
|
||||
tx("S4"),
|
||||
lb.doc.cards.map((c) => itemView(ctx, c)),
|
||||
ctx.ro ? undefined : addBar(ctx),
|
||||
),
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/* =============================================================================
|
||||
* M01_LB_Steps_Calc.ts
|
||||
* 로직 만들기 판 문장 보기 ③ 계산 — 값 · 곱나눔 · 할증 · 조건 목록 · 최소/최대 · 올림
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import type { CondOp, CondRead, CondShape, CondSlot } from "../EasyCalc/M01_EasyCalc_Cond";
|
||||
import { setCalc, type LbCalc } from "./M01_LB_Blocks";
|
||||
import {
|
||||
chip,
|
||||
middleOf,
|
||||
note,
|
||||
numBox,
|
||||
refBox,
|
||||
selectBox,
|
||||
sentence,
|
||||
span,
|
||||
textBox,
|
||||
tx,
|
||||
valueNote,
|
||||
type Ctx,
|
||||
type TEXT,
|
||||
} from "./M01_LB_Steps_Cell";
|
||||
|
||||
const OPS: [CondOp, keyof typeof TEXT][] = [
|
||||
["==", "OpEq"],
|
||||
["!=", "OpNe"],
|
||||
[">", "OpGt"],
|
||||
[">=", "OpGe"],
|
||||
["<", "OpLt"],
|
||||
["<=", "OpLe"],
|
||||
];
|
||||
|
||||
function slotBox(ctx: Ctx, slot: CondSlot, put: (s: CondSlot) => void): HTMLElement {
|
||||
if (slot.kind === "num") return numBox(ctx, slot.value, (n) => put({ kind: "num", value: n }));
|
||||
if (slot.kind === "text") return textBox(ctx, slot.value, (v) => put({ kind: "text", value: v }));
|
||||
if (slot.kind === "input") {
|
||||
const options = ctx.lb.inputs.map((i) => ({ value: i.이름, text: i.이름 }));
|
||||
return selectBox(ctx, options, slot.name, (v) => put({ kind: "input", name: v }), tx("S1"));
|
||||
}
|
||||
return chip(tx(slot.kind === "table" ? "SlotTable" : "SlotCalc"));
|
||||
}
|
||||
|
||||
function shapeView(ctx: Ctx, shape: CondShape, put: (s: CondShape) => void): HTMLElement {
|
||||
if (shape.type === "pick") {
|
||||
const items: (HTMLElement | string)[] = [];
|
||||
shape.items.forEach((s, i) => {
|
||||
if (i) items.push(tx("And"));
|
||||
items.push(
|
||||
slotBox(ctx, s, (v) =>
|
||||
put({ ...shape, items: shape.items.map((x, j) => (j === i ? v : x)) }),
|
||||
),
|
||||
);
|
||||
});
|
||||
const fn = [
|
||||
{ value: "최소", text: tx("Small") },
|
||||
{ value: "최대", text: tx("Big") },
|
||||
];
|
||||
return sentence(
|
||||
...items,
|
||||
selectBox(ctx, fn, shape.fn, (v) => put({ ...shape, fn: v as "최소" | "최대" }), tx("S3")),
|
||||
);
|
||||
}
|
||||
if (shape.type === "round") {
|
||||
const fns = [
|
||||
{ value: "올림", text: tx("Up") },
|
||||
{ value: "버림", text: tx("Down") },
|
||||
{ value: "반올림", text: tx("Half") },
|
||||
];
|
||||
return sentence(
|
||||
slotBox(ctx, shape.value, (v) => put({ ...shape, value: v })),
|
||||
tx("RoundA"),
|
||||
numBox(ctx, shape.places, (n) => put({ ...shape, places: n, hadPlaces: true })),
|
||||
tx("RoundB"),
|
||||
selectBox(
|
||||
ctx,
|
||||
fns,
|
||||
shape.fn,
|
||||
(v) => put({ ...shape, fn: v as "올림" | "버림" | "반올림" }),
|
||||
tx("S3"),
|
||||
),
|
||||
);
|
||||
}
|
||||
const ops = OPS.map(([value, key]) => ({ value, text: tx(key) }));
|
||||
const rows = shape.branches.map((b, i) => {
|
||||
const edit = (patch: Partial<typeof b>): void =>
|
||||
put({ ...shape, branches: shape.branches.map((x, j) => (j === i ? { ...x, ...patch } : x)) });
|
||||
return el("li", {
|
||||
className: "m01-lbsteps-branch",
|
||||
children: [
|
||||
sentence(
|
||||
span("m01-lbsteps-order", String(i + 1)),
|
||||
slotBox(ctx, b.test.left, (v) => edit({ test: { ...b.test, left: v } })),
|
||||
tx("Subject"),
|
||||
slotBox(ctx, b.test.right, (v) => edit({ test: { ...b.test, right: v } })),
|
||||
selectBox(
|
||||
ctx,
|
||||
ops,
|
||||
b.test.op,
|
||||
(v) => edit({ test: { ...b.test, op: v as CondOp } }),
|
||||
tx("S3"),
|
||||
),
|
||||
"→",
|
||||
slotBox(ctx, b.value, (v) => edit({ value: v })),
|
||||
),
|
||||
],
|
||||
});
|
||||
});
|
||||
const last = el("li", {
|
||||
className: "m01-lbsteps-branch",
|
||||
children: [
|
||||
sentence(
|
||||
tx("Otherwise"),
|
||||
slotBox(ctx, shape.otherwise, (v) => put({ ...shape, otherwise: v })),
|
||||
),
|
||||
],
|
||||
});
|
||||
return el("div", {
|
||||
className: "m01-lbsteps-shape",
|
||||
children: [
|
||||
note(tx("Cond1")),
|
||||
el("ol", { className: "m01-lbsteps-branches", children: [...rows, last] }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function placeText(ctx: Ctx, calc: LbCalc): string {
|
||||
if (calc.name !== null) return calc.name;
|
||||
const { part, index } = calc.at;
|
||||
if (part === "결과") return tx("PlaceResult");
|
||||
const card = ctx.lb.doc.cards.find(
|
||||
(c) => c.source?.index === index && c.source.part === (part === "수량" ? "호표" : "덧줄"),
|
||||
);
|
||||
return `${card?.name ?? ""} ${tx(part === "수량" ? "PlaceQty" : "PlaceRate")}`.trim();
|
||||
}
|
||||
|
||||
export function calcBody(ctx: Ctx, calc: LbCalc): HTMLElement {
|
||||
const put = (patch: Partial<LbCalc>): void => ctx.commit(() => setCalc(ctx.lb, calc, patch));
|
||||
const refAt = (i: number): HTMLElement =>
|
||||
refBox(ctx, calc.parts[i], calc, (r) =>
|
||||
put({ parts: calc.parts.map((p, j) => (j === i ? r : p)) }),
|
||||
);
|
||||
if (calc.cond && calc.cond.blocks.length) {
|
||||
const read: CondRead = calc.cond;
|
||||
return el("div", {
|
||||
className: "m01-lbsteps-shapes",
|
||||
children: read.blocks.map((b, bi) =>
|
||||
shapeView(ctx, b.shape, (shape) =>
|
||||
put({
|
||||
cond: { ...read, blocks: read.blocks.map((x, j) => (j === bi ? { ...x, shape } : x)) },
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (calc.kind === "값" && calc.parts.length) return sentence(refAt(0));
|
||||
if (calc.kind === "할증" && calc.parts.length >= 2) {
|
||||
return sentence(refAt(0), tx("Mark1"), refAt(1), tx("Mark2"));
|
||||
}
|
||||
if ((calc.kind === "곱나눔" || calc.kind === "더하기") && calc.parts.length) {
|
||||
const ops = calc.kind === "곱나눔" ? ["*", "/"] : ["+", "-"];
|
||||
const glyph = (o: string): string =>
|
||||
tx(o === "*" ? "Mul" : o === "/" ? "Div" : o === "+" ? "Plus" : "Minus");
|
||||
const out: (HTMLElement | string)[] = [refAt(0)];
|
||||
calc.parts.slice(1).forEach((_, i) => {
|
||||
const op = calc.ops?.[i] ?? ops[0];
|
||||
const choices = ops.map((o) => ({ value: o, text: glyph(o) }));
|
||||
out.push(
|
||||
selectBox(
|
||||
ctx,
|
||||
choices,
|
||||
op,
|
||||
(v) =>
|
||||
put({
|
||||
ops: calc.parts
|
||||
.slice(1)
|
||||
.map((_, j) => (j === i ? v : (calc.ops?.[j] ?? ops[0])) as "*" | "/" | "+" | "-"),
|
||||
}),
|
||||
tx("S3"),
|
||||
),
|
||||
refAt(i + 1),
|
||||
);
|
||||
});
|
||||
return sentence(...out);
|
||||
}
|
||||
return sentence(note(tx("Steps")));
|
||||
}
|
||||
|
||||
export function calcView(ctx: Ctx, calc: LbCalc, n: number): HTMLElement {
|
||||
return el("li", {
|
||||
className: "m01-lbsteps-row",
|
||||
children: [
|
||||
sentence(
|
||||
span("m01-lbsteps-name", `${tx("Calc")}${n}`),
|
||||
chip(placeText(ctx, calc)),
|
||||
valueNote(middleOf(ctx, calc.name)),
|
||||
),
|
||||
calcBody(ctx, calc),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/* =============================================================================
|
||||
* M01_LB_Steps_Cell.ts
|
||||
* 로직 만들기 판 문장 보기 공용 — 글 [한, 영] 짝 · 작은 칸(숫자 · 글 · 고르기) · 값이 오는 곳 고르기
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import type { CalcAnswer, CalcLine } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatNumber } from "../M01_MasterData_UI_Logic_Edit";
|
||||
import type { LbCalc, LbExpr, LbLogic, LbLookup, LbRef } from "./M01_LB_Blocks";
|
||||
|
||||
export const TEXT = {
|
||||
Blocked: ["이 화면에서 읽지 못하는 줄이 있음", "Some lines cannot be read on this screen"],
|
||||
Cannot: ["이 고침은 지금 할 수 없음", "This change cannot be made now"],
|
||||
BaseNote: ["표준 로직은 읽기만 — 복사해서 고침", "Standard logic is read-only — copy to edit"],
|
||||
S1: ["① 입력값", "① Input values"],
|
||||
S2: ["② 표 조회", "② Table lookup"],
|
||||
S3: ["③ 계산", "③ Calculation"],
|
||||
S4: ["④ 항목 연결", "④ Items"],
|
||||
S5: ["⑤ 결과값", "⑤ Result"],
|
||||
None: ["없음", "none"],
|
||||
PickList: ["선택 목록", "choose from"],
|
||||
Number: ["숫자", "number"],
|
||||
Range: ["범위", "range"],
|
||||
Unit: ["단위", "unit"],
|
||||
Eg: ["예: 3", "e.g. 3"],
|
||||
NotNumber: ["숫자만 넣음", "Numbers only"],
|
||||
LkFrom: [" 에서 ", " : "],
|
||||
LkIs: [" 이 ", " is "],
|
||||
LkRow: [" 인 줄의 ", " in the row, take "],
|
||||
LkNum: ["표 조회 ", "Lookup "],
|
||||
SrcInput: ["입력값 · ", "input · "],
|
||||
SrcCalc: ["계산 · ", "calc · "],
|
||||
SrcFixed: ["고정값", "fixed value"],
|
||||
SrcAny: ["무관(조건에서 뺌)", "any (remove condition)"],
|
||||
Other: ["다른 일위대가", "Other unit price"],
|
||||
Hard: ["이 화면에서 못 고치는 값", "Value not editable here"],
|
||||
Mul: ["×", "×"],
|
||||
Div: ["÷", "÷"],
|
||||
Plus: ["+", "+"],
|
||||
Minus: ["−", "−"],
|
||||
Mark1: ["에 ", ": add "],
|
||||
Mark2: [" % 를 더 얹음", " % on top"],
|
||||
Steps: ["여러 단계로 된 계산 — 읽기만", "Multi-step calculation — read only"],
|
||||
Cond1: ["위에서 처음 맞는 줄의 값을 씀", "The first matching line from the top is used"],
|
||||
Subject: ["가", "is"],
|
||||
Otherwise: ["그 외 →", "Otherwise →"],
|
||||
Big: ["가운데 큰 값", "the larger"],
|
||||
Small: ["가운데 작은 값", "the smaller"],
|
||||
And: ["와", "and"],
|
||||
RoundA: ["을 소수 ", "round to "],
|
||||
RoundB: [" 자리까지 ", " decimals, "],
|
||||
Up: ["올림", "round up"],
|
||||
Down: ["버림", "round down"],
|
||||
Half: ["반올림", "round off"],
|
||||
OpEq: ["이면", "equal to"],
|
||||
OpNe: ["이 아니면", "not equal to"],
|
||||
OpGt: ["보다 크면", "greater than"],
|
||||
OpGe: ["이상이면", "at least"],
|
||||
OpLt: ["보다 작으면", "less than"],
|
||||
OpLe: ["이하이면", "at most"],
|
||||
SlotTable: ["표에서 찾은 값", "Value from table"],
|
||||
SlotCalc: ["계산한 값", "Calculated value"],
|
||||
PlaceQty: ["수량", "amount"],
|
||||
PlaceRate: ["비율", "rate"],
|
||||
PlaceResult: ["결과값", "result"],
|
||||
Calc: ["계산 ", "Calc "],
|
||||
Tag_person: ["노무", "Labor"],
|
||||
Tag_material: ["재료", "Material"],
|
||||
Tag_machine: ["장비", "Equipment"],
|
||||
Tag_logic: ["일위대가", "Unit price"],
|
||||
Tag_percent: ["비율", "Rate"],
|
||||
Tag_locked: ["기타", "Other"],
|
||||
Pre_person: [" 이 ", " works "],
|
||||
Pre_pick: [" 를 ", " uses "],
|
||||
Pre_percent: [" 의 ", " of "],
|
||||
Post_percent: [" % ", " % "],
|
||||
Repick: ["다른 것으로 다시 고르기", "Choose another"],
|
||||
Remove: ["지움", "Remove"],
|
||||
Pending: ["계산 중", "calculating"],
|
||||
Won: ["원", "KRW"],
|
||||
Add_person: ["+ 노무", "+ Labor"],
|
||||
Add_material: ["+ 재료", "+ Material"],
|
||||
Add_machine: ["+ 장비", "+ Equipment"],
|
||||
Add_logic: ["+ 일위대가", "+ Unit price"],
|
||||
Add_percent: ["+ 비율", "+ Rate"],
|
||||
Base_노무비: ["노무비", "labor cost"],
|
||||
Base_재료비: ["재료비", "material cost"],
|
||||
Base_경비: ["경비", "expenses"],
|
||||
Base_사람줄합: ["노무 줄의 합", "sum of labor lines"],
|
||||
ResultMoney: ["결과 = 금액", "Result = amount"],
|
||||
ResultValue: [
|
||||
"결과 = 값 — 다른 일위대가가 불러 쓸 수 있음",
|
||||
"Result = value — other unit prices can use it",
|
||||
],
|
||||
Total: ["합계", "Total"],
|
||||
} as const;
|
||||
|
||||
export const tx = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
|
||||
|
||||
export type Ref = LbRef | LbExpr;
|
||||
|
||||
export interface Ctx {
|
||||
lb: LbLogic;
|
||||
ans: CalcAnswer | null;
|
||||
ro: boolean;
|
||||
on: { change(lb: LbLogic): void; tableName?(table: string): string | null };
|
||||
lines: Map<string, CalcLine>;
|
||||
/** 바꾸기 — 못 하면 쉬운 말 한 줄 */
|
||||
commit(make: () => LbLogic): void;
|
||||
}
|
||||
|
||||
/* ------------------------------ 작은 칸 ------------------------------ */
|
||||
|
||||
export const span = (cls: string, text: string): HTMLSpanElement =>
|
||||
el("span", { className: cls, text });
|
||||
export const chip = (text: string, title?: string): HTMLSpanElement => {
|
||||
const c = span("m01-lbsteps-chip", text);
|
||||
if (title) c.title = title;
|
||||
return c;
|
||||
};
|
||||
export const note = (text: string): HTMLSpanElement => span("m01-lbsteps-note", text);
|
||||
export const sentence = (...parts: (HTMLElement | string)[]): HTMLElement =>
|
||||
el("div", { className: "m01-lbsteps-sentence", children: parts });
|
||||
|
||||
export const parseNum = (text: string): number | null => {
|
||||
const t = text.replace(/[,\s]/g, "");
|
||||
return /^[+-]?(\d+\.?\d*|\.\d+)$/.test(t) ? Number(t) : null;
|
||||
};
|
||||
|
||||
export function numBox(ctx: Ctx, value: number | null, commit: (n: number) => void): HTMLElement {
|
||||
if (ctx.ro) return chip(value === null ? "" : String(value));
|
||||
const input = el("input", {
|
||||
className: "ui-input m01-lbsteps-num",
|
||||
attrs: { type: "text", inputmode: "decimal", placeholder: tx("Eg") },
|
||||
});
|
||||
input.value = value === null ? "" : String(value);
|
||||
input.addEventListener("change", () => {
|
||||
const n = parseNum(input.value);
|
||||
input.classList.toggle("is-bad", n === null);
|
||||
input.title = n === null ? tx("NotNumber") : "";
|
||||
if (n !== null && n !== value) commit(n);
|
||||
});
|
||||
return input;
|
||||
}
|
||||
|
||||
export function textBox(ctx: Ctx, value: string, commit: (v: string) => void): HTMLElement {
|
||||
if (ctx.ro) return chip(value);
|
||||
const input = el("input", { className: "ui-input m01-lbsteps-text", attrs: { type: "text" } });
|
||||
input.value = value;
|
||||
input.addEventListener("change", () => input.value !== value && commit(input.value.trim()));
|
||||
return input;
|
||||
}
|
||||
|
||||
export function selectBox(
|
||||
ctx: Ctx,
|
||||
options: { value: string; text: string }[],
|
||||
current: string,
|
||||
commit: (v: string) => void,
|
||||
aria: string,
|
||||
): HTMLElement {
|
||||
const hit = options.find((o) => o.value === current);
|
||||
if (ctx.ro) return chip(hit?.text ?? "");
|
||||
const select = el("select", {
|
||||
className: "ui-select m01-lbsteps-select",
|
||||
attrs: { "aria-label": aria },
|
||||
});
|
||||
for (const o of options) select.append(el("option", { attrs: { value: o.value }, text: o.text }));
|
||||
select.value = hit ? current : (options[0]?.value ?? "");
|
||||
select.addEventListener("change", () => commit(select.value));
|
||||
return select;
|
||||
}
|
||||
|
||||
export const show = (v: unknown): string => (v === undefined || v === null ? "" : formatNumber(v));
|
||||
export const valueNote = (v: unknown): HTMLElement | string =>
|
||||
v === undefined || v === null ? "" : span("m01-lbsteps-value", `= ${show(v)}`);
|
||||
|
||||
/* ------------------------------ 값 찾기 ------------------------------ */
|
||||
|
||||
export const middleOf = (ctx: Ctx, name: string | null): unknown =>
|
||||
name !== null && ctx.ans?.ok ? ctx.ans.middle[name] : undefined;
|
||||
|
||||
/** 표 조회 값 — 그 값 하나만 이름 붙은 계산이 되어 있을 때만 앎 */
|
||||
export function lookupValue(ctx: Ctx, lk: LbLookup): unknown {
|
||||
const calc = ctx.lb.calcs.find(
|
||||
(c) =>
|
||||
c.kind === "값" &&
|
||||
c.name !== null &&
|
||||
c.parts.length === 1 &&
|
||||
isRef(c.parts[0], "lookup", lk.id),
|
||||
);
|
||||
return middleOf(ctx, calc?.name ?? null);
|
||||
}
|
||||
|
||||
export const isRef = (r: Ref, k: "lookup", id: string): boolean => r.k === k && r.id === id;
|
||||
|
||||
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":
|
||||
return r.name;
|
||||
case "fixed":
|
||||
return String(r.value);
|
||||
case "calc":
|
||||
return r.name;
|
||||
case "lookup": {
|
||||
const lk = ctx.lb.lookups.find((l) => l.id === r.id);
|
||||
return `${tx("LkNum")}${lookupNo(ctx, r.id)}${lk ? ` · ${lk.col}` : ""}`;
|
||||
}
|
||||
case "logic":
|
||||
return `${logicNames.get(r.key) ?? tx("Other")}${r.field ? ` · ${r.field}` : ""}`;
|
||||
case "cost":
|
||||
return tx(`Base_${r.name}`);
|
||||
default:
|
||||
return tx("Hard");
|
||||
}
|
||||
}
|
||||
|
||||
export function refValue(ctx: Ctx, r: Ref): unknown {
|
||||
if (r.k === "calc") return middleOf(ctx, r.name);
|
||||
if (r.k === "fixed") return r.value;
|
||||
if (r.k === "lookup") {
|
||||
const lk = ctx.lb.lookups.find((l) => l.id === r.id);
|
||||
return lk ? lookupValue(ctx, lk) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 값이 오는 곳 고르기 — 입력값 · 표 조회 · 다른 계산 · 고정값 */
|
||||
export function refBox(
|
||||
ctx: Ctx,
|
||||
ref: Ref,
|
||||
self: LbCalc | null,
|
||||
pick: (r: LbRef) => void,
|
||||
): HTMLElement {
|
||||
const label = refText(ctx, ref);
|
||||
const editable = ref.k === "input" || ref.k === "calc" || ref.k === "lookup" || ref.k === "fixed";
|
||||
if (ctx.ro || !editable) {
|
||||
return el("span", {
|
||||
className: "m01-lbsteps-ref",
|
||||
children: [chip(label), valueNote(refValue(ctx, ref))],
|
||||
});
|
||||
}
|
||||
const options = [
|
||||
...ctx.lb.inputs.map((i) => ({ value: `i:${i.이름}`, text: `${tx("SrcInput")}${i.이름}` })),
|
||||
...ctx.lb.lookups.map((l) => ({
|
||||
value: `l:${l.id}`,
|
||||
text: refText(ctx, { k: "lookup", id: l.id }),
|
||||
})),
|
||||
...ctx.lb.calcs
|
||||
.filter((c) => c.name !== null && c !== self)
|
||||
.map((c) => ({ value: `c:${c.name}`, text: `${tx("SrcCalc")}${c.name}` })),
|
||||
{ value: "f", text: tx("SrcFixed") },
|
||||
];
|
||||
const current =
|
||||
ref.k === "input"
|
||||
? `i:${ref.name}`
|
||||
: ref.k === "calc"
|
||||
? `c:${ref.name}`
|
||||
: ref.k === "lookup"
|
||||
? `l:${ref.id}`
|
||||
: "f";
|
||||
const toRef = (v: string): LbRef =>
|
||||
v === "f"
|
||||
? { k: "fixed", value: 0 }
|
||||
: v.startsWith("i:")
|
||||
? { k: "input", name: v.slice(2) }
|
||||
: v.startsWith("l:")
|
||||
? { k: "lookup", id: v.slice(2) }
|
||||
: { k: "calc", name: v.slice(2) };
|
||||
const parts: (HTMLElement | string)[] = [
|
||||
selectBox(ctx, options, current, (v) => pick(toRef(v)), tx("S3")),
|
||||
];
|
||||
if (ref.k === "fixed") {
|
||||
parts.push(
|
||||
numBox(ctx, typeof ref.value === "number" ? ref.value : null, (n) =>
|
||||
pick({ k: "fixed", value: n }),
|
||||
),
|
||||
);
|
||||
} else parts.push(valueNote(refValue(ctx, ref)));
|
||||
return el("span", { className: "m01-lbsteps-ref", children: parts });
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
_data/
|
||||
@@ -0,0 +1,108 @@
|
||||
/* =============================================================================
|
||||
* M01_SL_Api.ts
|
||||
* 시트 로직 Api — 목록 · 열기 · 저장(서버가 바깥 값을 다시 넣고 같은 엔진으로 다시 셈) · 지우기 ·
|
||||
* 바깥 값 한 번에 받기(품셈 표 · 노임 · 자재 · 장비 · 기존 로직 결과)
|
||||
* 서버 = `M01_SL_Router.py`(`/api/m01/sl/*`) · 정함 `docs/raw/M01_쉬운단가/시트로직_정함.md` 4 · 5 · 6
|
||||
* ========================================================================== */
|
||||
|
||||
import type { Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
/** 바깥 값 하나 — 값은 열 때 · 저장 때 마스터에서 새로(표는 사본으로 들고 있지 않음)
|
||||
* · 표 = 품셈 표 통째(머리 줄 + 줄 · 범위 조건 · 값은 「이름·아래」 「이름·위」 두 칸)
|
||||
* · 노임 · 자재 = 그 줄의 단가(`/calc` 와 같은 셈 · 준용 · 낮은 값)
|
||||
* · 장비 = 기계경비 로직 부름 글(EasyCalc `machineElement` 의 element) + 넘길 입력값 → 한 시간 값
|
||||
* · 로직 = 기존 로직 결과(`/calc` · 돈 로직은 `field` 비목 · 기본 계) */
|
||||
export type SlOutside =
|
||||
| { id: string; kind: "표"; table: string }
|
||||
| { id: string; kind: "노임"; key: string }
|
||||
| { id: string; kind: "자재"; key: string }
|
||||
| {
|
||||
id: string;
|
||||
kind: "장비";
|
||||
element: string;
|
||||
inputs: Record<string, unknown>;
|
||||
field?: SlField;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "로직";
|
||||
key: string;
|
||||
inputs: Record<string, unknown>;
|
||||
field?: SlField;
|
||||
};
|
||||
export type SlField = "노무비" | "재료비" | "경비" | "계";
|
||||
|
||||
/** 바깥 값이 들어갈 자리 — 시트 id · 칸(표는 이 칸을 왼쪽 위로 펼침) */
|
||||
export interface SlOutsideAt {
|
||||
item: SlOutside;
|
||||
sheet: string;
|
||||
cell: string;
|
||||
}
|
||||
|
||||
/** 바깥 값 답 — 표는 `rows`(머리 줄 포함) · 그 밖은 `value` · 못 구하면 ok false + 쉬운 말 */
|
||||
export interface SlOutsideValue {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
value?: number | null;
|
||||
rows?: (string | number | null)[][];
|
||||
name?: string;
|
||||
unit?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** 시트 로직 한 벌 = 통합문서 하나(상자 = 시트) + 바깥 값 자리 */
|
||||
export interface SlLogic {
|
||||
/** 새 로직 null · 저장하면 서버가 `SL-0001` 꼴로 줌 */
|
||||
key: string | null;
|
||||
이름: string;
|
||||
결과단위: string;
|
||||
book: Workbook;
|
||||
outside: SlOutsideAt[];
|
||||
/** 다른 창이 더하는 칸(상자 · 알약 · 선 …)은 그대로 저장 · 돌려줌 */
|
||||
[extra: string]: unknown;
|
||||
}
|
||||
|
||||
/** 목록 한 줄 */
|
||||
export interface SlListItem {
|
||||
key: string;
|
||||
이름: string;
|
||||
결과단위: string;
|
||||
/** 마지막 저장 때(ISO) */
|
||||
saved: string;
|
||||
}
|
||||
|
||||
/** 저장 답 — `logic.book.계산값` 은 서버가 다시 셈한 값 · `values` 는 넣은 바깥 값 */
|
||||
export interface SlSaved {
|
||||
key: string;
|
||||
logic: SlLogic;
|
||||
values: SlOutsideValue[];
|
||||
}
|
||||
|
||||
const notReady = (): never => {
|
||||
throw new Error("준비 중");
|
||||
};
|
||||
|
||||
export async function slList(): Promise<SlListItem[]> {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
export async function slGet(_key: string): Promise<SlLogic> {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
export async function slSave(_logic: SlLogic): Promise<SlSaved> {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
export async function slDelete(_key: string): Promise<void> {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
export async function slOutside(_items: SlOutside[]): Promise<SlOutsideValue[]> {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
/** 실패 → 쉬운 말(EasyCalc `plainError` 그대로) */
|
||||
export function slPlain(_e: unknown): string {
|
||||
return notReady();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/* =============================================================================
|
||||
* M01_SL_Eval.ts
|
||||
* 시트 로직 화면 계산 — 바깥 값 넣기 → 우리 스프레드시트 엔진(브라우저) → 출력 알약 · 결과 읽기 · 검사
|
||||
* 저장 때는 서버가 같은 엔진을 Node 로(정함 5) · DOM 없음
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SheetLogic, SlOutside } from "./M01_SL_Model";
|
||||
|
||||
/** 알약 값 — 수 · 글 · 참거짓 · 빈 칸 */
|
||||
export type SlValue = number | string | boolean | null;
|
||||
|
||||
/** 바깥 값 하나 — 표는 칸 덩어리(cell 이 왼위) */
|
||||
export interface SlOutsideValue {
|
||||
node: string;
|
||||
cell: string;
|
||||
value: SlValue | SlValue[][];
|
||||
}
|
||||
|
||||
export interface SlEvalResult {
|
||||
/** 상자 id → 출력 알약 이름 → 값 */
|
||||
pills: Record<string, Record<string, SlValue>>;
|
||||
result: { 노무비: number | null; 재료비: number | null; 경비: number | null; 계: number | null };
|
||||
/** 쉬운 말 */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const notReady = (): never => {
|
||||
throw new Error("준비 중");
|
||||
};
|
||||
|
||||
/** 바깥 값 칸 목록 — 화면 · 서버가 이 칸에 값을 넣음 */
|
||||
export function outsideCells(_sl: SheetLogic): SlOutside[] {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
/** 바깥 값 넣기 — 새 시트 로직(받은 것은 안 바꿈) */
|
||||
export function applyOutside(_sl: SheetLogic, _values: SlOutsideValue[]): SheetLogic {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
/** 화면 계산 */
|
||||
export function evalLogic(_sl: SheetLogic): SlEvalResult {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
/** 검사 — 빈 입력 알약 · 순환 · 오류 칸(쉬운 말 목록) */
|
||||
export function checkLogic(_sl: SheetLogic): string[] {
|
||||
return notReady();
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/* =============================================================================
|
||||
* M01_SL_Model.ts
|
||||
* 시트 로직 Model — 로직 한 벌 = 통합문서 하나 · 상자 하나 = 시트 하나 · 알약 = 이름 붙은 칸
|
||||
* 선 = 받는 입력 알약 칸에 「'시트 이름'!칸」 참조 수식 · DOM 없음
|
||||
* 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` 정함 1~5 그대로
|
||||
* ========================================================================== */
|
||||
|
||||
import type { Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
/** 상자 종류 — 입력값 · 고정값 · 표 · 계산 · 항목 · 다른 로직 · 결과 */
|
||||
export type SlKind = "input" | "const" | "table" | "calc" | "item" | "logic" | "result";
|
||||
|
||||
/** 알약 = 이름 붙은 칸 */
|
||||
export interface SlPill {
|
||||
name: string;
|
||||
/** 「B3」 */
|
||||
cell: string;
|
||||
unit?: string;
|
||||
/** 바깥 값 칸 — 열 때 · 저장 때 마스터에서 넣음 */
|
||||
outside?: boolean;
|
||||
}
|
||||
|
||||
/** 상자 = 시트 하나 */
|
||||
export interface SlNode {
|
||||
id: string;
|
||||
kind: SlKind;
|
||||
name: string;
|
||||
sheetId: string;
|
||||
pillsIn: SlPill[];
|
||||
pillsOut: SlPill[];
|
||||
/** 표 키 · 요소 · 기존 로직 키 · 내 표 id */
|
||||
ref?: string;
|
||||
/** 띠 단계 — 0 입력값 · 1 표 · 2 계산 · 3 항목 · 4 결과 */
|
||||
stage: number;
|
||||
}
|
||||
|
||||
/** 선 — 출력 알약 → 입력 알약 */
|
||||
export interface SlEdge {
|
||||
id: string;
|
||||
from: { node: string; pill: string };
|
||||
to: { node: string; pill: string };
|
||||
}
|
||||
|
||||
/** 바깥 값 칸 */
|
||||
export interface SlOutside {
|
||||
node: string;
|
||||
cell: string;
|
||||
kind: "table" | "price" | "logic";
|
||||
ref: string;
|
||||
}
|
||||
|
||||
/** 내 표 — 품셈에 없는 표(사용자가 만듦) */
|
||||
export interface SlUserTable {
|
||||
id: string;
|
||||
name: string;
|
||||
cols: string[];
|
||||
rows: (number | string | null)[][];
|
||||
}
|
||||
|
||||
export interface SheetLogic {
|
||||
key: string | null;
|
||||
name: string;
|
||||
resultUnit: string;
|
||||
workbook: Workbook;
|
||||
nodes: SlNode[];
|
||||
edges: SlEdge[];
|
||||
userTables?: SlUserTable[];
|
||||
}
|
||||
|
||||
/** 알약 한쪽 끝 */
|
||||
export interface SlEnd {
|
||||
node: string;
|
||||
pill: string;
|
||||
}
|
||||
|
||||
const notReady = (): never => {
|
||||
throw new Error("준비 중");
|
||||
};
|
||||
|
||||
/** 빈 시트 로직 */
|
||||
export function newLogic(_name?: string, _resultUnit?: string): SheetLogic {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
/** 상자 더하기 — 그 종류 틀(칸 A 이름 · B 값 · C 설명)로 시트를 만듦 */
|
||||
export function addNode(
|
||||
_sl: SheetLogic,
|
||||
_kind: SlKind,
|
||||
_name: string,
|
||||
): { sl: SheetLogic; node: SlNode } {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
/** 상자 지우기 — 시트 · 이어진 선 · 받던 참조 수식을 함께 지움 */
|
||||
export function removeNode(_sl: SheetLogic, _node: string): SheetLogic {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
/** 선 잇기 — 받는 알약 칸에 참조 수식 · 순환 · 없는 알약이면 막고 까닭(쉬운 말) */
|
||||
export function connect(
|
||||
_sl: SheetLogic,
|
||||
_from: SlEnd,
|
||||
_to: SlEnd,
|
||||
): { sl: SheetLogic; why: string | null } {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
/** 선 끊기 — 그 참조를 받는 칸 수식에서 뺌 */
|
||||
export function disconnect(_sl: SheetLogic, _edge: string): SheetLogic {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
/** 알약 정하기 — 같은 칸이나 같은 이름이 있으면 바꿈 */
|
||||
export function setPill(
|
||||
_sl: SheetLogic,
|
||||
_node: string,
|
||||
_cell: string,
|
||||
_dir: "in" | "out",
|
||||
_name: string,
|
||||
_extra?: Pick<SlPill, "unit" | "outside">,
|
||||
): SheetLogic {
|
||||
return notReady();
|
||||
}
|
||||
|
||||
/** 알약 빼기 — 이어진 선도 끊음 */
|
||||
export function removePill(
|
||||
_sl: SheetLogic,
|
||||
_node: string,
|
||||
_dir: "in" | "out",
|
||||
_name: string,
|
||||
): SheetLogic {
|
||||
return notReady();
|
||||
}
|
||||
@@ -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,382 @@
|
||||
"""로직 만들기 판 — 검사(`M01_LB_Check.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_Check.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 stub = new Proxy({}, { get: () => () => undefined });
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, () => stub);
|
||||
const m = box.exports;
|
||||
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
Promise.all(
|
||||
jobs.map(async (job) => {
|
||||
if (job.op === "gaps") return m.gaps(job.lb, job.tables);
|
||||
if (job.op === "skipped") return m.gapSkipped(job.lb, job.tables);
|
||||
if (job.op === "hits") return m.lookupHits(job.lb, job.tables, job.values);
|
||||
if (job.op === "find") return m.findRows(job.table, job.conds, job.want ?? "");
|
||||
if (job.op === "same") return m.sameAsExpect(job.got, job.expect);
|
||||
if (job.op === "expect") {
|
||||
const answers = job.answers;
|
||||
let i = 0;
|
||||
return m.runExpect({}, {}, job.rows, async () => answers[i++]);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
).then((out) => process.stdout.write(JSON.stringify(out)));
|
||||
"""
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# 견본 표 ① — 돌쌓기 꼴: 뒷길이 × 돌 × 쌓기 · 「호박돌」 줄은 쌓기 칸이 비어 무관
|
||||
STONE = {
|
||||
"키": "T1",
|
||||
"조건": {"뒷길이": "수", "돌": "고르기", "쌓기": "고르기"},
|
||||
"값칸": {"석공": "인"},
|
||||
"줄": [
|
||||
{"뒷길이": 25, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.15},
|
||||
{"뒷길이": 25, "돌": "깬잡석", "쌓기": "켜쌓기", "석공": 0.13},
|
||||
{"뒷길이": 25, "돌": "호박돌", "석공": 0.10},
|
||||
{"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.22},
|
||||
{"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.23}, # 같은 조합 두 줄 = 중복
|
||||
],
|
||||
}
|
||||
|
||||
# 견본 표 ② — 범위 줄(이상·미만 · 마지막 칸 이하 포함)
|
||||
HEIGHT = {
|
||||
"키": "T2",
|
||||
"범위규칙": "이상·미만 · 마지막 칸(4~6)은 이하 포함",
|
||||
"조건": {"높이": "범위"},
|
||||
"값칸": {"증가율": "%"},
|
||||
"줄": [
|
||||
{"높이": [None, 2], "증가율": 0},
|
||||
{"높이": [2, 4], "증가율": 30},
|
||||
{"높이": [4, 6], "증가율": 40},
|
||||
],
|
||||
}
|
||||
|
||||
# 견본 표 ③ — 범위 줄(이하 · 위 끝만 · 여러 줄이면 위 끝 가장 작은 줄)
|
||||
UPTO = {
|
||||
"키": "T3",
|
||||
"범위규칙": "이하",
|
||||
"조건": {"거리": "범위"},
|
||||
"값칸": {"값": ""},
|
||||
"줄": [{"거리": [None, 10], "값": 1}, {"거리": [None, 20], "값": 2}],
|
||||
}
|
||||
|
||||
TABLES = {"T1": STONE, "T2": HEIGHT, "T3": UPTO}
|
||||
|
||||
|
||||
def lk(id_, table, conds, col):
|
||||
return {"id": id_, "table": table, "conds": conds, "col": col}
|
||||
|
||||
|
||||
LOGIC = {
|
||||
"inputs": [
|
||||
{"이름": "뒷길이", "고르기": [25, 30]},
|
||||
{"이름": "돌", "고르기": ["깬잡석", "호박돌"]},
|
||||
{"이름": "쌓기", "고르기": ["골쌓기", "켜쌓기"]},
|
||||
{"이름": "높이", "고르기": [1, 3, 6, 7]},
|
||||
{"이름": "길이"}, # 숫자 입력 — 선택 목록 없음
|
||||
],
|
||||
"lookups": [
|
||||
lk(
|
||||
"a",
|
||||
"T1",
|
||||
[
|
||||
{"col": "뒷길이", "from": {"k": "input", "name": "뒷길이"}},
|
||||
{"col": "돌", "from": {"k": "input", "name": "돌"}},
|
||||
{"col": "쌓기", "from": {"k": "input", "name": "쌓기"}},
|
||||
],
|
||||
"석공",
|
||||
),
|
||||
lk("b", "T2", [{"col": "높이", "from": {"k": "input", "name": "높이"}}], "증가율"),
|
||||
lk("c", "T3", [{"col": "거리", "from": {"k": "input", "name": "길이"}}], "값"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def by(gaps, lookup):
|
||||
return sorted(
|
||||
(
|
||||
(g["kind"], json.dumps(g["conds"], ensure_ascii=False))
|
||||
for g in gaps
|
||||
if g["lookup"] == lookup
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_빈틈_중복_무관_빈칸():
|
||||
[gaps] = run([{"op": "gaps", "lb": LOGIC, "tables": TABLES}])
|
||||
got = by(gaps, "a")
|
||||
# 뒷길이 2 × 돌 2 × 쌓기 2 = 8 조합
|
||||
# 무관 빈칸 — 25 · 호박돌 은 쌓기 둘 다 한 줄로 걸림(빈틈 아님)
|
||||
# 빈틈 — 30 · 깬잡석 · 켜쌓기 · 30 · 호박돌 · 골/켜
|
||||
# 중복 — 30 · 깬잡석 · 골쌓기(두 줄)
|
||||
assert got == sorted(
|
||||
[
|
||||
(
|
||||
"없음",
|
||||
json.dumps({"뒷길이": 30, "돌": "깬잡석", "쌓기": "켜쌓기"}, ensure_ascii=False),
|
||||
),
|
||||
(
|
||||
"없음",
|
||||
json.dumps({"뒷길이": 30, "돌": "호박돌", "쌓기": "골쌓기"}, ensure_ascii=False),
|
||||
),
|
||||
(
|
||||
"없음",
|
||||
json.dumps({"뒷길이": 30, "돌": "호박돌", "쌓기": "켜쌓기"}, ensure_ascii=False),
|
||||
),
|
||||
(
|
||||
"중복",
|
||||
json.dumps({"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기"}, ensure_ascii=False),
|
||||
),
|
||||
]
|
||||
)
|
||||
dup = next(g for g in gaps if g["kind"] == "중복")
|
||||
assert dup["rows"] == [3, 4]
|
||||
|
||||
|
||||
def test_범위_줄_이상미만_마지막칸_이하포함():
|
||||
[gaps] = run([{"op": "gaps", "lb": LOGIC, "tables": TABLES}])
|
||||
# 높이 1 → [null,2) · 3 → [2,4) · 6 → 마지막 칸 이하 포함 · 7 → 없음
|
||||
assert by(gaps, "b") == [("없음", json.dumps({"높이": 7}, ensure_ascii=False))]
|
||||
out = run(
|
||||
[
|
||||
{"op": "find", "table": HEIGHT, "conds": {"높이": 2}},
|
||||
{"op": "find", "table": HEIGHT, "conds": {"높이": 4}},
|
||||
{"op": "find", "table": HEIGHT, "conds": {"높이": 6}},
|
||||
]
|
||||
)
|
||||
assert out == [[1], [2], [2]]
|
||||
|
||||
|
||||
def test_범위_이하_위끝_가장_작은_줄():
|
||||
out = run(
|
||||
[
|
||||
{"op": "find", "table": UPTO, "conds": {"거리": 5}},
|
||||
{"op": "find", "table": UPTO, "conds": {"거리": 15}},
|
||||
{"op": "find", "table": UPTO, "conds": {"거리": 25}},
|
||||
]
|
||||
)
|
||||
assert out == [[0], [1], []]
|
||||
|
||||
|
||||
def test_셀_수_없는_조회는_건너뜀():
|
||||
gaps, skipped = run(
|
||||
[
|
||||
{"op": "gaps", "lb": LOGIC, "tables": TABLES},
|
||||
{"op": "skipped", "lb": LOGIC, "tables": TABLES},
|
||||
]
|
||||
)
|
||||
assert by(gaps, "c") == []
|
||||
assert skipped == ["c"]
|
||||
|
||||
|
||||
def test_예시값으로_걸린_줄():
|
||||
[hits] = run(
|
||||
[
|
||||
{
|
||||
"op": "hits",
|
||||
"lb": LOGIC,
|
||||
"tables": TABLES,
|
||||
"values": {"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "높이": 3, "길이": 15},
|
||||
}
|
||||
]
|
||||
)
|
||||
assert hits == {"a": "중복", "b": 1, "c": 1}
|
||||
[hits] = run(
|
||||
[
|
||||
{
|
||||
"op": "hits",
|
||||
"lb": LOGIC,
|
||||
"tables": TABLES,
|
||||
"values": {"뒷길이": 30, "돌": "호박돌", "쌓기": "골쌓기"},
|
||||
}
|
||||
]
|
||||
)
|
||||
assert hits == {"a": "없음", "b": None, "c": None} # 높이 · 길이 예시값 없음 = 모름
|
||||
|
||||
|
||||
def test_고정값_조건():
|
||||
lb = {
|
||||
"inputs": [],
|
||||
"lookups": [
|
||||
lk(
|
||||
"f",
|
||||
"T1",
|
||||
[
|
||||
{"col": "돌", "from": {"k": "fixed", "value": "호박돌"}},
|
||||
{"col": "뒷길이", "from": {"k": "fixed", "value": 25}},
|
||||
],
|
||||
"석공",
|
||||
),
|
||||
lk("g", "T1", [{"col": "돌", "from": {"k": "calc", "name": "x"}}], "석공"),
|
||||
],
|
||||
}
|
||||
gaps, hits = run(
|
||||
[
|
||||
{"op": "gaps", "lb": lb, "tables": TABLES},
|
||||
{"op": "hits", "lb": lb, "tables": TABLES, "values": {}},
|
||||
]
|
||||
)
|
||||
assert gaps == []
|
||||
assert hits == {"f": 2, "g": None}
|
||||
|
||||
|
||||
def test_기대값_시험표():
|
||||
answers = [
|
||||
{
|
||||
"ok": True,
|
||||
"lines": [{"이름": "석공", "수량": 0.26, "금액": 52437.06}],
|
||||
"sums": {"계": 52437.06},
|
||||
"middle": {},
|
||||
},
|
||||
{
|
||||
"ok": True,
|
||||
"lines": [{"이름": "석공", "수량": 0.25, "금액": 50000}],
|
||||
"sums": {"계": 50000},
|
||||
"middle": {},
|
||||
},
|
||||
{"ok": False, "reason": "표에 줄이 없음"},
|
||||
]
|
||||
rows = [
|
||||
{"inputs": {"뒷길이": 30}, "expect": {"석공": 0.26, "계": 52437}},
|
||||
{"inputs": {"뒷길이": 35}, "expect": {"석공": 0.26}},
|
||||
{"inputs": {"뒷길이": 99}, "expect": {"계": 1}},
|
||||
]
|
||||
[res] = run([{"op": "expect", "rows": rows, "answers": answers}])
|
||||
assert [r["ok"] for r in res] == [True, False, False]
|
||||
assert res[0]["got"]["석공 금액"] == 52437.06
|
||||
assert res[2]["why"] == "표에 줄이 없음"
|
||||
same = run(
|
||||
[
|
||||
{"op": "same", "got": 52437.4, "expect": 52437},
|
||||
{"op": "same", "got": 52437.6, "expect": 52437},
|
||||
{"op": "same", "got": 0.264, "expect": 0.26},
|
||||
]
|
||||
)
|
||||
assert same == [True, False, True]
|
||||
|
||||
|
||||
SOURCE_UI = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Source.ts"
|
||||
|
||||
RUNNER_UI = 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 stub = new Proxy({}, { get: () => () => undefined });
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, () => stub);
|
||||
const m = box.exports;
|
||||
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const out = jobs.map((job) => {
|
||||
if (job.op === "parse") return m.parseCell(job.text, job.before);
|
||||
if (job.op === "notes") return [...m.usedNotes(job.row)].map(([k, v]) => [k, [...v]]);
|
||||
if (job.op === "role") return m.roleOf(job.lookups, job.col);
|
||||
return null;
|
||||
});
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def run_ui(jobs: list[dict]) -> list:
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node 가 없음")
|
||||
done = subprocess.run(
|
||||
["node", "-e", RUNNER_UI, str(ROOT), str(SOURCE_UI)],
|
||||
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_ui(
|
||||
[
|
||||
{"op": "parse", "text": "0.3", "before": 0.26},
|
||||
{"op": "parse", "text": "1,200", "before": 1000},
|
||||
{"op": "parse", "text": "abc", "before": 0.26}, # 수 칸에 글 — 못 읽음
|
||||
{"op": "parse", "text": "3~4", "before": [2, 3]},
|
||||
{"op": "parse", "text": "~5", "before": [None, 4]},
|
||||
{"op": "parse", "text": "7~", "before": [6, None]},
|
||||
{"op": "parse", "text": "5~3", "before": [3, 5]}, # 거꾸로 — 못 읽음
|
||||
{"op": "parse", "text": "깬돌", "before": "깬잡석"},
|
||||
{"op": "parse", "text": "25", "before": "25"}, # 글 칸은 글 그대로
|
||||
{"op": "parse", "text": "", "before": 1},
|
||||
]
|
||||
)
|
||||
assert out == [0.3, 1200, None, [3, 4], [None, 5], [7, None], None, "깬돌", "25", None]
|
||||
|
||||
|
||||
def test_계산에_쓰인_주와_칸_역할():
|
||||
[notes] = run_ui(
|
||||
[
|
||||
{
|
||||
"op": "notes",
|
||||
"row": {
|
||||
"중간": [{"이름": "증가율", "출처": "산림품셈 13-4-1 주③"}],
|
||||
"비고": "주① 참고",
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
assert sorted(notes) == [["*", ["①"]], ["13-4-1", ["③"]]]
|
||||
lookups = [
|
||||
{
|
||||
"id": "a",
|
||||
"table": "T1",
|
||||
"col": "석공",
|
||||
"conds": [
|
||||
{"col": "뒷길이", "from": {"k": "input", "name": "뒷길이"}},
|
||||
{"col": "돌", "from": {"k": "fixed", "value": "깬잡석"}},
|
||||
],
|
||||
}
|
||||
]
|
||||
out = run_ui(
|
||||
[
|
||||
{"op": "role", "lookups": lookups, "col": "석공"},
|
||||
{"op": "role", "lookups": lookups, "col": "뒷길이"},
|
||||
{"op": "role", "lookups": lookups, "col": "돌"},
|
||||
{"op": "role", "lookups": lookups, "col": "쌓기"},
|
||||
]
|
||||
)
|
||||
assert out == ["결과", "조건", "고정", None]
|
||||
Reference in New Issue
Block a user