feat(M01): 쉬운 단가산출 2단계 표 보고 고르기 — openEasyTable(PLAN 34-3c)
- 잠금 카드 수량 식 속 표 찾기를 엔진 문법대로 읽음(따옴표 · 괄호 · 겹친 찾기) · 못 읽는 꼴은 한 줄 안내 - 표를 그대로(머리 · 줄) · 지금 시험값으로 걸린 줄 노랑(서버 /text 표 조각 줄 차례) · 값 칸 굵게 - 줄 누르면 그 줄 값으로 · 조건마다 「설계 때 정함 / 이 값으로 고정」 · 범위 칸은 범위규칙대로 안쪽 수 - 바뀐 식으로 이 줄 금액 · 합계 미리 보임(/text · /calc) · 고른 줄이 안 걸리면 [이걸로] 막음 - 시험 test_m01_easycalc_table.py 6 통과 · typecheck 통과 - ORCA: GF000219 석공 줄 35 · 깬잡석 고정 → 87,395 원(서버 87,395.1) · 합계 132,133 · GC000129 범위 줄 5~8 → 나무높이 6.5 · 541,940 → 709,080 · 값 칸 · 취소 · 닫기 null Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
/* 쉬운 단가산출 2단계 — 표 보고 고르기 창 */
|
||||
.ui-modal.m01-easytable {
|
||||
width: min(1100px, 96vw);
|
||||
}
|
||||
|
||||
.m01-easytable .ui-modal__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.m01-easytable__note {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easytable__which,
|
||||
.m01-easytable__actions,
|
||||
.m01-easytable__seg {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-easytable__which:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m01-easytable__actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.m01-easytable__head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01-easytable__conds {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-easytable__conds h4 {
|
||||
margin: 0;
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-easytable__cond {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(6rem, 10rem) minmax(5rem, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easytable__cond-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01-easytable__grid {
|
||||
max-height: 46vh;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.m01-easytable__grid table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easytable__grid th,
|
||||
.m01-easytable__grid td {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-easytable__grid thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01-easytable__grid td.is-value {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.m01-easytable__grid .is-col {
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.m01-easytable__grid tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-easytable__grid tbody tr:hover {
|
||||
background: color-mix(in srgb, var(--color-accent) 10%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.m01-easytable__grid tr.is-hit {
|
||||
background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.m01-easytable__grid tr.is-picked td:first-child {
|
||||
box-shadow: inset 3px 0 0 var(--color-accent);
|
||||
}
|
||||
|
||||
.m01-easytable__money {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-easytable__money.is-busy {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.m01-easytable__warn {
|
||||
margin: 0;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easytable__warn:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.m01-easytable__cond {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.m01-easytable__seg {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
/* =============================================================================
|
||||
* M01_EasyCalc_Table.ts
|
||||
* 쉬운 단가산출 2단계 — 표 보고 고르기(설계.md 1절 7 · 3절 `openEasyTable`)
|
||||
* 잠금 카드 수량 식 속 `찾기(표, 조건=값 …).값칸` 을 읽어 그 표를 그대로 보이고,
|
||||
* 줄 · 칸을 눌러 조건 값 · 값 칸을 바꾼 새 식을 돌려줌(취소 null).
|
||||
*
|
||||
* 식 읽기 = 엔진 문법(`master_formula._Parser` 의 find · args · field) 그대로 화면에서 —
|
||||
* 서버에 찾기 마디를 돌려주는 길이 없어서. 걸린 줄 · 금액은 서버(`/text` 표 조각 `참조.줄` · `/calc`).
|
||||
* 화면 글에 식 · 키 안 보임 — 표 이름 · 칸 이름 · 값만.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { fetchTable } from "../M01_MasterData_Api_Fetch";
|
||||
import {
|
||||
fetchAuto,
|
||||
runCalc,
|
||||
runText,
|
||||
searchElements,
|
||||
type HoLine,
|
||||
type LogicRow,
|
||||
type NamedFormula,
|
||||
} from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
|
||||
import { toRow, type EasyCard, type EasyDoc } from "./M01_EasyCalc_Model";
|
||||
import "./M01_EasyCalc_Table.css";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["표 보고 고르기", "Pick from the table"],
|
||||
Close: ["닫기", "Close"],
|
||||
Cannot: ["이 줄은 지금 화면에서 바꿀 수 없음", "This line can't be changed here yet"],
|
||||
NoTable: ["표를 불러오지 못함", "Couldn't load the table"],
|
||||
Loading: ["불러오는 중", "Loading"],
|
||||
Hint: [
|
||||
"줄을 누르면 그 줄 값으로 정함 · 노란 줄 = 지금 쓰는 줄 · 굵은 칸 = 수량을 가져오는 칸",
|
||||
"Tap a row to use it · yellow = row in use · bold = column the quantity comes from",
|
||||
],
|
||||
Conds: ["어느 줄을 쓸까", "Which row"],
|
||||
Design: ["설계 때 정함", "Set at design"],
|
||||
Fix: ["이 값으로 고정", "Fix this value"],
|
||||
AsIs: ["지금대로", "As is"],
|
||||
NotYet: ["(아직 없음)", "(none yet)"],
|
||||
Amount: ["이 줄 금액", "This line"],
|
||||
Total: ["합계", "Total"],
|
||||
Wait: ["계산 중", "Calculating"],
|
||||
Stopped: ["계산 멈춤", "Calculation stopped"],
|
||||
Mismatch: [
|
||||
"고른 줄이 이 조건으로는 안 걸림 — 조건을 다시 보세요",
|
||||
"The picked row isn't matched by these conditions",
|
||||
],
|
||||
Use: ["이걸로", "Use this"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
Which: ["표 고르기", "Choose table"],
|
||||
} as const;
|
||||
|
||||
const tt = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex as 0 | 1];
|
||||
|
||||
/* ── 식 읽기 · 짜기(DOM 없음) ─────────────────────────────────────────── */
|
||||
|
||||
export interface FindArg {
|
||||
/** 표 조건 칸 이름 */
|
||||
key: string;
|
||||
/** `=` 오른쪽 식 글 그대로 */
|
||||
rhs: string;
|
||||
}
|
||||
|
||||
export interface FindCall {
|
||||
/** 식 안 자리 — `찾기` 첫 글자부터 값 칸 끝까지 */
|
||||
start: number;
|
||||
end: number;
|
||||
table: string;
|
||||
args: FindArg[];
|
||||
col: string;
|
||||
}
|
||||
|
||||
const NAME = /[\p{L}\p{N}_]/u;
|
||||
const isName = (ch: string | undefined): boolean => !!ch && NAME.test(ch);
|
||||
|
||||
class Reader {
|
||||
i = 0;
|
||||
constructor(readonly s: string) {}
|
||||
ws(): void {
|
||||
while (this.i < this.s.length && /\s/.test(this.s[this.i])) this.i++;
|
||||
}
|
||||
take(ch: string): boolean {
|
||||
this.ws();
|
||||
if (this.s[this.i] !== ch) return false;
|
||||
this.i++;
|
||||
return true;
|
||||
}
|
||||
name(): string | null {
|
||||
this.ws();
|
||||
const start = this.i;
|
||||
while (isName(this.s[this.i])) this.i++;
|
||||
return this.i > start ? this.s.slice(start, this.i) : null;
|
||||
}
|
||||
quoted(): string | null {
|
||||
const end = this.s.indexOf("'", this.i);
|
||||
if (end < 0) return null;
|
||||
const text = this.s.slice(this.i, end);
|
||||
this.i = end + 1;
|
||||
return text;
|
||||
}
|
||||
/** 괄호 · 따옴표 밖 `,` · `)` 앞까지 */
|
||||
until(head: boolean): string | null {
|
||||
const start = this.i;
|
||||
let depth = 0;
|
||||
while (this.i < this.s.length) {
|
||||
const ch = this.s[this.i];
|
||||
if (ch === "'" && !head) {
|
||||
const end = this.s.indexOf("'", this.i + 1);
|
||||
if (end < 0) return null;
|
||||
this.i = end + 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "(" || ch === "{") depth++;
|
||||
else if (ch === ")" || ch === "}") {
|
||||
if (depth === 0) break;
|
||||
depth--;
|
||||
} else if (ch === "," && depth === 0) break;
|
||||
this.i++;
|
||||
}
|
||||
return this.i < this.s.length ? this.s.slice(start, this.i).trim() : null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 한 자리의 찾기 — 못 읽으면 null */
|
||||
function readOne(s: string, at: number): FindCall | null {
|
||||
const r = new Reader(s);
|
||||
r.i = at + 2;
|
||||
if (!r.take("(")) return null;
|
||||
r.ws();
|
||||
const table = r.until(true);
|
||||
if (!table || !/^[A-Za-z]{2}\d+$/.test(table)) return null; // {자리} 낀 키는 화면에서 안 다룸
|
||||
const args: FindArg[] = [];
|
||||
while (r.take(",")) {
|
||||
r.ws();
|
||||
const key = r.s[r.i] === "'" ? (r.i++, r.quoted()) : r.name();
|
||||
if (!key || !r.take("=")) return null;
|
||||
r.ws();
|
||||
const rhs = r.until(false);
|
||||
if (!rhs) return null;
|
||||
args.push({ key, rhs });
|
||||
}
|
||||
if (!r.take(")") || !r.take(".")) return null;
|
||||
r.ws();
|
||||
const col = r.s[r.i] === "'" ? (r.i++, r.quoted()) : r.name();
|
||||
if (!col) return null;
|
||||
return { start: at, end: r.i, table, args, col };
|
||||
}
|
||||
|
||||
/** 식 속 찾기 전부(바깥 것만 · 차례대로) — 하나라도 못 읽으면 null · 없으면 [] */
|
||||
export function readFinds(formula: string): FindCall[] | null {
|
||||
const out: FindCall[] = [];
|
||||
let i = 0;
|
||||
while (i < formula.length) {
|
||||
const ch = formula[i];
|
||||
if (ch === "'") {
|
||||
const end = formula.indexOf("'", i + 1);
|
||||
if (end < 0) return null;
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
if (formula.startsWith("찾기", i) && !isName(formula[i - 1]) && !isName(formula[i + 2])) {
|
||||
const call = readOne(formula, i);
|
||||
if (!call) return null;
|
||||
out.push(call);
|
||||
i = call.end;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const plain = (word: string): string => (/^[\p{L}\p{N}_]+$/u.test(word) ? word : `'${word}'`);
|
||||
|
||||
/** 찾기 하나를 글로 — 마스터 꼴 `찾기(표, 가=나, 다=라).칸` */
|
||||
export function writeFind(table: string, args: FindArg[], col: string): string {
|
||||
const parts = [table, ...args.map((a) => `${plain(a.key)}=${a.rhs}`)];
|
||||
return `찾기(${parts.join(", ")}).${plain(col)}`;
|
||||
}
|
||||
|
||||
/** 식의 찾기들을 갈아 끼움 — `swaps` 의 차례 = `readFinds` 차례 · null 은 그대로 */
|
||||
export function replaceFinds(formula: string, calls: FindCall[], swaps: (string | null)[]): string {
|
||||
let out = formula;
|
||||
for (let k = calls.length - 1; k >= 0; k--) {
|
||||
const text = swaps[k];
|
||||
if (text !== null && text !== undefined)
|
||||
out = out.slice(0, calls[k].start) + text + out.slice(calls[k].end);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 표 칸 값 → 식 글(수 · 따옴표 글) — 못 쓰면 null */
|
||||
export function literal(value: unknown, rule = ""): string | null {
|
||||
if (typeof value === "number") return String(value);
|
||||
if (typeof value === "string") return value.includes("'") ? null : `'${value}'`;
|
||||
if (Array.isArray(value)) {
|
||||
const n = rangeNumber(value as [number | null, number | null], rule);
|
||||
return n === null ? null : String(n);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 범위 칸 [아래, 위] 안에 드는 수 하나 — 엔진 `_match` 의 범위규칙대로 */
|
||||
export function rangeNumber(cell: [number | null, number | null], rule: string): number | null {
|
||||
const [low, high] = cell;
|
||||
const kind = (rule || "초과·이하").split(" · ")[0].split(" (")[0];
|
||||
const round = (n: number): number => Number(n.toFixed(6));
|
||||
if (low !== null && high !== null) return round((low + high) / 2);
|
||||
if (high !== null) {
|
||||
if (kind !== "이상·미만") return high;
|
||||
return round(high > 1 ? high - 1 : high / 2);
|
||||
}
|
||||
if (low !== null) return kind.startsWith("이상") ? low : low + 1;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 식 글이 따옴표 글 · 수면 그 값 */
|
||||
export function literalValue(rhs: string): string | number | null {
|
||||
const q = rhs.match(/^'([^']*)'$/);
|
||||
if (q) return q[1];
|
||||
return /^-?\d+(\.\d+)?$/.test(rhs) ? Number(rhs) : null;
|
||||
}
|
||||
|
||||
/* ── 표 · 서버 ──────────────────────────────────────────────────────── */
|
||||
|
||||
type Cell = string | number | [number | null, number | null] | null | undefined;
|
||||
interface EasyTable {
|
||||
이름?: string;
|
||||
기준?: string;
|
||||
범위규칙?: string;
|
||||
조건: Record<string, string>;
|
||||
값칸: Record<string, string>;
|
||||
줄: Record<string, Cell>[];
|
||||
}
|
||||
|
||||
const tables = new Map<string, Promise<EasyTable | null>>();
|
||||
|
||||
function loadTable(key: string): Promise<EasyTable | null> {
|
||||
const hit = tables.get(key);
|
||||
if (hit) return hit;
|
||||
const groups = key[0] === "C" ? ["계수", "소요량"] : ["소요량", "계수"];
|
||||
const job = (async (): Promise<EasyTable | null> => {
|
||||
for (const group of groups) {
|
||||
const found = await searchElements(group, key).catch(() => null);
|
||||
const file = found?.items.find((x) => x.ref === key)?.file;
|
||||
if (!file) continue;
|
||||
const got = await fetchTable(file, key).catch(() => null);
|
||||
if (got?.table) return got.table as unknown as EasyTable;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
tables.set(key, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
/** 카드 줄을 새 식으로 — 호표면 `수량` · 덧붙임이면 `식` */
|
||||
function cardLine(card: EasyCard, formula: string): HoLine | NamedFormula {
|
||||
const raw = (card.raw ?? {}) as Record<string, unknown>;
|
||||
if (card.source?.part === "덧줄") return { ...raw, 이름: card.name, 식: formula } as NamedFormula;
|
||||
return {
|
||||
종류: "인력",
|
||||
요소: String(card.element ?? ""),
|
||||
이름: card.name,
|
||||
단위: card.unit,
|
||||
...raw,
|
||||
수량: formula,
|
||||
} as HoLine;
|
||||
}
|
||||
|
||||
/** 이 카드 하나만 든 시험 줄 — `/text` 답에서 그 줄 표 조각을 바로 찾으려고 */
|
||||
function probeRow(card: EasyCard, doc: EasyDoc, formula: string): LogicRow {
|
||||
const line = cardLine(card, formula);
|
||||
if (card.source?.part === "덧줄") return { ...doc.row, 키: "", 덧줄: [line as NamedFormula] };
|
||||
return { ...doc.row, 키: "", 호표: [line as HoLine], 덧줄: [] };
|
||||
}
|
||||
|
||||
/** 로직 전체 줄 — 카드 식만 바꿈 · Model 이 아직이면 원래 줄의 그 자리만 */
|
||||
function fullRow(card: EasyCard, doc: EasyDoc, formula: string): LogicRow {
|
||||
try {
|
||||
const cards = doc.cards.map((c) => (c.id === card.id ? { ...c, qtyFormula: formula } : c));
|
||||
return { ...toRow({ ...doc, cards }), 키: "" };
|
||||
} catch {
|
||||
const row: LogicRow = { ...doc.row, 키: "" };
|
||||
const at = card.source?.index ?? -1;
|
||||
if (card.source?.part === "덧줄" && row.덧줄?.[at])
|
||||
row.덧줄 = row.덧줄.map((x, i) => (i === at ? { ...x, 식: formula } : x));
|
||||
else if (row.호표?.[at])
|
||||
row.호표 = row.호표.map((x, i) => (i === at ? { ...x, 수량: formula } : x));
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
||||
interface Preview {
|
||||
ok: boolean;
|
||||
/** 찾기 차례 → 걸린 줄 차례(못 찾으면 null) */
|
||||
hits: (number | null)[];
|
||||
line: number | null;
|
||||
total: number | null;
|
||||
}
|
||||
|
||||
async function preview(
|
||||
card: EasyCard,
|
||||
doc: EasyDoc,
|
||||
formula: string,
|
||||
calls: FindCall[],
|
||||
inputs: Record<string, unknown>,
|
||||
): Promise<Preview> {
|
||||
const [text, calc] = await Promise.all([
|
||||
runText({ key: "", inputs, row: probeRow(card, doc, formula) }).catch(() => null),
|
||||
runCalc({ key: "", inputs, row: fullRow(card, doc, formula) }).catch(() => null),
|
||||
]);
|
||||
const lines = text?.ok
|
||||
? text.groups.flatMap((g) => g.줄).filter((l) => l.이름 === card.name)
|
||||
: [];
|
||||
const pieces = (lines[0]?.조각 ?? []).filter((p) => p.kind === "표");
|
||||
const seen = new Map<string, number>();
|
||||
const hits = calls.map((c) => {
|
||||
const nth = seen.get(c.table) ?? 0;
|
||||
seen.set(c.table, nth + 1);
|
||||
const piece = pieces.filter((p) => p.참조?.키 === c.table)[nth];
|
||||
return typeof piece?.참조?.줄 === "number" ? piece.참조.줄 : null;
|
||||
});
|
||||
const sum = lines.reduce((s, l) => s + (Number(l.금액) || 0), 0);
|
||||
return {
|
||||
ok: !!text?.ok && !!calc?.ok,
|
||||
hits,
|
||||
line: lines.length ? sum : null,
|
||||
total: calc?.ok && calc.sums ? (calc.sums["계"] ?? null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 창 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
type Mode = "design" | "fix" | "asis";
|
||||
interface CondState {
|
||||
key: string;
|
||||
/** 원래 식 글 · 새로 더한 조건은 null */
|
||||
orig: string | null;
|
||||
mode: Mode;
|
||||
/** 설계 값 이름 — 있으면 「설계 때 정함」 고를 수 있음 */
|
||||
input: string | null;
|
||||
fix: Cell;
|
||||
}
|
||||
interface CallState {
|
||||
conds: CondState[];
|
||||
col: string;
|
||||
picked: number | null;
|
||||
/** 「설계 때 정함」 조건을 고른 줄 값으로 미리 봄 */
|
||||
tryValues: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const showCell = (v: Cell): string => {
|
||||
if (v === null || v === undefined) return "";
|
||||
if (Array.isArray(v)) return `${v[0] ?? ""}~${v[1] ?? ""}`;
|
||||
return String(v);
|
||||
};
|
||||
|
||||
function startState(call: FindCall, doc: EasyDoc): CallState {
|
||||
const names = new Set(doc.inputs.map((x) => x.이름));
|
||||
const conds = call.args.map((a): CondState => {
|
||||
const input = names.has(a.rhs) ? a.rhs : names.has(a.key) ? a.key : null;
|
||||
const lit = literalValue(a.rhs);
|
||||
const mode: Mode = names.has(a.rhs) ? "design" : lit !== null ? "fix" : "asis";
|
||||
return { key: a.key, orig: a.rhs, mode, input, fix: lit };
|
||||
});
|
||||
return { conds, col: call.col, picked: null, tryValues: {} };
|
||||
}
|
||||
|
||||
function stateArgs(st: CallState, table: EasyTable | null): FindArg[] | null {
|
||||
const out: FindArg[] = [];
|
||||
for (const c of st.conds) {
|
||||
if (c.mode === "design" && c.input) out.push({ key: c.key, rhs: c.input });
|
||||
else if (c.mode === "fix") {
|
||||
const rhs = literal(c.fix, table?.범위규칙);
|
||||
if (rhs === null) return null;
|
||||
out.push({ key: c.key, rhs });
|
||||
} else if (c.orig !== null) out.push({ key: c.key, rhs: c.orig });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 이 창 모양 그대로의 새 식 — 바뀐 것이 없으면 원래 식 */
|
||||
function compose(
|
||||
formula: string,
|
||||
calls: FindCall[],
|
||||
states: CallState[],
|
||||
tbl: (EasyTable | null)[],
|
||||
) {
|
||||
const swaps = calls.map((call, k) => {
|
||||
const args = stateArgs(states[k], tbl[k]);
|
||||
if (!args) return null;
|
||||
const same =
|
||||
states[k].col === call.col &&
|
||||
args.length === call.args.length &&
|
||||
args.every((a, i) => a.key === call.args[i].key && a.rhs === call.args[i].rhs);
|
||||
return same ? null : writeFind(call.table, args, states[k].col);
|
||||
});
|
||||
return replaceFinds(formula, calls, swaps);
|
||||
}
|
||||
|
||||
/** 잠금 카드 수량을 표에서 골라 바꿈 — 새 식 · 취소 null(설계.md 3절) · trial 없으면 자동값 */
|
||||
export function openEasyTable(
|
||||
card: EasyCard,
|
||||
doc: EasyDoc,
|
||||
trial?: Record<string, unknown>,
|
||||
): Promise<string | null> {
|
||||
const formula = card.qtyFormula ?? "";
|
||||
const calls = readFinds(formula);
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (value: string | null): void => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
resolve(value);
|
||||
};
|
||||
openModal({
|
||||
title: tt("Title"),
|
||||
closeLabel: tt("Close"),
|
||||
dialogClass: "m01-easytable",
|
||||
beforeClose: () => (finish(null), true),
|
||||
mount: (body, close) => {
|
||||
if (!calls || !calls.length) {
|
||||
body.append(el("p", { className: "m01-easytable__note", text: tt("Cannot") }));
|
||||
return;
|
||||
}
|
||||
void mountTable(body, { card, doc, formula, calls, trial }, (v) => {
|
||||
finish(v);
|
||||
close();
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function mountTable(
|
||||
body: HTMLElement,
|
||||
ctx: {
|
||||
card: EasyCard;
|
||||
doc: EasyDoc;
|
||||
formula: string;
|
||||
calls: FindCall[];
|
||||
trial?: Record<string, unknown>;
|
||||
},
|
||||
finish: (value: string | null) => void,
|
||||
): Promise<void> {
|
||||
const { card, doc, formula, calls } = ctx;
|
||||
body.append(el("p", { className: "m01-easytable__note", text: tt("Loading") }));
|
||||
const [base, tbl] = await Promise.all([
|
||||
ctx.trial ?? (doc.key ? fetchAuto(doc.key) : Promise.resolve({})),
|
||||
Promise.all(calls.map((c) => loadTable(c.table))),
|
||||
]);
|
||||
const states = calls.map((c) => startState(c, doc));
|
||||
let now = 0;
|
||||
let last: Preview = { ok: false, hits: calls.map(() => null), line: null, total: null };
|
||||
let seq = 0;
|
||||
let busy = false;
|
||||
|
||||
const which = el("div", { className: "m01-easytable__which" });
|
||||
const head = el("div", { className: "m01-easytable__head" });
|
||||
const condBox = el("div", { className: "m01-easytable__conds" });
|
||||
const grid = el("div", { className: "m01-easytable__grid" });
|
||||
const money = el("div", { className: "m01-easytable__money" });
|
||||
const warn = el("p", { className: "m01-easytable__warn" });
|
||||
const use = createButton({ label: tt("Use"), onClick: () => finish(newFormula()) });
|
||||
const cancel = createButton({
|
||||
label: tt("Cancel"),
|
||||
variant: "ghost",
|
||||
onClick: () => finish(null),
|
||||
});
|
||||
|
||||
const newFormula = (): string => compose(formula, calls, states, tbl);
|
||||
const inputs = (): Record<string, unknown> => ({ ...base, ...states[now].tryValues });
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
busy = true;
|
||||
paint();
|
||||
const got = await preview(card, doc, newFormula(), calls, inputs());
|
||||
if (mine !== seq) return;
|
||||
last = got;
|
||||
busy = false;
|
||||
paint();
|
||||
};
|
||||
|
||||
const pickRow = (idx: number, col: string | null): void => {
|
||||
const table = tbl[now];
|
||||
const st = states[now];
|
||||
const row = table?.줄[idx];
|
||||
if (!table || !row) return;
|
||||
for (const key of Object.keys(table.조건)) {
|
||||
if (row[key] === undefined) continue;
|
||||
let c = st.conds.find((x) => x.key === key);
|
||||
if (!c) {
|
||||
const input = doc.inputs.some((x) => x.이름 === key) ? key : null;
|
||||
c = { key, orig: null, mode: "fix", input, fix: null };
|
||||
st.conds.push(c);
|
||||
}
|
||||
if (c.mode === "design" && c.input) {
|
||||
const n = Array.isArray(row[key])
|
||||
? rangeNumber(row[key] as [number | null, number | null], table.범위규칙 ?? "")
|
||||
: row[key];
|
||||
st.tryValues[c.input] = n;
|
||||
} else {
|
||||
c.mode = "fix";
|
||||
c.fix = row[key];
|
||||
}
|
||||
}
|
||||
if (col) st.col = col;
|
||||
st.picked = idx;
|
||||
void refresh();
|
||||
};
|
||||
|
||||
const setMode = (c: CondState, mode: Mode): void => {
|
||||
if (mode === "fix" && (c.fix === null || c.fix === undefined)) {
|
||||
const hit = last.hits[now];
|
||||
const row = hit === null ? null : tbl[now]?.줄[hit];
|
||||
c.fix = row?.[c.key] ?? (c.input ? (inputs()[c.input] as Cell) : null);
|
||||
}
|
||||
c.mode = mode;
|
||||
void refresh();
|
||||
};
|
||||
|
||||
const paintConds = (): void => {
|
||||
const st = states[now];
|
||||
condBox.replaceChildren(
|
||||
el("h4", { text: tt("Conds") }),
|
||||
...st.conds.map((c) => {
|
||||
const opts: [Mode, string][] = [];
|
||||
if (c.input) opts.push(["design", tt("Design")]);
|
||||
if (c.orig !== null && c.orig !== c.input && literalValue(c.orig) === null)
|
||||
opts.push(["asis", tt("AsIs")]);
|
||||
opts.push(["fix", tt("Fix")]);
|
||||
const hit = last.hits[now];
|
||||
const used = hit === null ? "" : showCell(tbl[now]?.줄[hit]?.[c.key]);
|
||||
const value =
|
||||
c.mode === "fix"
|
||||
? showCell(c.fix)
|
||||
: c.mode === "design" && c.input
|
||||
? showCell(inputs()[c.input] as Cell) || used
|
||||
: used;
|
||||
return el("div", {
|
||||
className: "m01-easytable__cond",
|
||||
children: [
|
||||
el("span", { className: "m01-easytable__cond-name", text: c.key }),
|
||||
el("span", { className: "m01-easytable__cond-value", text: value || tt("NotYet") }),
|
||||
el("span", {
|
||||
className: "m01-easytable__seg",
|
||||
children: opts.map(([mode, label]) => {
|
||||
const b = createButton({
|
||||
label,
|
||||
size: "sm",
|
||||
variant: c.mode === mode ? "filled" : "ghost",
|
||||
onClick: () => setMode(c, mode),
|
||||
});
|
||||
b.setAttribute("aria-pressed", String(c.mode === mode));
|
||||
return b;
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const paintGrid = (): void => {
|
||||
const table = tbl[now];
|
||||
if (!table) {
|
||||
grid.replaceChildren(el("p", { className: "m01-easytable__note", text: tt("NoTable") }));
|
||||
return;
|
||||
}
|
||||
const st = states[now];
|
||||
const conds = Object.keys(table.조건);
|
||||
const vals = Object.keys(table.값칸);
|
||||
const hit = last.hits[now];
|
||||
const th = (text: string, cls = ""): HTMLElement => el("th", { className: cls, text });
|
||||
const rows = table.줄.map((row, idx) => {
|
||||
const tr = el("tr", {
|
||||
className: [
|
||||
idx === hit && !busy ? "is-hit" : "",
|
||||
idx === st.picked ? "is-picked" : "",
|
||||
].join(" "),
|
||||
children: [
|
||||
...conds.map((k) => el("td", { text: showCell(row[k]) })),
|
||||
...vals.map((k) =>
|
||||
el("td", {
|
||||
className: `is-value${k === st.col ? " is-col" : ""}`,
|
||||
text: showCell(row[k]),
|
||||
attrs: { "data-col": k },
|
||||
}),
|
||||
),
|
||||
],
|
||||
});
|
||||
tr.addEventListener("click", (ev) => {
|
||||
const cell = (ev.target as HTMLElement).closest("td");
|
||||
pickRow(idx, cell?.dataset.col ?? null);
|
||||
});
|
||||
return tr;
|
||||
});
|
||||
grid.replaceChildren(
|
||||
el("table", {
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [
|
||||
...conds.map((k) => th(k)),
|
||||
...vals.map((k) =>
|
||||
th(
|
||||
`${k}${table.값칸[k] ? ` (${table.값칸[k]})` : ""}`,
|
||||
k === st.col ? "is-col" : "",
|
||||
),
|
||||
),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("tbody", { children: rows }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const paint = (): void => {
|
||||
const table = tbl[now];
|
||||
which.replaceChildren(
|
||||
...(calls.length > 1
|
||||
? calls.map((_c, k) =>
|
||||
createButton({
|
||||
label: tbl[k]?.이름 ?? `${tt("Which")} ${k + 1}`,
|
||||
size: "sm",
|
||||
variant: k === now ? "filled" : "ghost",
|
||||
onClick: () => {
|
||||
now = k;
|
||||
void refresh();
|
||||
},
|
||||
}),
|
||||
)
|
||||
: []),
|
||||
);
|
||||
head.replaceChildren(
|
||||
el("strong", { text: [table?.이름, table?.기준].filter(Boolean).join(" · ") }),
|
||||
el("span", { className: "m01-easytable__note", text: tt("Hint") }),
|
||||
);
|
||||
paintConds();
|
||||
paintGrid();
|
||||
const picked = states[now].picked;
|
||||
const mismatch = !busy && picked !== null && last.hits[now] !== picked;
|
||||
warn.textContent = mismatch ? tt("Mismatch") : "";
|
||||
const fmt = (v: number | null): string => (v === null ? "—" : `${formatMoney(v)} 원`);
|
||||
money.replaceChildren(
|
||||
el("span", { text: `${tt("Amount")} ${fmt(last.line)}` }),
|
||||
el("strong", { text: `${tt("Total")} ${fmt(last.total)}` }),
|
||||
el("span", {
|
||||
className: "m01-easytable__note",
|
||||
text: busy ? tt("Wait") : last.ok ? "" : tt("Stopped"),
|
||||
}),
|
||||
);
|
||||
money.classList.toggle("is-busy", busy);
|
||||
use.disabled = busy || mismatch || !last.ok;
|
||||
};
|
||||
|
||||
body.replaceChildren(
|
||||
which,
|
||||
head,
|
||||
condBox,
|
||||
grid,
|
||||
money,
|
||||
warn,
|
||||
el("div", { className: "m01-easytable__actions", children: [cancel, use] }),
|
||||
);
|
||||
await refresh();
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"""쉬운 단가산출 2단계 — 표 보고 고르기(`M01_EasyCalc_Table.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" / "EasyCalc" / "M01_EasyCalc_Table.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"));
|
||||
const out = jobs.map((job) => {
|
||||
if (job.op === "read") return m.readFinds(job.formula);
|
||||
if (job.op === "swap") {
|
||||
const calls = m.readFinds(job.formula);
|
||||
const swaps = calls.map((c, i) => {
|
||||
const s = job.swaps[i];
|
||||
return s ? m.writeFind(c.table, s.args ?? c.args, s.col ?? c.col) : null;
|
||||
});
|
||||
return m.replaceFinds(job.formula, calls, swaps);
|
||||
}
|
||||
if (job.op === "literal") return m.literal(job.value, job.rule ?? "");
|
||||
return null;
|
||||
});
|
||||
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)
|
||||
|
||||
|
||||
MESSANGI = "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)"
|
||||
|
||||
|
||||
def test_식_읽기_흔한_꼴():
|
||||
[calls] = run([{"op": "read", "formula": MESSANGI}])
|
||||
assert len(calls) == 1
|
||||
c = calls[0]
|
||||
assert c["table"] == "QF000421" and c["col"] == "석공"
|
||||
assert [(a["key"], a["rhs"]) for a in c["args"]] == [
|
||||
("뒷길이", "뒷길이"),
|
||||
("돌", "돌"),
|
||||
("쌓기", "쌓기"),
|
||||
]
|
||||
assert MESSANGI[c["start"] : c["end"]] == "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공"
|
||||
|
||||
|
||||
def test_식_읽기_따옴표_쉼표_괄호_겹침():
|
||||
f = (
|
||||
"(찾기(QC000524, 형식=형식).특별인부 - 만약(정밀절단 == '아님', "
|
||||
"찾기(QC000583, 구분='가, 나 (다)').값, 0)) / 시공량"
|
||||
)
|
||||
[calls] = run([{"op": "read", "formula": f}])
|
||||
assert [c["table"] for c in calls] == ["QC000524", "QC000583"]
|
||||
assert calls[1]["args"] == [{"key": "구분", "rhs": "'가, 나 (다)'"}]
|
||||
assert calls[1]["col"] == "값"
|
||||
|
||||
|
||||
def test_식_읽기_못_읽는_꼴과_없는_꼴():
|
||||
out = run(
|
||||
[
|
||||
{"op": "read", "formula": "찾기(QF:{지역}, 구분='a').값"}, # {자리} 낀 키
|
||||
{"op": "read", "formula": "찾기(QF000001, 구분='a'"}, # 안 닫힘
|
||||
{"op": "read", "formula": "품 * 1.2"},
|
||||
{"op": "read", "formula": "'찾기(QF000001, 구분=1).값'"}, # 따옴표 글 안
|
||||
]
|
||||
)
|
||||
assert out[0] is None and out[1] is None
|
||||
assert out[2] == [] and out[3] == []
|
||||
|
||||
|
||||
def test_새_식_짜기_줄_고정과_값칸_바꾸기():
|
||||
[new] = run(
|
||||
[
|
||||
{
|
||||
"op": "swap",
|
||||
"formula": MESSANGI,
|
||||
"swaps": [
|
||||
{
|
||||
"args": [
|
||||
{"key": "뒷길이", "rhs": "35"},
|
||||
{"key": "돌", "rhs": "'깬잡석'"},
|
||||
{"key": "쌓기", "rhs": "쌓기"},
|
||||
],
|
||||
"col": "보통인부",
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
assert new == (
|
||||
"찾기(QF000421, 뒷길이=35, 돌='깬잡석', 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)"
|
||||
)
|
||||
|
||||
|
||||
def test_새_식_짜기_둘째_찾기만_바꿈_나머지_글_그대로():
|
||||
f = "찾기(QC1, 구분='가새').수량 * 찾기(QC2, 공기=공기).손율 / 100"
|
||||
[new] = run(
|
||||
[
|
||||
{
|
||||
"op": "swap",
|
||||
"formula": f,
|
||||
"swaps": [None, {"args": [{"key": "공기", "rhs": "'1개월'"}]}],
|
||||
}
|
||||
]
|
||||
)
|
||||
assert new == "찾기(QC1, 구분='가새').수량 * 찾기(QC2, 공기='1개월').손율 / 100"
|
||||
# 다시 읽어도 같은 모양(왕복)
|
||||
[calls] = run([{"op": "read", "formula": new}])
|
||||
assert calls[1]["args"] == [{"key": "공기", "rhs": "'1개월'"}]
|
||||
|
||||
|
||||
def test_칸_값을_식_글로():
|
||||
out = run(
|
||||
[
|
||||
{"op": "literal", "value": 25},
|
||||
{"op": "literal", "value": "깬잡석"},
|
||||
{"op": "literal", "value": "it's"}, # 따옴표 든 글은 못 씀
|
||||
{"op": "literal", "value": [20, 40], "rule": "초과·이하"},
|
||||
{"op": "literal", "value": [None, 60], "rule": "이하"},
|
||||
{"op": "literal", "value": [None, 60], "rule": "이상·미만"},
|
||||
{"op": "literal", "value": [80, None], "rule": "초과·이하"},
|
||||
{"op": "literal", "value": [80, None], "rule": "이상·미만"},
|
||||
]
|
||||
)
|
||||
assert out == ["25", "'깬잡석'", None, "30", "60", "59", "81", "80"]
|
||||
Reference in New Issue
Block a user