Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1

This commit is contained in:
2026-10-01 23:30:26 +09:00
6 changed files with 552 additions and 2 deletions
@@ -72,6 +72,9 @@ export interface DetailContext {
onChange: () => void;
/** 시험 계산 칸(`buildCalc` 가 채움) — 넷째 컨테이너 안에 놓음 */
calcHost: HTMLElement;
/** 흐름이 있는 로직 — 이 칸에 왼쪽 「지금 내용」(단가산출 상세 · 텍스트 수식 = `buildFlowView`)을 그림 ·
* 있으면 호표 · 읽는 식 두 묶음 대신 이 칸을 놓음(기본정보 · 시험 계산은 그대로) */
flowHost?: HTMLElement;
}
type Group = "labor" | "material" | "cost" | "other";
@@ -482,8 +485,7 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
: null;
host.replaceChildren(
infoBox(ctx),
hoBox(ctx),
section(tl("Formula_Title"), [formula]),
...(ctx.flowHost ? [ctx.flowHost] : [hoBox(ctx), section(tl("Formula_Title"), [formula])]),
section(tl("Calc_Title"), [ctx.calcHost]),
);
const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음
@@ -0,0 +1,122 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_FlowRows.ts
* 흐름 로직의 왼쪽 「단가산출 상세」 줄 만들기(DOM 없음 · 순수) — 흐름도 출력 상자를 비용 종류별로 묶어
* 줄(이름 · 단위 · 값) · 소계 · 계 · 함수 상자별 결과 값. 값은 DataFlow 계산 결과(`CfEvalResult`)를 읽기만 함.
* 모양은 docs/raw/M01_쉬운단가/흐름도_적용_정함.md 7. 시험 = resources/tester/test_m01_logic_flowview.py
* ========================================================================== */
import type { CfEvalResult } from "../M02_MasterTemplete/DataFlow/M02_DF_Eval";
import type { CfBox, CfCost, CfLogic, CfRound } from "../M02_MasterTemplete/DataFlow/M02_DF_Model";
import { roundBy, showRounded } from "../M02_MasterTemplete/DataFlow/M02_DF_Round";
export type FlowGroupKey = "labor" | "material" | "cost" | "plain";
/** 비용 종류 → 묶음(인력 = 노무비 · 재료 = 재료비 · 경비) · 값(비용 아님)은 따로 */
export const GROUP_OF: Record<CfCost, FlowGroupKey> = {
노무비: "labor",
재료비: "material",
경비: "cost",
값: "plain",
};
export const GROUP_ORDER: FlowGroupKey[] = ["labor", "material", "cost", "plain"];
const COST_OF: Record<Exclude<FlowGroupKey, "plain">, "노무비" | "재료비" | "경비"> = {
labor: "노무비",
material: "재료비",
cost: "경비",
};
export interface FlowLine {
box: string;
name: string;
unit: string;
value: number | string | boolean | null;
/** 보이는 글 — 숫자는 그 출력의 끝수 자리대로 */
text: string;
}
export interface FlowGroup {
key: FlowGroupKey;
lines: FlowLine[];
/** 비용 묶음만 — 합계 끝수를 적용한 소계 */
sub: number | null;
subText: string;
}
export interface FlowFunc {
box: string;
name: string;
unit: string;
value: unknown;
text: string;
}
export interface FlowRows {
groups: FlowGroup[];
/** 합계 끝수(기본 1원 미만 버림)를 적용한 계 */
total: number;
totalText: string;
funcs: FlowFunc[];
errors: string[];
}
const plain = (v: unknown): string =>
v === null || v === undefined || Array.isArray(v)
? "—"
: typeof v === "number"
? v.toLocaleString("ko-KR", { maximumFractionDigits: 6 })
: String(v);
/** 줄 모으기 — round.item = 출력 기본 끝수 · round.total = 합계 기본 끝수 */
export function flowRows(
lg: CfLogic,
ev: CfEvalResult,
round: { item: CfRound; total: CfRound },
): FlowRows {
const outs: CfBox[] = lg.boxes.filter((b) => b.kind === "output");
const groups = GROUP_ORDER.map((key): FlowGroup => {
const mine = outs.filter((b) => GROUP_OF[b.output?.cost ?? "값"] === key);
const lines = mine.map((b): FlowLine => {
const v = ev.outputs[b.id] ?? null;
const r = b.output?.round ?? (key === "plain" ? undefined : round.item);
return {
box: b.id,
name: b.name,
unit: b.output?.unit ?? b.unit,
value: v,
text: typeof v === "number" ? showRounded(v, r) : plain(v),
};
});
const raw = key === "plain" ? null : ev.cost[COST_OF[key]];
const sub = raw === null ? null : roundBy(round.total, raw);
return {
key,
lines,
sub,
subText: sub === null ? "" : showRounded(sub, round.total),
};
}).filter((g) => g.lines.length);
const total = roundBy(round.total, ev.cost.계);
const funcs = lg.boxes
.filter((b) => b.kind === "function" && b.func)
.map((b): FlowFunc => {
const out = b.func!.output;
const v = ev.ports[b.id]?.[out.name];
return {
box: b.id,
name: b.name,
unit: out.unit || b.unit,
value: v,
text:
typeof v === "number"
? v.toLocaleString("ko-KR", { maximumFractionDigits: 6 })
: plain(v),
};
});
return {
groups,
total,
totalText: showRounded(total, round.total),
funcs,
errors: ev.errors,
};
}
@@ -0,0 +1,30 @@
.m01flow__group {
margin: 0 0 var(--spacing-12);
}
.m01flow__block {
margin: 0 0 var(--spacing-8);
}
.m01flow__func {
margin: 0 0 var(--spacing-4);
color: var(--color-text-body);
font-weight: 600;
}
.m01flow__branch {
margin: 0 0 0 var(--spacing-16);
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m01flow__branch--now {
color: var(--color-text-body);
font-weight: 600;
}
.m01flow__warn {
margin: var(--spacing-4) 0 0;
color: var(--color-danger);
font-size: var(--text-body-sm);
}
@@ -0,0 +1,207 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_FlowView.ts
* 흐름 로직의 왼쪽 「지금 내용」 — ② 단가산출 상세(출력 상자를 비용 종류별 줄 · 소계 · 계) · ③ 텍스트 수식(함수 상자마다
* 식 두 줄 — sub2 `formulaLines` 가 있으면 그것 · 없으면 함수 이름 · 결과 값만). 기본정보 · 시험 계산은 Detail 이 둠.
* 값은 DataFlow 계산(Eval · 같은 코드)으로 바로 — 오른쪽 패널이 고치면 LogicLab 이 이 함수를 다시 부름.
* 화면은 `buildFlowView(host, lg, values)` 하나. 이 파일은 DataFlow 를 정적으로 가져오므로 LogicLab 은 import() 로 늦게 부름.
* 모양은 docs/raw/M01_쉬운단가/흐름도_적용_정함.md 7 · 8.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { el } from "@ui/ui_template_elements";
import {
evalLogic,
type CfEvalOptions,
type CfEvalResult,
type CfValues,
} from "../M02_MasterTemplete/DataFlow/M02_DF_Eval";
import type { CfBox, CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model";
import { itemRoundNow, totalRoundNow } from "../M02_MasterTemplete/DataFlow/M02_DF_Round";
import {
flowRows,
type FlowFunc,
type FlowGroup,
type FlowGroupKey,
} from "./M01_MasterData_UI_LogicLab_FlowRows";
import "./M01_MasterData_UI_LogicLab_FlowView.css";
const TEXT = {
Detail: ["단가산출 상세", "Unit-price detail"],
Formula: ["텍스트 수식", "Text formulas"],
Labor: ["인력 · 노무비", "Labor · labor cost"],
Material: ["재료 · 재료비", "Material · material cost"],
Cost: ["경비", "Expense"],
Plain: ["값(비용 아님)", "Values (not costs)"],
HName: ["이름", "Name"],
HUnit: ["단위", "Unit"],
HValue: ["값", "Value"],
Sub: ["소계", "Subtotal"],
Total: ["계", "Total"],
TotalNote: ["합계 끝수 기본 = 1원 미만 버림", "Total rounding default: floor to 1 won"],
None: ["출력 상자가 없음", "No output box"],
NoFunc: ["함수 상자가 없음", "No function box"],
} as const;
type TextKey = keyof typeof TEXT;
const tf = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
const GROUP_TITLE: Record<FlowGroupKey, TextKey> = {
labor: "Labor",
material: "Material",
cost: "Cost",
plain: "Plain",
};
/** sub2 의 식 보기(`M02_DF_FormulaView.ts` · 정함 8) — 아직 없으면 비어 있음 */
interface FormulaLine {
name: string;
named: string;
valued: string;
branches?: { when: string; then: string; now: boolean }[];
}
type FormulaFn = (box: CfBox, values: CfValues) => FormulaLine[];
const FORMULA = import.meta.glob<{ formulaLines?: FormulaFn }>(
"../M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts",
);
const loadFormula = async (): Promise<FormulaFn | null> => {
const load = Object.values(FORMULA)[0];
return load ? ((await load()).formulaLines ?? null) : null;
};
const section = (title: string, body: HTMLElement[]): HTMLElement =>
el("section", {
className: "m01-logic__section m01lab__box",
children: [
el("div", { className: "m01-logic__section-head", children: [el("h3", { text: title })] }),
...body,
],
});
const groupTable = (g: FlowGroup): HTMLElement => {
const sub =
g.sub === null
? []
: [
el("tr", {
className: "m01lab__sub",
children: [
el("td", { text: tf("Sub"), attrs: { colspan: "2" } }),
el("td", { className: "m01-logic__money", text: g.subText }),
],
}),
];
return el("div", {
className: "m01flow__group",
children: [
el("h4", { text: tf(GROUP_TITLE[g.key]) }),
el("div", {
className: "m01-logic__scroll",
children: [
el("table", {
className: "m01-logic__grid",
children: [
el("thead", {
children: [
el("tr", {
children: [tf("HName"), tf("HUnit"), tf("HValue")].map((h) =>
el("th", { text: h }),
),
}),
],
}),
el("tbody", {
children: [
...g.lines.map((l) =>
el("tr", {
children: [
el("td", { text: l.name }),
el("td", { text: l.unit }),
el("td", { className: "m01-logic__money", text: l.text }),
],
}),
),
...sub,
],
}),
],
}),
],
}),
],
});
};
/** 함수 상자 하나의 글 줄 — 식 보기가 있으면 두 줄(+ 갈래) · 없으면 이름 = 값 */
function funcBlock(f: FlowFunc, lines: FormulaLine[] | null): HTMLElement {
const unit = f.unit && f.unit !== "-" ? ` ${f.unit}` : "";
const head = el("p", { className: "m01flow__func", text: `${f.name} = ${f.text}${unit}` });
if (!lines?.length) return el("div", { className: "m01flow__block", children: [head] });
return el("div", {
className: "m01flow__block",
children: [
head,
...lines.flatMap((l) => [
el("p", { className: "m01lab__expr", text: `${l.name} = ${l.named}` }),
el("p", { className: "m01lab__expr", text: `${l.name} = ${l.valued}` }),
...(l.branches ?? []).map((b) =>
el("p", {
className: `m01flow__branch${b.now ? " m01flow__branch--now" : ""}`,
text: `${b.now ? "▶ " : ""}${b.when} → ${b.then}`,
}),
),
]),
],
});
}
/** 왼쪽 「지금 내용」 — 단가산출 상세 + 텍스트 수식을 `host` 에 그림(`values` = 입력 값 · 다시 부르면 다시 그림) */
export function buildFlowView(
host: HTMLElement,
lg: CfLogic,
values: CfValues,
opts: { logics?: CfEvalOptions["logics"]; result?: CfEvalResult } = {},
): void {
let ev: CfEvalResult;
try {
ev = opts.result ?? evalLogic(lg, values, { logics: opts.logics });
} catch (e) {
host.replaceChildren(
section(tf("Detail"), [el("p", { className: "m01-logic__muted", text: String(e) })]),
);
return;
}
const rows = flowRows(lg, ev, { item: itemRoundNow(), total: totalRoundNow() });
const detail = section(tf("Detail"), [
...(rows.groups.length
? rows.groups.map(groupTable)
: [el("p", { className: "m01-logic__muted", text: tf("None") })]),
el("p", {
className: "m01lab__total",
attrs: { "data-sum": String(rows.total) },
text: `${tf("Total")} ${rows.totalText}`,
}),
el("p", { className: "m01-logic__muted", text: tf("TotalNote") }),
...rows.errors.slice(0, 3).map((t) => el("p", { className: "m01flow__warn", text: t })),
]);
const bodies = rows.funcs.map((f) => funcBlock(f, null));
const text = section(
tf("Formula"),
bodies.length ? bodies : [el("p", { className: "m01-logic__muted", text: tf("NoFunc") })],
);
host.replaceChildren(detail, text);
// sub2 식 보기가 올라와 있으면 함수 상자마다 식 두 줄을 더함
void loadFormula().then((fn) => {
if (!fn || !bodies.length) return;
const boxes = lg.boxes.filter((b) => b.kind === "function" && b.func);
const merged = { ...ev.ports, ...ev.arrived } as CfValues;
text.replaceChildren(
text.firstElementChild!,
...rows.funcs.map((f, i) => {
try {
return funcBlock(f, fn(boxes[i], merged));
} catch {
return bodies[i];
}
}),
);
});
}
@@ -102,3 +102,8 @@ export function showRounded(v: number, r?: CfRound): string {
maximumFractionDigits: Math.max(0, r.places),
});
}
/** 원가 합계 기본 끝수(표를 읽어 오기 전에는 계금 1원 미만 버림) — 화면이 바로 쓰는 값 */
let totalNow: CfRound = { places: 0, method: "버림" };
void defaultTotalRound().then((r) => (totalNow = r));
export const totalRoundNow = (): CfRound => totalNow;
+184
View File
@@ -0,0 +1,184 @@
"""흐름 로직 왼쪽 「단가산출 상세」 줄 만들기(`M01_MasterData_UI_LogicLab_FlowRows.ts`) — 순수 부분.
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS(DataFlow Round …)는 이어 트랜스파일 ·
화면 부품(@ui · @config)은 빈 흉내. 견본 로직 · 계산 결과는 이 시험 안에 둠(마스터 내용에 기대지 않음).
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_FlowRows.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 stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? 0 : k === "API_BASE_URL" ? "http://127.0.0.1:1/api" : () => undefined) });
const cache = {};
function load(f) {
if (cache[f]) return cache[f].exports;
const js = ts.transpileModule(fs.readFileSync(f, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = (cache[f] = { exports: {} });
const req = (id) => {
if (id.startsWith(".")) {
const p = path.resolve(path.dirname(f), id);
return fs.existsSync(p + ".ts") ? load(p + ".ts") : stub;
}
return stub;
};
new Function("exports", "module", "require", js)(box.exports, box, req);
return box.exports;
}
const m = load(file);
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
const out = jobs.map((job) => m.flowRows(job.lg, job.ev, job.round));
process.stdout.write(JSON.stringify(out));
setTimeout(() => process.exit(0), 50);
"""
ROUND = {"item": {"places": 1, "method": "버림"}, "total": {"places": 0, "method": "버림"}}
def box(id_, kind, name, unit="원", **extra):
return {
"id": id_,
"kind": kind,
"name": name,
"color": "#000",
"unit": unit,
"x": 0,
"y": 0,
**extra,
}
def out_box(id_, name, cost, unit="원", round_=None):
output = {"cost": cost, "unit": unit}
if round_:
output["round"] = round_
return box(id_, "output", name, unit, output=output)
def func_box(id_, name, out_name, unit="시간"):
func = {
"sheet": {"id": "s-" + id_, "이름": name, "칸": {}},
"inputs": [],
"variables": [],
"output": {"cell": "B1", "name": out_name, "unit": unit},
}
return box(id_, "function", name, unit, func=func)
def logic(boxes):
return {
"key": None,
"name": "시험",
"resultUnit": "원/㎡",
"boxes": boxes,
"links": [],
"userElements": [],
}
def result(outputs, cost, ports=None, errors=None):
return {"ports": ports or {}, "outputs": outputs, "cost": cost, "errors": errors or []}
def run(jobs: list[dict]) -> list:
node = "node"
done = subprocess.run(
[node, "-e", RUNNER, str(ROOT), str(SOURCE)],
input=json.dumps(jobs),
capture_output=True,
text=True,
timeout=60,
check=False,
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
@pytest.fixture(scope="module")
def sample():
lg = logic(
[
out_box("o1", "인건비", "노무비"),
out_box("o2", "재료값", "재료비"),
out_box("o3", "시간당 작업량", "값", "㎥/시간"),
out_box("o4", "특별 끝수", "경비", round_={"places": 2, "method": "반올림"}),
func_box("f1", "운반 시간", "시간", "시간"),
]
)
ev = result(
{"o1": 3216.69, "o2": 100.04, "o3": 12.5, "o4": 1.239},
{
"노무비": 3216.69,
"재료비": 100.04,
"경비": 1.239,
"값들": {"시간당 작업량": 12.5},
"계": 3317.969,
},
ports={"f1": {"시간": 6.024}},
)
return run([{"lg": lg, "ev": ev, "round": ROUND}])[0]
def group(rows, key):
return next(g for g in rows["groups"] if g["key"] == key)
def test_group_order_and_costs_only(sample):
assert [g["key"] for g in sample["groups"]] == ["labor", "material", "cost", "plain"]
def test_line_uses_output_round(sample):
# 출력 끝수를 안 정한 비용 줄 = 기본 0.1원 버림 → 3,216.6 (정수로 반올림하지 않음)
assert group(sample, "labor")["lines"][0]["text"] == "3,216.6"
assert group(sample, "material")["lines"][0]["text"] == "100"
# 그 출력의 끝수가 있으면 그 자리대로(소수 둘째 반올림)
assert group(sample, "cost")["lines"][0]["text"] == "1.24"
def test_plain_value_is_not_rounded_and_has_no_subtotal(sample):
plain = group(sample, "plain")
assert plain["lines"][0]["text"] == "12.5"
assert plain["lines"][0]["unit"] == "㎥/시간"
assert plain["sub"] is None
def test_subtotal_and_total_floor_to_won(sample):
assert group(sample, "labor")["sub"] == 3216
assert group(sample, "labor")["subText"] == "3,216"
assert sample["total"] == 3317
assert sample["totalText"] == "3,317"
def test_function_rows(sample):
assert sample["funcs"] == [
{"box": "f1", "name": "운반 시간", "unit": "시간", "value": 6.024, "text": "6.024"}
]
def test_empty_group_is_hidden():
lg = logic([out_box("o1", "인건비", "노무비")])
ev = result({"o1": 10}, {"노무비": 10, "재료비": 0, "경비": 0, "값들": {}, "계": 10})
rows = run([{"lg": lg, "ev": ev, "round": ROUND}])[0]
assert [g["key"] for g in rows["groups"]] == ["labor"]
assert rows["total"] == 10
def test_missing_value_shows_dash():
lg = logic([out_box("o1", "인건비", "노무비")])
ev = result(
{"o1": None}, {"노무비": 0, "재료비": 0, "경비": 0, "값들": {}, "계": 0}, errors=["끊김"]
)
rows = run([{"lg": lg, "ev": ev, "round": ROUND}])[0]
assert group(rows, "labor")["lines"][0]["text"] == "—"
assert rows["errors"] == ["끊김"]