Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7
충돌 넷 풂 — 합계 끝수 정본 = CfLogic.totalRound(totalRoundOf(lg, fallback) 하나 · 옮긴 로직 = 자신 끝수 · null = 없음 · 안 정함 = 기본 1원 버림) · sub5 줄 · 소계 꼴 살림 · sub1 fillTemplate(InputPanel_Build) 살리고 Entry_Detail 의 같은 함수 뺌 · flowview 시험을 정본 뜻으로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
This commit is contained in:
@@ -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, totalRoundFor } 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: totalRoundFor(lg) });
|
||||
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 식 보기 · 받는 알약 값을 칸에 넣고 셈) — 못 그리면 이름 = 값만 */
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -169,18 +170,6 @@ export async function unitChoices(): Promise<string[]> {
|
||||
return units.length ? units : ["원/㎡"];
|
||||
}
|
||||
|
||||
/** 재료 검색어 틀(「레미콘 {a}-{b}」)의 자리에 알약 값 — 빈 자리가 있으면 null(서버 Compute `_fill_template` 과 같음) */
|
||||
function fillTemplate(
|
||||
rule: NonNullable<CfElementRef["rule"]>,
|
||||
pill: (p: CfPort) => unknown,
|
||||
): unknown {
|
||||
const got = new Map((rule.slots ?? []).map((x) => [x.name, pill(x.from)]));
|
||||
if ([...got.values()].some((v) => v === null || v === undefined)) return null;
|
||||
return (rule.template ?? "").replace(/\{([^{}]+)\}/g, (m, k: string) =>
|
||||
got.has(k) ? String(got.get(k)) : m,
|
||||
);
|
||||
}
|
||||
|
||||
/** 조건 찾기를 쓰는 그룹 — 재료 밖은 인력 · 기계 · 유가전력(field 키 · 서버 Values.GROUP_RULE) */
|
||||
const RULE_GROUPS = ["인력", "기계", "유가전력"];
|
||||
|
||||
@@ -221,8 +210,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 = e.rule.field === "검색어" ? fillTemplate(e.rule, pill) : pill(e.rule.from);
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<typeof catchNote> =>
|
||||
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,
|
||||
|
||||
@@ -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, string> = {}): 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<string, HTMLSelectElement>();
|
||||
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 }] : [];
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -110,6 +110,11 @@ let totalNow: CfRound = { places: 0, method: "버림" };
|
||||
void defaultTotalRound().then((r) => (totalNow = r));
|
||||
export const totalRoundNow = (): CfRound => totalNow;
|
||||
|
||||
/** 그 흐름의 합계 끝수 — 흐름에 정한 것(null = 끝수 없음) · 없으면 기본 */
|
||||
export const totalRoundFor = (lg: { totalRound?: CfRound | null }): CfRound | null =>
|
||||
lg.totalRound === undefined ? totalNow : lg.totalRound;
|
||||
/** 그 로직의 합계 끝수 — 정본 = CfLogic.totalRound(옛 로직을 옮긴 흐름 = 자신의 끝수 · null = 끝수 없음) ·
|
||||
* 정하지 않았으면 fallback(새 로직 = 일위대가표 계금 1원 미만 버림) */
|
||||
export function totalRoundOf(
|
||||
lg: { totalRound?: CfRound | null },
|
||||
fallback: CfRound = totalNow,
|
||||
): CfRound | null {
|
||||
return lg.totalRound === undefined ? fallback : lg.totalRound;
|
||||
}
|
||||
|
||||
@@ -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,19 +76,20 @@ export function renderSummary(
|
||||
? ts("Total")
|
||||
: ts("Per", { per: /^\d/.test(perUnit) ? perUnit : `1${perUnit}` });
|
||||
const { cost } = result;
|
||||
const tr = lg.totalRound === undefined ? totalRound : lg.totalRound; // 흐름 합계 끝수(null = 없음)
|
||||
/** 합계 끝수 — 비용 출력 상자에 실린 끝수(옮긴 로직의 자신 끝수) · 없으면 기본 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(tr, 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(tr, cost[c])) }),
|
||||
el("dd", { text: formatMoney(roundBy(sumRound, cost[c])) }),
|
||||
]),
|
||||
});
|
||||
|
||||
@@ -162,7 +170,7 @@ export function renderSummary(
|
||||
: []),
|
||||
el("p", {
|
||||
className: "m02-dfsum__muted",
|
||||
text: `${ts("RoundTotal")}: ${roundText(tr)}`,
|
||||
text: `${ts("RoundTotal")}: ${roundText(sumRound)}`,
|
||||
}),
|
||||
verifyHost,
|
||||
);
|
||||
|
||||
@@ -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():
|
||||
"""합계 끝수 정본 = CfLogic.totalRound — 옮긴 로직 = 자신 끝수(null = 없음) · 안 정함 = 기본."""
|
||||
default = {"places": 0, "method": "버림"}
|
||||
own = {"places": 1, "method": "반올림"}
|
||||
converted = {**logic([out_box("o1", "인건비", "노무비")]), "totalRound": own}
|
||||
no_round = {**logic([out_box("o1", "인건비", "노무비")]), "totalRound": None}
|
||||
fresh = logic(
|
||||
[out_box("o1", "인건비", "노무비", round_=own)]
|
||||
) # 출력 상자 끝수는 합계 끝수 아님
|
||||
got = run(
|
||||
[
|
||||
{"op": "totalRound", "lg": converted, "fallback": default},
|
||||
{"op": "totalRound", "lg": no_round, "fallback": default},
|
||||
{"op": "totalRound", "lg": fresh, "fallback": default},
|
||||
]
|
||||
)
|
||||
assert got == [own, None, 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"
|
||||
|
||||
@@ -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 ×") # 들어온 값이 없을 때만 칸 값
|
||||
|
||||
@@ -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, [])
|
||||
|
||||
Reference in New Issue
Block a user