From cca0e09cd216d7ebd7d977512130967616a32672 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 2 Oct 2026 00:33:38 +0900 Subject: [PATCH 1/4] =?UTF-8?q?test(M02-=ED=9D=90=EB=A6=84=EB=8F=84):=20?= =?UTF-8?q?=EC=8B=9D=20=EB=B3=B4=EA=B8=B0=20=EA=B0=92=20=EC=A4=84=EC=9D=80?= =?UTF-8?q?=20=EC=84=A0=20=EA=B7=9C=EC=B9=99=EC=9D=84=20=EC=A7=80=EB=82=98?= =?UTF-8?q?=20=EB=93=A4=EC=96=B4=EC=98=A8=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- resources/tester/test_m02_df_formula_view.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/resources/tester/test_m02_df_formula_view.py b/resources/tester/test_m02_df_formula_view.py index d015c2a9e..be3d768f0 100644 --- a/resources/tester/test_m02_df_formula_view.py +++ b/resources/tester/test_m02_df_formula_view.py @@ -178,3 +178,18 @@ def test_조건_만들기_식과_다시_줄(): def test_못_바꾸는_식은_null(formula): (back,) = run([{"op": "toDraft", "formula": formula}]) assert back is None + + +def test_받는_칸은_선_규칙을_지난_값으로_그림(): + # 시트에 남은 옛 값(99)이 아니라 규칙을 지나 들어온 값(arrived 0.15)으로 값 식을 그림 + box = func_box( + {"B1": {"값": 99}, "B3": {"식": "B1*(1+B2/100)"}}, + [{"port": "석공량", "cell": "B1"}], + [var("높이", "B2", 4)], + "B3", + "석공 인", + ) + (got,) = run([{"op": "lines", "box": box, "arrived": {"석공량": 0.15}}]) + assert got["lines"][0]["valued"] == "0.15 × (1 + 4 ÷ 100) = 0.156" + (stale,) = run([{"op": "lines", "box": box, "arrived": {}}]) + assert stale["lines"][0]["valued"].startswith("99 ×") # 들어온 값이 없을 때만 칸 값 From a5edf43d6e4cdecbaf0d20a771bcdc1ca07a7449 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 2 Oct 2026 00:34:17 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(m01):=20=ED=9D=90=EB=A6=84=20=ED=95=A9?= =?UTF-8?q?=EA=B3=84=20=EB=81=9D=EC=88=98=20=3D=20=EB=B9=84=EC=9A=A9=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=20=EC=83=81=EC=9E=90=EC=97=90=20=EC=8B=A4?= =?UTF-8?q?=EB=A6=B0=20=EB=A1=9C=EC=A7=81=20=EC=9E=90=EC=8B=A0=EC=9D=98=20?= =?UTF-8?q?=EB=81=9D=EC=88=98=20=C2=B7=20=EC=83=88=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EB=A7=8C=20=EA=B8=B0=EB=B3=B8=201=EC=9B=90=20=EB=AF=B8?= =?UTF-8?q?=EB=A7=8C=20=EB=B2=84=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 왼쪽 흐름 보기 소계 · 계와 오른쪽 원가 합계가 같은 끝수를 씀(Round.totalRoundOf) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- .../M01_MasterData_UI_LogicLab_FlowView.ts | 17 +++++++--- M02_MasterTemplete/DataFlow/M02_DF_Round.ts | 12 +++++++ M02_MasterTemplete/DataFlow/M02_DF_Summary.ts | 17 +++++++--- resources/tester/test_m01_logic_flowview.py | 33 ++++++++++++++++++- 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts index cdf1e7456..6b4acb35c 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts @@ -21,7 +21,12 @@ import { type FormulaLine, } from "../M02_MasterTemplete/DataFlow/M02_DF_FormulaView"; import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; -import { itemRoundNow, totalRoundNow } from "../M02_MasterTemplete/DataFlow/M02_DF_Round"; +import { + itemRoundNow, + roundText, + totalRoundNow, + totalRoundOf, +} from "../M02_MasterTemplete/DataFlow/M02_DF_Round"; import { flowRows, type FlowFunc, @@ -42,7 +47,7 @@ const TEXT = { HValue: ["값", "Value"], Sub: ["소계", "Subtotal"], Total: ["계", "Total"], - TotalNote: ["합계 끝수 기본 = 1원 미만 버림", "Total rounding default: floor to 1 won"], + TotalNote: ["합계 끝수", "Total rounding"], None: ["출력 상자가 없음", "No output box"], NoFunc: ["함수 상자가 없음", "No function box"], } as const; @@ -158,7 +163,8 @@ export function buildFlowView( ); return; } - const rows = flowRows(lg, ev, { item: itemRoundNow(), total: totalRoundNow() }); + const totalRound = totalRoundOf(lg, totalRoundNow()); + const rows = flowRows(lg, ev, { item: itemRoundNow(), total: totalRound }); const detail = section(tf("Detail"), [ ...(rows.groups.length ? rows.groups.map(groupTable) @@ -168,7 +174,10 @@ export function buildFlowView( attrs: { "data-sum": String(rows.total) }, text: `${tf("Total")} ${rows.totalText}`, }), - el("p", { className: "m01-logic__muted", text: tf("TotalNote") }), + el("p", { + className: "m01-logic__muted", + text: `${tf("TotalNote")}: ${roundText(totalRound)}`, + }), ...rows.errors.slice(0, 3).map((t) => el("p", { className: "m01flow__warn", text: t })), ]); /** 함수 상자마다 식 두 줄(sub2 식 보기 · 받는 알약 값을 칸에 넣고 셈) — 못 그리면 이름 = 값만 */ diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Round.ts b/M02_MasterTemplete/DataFlow/M02_DF_Round.ts index 5101ad946..f4ddf13d0 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Round.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Round.ts @@ -107,3 +107,15 @@ export function showRounded(v: number, r?: CfRound): string { let totalNow: CfRound = { places: 0, method: "버림" }; void defaultTotalRound().then((r) => (totalNow = r)); export const totalRoundNow = (): CfRound => totalNow; + +/** 그 로직의 합계 끝수 — 비용 출력 상자에 끝수가 실려 있으면 그것(옛 로직을 옮긴 흐름은 자신의 끝수) · + * 없으면 기본(새 로직 = 일위대가표 계금 1원 미만 버림) */ +export function totalRoundOf( + lg: { boxes: { kind: string; output?: { cost: string; round?: CfRound } }[] }, + fallback: CfRound, +): CfRound { + const hit = lg.boxes.find( + (b) => b.kind === "output" && b.output?.cost !== "값" && b.output?.round, + ); + return hit?.output?.round ?? fallback; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Summary.ts b/M02_MasterTemplete/DataFlow/M02_DF_Summary.ts index a4686af39..148e15827 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Summary.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Summary.ts @@ -12,7 +12,14 @@ import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money" import type { CfEvalOptions, CfEvalResult, CfValues } from "./M02_DF_Eval"; import type { CfBox, CfLogic, CfRound } from "./M02_DF_Model"; import { showValue } from "./M02_DF_OutputPanel"; -import { defaultTotalRound, itemRoundNow, roundBy, roundText, showRounded } from "./M02_DF_Round"; +import { + defaultTotalRound, + itemRoundNow, + roundBy, + roundText, + showRounded, + totalRoundOf, +} from "./M02_DF_Round"; import { renderVerify, type CfSel } from "./M02_DF_Verify"; import "./M02_DF_Summary.css"; @@ -69,18 +76,20 @@ export function renderSummary( ? ts("Total") : ts("Per", { per: /^\d/.test(perUnit) ? perUnit : `1${perUnit}` }); const { cost } = result; + /** 합계 끝수 — 비용 출력 상자에 실린 끝수(옮긴 로직의 자신 끝수) · 없으면 기본 1원 미만 버림 */ + const sumRound = totalRoundOf(lg, totalRound); const big = el("p", { className: "m02-dfsum__big", children: [ el("span", { text: money ? perText : ts("Total") }), - el("strong", { text: `${formatMoney(roundBy(totalRound, cost.계))} ${ts("Won")}` }), + el("strong", { text: `${formatMoney(roundBy(sumRound, cost.계))} ${ts("Won")}` }), ], }); const costs = el("dl", { className: "m02-dfsum__costs", children: COSTS.flatMap((c) => [ el("dt", { text: ts(COST_KEY[c]) }), - el("dd", { text: formatMoney(roundBy(totalRound, cost[c])) }), + el("dd", { text: formatMoney(roundBy(sumRound, cost[c])) }), ]), }); @@ -161,7 +170,7 @@ export function renderSummary( : []), el("p", { className: "m02-dfsum__muted", - text: `${ts("RoundTotal")}: ${roundText(totalRound)}`, + text: `${ts("RoundTotal")}: ${roundText(sumRound)}`, }), verifyHost, ); diff --git a/resources/tester/test_m01_logic_flowview.py b/resources/tester/test_m01_logic_flowview.py index 67a4d35ea..de7d5380e 100644 --- a/resources/tester/test_m01_logic_flowview.py +++ b/resources/tester/test_m01_logic_flowview.py @@ -37,8 +37,9 @@ function load(f) { return box.exports; } const m = load(file); +const r = load(path.join(root, "M02_MasterTemplete", "DataFlow", "M02_DF_Round.ts")); const jobs = JSON.parse(fs.readFileSync(0, "utf8")); -const out = jobs.map((job) => m.flowRows(job.lg, job.ev, job.round)); +const out = jobs.map((job) => job.op === "totalRound" ? r.totalRoundOf(job.lg, job.fallback) : m.flowRows(job.lg, job.ev, job.round)); process.stdout.write(JSON.stringify(out)); setTimeout(() => process.exit(0), 50); """ @@ -182,3 +183,33 @@ def test_missing_value_shows_dash(): rows = run([{"lg": lg, "ev": ev, "round": ROUND}])[0] assert group(rows, "labor")["lines"][0]["text"] == "—" assert rows["errors"] == ["끊김"] + + +def test_total_round_follows_converted_logic_own_round(): + """옮긴 로직 = 비용 출력 상자에 실린 끝수 · 새 로직(끝수 없음) = 기본.""" + default = {"places": 0, "method": "버림"} + own = {"places": 1, "method": "반올림"} + converted = logic( + [out_box("o1", "인건비", "노무비", round_=own), out_box("o2", "작업량", "값")] + ) + fresh = logic([out_box("o1", "인건비", "노무비")]) + only_plain = logic([out_box("o3", "작업량", "값", round_={"places": 3, "method": "버림"})]) + got = run( + [ + {"op": "totalRound", "lg": converted, "fallback": default}, + {"op": "totalRound", "lg": fresh, "fallback": default}, + {"op": "totalRound", "lg": only_plain, "fallback": default}, + ] + ) + assert got == [own, default, default] # 값(비용 아님)의 끝수는 합계 끝수가 아님 + + +def test_subtotal_and_total_use_converted_round(): + own = {"places": 1, "method": "반올림"} + lg = logic([out_box("o1", "인건비", "노무비", round_=own)]) + ev = result( + {"o1": 3216.7}, {"노무비": 3216.7, "재료비": 0, "경비": 0, "값들": {}, "계": 3216.7} + ) + rows = run([{"lg": lg, "ev": ev, "round": {"item": ROUND["item"], "total": own}}])[0] + assert group(rows, "labor")["subText"] == "3,216.7" + assert rows["totalText"] == "3,216.7" From df2368a48f827f775b0e93f2dc4d37b6add58b8e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 2 Oct 2026 00:38:35 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(m02-df):=20=EC=9E=AC=EB=A3=8C=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=B0=BE=EA=B8=B0=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=EC=96=B4=20=ED=8B=80=20=E2=80=94=20=ED=8B=80=20=EC=B9=B8=20?= =?UTF-8?q?=C2=B7=20=EC=9E=90=EB=A6=AC=EB=B3=84=20=EC=95=8C=EC=95=BD=20?= =?UTF-8?q?=C2=B7=20=EC=A7=80=EA=B8=88=20=EC=9E=A1=ED=9E=88=EB=8A=94=20?= =?UTF-8?q?=EC=9E=AC=EB=A3=8C=20=EB=AF=B8=EB=A6=AC=20=EB=B3=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU --- .../DataFlow/M02_DF_InputPanel_Build.ts | 53 +++++++++++ .../DataFlow/M02_DF_InputPanel_Material.ts | 86 ++++++++++++++++-- .../DataFlow/M02_DF_InputPanel_Template.ts | 90 +++++++++++++++++++ resources/tester/test_m02_df_input_logic.py | 38 ++++++++ 4 files changed, 260 insertions(+), 7 deletions(-) create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Template.ts diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts index 46d22c4ae..d52c9bdd8 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts @@ -231,6 +231,16 @@ export function materialElement( export function checkMaterial(el: CfElementRef, others: CfElementRef[]): string | null { if (!el.name) return "자리 이름을 적어 주세요"; if (others.some((o) => o.name === el.name)) return "같은 이름의 알약이 이미 있음"; + if (el.mode === "rule" && el.rule?.field === "검색어") { + if (!el.rule.template?.trim() || !templateSlots(el.rule.template).length) + return "검색어 틀에 자리 {이름} 이 하나는 있어야 함"; + if ( + (el.rule.slots ?? []).length < templateSlots(el.rule.template).length || + (el.rule.slots ?? []).some((x) => !x.from.name) + ) + return "틀의 자리마다 변수 알약을 골라 주세요"; + return null; + } if (el.mode === "rule" && !el.rule?.from.name) return "규칙에 쓸 변수 알약을 골라 주세요"; if ( el.mode !== "fixed" && @@ -626,3 +636,46 @@ export function checkGroupRule(el: CfElementRef, others: CfElementRef[]): string if (!el.rule?.from.name) return "규칙에 쓸 변수 알약을 골라 주세요"; return null; } + +/* ── 재료 조건 찾기 field 「검색어」 — 틀(「레미콘 {굵은골재최대치수}-{강도}-{슬럼프}」) 의 자리마다 변수 알약 값을 끼운 글로 찾음 ── */ + +/** 틀 안 자리 이름들 — 나온 차례 · 겹침 없이 */ +export function templateSlots(template: string): string[] { + return [ + ...new Set([...template.matchAll(/\{([^{}]+)\}/g)].map((m) => m[1].trim()).filter(Boolean)), + ]; +} + +/** 틀을 채운 글 — valueOf 가 알약 값을 줌 · 값이 빈 자리는 missing 에 모음(그 자리는 그대로 둠) */ +export function fillTemplate( + template: string, + slots: { name: string; from: CfPort }[], + valueOf: (port: CfPort) => unknown, +): { text: string; missing: string[] } { + const missing: string[] = []; + const text = template.replace(/\{([^{}]+)\}/g, (all, raw: string) => { + const name = raw.trim(); + const slot = slots.find((x) => x.name === name); + const v = slot ? valueOf(slot.from) : undefined; + if (v === null || v === undefined || v === "") { + if (!missing.includes(name)) missing.push(name); + return all; + } + return String(v); + }); + return { text, missing }; +} + +/** 검색어 틀 규칙 — 첫 자리의 알약이 from(옛 Model 호환) */ +export function templateRule( + template: string, + slots: { name: string; from: CfPort }[], +): CfMaterialRule { + return { + field: "검색어", + op: "포함", + from: slots[0]?.from ?? { box: "", name: "" }, + template, + slots, + }; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Material.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Material.ts index 80a288717..0d5ec6384 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Material.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Material.ts @@ -15,9 +15,12 @@ import { } from "@ui/ui_template_elements"; import { fetchFiles, fetchSubs, type SubInfo } from "../../M01_MasterData/M01_MasterData_Api_Fetch"; import { cfMaterials, cfValues } from "./M02_DF_Api"; +import { templateBox } from "./M02_DF_InputPanel_Template"; import type { CfElementRef, CfLogic, CfMaterialMode, CfMaterialRule } from "./M02_DF_Model"; import { catchNote, + fillTemplate, + templateRule, checkMaterial, elementOf, matches, @@ -59,6 +62,11 @@ const TEXT = { Default: ["기본 품목", "Default item"], DefaultNone: ["아직 안 고름 — 아래 목록에서 눌러 고름", "Not chosen — pick one below"], DefaultClear: ["기본 품목 비움", "Clear"], + RuleHow: ["찾는 방식", "How to find"], + HowPlain: ["규격 · 이름에 알약 값", "Spec / name vs a pill"], + HowTemplate: ["검색어 틀", "Search template"], + TemplateLine: ["검색어 틀 「{t}」", "template “{t}”"], + Missing: ["자리 「{n}」 알약 값이 비어 있음", "Slot “{n}” has no value"], RuleField: ["찾는 칸", "Field"], FieldSpec: ["규격", "Spec"], FieldName: ["이름", "Name"], @@ -251,9 +259,35 @@ function itemList( export async function previewOf( el1: CfElementRef, lg: CfLogic, -): Promise<{ found: number; names: string[]; cheapest: MatItem | null; unit: string }> { +): Promise<{ + found: number; + names: string[]; + cheapest: MatItem | null; + unit: string; + missing?: string[]; +}> { const scope = el1.scope ?? {}; const word = scope.찾는말 ?? ""; + if (el1.mode === "rule" && el1.rule?.field === "검색어") { + // 틀의 자리마다 알약 값을 끼운 글로 찾음(띄어쓴 말은 모두 들어간 것) + const valueOf = (port: { box: string; name: string }): unknown => + variablePills(lg).find((p) => p.port.box === port.box && p.port.name === port.name)?.example; + const { text, missing } = fillTemplate(el1.rule.template ?? "", el1.rule.slots ?? [], valueOf); + if (missing.length) return { found: 0, names: [], cheapest: null, unit: "", missing }; + const r = await searchMaterials({ + q: `${word} ${text}`.trim(), + sub: scope.구분, + detail: scope.상세구분, + page: 1, + size: 200, + }); + return { + found: r.total, + names: r.items.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()), + cheapest: cheapestOf(r.items), + unit: commonUnit(r.items), + }; + } if (el1.mode === "rule" && el1.rule) { const pill = variablePills(lg).find( (p) => p.port.box === el1.rule!.from.box && p.port.name === el1.rule!.from.name, @@ -300,6 +334,16 @@ const cheapestOf = (items: MatItem[]): MatItem | null => .filter((i) => i.price !== null) .sort((a, b) => (a.price as number) - (b.price as number))[0] ?? null; +/** 미리 보기 글 — 틀 자리 값이 비면 그 까닭 · 아니면 잡힌 수에 따른 알림 */ +const noteOf = (p: { + found: number; + names: string[]; + missing?: string[]; +}): ReturnType => + p.missing?.length + ? { kind: "none", text: t("Missing", { n: p.missing.join(" · ") }) } + : catchNote(p.found, p.names[0]); + /** 요소 한 줄 요약 — 방식 · 범위 · 규칙 */ function summaryOf(e: CfElementRef, lg: CfLogic): string { const parts: string[] = []; @@ -307,7 +351,8 @@ function summaryOf(e: CfElementRef, lg: CfLogic): string { .map(([, v]) => v) .join(" / "); if (scope) parts.push(t("ScopeLine", { s: scope })); - if (e.rule) { + if (e.rule?.field === "검색어") parts.push(t("TemplateLine", { t: e.rule.template ?? "" })); + else if (e.rule) { const p = variablePills(lg).find( (x) => x.port.box === e.rule!.from.box && x.port.name === e.rule!.from.name, ); @@ -336,6 +381,7 @@ export function renderMaterial( const chosen = el("div", { className: "m02-dfip__chosen" }); const body = el("div", { className: "m02-dfip__stack" }); let mode: CfMaterialMode = "fixed"; + let how: "plain" | "template" = "plain"; const drawChosen = (): void => { chosen.replaceChildren( @@ -380,7 +426,7 @@ export function renderMaterial( if (e.mode && e.mode !== "fixed") void previewOf(e, lg).then( (p) => { - const n = catchNote(p.found, p.names[0]); + const n = noteOf(p); note.textContent = `${t("Preview")}: ${n.text}`; note.classList.toggle("is-bad", n.kind !== "one"); if (p.cheapest?.price != null && !ref) val.textContent = fmt(p.cheapest.price); @@ -463,9 +509,30 @@ export function renderMaterial( compact: true, onChange: () => void refresh(), }); + const howBox = createSelectField({ + label: t("RuleHow"), + options: [ + { value: "plain", text: t("HowPlain") }, + { value: "template", text: t("HowTemplate") }, + ], + value: how, + compact: true, + onChange: (v) => ((how = v as "plain" | "template"), drawBody()), + }); + const tpl = templateBox(lg, () => void refresh()); const defNote = el("p", { className: "m02-dfip__muted" }); const build = (): CfElementRef => { const sc = scope.get(); + if (mode === "rule" && howBox.select.value === "template") { + const g = tpl.get(); + const sc2 = scope.get(); + return materialElement( + mode, + name.input.value, + { 구분: sc2.sub, 상세구분: sc2.detail, 찾는말: word.input.value.trim() }, + { rule: templateRule(g.template, g.slots) }, + ); + } const rule: CfMaterialRule | undefined = mode === "rule" && pills[Number(pillBox.select.value)] ? { @@ -490,7 +557,7 @@ export function renderMaterial( try { const p = await previewOf(cand, lg); if (mine !== seq) return; - const n = catchNote(p.found, p.names[0]); + const n = noteOf(p); note.textContent = `${t("Preview")}: ${n.text}${p.cheapest?.price != null ? ` · ${t("Price", { p: fmt(p.cheapest.price), u: p.cheapest.unit })}` : ""}`; note.className = `m02-dfip__note${n.kind === "one" ? "" : " is-bad"}`; } catch { @@ -530,9 +597,14 @@ export function renderMaterial( scope.root, word.root, ...(mode === "rule" - ? pills.length - ? [fieldBox.root, opBox.root, pillBox.root] - : [el("p", { className: "m02-dfip__muted is-bad", text: t("NoPill") })] + ? [ + howBox.root, + ...(howBox.select.value === "template" + ? [tpl.root] + : pills.length + ? [fieldBox.root, opBox.root, pillBox.root] + : [el("p", { className: "m02-dfip__muted is-bad", text: t("NoPill") })]), + ] : [ el("h5", { text: t("Default") }), defNote, diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Template.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Template.ts new file mode 100644 index 000000000..f2cfa13ef --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Template.ts @@ -0,0 +1,90 @@ +/* ============================================================================= + * M02_DF_InputPanel_Template.ts + * 입력 판 — 재료 조건 찾기 「검색어 틀」: 틀(예: 「레미콘 {굵은골재최대치수}-{강도}-{슬럼프}」) 입력 · 틀 자리 {이름} 마다 + * 값을 줄 변수 알약 고르기 · 글 [한, 영] 짝은 아래 · 재료 판(`M02_DF_InputPanel_Material.ts`)이 부름 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createInputField, createSelectField, el } from "@ui/ui_template_elements"; +import type { CfLogic, CfPort } from "./M02_DF_Model"; +import { templateSlots, variablePills } from "./M02_DF_InputPanel_Build"; + +const TEXT = { + Template: ["검색어 틀 — 자리는 {이름} 으로", "Search template — slots as {name}"], + Placeholder: ["예: 레미콘 {굵은골재최대치수}-{강도}-{슬럼프}", "e.g. Ready-mix {a}-{b}-{c}"], + Help: [ + "틀의 자리마다 변수 알약을 고르면 그 값이 끼워진 글로 재료를 찾음(띄어쓴 말은 모두 들어간 것)", + "Each slot takes a variable pill's value; words must all match", + ], + Slot: ["자리 「{n}」 ← 알약", "Slot “{n}” ← pill"], + NoSlot: ["틀에 자리 {이름} 을 넣어 주세요", "Add a {slot} to the template"], + NoPill: ["변수 알약이 없음 — 변수 입력 상자를 먼저 만듦", "No variable pill yet"], +} as const; + +type TKey = keyof typeof TEXT; +const t = (key: TKey, v: Record = {}): string => + Object.entries(v).reduce( + (s, [k, x]) => s.replace(`{${k}}`, x), + TEXT[key][currentLanguageIndex] as string, + ); + +export interface TemplateBox { + root: HTMLElement; + /** 지금 틀 · 자리별 알약 — 틀이 비었으면 null */ + get(): { template: string; slots: { name: string; from: CfPort }[] }; +} + +/** 틀 칸 + 자리 고르기 칸 — 바뀌면 onChange */ +export function templateBox(lg: CfLogic, onChange: () => void): TemplateBox { + const pills = variablePills(lg); + const tpl = createInputField({ + type: "text", + label: t("Template"), + compact: true, + placeholder: t("Placeholder"), + }); + const slotBox = el("div", { className: "m02-dfip__stack" }); + const picks = new Map(); + const draw = (): void => { + const names = templateSlots(tpl.input.value); + const before = new Map([...picks].map(([k, s]) => [k, s.value])); + picks.clear(); + slotBox.replaceChildren( + ...(names.length + ? pills.length + ? names.map((n) => { + // 자리 이름과 같은 알약이 있으면 먼저 고름 + const same = pills.findIndex((p) => p.port.name === n); + const sel = createSelectField({ + label: t("Slot", { n }), + options: pills.map((p, i) => ({ + value: String(i), + text: `${p.port.name}${p.example !== null ? ` = ${p.example}` : ""}`, + })), + value: before.get(n) ?? String(same >= 0 ? same : 0), + compact: true, + onChange: () => onChange(), + }); + picks.set(n, sel.select); + return sel.root; + }) + : [el("p", { className: "m02-dfip__muted is-bad", text: t("NoPill") })] + : [el("p", { className: "m02-dfip__muted", text: t("NoSlot") })]), + ); + }; + tpl.input.addEventListener("input", () => (draw(), onChange())); + draw(); + return { + root: el("div", { + className: "m02-dfip__stack", + children: [el("p", { className: "m02-dfip__muted", text: t("Help") }), tpl.root, slotBox], + }), + get: () => ({ + template: tpl.input.value.trim(), + slots: templateSlots(tpl.input.value).flatMap((n) => { + const p = pills[Number(picks.get(n)?.value)]; + return p ? [{ name: n, from: p.port }] : []; + }), + }), + }; +} diff --git a/resources/tester/test_m02_df_input_logic.py b/resources/tester/test_m02_df_input_logic.py index d5351d187..bbe4d9bb9 100644 --- a/resources/tester/test_m02_df_input_logic.py +++ b/resources/tester/test_m02_df_input_logic.py @@ -31,6 +31,9 @@ const m = box.exports; if (j.name === "wouldCycle") { const graph = j.args[2]; out.push(await m.wouldCycle(j.args[0], j.args[1], async (k) => graph[k] ?? null)); + } else if (j.name === "fillTemplate") { + const vals = j.args[2]; + out.push(m.fillTemplate(j.args[0], j.args[1], (p) => vals[`${p.box}.${p.name}`])); } else out.push(await m[j.name](...j.args)); } process.stdout.write(JSON.stringify(out)); @@ -266,3 +269,38 @@ def test_옛_줄_입력과_자동값으로_다른_로직_입력을_읽음(): {"name": "쌓기", "unit": "-"}, ] assert ref["examples"] == {"뒷길이": 25, "돌": "깬잡석"} # 빈 글 · 입력에 없는 이름은 뺌 + + +def test_검색어_틀_자리와_채움(): + tpl = "레미콘 {굵은골재최대치수}-{강도}-{슬럼프}" + assert call("templateSlots", tpl) == ["굵은골재최대치수", "강도", "슬럼프"] + assert call("templateSlots", "{a} {a} {b}") == ["a", "b"] # 겹친 자리는 한 번 + slots = [ + {"name": "굵은골재최대치수", "from": {"box": "b2", "name": "굵은"}}, + {"name": "강도", "from": {"box": "b2", "name": "강도"}}, + {"name": "슬럼프", "from": {"box": "b2", "name": "슬럼프"}}, + ] + values = {"b2.굵은": 25, "b2.강도": 18, "b2.슬럼프": 120} + got = call("fillTemplate", tpl, slots, values) + assert got == {"text": "레미콘 25-18-120", "missing": []} + part = call("fillTemplate", tpl, slots, {"b2.굵은": 25, "b2.강도": ""}) + assert part["missing"] == ["강도", "슬럼프"] + assert part["text"] == "레미콘 25-{강도}-{슬럼프}" # 빈 자리는 그대로 둠 + + +def test_검색어_틀_규칙과_검사(): + slots = [{"name": "a", "from": {"box": "b2", "name": "굵은"}}] + rule = call("templateRule", "레미콘 {a}", slots) + assert ( + rule["field"] == "검색어" + and rule["template"] == "레미콘 {a}" + and rule["from"] == slots[0]["from"] + ) + el = call("materialElement", "rule", "콘크리트", {"구분": "레미콘"}, {"rule": rule}) + assert call("checkMaterial", el, []) is None + no_slot = call("templateRule", "레미콘", []) + bad = call("materialElement", "rule", "콘크리트", {"구분": "레미콘"}, {"rule": no_slot}) + assert "자리" in call("checkMaterial", bad, []) + short = call("templateRule", "레미콘 {a}-{b}", slots) # 자리 둘인데 알약은 하나 + two = call("materialElement", "rule", "콘크리트", {}, {"rule": short}) + assert "알약" in call("checkMaterial", two, []) From fcd45afe9f691c4df4d52d826c3f9ba97fac8c91 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 2 Oct 2026 00:40:53 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(m02-df):=20=EC=9E=AC=EB=A3=8C=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=B0=BE=EA=B8=B0=20=E3=80=8C=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=96=B4=E3=80=8D=20=EB=A9=B4=20=ED=8B=80=EC=9D=84=20?= =?UTF-8?q?=EC=B1=84=EC=9A=B4=20=EA=B8=80=EB=A1=9C=20=EA=B0=92=20=EB=AC=BC?= =?UTF-8?q?=EC=9D=8C(fillTemplate)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail.ts b/M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail.ts index 4cd321278..a27c7a895 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail.ts @@ -9,6 +9,7 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { el } from "@ui/ui_template_elements"; import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; import type { CfAnswer, CfQuery } from "./M02_DF_Api"; +import { fillTemplate } from "./M02_DF_InputPanel_Build"; import type { CfBreak, CfValues } from "./M02_DF_Eval"; import type { CfElementRef, CfLogic, CfPort } from "./M02_DF_Model"; @@ -190,7 +191,12 @@ export function queriesOf( unit: e.unit, slot: { ...e, field: e.rule?.field, op: e.rule?.op }, }; - if (e.mode === "rule" && e.rule) q.value = pill(e.rule.from); + // 조건 찾기 — 「검색어」 면 틀에 알약 값을 채운 글 · 그 밖은 이어진 알약 값 + if (e.mode === "rule" && e.rule) + q.value = + e.rule.field === "검색어" + ? fillTemplate(e.rule.template ?? "", e.rule.slots ?? [], pill).text + : pill(e.rule.from); return { q, box: b.id, port }; }); const els = inp.elements