fix(M02): 흐름 셈 = 옛 셈 — 표 「·범위규칙」 줄 경계 말 · 검색어 조건 찾기 · 재료가 로직 입력 읽음 · 빈 단가 알림 · 고르기 글 값 그대로(PLAN 36-2 대조 49 → 8)
- main(sub7 a774b4e97 Model 덧) 받아 합침 · Compute 재료 조건 찾기는 sub7 의 틀 채우기 + 빈 단가 알림
- 남은 8: 표에 여럿 맞음 5(옛 find 의 값 칸 있는 줄만 거름 — lookup) · 빈 값이 든 식 3(옛 = 그 줄 비움 · 흐름 = 빈 칸 0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
This commit is contained in:
@@ -155,8 +155,10 @@ def old_designs(key: str) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def _value(v: Any) -> Any:
|
||||
"""화면이 보낸 값 — 수 글은 수로(「3」 → 3) · 그 밖은 그대로."""
|
||||
def _value(v: Any, words: set | frozenset = frozenset()) -> Any:
|
||||
"""화면이 보낸 값 — 수 글은 수로(「3」 → 3) · 고르기 글 값(「080」 · 「1048」)은 글 그대로."""
|
||||
if isinstance(v, str) and v in words:
|
||||
return v
|
||||
if isinstance(v, str) and v.strip():
|
||||
try:
|
||||
return Decimal(v.strip())
|
||||
@@ -172,7 +174,13 @@ def run(flow: dict, key: str, inputs: dict) -> dict:
|
||||
raise store.cm.mf.FormulaError(f"로직이 서로를 부름 — {' → '.join([*active, key])}")
|
||||
token = _ACTIVE.set((*active, key))
|
||||
try:
|
||||
given = {k: _value(v) for k, v in (inputs or {}).items()}
|
||||
words = { # 고르기에 글로 든 값 — 옛 셈처럼 글 그대로(수로 바꾸면 고르기 밖)
|
||||
c
|
||||
for d in dc.design_inputs(flow)
|
||||
for c in (d.get("variable") or {}).get("choices") or []
|
||||
if isinstance(c, str)
|
||||
}
|
||||
given = {k: _value(v, words) for k, v in (inputs or {}).items()}
|
||||
flow = {**flow, "key": key}
|
||||
values = dc._design_values(flow, given)
|
||||
return dc.run(flow, values, load, flow.get("userElements") or [], [key])
|
||||
|
||||
@@ -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 식 보기 · 받는 알약 값을 칸에 넣고 셈) — 못 그리면 이름 = 값만 */
|
||||
|
||||
@@ -28,7 +28,10 @@ export interface CfQuery {
|
||||
unit?: string;
|
||||
field?: string;
|
||||
inputs?: Record<string, unknown>;
|
||||
slot?: Partial<CfElementRef> & { field?: "규격" | "이름" | "키"; op?: "포함" | "같음" };
|
||||
slot?: Partial<CfElementRef> & {
|
||||
field?: "규격" | "이름" | "키" | "검색어";
|
||||
op?: "포함" | "같음";
|
||||
};
|
||||
value?: unknown;
|
||||
pick?: string;
|
||||
}
|
||||
|
||||
@@ -131,7 +131,10 @@ def _element_value(
|
||||
"unit": el.get("unit"),
|
||||
"inputs": env,
|
||||
}
|
||||
got = dv.slot_price(slot, ports.get(src.get("box"), {}).get(src.get("name")))
|
||||
value = ports.get(src.get("box"), {}).get(src.get("name"))
|
||||
if rule.get("field") == "검색어": # 틀의 자리마다 알약 값 → 채운 검색어 글(빈 자리면 None)
|
||||
value, slot["unit"] = _fill_template(rule, ports), el.get("unit")
|
||||
got = dv.slot_price(slot, value)
|
||||
if not got["ok"] and not got.get("blank") and el.get("default"):
|
||||
errors.append(
|
||||
f"「{box.get('name')} · {el.get('name')}」 {got['reason']} — 기본 품목으로 셈"
|
||||
@@ -160,6 +163,20 @@ def _element_value(
|
||||
return got.get("value")
|
||||
|
||||
|
||||
def _fill_template(rule: dict, ports: dict) -> str | None:
|
||||
"""재료 검색어 틀의 자리에 slots 알약 값 — 옛 엔진 fill 그대로(빈 자리 = None)."""
|
||||
env = {}
|
||||
for x in rule.get("slots") or []:
|
||||
src = x.get("from") or {}
|
||||
env[x.get("name")] = ports.get(src.get("box"), {}).get(src.get("name"))
|
||||
if any(v is None for v in env.values()):
|
||||
return None
|
||||
try:
|
||||
return store.cm.mf.fill(str(rule.get("template") or ""), env)
|
||||
except store.cm.mf.FormulaError:
|
||||
return None
|
||||
|
||||
|
||||
def _port(el: dict) -> str:
|
||||
"""요소 알약 이름 — Model `elementPort`(고정 아니면 자리 이름 slot · 없으면 name)."""
|
||||
slot = str(el.get("slot") or "").strip()
|
||||
@@ -349,6 +366,7 @@ def _lookup(rule: dict, block: Any, conds: dict) -> tuple[Any, str | None]:
|
||||
if out not in head and not two:
|
||||
return None, "오류"
|
||||
tests, ups = [], []
|
||||
rk = head.index(RANGE_RULE) if RANGE_RULE in head else None
|
||||
for c in rule.get("conds") or []:
|
||||
col, want = str(c.get("col")), _cond_of(conds, c.get("from") or {})
|
||||
if col in head:
|
||||
@@ -366,10 +384,17 @@ def _lookup(rule: dict, block: Any, conds: dict) -> tuple[Any, str | None]:
|
||||
low, high = _num_of(row[lo]), _num_of(row[hi])
|
||||
if low is not None and low == high: # 숫자 하나(아래 = 위) = 같음(브레인 정함)
|
||||
return x is not None and x == low
|
||||
word = str(row[rk] or "") if rk is not None else "" # 경계 — 이상 · 미만(Rule 같음)
|
||||
return (
|
||||
x is not None
|
||||
and (row[lo] is None or (low is not None and low < x))
|
||||
and (row[hi] is None or (high is not None and x <= high))
|
||||
and (
|
||||
row[lo] is None
|
||||
or (low is not None and (low <= x if word[:2] == "이상" else low < x))
|
||||
)
|
||||
and (
|
||||
row[hi] is None
|
||||
or (high is not None and (x < high if word[-2:] == "미만" else x <= high))
|
||||
)
|
||||
)
|
||||
|
||||
tests.append(fits)
|
||||
@@ -451,7 +476,12 @@ def _depends(lg: dict) -> list[tuple[str, str]]:
|
||||
for b in lg.get("boxes") or []:
|
||||
for el in (b.get("input") or {}).get("elements") or []:
|
||||
if el.get("mode") == "rule" and el.get("rule"):
|
||||
out.append(((el["rule"].get("from") or {}).get("box"), b["id"]))
|
||||
rule = el["rule"]
|
||||
srcs = [
|
||||
rule.get("from") or {},
|
||||
*[x.get("from") or {} for x in rule.get("slots") or []],
|
||||
]
|
||||
out += [(p.get("box"), b["id"]) for p in srcs]
|
||||
return out
|
||||
|
||||
|
||||
@@ -489,6 +519,7 @@ def _sheet_of(box: dict, arrived: dict, given: dict) -> dict:
|
||||
|
||||
|
||||
REST = "앞 조건이 안 맞아 쉬는 갈래"
|
||||
RANGE_RULE = "·범위규칙" # 표 블록 줄마다 범위 경계 규칙 칸(Values._table · Rule RANGE_RULE)
|
||||
NA_VALUE = "#N/A" # 함수 칸으로 가는 표 찾기 실패 — 칸에 NA() · 결과 오류일 때만 끊긴 곳(Eval 같음)
|
||||
|
||||
|
||||
|
||||
@@ -103,10 +103,10 @@ class _Flow:
|
||||
|
||||
# ── 바깥 값 알약 ──────────────────────────────────────────────────
|
||||
def element(
|
||||
self, group: str, ref: str, unit: str, label: str, rule: dict | None = None
|
||||
self, group: str, ref: str, unit: str, label: str, rule: dict | None = None, **more: Any
|
||||
) -> dict:
|
||||
"""그룹 입력 상자의 요소 알약 — 그룹마다 상자 하나 · 같은 ref · 단위 · 조건이면 알약 하나
|
||||
· rule = 조건 찾기(field 「키」 — ref 의 {…} 에 알약 값을 끼운 요소 · CfMaterialRule)."""
|
||||
· rule = 조건 찾기(CfMaterialRule · 알약 값은 Model depends 로 · 선 없음) · more = scope."""
|
||||
key = f"el|{group}|{ref}|{unit}|{json.dumps(rule, ensure_ascii=False, sort_keys=True)}"
|
||||
if key in self.cache:
|
||||
return self.cache[key]
|
||||
@@ -120,8 +120,7 @@ class _Flow:
|
||||
name += "·"
|
||||
one = {"ref": ref, "name": name, "spec": "", "unit": unit or "-", "mode": "fixed"}
|
||||
if rule:
|
||||
one |= {"mode": "rule", "rule": rule, "slot": name}
|
||||
self.link(rule["from"], {"box": self.cache[gkey]["id"], "name": name})
|
||||
one |= {"mode": "rule", "rule": rule, "slot": name, **more}
|
||||
els.append(one)
|
||||
self.cache[key] = {"box": self.cache[gkey]["id"], "name": name}
|
||||
return self.cache[key]
|
||||
@@ -139,14 +138,20 @@ class _Flow:
|
||||
if len(texts) > 1:
|
||||
raise Unsupported(f"자리 표시가 여러 칸에 낀 요소 「{ref}」")
|
||||
((at, text),) = texts.items()
|
||||
if at == "검색어": # 재료 검색어 틀 — 자리마다 알약 · 서버는 옛 검색어 찾기(정함 · sub4)
|
||||
names = list(dict.fromkeys(_HOLE.findall(text)))
|
||||
slots = [{"name": n, "from": self.hole(n)} for n in names]
|
||||
rule = {"field": "검색어", "op": "같음", "from": slots[0]["from"]}
|
||||
rule |= {"template": text, "slots": slots}
|
||||
scope = {k: str(v) for k, v in ref.items() if k in ("구분", "상세구분") and v}
|
||||
tpl = json.dumps(plain(ref), ensure_ascii=False)
|
||||
return ("@port", self.element(group, tpl, unit, f"{label} 자리", rule, scope=scope))
|
||||
# 찾는 값 = 원문번호(「EQ:{기계}」 → 기계 · 「MO:경유:{지역}」 → "경유:"&지역)
|
||||
want = text.partition(":")[2] if at is None and ":" in text else text
|
||||
if _HOLE.fullmatch(want):
|
||||
src = self.hole(want[1:-1])
|
||||
else:
|
||||
src = self.func(f"{label} 찾는 말", ("@text", want))
|
||||
if at is not None:
|
||||
self.notes.append(f"재료 검색어에 입력이 낌 「{text}」 — 조건 찾기(키 같음)로 옮김")
|
||||
tpl = text if at is None else json.dumps(plain(ref), ensure_ascii=False)
|
||||
tpl += f"#{field}" if field else ""
|
||||
rule = {"field": "키", "op": "같음", "from": src}
|
||||
@@ -383,6 +388,11 @@ def _layout(f: _Flow) -> None:
|
||||
for ln in f.links:
|
||||
srcs = [ln["from"], *[c["from"] for c in (ln.get("rule") or {}).get("conds", [])]]
|
||||
feeds.setdefault(ln["to"]["box"], set()).update(s["box"] for s in srcs)
|
||||
for bid, b in f.boxes.items(): # 조건 찾기 요소 — 값을 주는 알약 상자 뒤
|
||||
for e in (b.get("input") or {}).get("elements") or []:
|
||||
rule = e.get("rule") or {}
|
||||
srcs = [rule.get("from"), *[x["from"] for x in rule.get("slots") or []]]
|
||||
feeds.setdefault(bid, set()).update(p["box"] for p in srcs if p)
|
||||
depth: dict[str, int] = {}
|
||||
|
||||
def level(bid: str, path: frozenset = frozenset()) -> int:
|
||||
|
||||
@@ -27,9 +27,19 @@ export interface CfScope {
|
||||
* field 「키」 = 요소 ref 의 자리 표시 {…} 에 알약 값을 끼운 요소(op 같음) — ref 꼴 「EQ:{기계}」 · 「EQ:{기계}#가격」 ·
|
||||
* 「MO:경유:{지역}」 · 「LB:{직종}」 · 재료 검색어 묶음 JSON(검색어 안 {…}) · 옛 엔진 `fill` → `Master.get` 과 같은 뜻 */
|
||||
export interface CfMaterialRule {
|
||||
field: "규격" | "이름" | "키";
|
||||
field: "규격" | "이름" | "키" | "검색어";
|
||||
op: "포함" | "같음";
|
||||
from: CfPort;
|
||||
/** field 「검색어」 — 옛 재료 검색어 찾기(master_pick.search) 의 틀(「레미콘 {a}-{b}-{c}」) · scope 구분 · 상세구분 안 */
|
||||
template?: string;
|
||||
/** 틀의 자리 {이름} 마다 값을 줄 알약 — from = 첫 자리 */
|
||||
slots?: { name: string; from: CfPort }[];
|
||||
}
|
||||
|
||||
/** 재료 조건 찾기가 값을 받는 알약 — from + 틀 자리들(겹침 없이) */
|
||||
export function materialSources(rule: CfMaterialRule): CfPort[] {
|
||||
const all = [rule.from, ...(rule.slots ?? []).map((x) => x.from)];
|
||||
return all.filter((p, i) => all.findIndex((q) => q.box === p.box && q.name === p.name) === i);
|
||||
}
|
||||
|
||||
/** 입력 상자가 고른 요소 — ref = 마스터 키 또는 사용자 요소 id
|
||||
@@ -446,7 +456,8 @@ export function depends(lg: CfLogic): { from: string; to: string }[] {
|
||||
}
|
||||
for (const b of lg.boxes)
|
||||
for (const e of b.input?.elements ?? [])
|
||||
if (e.mode === "rule" && e.rule) out.push({ from: e.rule.from.box, to: b.id });
|
||||
if (e.mode === "rule" && e.rule)
|
||||
for (const p of materialSources(e.rule)) out.push({ from: p.box, to: b.id });
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -584,7 +595,8 @@ export function traceBack(lg: CfLogic, port: CfPort): CfStep[] {
|
||||
lg.links.find((l) => l.to.box === box.id && l.to.name === q.name),
|
||||
);
|
||||
for (const e of box.input?.elements ?? [])
|
||||
if (e.mode === "rule" && e.rule && elementPort(e) === p.name) visit(e.rule.from, "out");
|
||||
if (e.mode === "rule" && e.rule && elementPort(e) === p.name)
|
||||
for (const q of materialSources(e.rule)) visit(q, "out");
|
||||
};
|
||||
const box = boxOf(lg, port.box);
|
||||
if (!box) return [];
|
||||
@@ -630,7 +642,11 @@ export function traceForward(lg: CfLogic, port: CfPort): CfStep[] {
|
||||
visit(l.to, "in", l);
|
||||
for (const b of lg.boxes)
|
||||
for (const e of b.input?.elements ?? [])
|
||||
if (e.mode === "rule" && e.rule?.from.box === p.box && e.rule.from.name === p.name)
|
||||
if (
|
||||
e.mode === "rule" &&
|
||||
e.rule &&
|
||||
materialSources(e.rule).some((q) => q.box === p.box && q.name === p.name)
|
||||
)
|
||||
visit({ box: b.id, name: elementPort(e) }, "out");
|
||||
};
|
||||
const box = boxOf(lg, port.box);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -159,6 +159,9 @@ export function applyRule(
|
||||
return { value: got, why: null, formula };
|
||||
}
|
||||
|
||||
/** 표 블록 범위 경계 규칙 칸(줄마다 「초과·이하」 「이상·이하」 「이상·미만」 「이하」 · Values._table) */
|
||||
export const RANGE_RULE = "·범위규칙";
|
||||
|
||||
/** 표에서 찾기 — 줄마다 맞음 칸 · 맞은 줄 수 · INDEX/MATCH(범위 칸 「이름·아래」 「이름·위」 · 초과 · 이하)
|
||||
* 빈 조건 칸 = 무엇이든 · 꺼낼 칸이 빈 줄은 뺌(옛 엔진 find · 서버 Compute `_lookup` 과 같음) */
|
||||
function lookup(
|
||||
@@ -187,6 +190,12 @@ function lookup(
|
||||
// 조건 값 = 1행 · 블록 오른쪽 한 칸 띄고
|
||||
const tests: ((r: number) => string)[] = out ? [(r) => `NOT(ISBLANK(${out}${r}))`] : [];
|
||||
const ups: string[] = [];
|
||||
// 범위 경계 — 줄마다 「·범위규칙」 칸(이상 … = 아래 끝 포함 · … 미만 = 위 끝 뺌) · 없으면 초과 · 이하
|
||||
const rc = at(RANGE_RULE);
|
||||
const lowCmp = (c: string, r: number, x: string): string =>
|
||||
rc ? `IF(LEFT(${rc}${r},2)="이상",${c}${r}<=${x},${c}${r}<${x})` : `${c}${r}<${x}`;
|
||||
const highCmp = (c: string, r: number, x: string): string =>
|
||||
rc ? `IF(RIGHT(${rc}${r},2)="미만",${x}<${c}${r},${x}<=${c}${r})` : `${x}<=${c}${r}`;
|
||||
let bad = !out || rows < 1;
|
||||
rule.conds.forEach((c, i) => {
|
||||
const ref = `$${colName(W + 1 + i)}$1`;
|
||||
@@ -201,7 +210,7 @@ function lookup(
|
||||
tests.push(
|
||||
(r) =>
|
||||
`IF(AND(NOT(ISBLANK(${low}${r})),${low}${r}=${high}${r}),${ref}=${low}${r},` +
|
||||
`AND(OR(ISBLANK(${low}${r}),${low}${r}<${ref}),OR(ISBLANK(${high}${r}),${ref}<=${high}${r})))`,
|
||||
`AND(OR(ISBLANK(${low}${r}),${lowCmp(low, r, ref)}),OR(ISBLANK(${high}${r}),${highCmp(high, r, ref)})))`,
|
||||
);
|
||||
} else bad = true;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ from M01_MasterData import M01_MasterData_Store as store
|
||||
cm = store.cm
|
||||
_UKEY = re.compile(r"^UE-(\d{4,})$")
|
||||
RANGE_LOW, RANGE_HIGH = "·아래", "·위"
|
||||
RANGE_RULE = "범위규칙" # 표 블록 끝 칸 — 범위 경계(옛 find 와 같음 · 없으면 초과·이하)
|
||||
RANGE_RULE = "·범위규칙" # 표 블록 끝 칸 — 줄마다 경계 말(옛 find 와 같음 · 없으면 초과·이하)
|
||||
FIELDS = (*cm.mf.COST_ITEMS, "계")
|
||||
|
||||
|
||||
@@ -214,14 +214,28 @@ def _table_of(table: dict | None, key: str, at: Any) -> dict:
|
||||
else:
|
||||
line.append(None if v == "" else v if not isinstance(v, dict) else str(v))
|
||||
rows.append(line)
|
||||
if (
|
||||
"범위" in conds.values()
|
||||
): # 범위 경계 규칙 — 끝 칸 「범위규칙」 줄마다 같은 글(lookup 이 읽음)
|
||||
rule = str(table.get(RANGE_RULE) or "초과·이하")
|
||||
head, rows = [*head, RANGE_RULE], [[*r, rule] for r in rows]
|
||||
if "범위" in conds.values(): # 끝 칸 「·범위규칙」 = 줄마다 그 줄 경계 말(lookup 이 읽음)
|
||||
head, rows = [*head, RANGE_RULE], [[*r, _bound(table, x)] for r, x in zip(rows, lines)]
|
||||
return {"ok": True, "rows": [head, *rows], "name": table.get("이름"), "unit": table.get("기준")}
|
||||
|
||||
|
||||
def _bound(table: dict, row: dict) -> str:
|
||||
"""줄 경계 말 — 옛 `_match` 와 같음 · 첫 낱말 · 「이하 포함」 이면 맨 윗줄만 이상·이하."""
|
||||
rule, *more = str(table.get("범위규칙") or "초과·이하").split(" · ")
|
||||
rule = rule.split(" (")[0]
|
||||
if rule != "이상·미만" or not any("이하 포함" in x for x in more):
|
||||
return rule
|
||||
for c, kind in (table.get("조건") or {}).items(): # 옛 _match — 그 범위 칸 위 끝이 가장 큰 줄
|
||||
ups = [
|
||||
r[c][1]
|
||||
for r in table.get("줄") or []
|
||||
if isinstance(r.get(c), list) and r[c][1] is not None
|
||||
]
|
||||
if kind == "범위" and ups and isinstance(row.get(c), list) and row[c][1] == max(ups):
|
||||
return "이상·이하"
|
||||
return rule
|
||||
|
||||
|
||||
def _arith(v: Any) -> Any:
|
||||
"""값 칸 수 식 글(「(0.05)」 「1/1.30」) — 옛 셈처럼 수로(사칙연산 · 괄호만 · 옛 엔진 파서)."""
|
||||
if not isinstance(v, str) or not cm.mf._ARITH.fullmatch(v):
|
||||
@@ -412,13 +426,15 @@ def _filled(template: str, value: Any) -> str:
|
||||
|
||||
def _search(slot: dict, value: Any) -> dict:
|
||||
"""조건 찾기 field 검색어 — 틀(요소 ref 묶음)의 {자리} 를 알약 값으로 채워 옛 찾기 그대로."""
|
||||
cond = _pick(slot.get("ref"))
|
||||
if cond is None:
|
||||
return {"ok": False, "reason": "검색어 틀이 없음 — 요소에 검색어 묶음을 넣어 주세요"}
|
||||
if value in (None, ""):
|
||||
return {"ok": False, "reason": "찾을 값이 비어 있음 — 이어진 알약을 확인해 주세요"}
|
||||
cond = _pick(slot.get("ref")) # 요소 ref = 옛 검색어 묶음(규격 · 지역 칸도 그대로)
|
||||
if cond is None: # 묶음이 없으면 범위(구분 · 상세구분) + 채운 검색어
|
||||
scope = slot.get("scope") or {}
|
||||
cond = {k: scope[k] for k in ("구분", "상세구분") if scope.get(k)} | {"검색어": ""}
|
||||
fill = {k: _filled(v, value) for k, v in cond.items() if isinstance(v, str) and _HOLE.search(v)}
|
||||
return _material({**cond, **fill}, str(slot.get("unit") or ""), slot.get("inputs"))
|
||||
cond = {**cond, **fill} if fill else {**cond, "검색어": str(value)}
|
||||
return _material(cond, str(slot.get("unit") or ""), slot.get("inputs"))
|
||||
|
||||
|
||||
def slot_price(slot: dict, value: Any = None, pick: str | None = None) -> dict:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -216,9 +216,17 @@ def test_꼴_상자_선_규칙_배치() -> None:
|
||||
assert keyed and all(e["rule"]["field"] == "키" and "{" in e["ref"] for e in keyed)
|
||||
machine = [e for e in keyed if e["ref"].startswith("EQ:{기계}#")]
|
||||
assert machine and {e["rule"]["from"]["name"] for e in machine} == {"기계"}
|
||||
# 레미콘 {a}-{b}-{c} = 글 잇는 함수(&) → 재료 조건 찾기
|
||||
# 「MO:경유:{지역}」 = 원문번호 글을 잇는 함수(&) → 유가 조건 찾기
|
||||
assert any("&" in str(b.get("func")) for b in got["GC000267"]["flow"]["boxes"])
|
||||
# 레미콘 {a}-{b}-{c} = 재료 조건 찾기 field 검색어 · 틀 · 자리 셋 · 구분 범위(정함 · 선 없음)
|
||||
remi = got["GC000264"]["flow"]
|
||||
assert any("&" in str(b.get("func")) for b in remi["boxes"])
|
||||
found = [e for b in remi["boxes"] for e in (b.get("input") or {}).get("elements", [])]
|
||||
word = [e for e in found if (e.get("rule") or {}).get("field") == "검색어"]
|
||||
assert word and word[0]["rule"]["template"] == "레미콘 {굵은골재최대치수}-{강도}-{슬럼프}"
|
||||
assert [x["name"] for x in word[0]["rule"]["slots"]] == ["굵은골재최대치수", "강도", "슬럼프"]
|
||||
assert word[0]["scope"]["구분"] == "레미콘"
|
||||
groups = {b["id"] for b in remi["boxes"] if (b.get("input") or {}).get("mode") == "group"}
|
||||
assert not [ln for ln in remi["links"] if ln["to"]["box"] in groups]
|
||||
# 덧줄 줄.'이름' · 비목 합
|
||||
assert got["GF000185"]["notes"] == [] or all(
|
||||
not n.startswith("못 옮김") for n in got["GF000185"]["notes"]
|
||||
|
||||
@@ -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 ×") # 들어온 값이 없을 때만 칸 값
|
||||
|
||||
@@ -895,3 +895,23 @@ def test_표_찾기_여럿이면_위_끝_가장_작은_줄() -> None:
|
||||
)
|
||||
assert got == [[11, None], [10, None], [9, None], [None, "표에 없는 조건"]]
|
||||
assert [list(cp._lookup(rule, block, {"x": x})) for x in (1, 30, 60, 70)] == got
|
||||
|
||||
|
||||
def test_표_찾기_범위_경계_규칙_칸() -> None:
|
||||
from M02_MasterTemplete.DataFlow import M02_DF_Compute as cp
|
||||
|
||||
head = ["h·아래", "h·위", "Q", "·범위규칙"]
|
||||
block = [head, [None, 10, 1, "이상·미만"], [10, 20, 2, "이상·미만"], [20, 30, 3, "이상·이하"]]
|
||||
rule = {
|
||||
"kind": "lookup",
|
||||
"conds": [{"col": "h", "from": {"box": "v", "name": "x"}}],
|
||||
"col": "Q",
|
||||
}
|
||||
xs = [5, 10, 20, 30, 31]
|
||||
got = _run(
|
||||
RULE
|
||||
+ f"const block = {json.dumps(block, ensure_ascii=False)};\nconst rule = {json.dumps(rule, ensure_ascii=False)};\n"
|
||||
f"out({json.dumps(xs)}.map(x => {{ const r = R.applyRule(rule, block, {{ x }}); return [r.value, r.why]; }}));"
|
||||
)
|
||||
assert got == [[1, None], [2, None], [3, None], [3, None], [None, "표에 없는 조건"]]
|
||||
assert [list(cp._lookup(rule, block, {"x": x})) for x in xs] == got
|
||||
|
||||
@@ -270,8 +270,9 @@ def test_입력_값_그룹마다(client: TestClient) -> None:
|
||||
assert got["o"]["value"] == oil["값"] and got["f"]["value"] == fx["값"]
|
||||
assert got["r"]["value"] == rate["값"]["rate_percent"] and got["r"]["unit"] == "%"
|
||||
assert got["e"]["value"] == eq["값"]["가격"]
|
||||
assert got["t"]["rows"][0] == HEAD
|
||||
assert got["t1"]["rows"] == [HEAD, ["견치돌", 4, 4, 0.7, None, None]]
|
||||
ruled = [*HEAD, dv.RANGE_RULE] # 끝 칸 = 줄 경계 말(범위규칙 없는 표 = 초과·이하)
|
||||
assert got["t"]["rows"][0] == ruled
|
||||
assert got["t1"]["rows"] == [ruled, ["견치돌", 4, 4, 0.7, None, None, "초과·이하"]]
|
||||
assert got["u"]["value"] == 500 and got["u"]["unit"] == "개"
|
||||
for k in ("w", "z", "e2"): # 기계 묶음은 칸을 골라야 함
|
||||
assert got[k]["ok"] is False and got[k]["reason"], got[k]
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# ruff: noqa: E501
|
||||
"""흐름 셈 값 찾기 = 옛 셈과 같은 뜻(PLAN 36-2 변환 대조 · sub4) — 마스터 읽기만.
|
||||
|
||||
못박는 것
|
||||
- 표 블록 끝 칸 「·범위규칙」 = 줄마다 경계 말(범위규칙 첫 낱말 · 「이하 포함」 이면 맨 윗줄 이상·이하)
|
||||
- 재료 값이 로직 입력(자재지역 · 계약종별)을 읽음 · 빈 단가 = 알림(blank) — 셈은 계속
|
||||
- 재료 조건 찾기 field 검색어 = 옛 검색어 찾기(틀의 {자리} 를 채운 글)
|
||||
- 고르기 글 값(「080」)은 글 그대로 — 수로 바꾸면 고르기 밖
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from decimal import Decimal
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
from M01_MasterData import M01_MasterData_Store_Flow as sflow
|
||||
from M02_MasterTemplete.DataFlow import M02_DF_Compute as dc
|
||||
from M02_MasterTemplete.DataFlow import M02_DF_Values as dv
|
||||
|
||||
|
||||
def _old(key: str) -> dict:
|
||||
row = store.logic(key)["logic"]
|
||||
given = {k: str(v) for k, v in store.cm.시험입력(row).items()}
|
||||
return {"given": given, "got": store.calc(key, given), "row": row}
|
||||
|
||||
|
||||
def test_표_범위규칙_칸() -> None:
|
||||
tables = [
|
||||
t
|
||||
for d in store.loaded()[0].values()
|
||||
if d.get("그룹") in store.cm.mf.TABLE_GROUPS
|
||||
for t in d.get("표") or []
|
||||
]
|
||||
ranged = [t for t in tables if "범위" in (t.get("조건") or {}).values()]
|
||||
for t in ranged[:40]:
|
||||
got = dv._table(str(t["키"]))
|
||||
head, rows = got["rows"][0], got["rows"][1:]
|
||||
assert head[-1] == dv.RANGE_RULE
|
||||
first = str(t.get("범위규칙") or "초과·이하").split(" · ")[0].split(" (")[0]
|
||||
assert {r[-1] for r in rows} <= {first, "이상·이하"}, t["키"]
|
||||
closed = [
|
||||
t
|
||||
for t in ranged
|
||||
if str(t.get("범위규칙") or "").startswith("이상·미만")
|
||||
and "이하 포함" in str(t.get("범위규칙"))
|
||||
]
|
||||
for t in closed[:5]: # 맨 윗줄(위 끝이 가장 큰 줄)만 이상·이하
|
||||
words = [r[-1] for r in dv._table(str(t["키"]))["rows"][1:]]
|
||||
assert "이상·이하" in words and "이상·미만" in words, t["키"]
|
||||
plain = next(t for t in tables if "범위" not in (t.get("조건") or {}).values())
|
||||
assert dv.RANGE_RULE not in dv._table(str(plain["키"]))["rows"][0]
|
||||
|
||||
|
||||
def test_재료는_로직_입력을_읽고_빈_단가는_알림() -> None:
|
||||
old = _old("GC000685") # 모래 — 「지역: 입력」 · 입력 자재지역
|
||||
line = next(x for x in old["got"]["lines"] if x["이름"] == "모래")
|
||||
cond = next(x["요소"] for x in old["row"]["호표"] if x["이름"] == "모래")
|
||||
assert dv._material(cond, "㎥", old["given"])["value"] == line["단가"]
|
||||
alone = dv._material(cond, "㎥")
|
||||
assert alone["ok"] is False and alone["blank"] is True # 입력이 없으면 그 값만 비움
|
||||
el = {
|
||||
"ref": "MT030715",
|
||||
"name": "바퀴숫돌",
|
||||
"spec": "",
|
||||
"unit": "개",
|
||||
"mode": "fixed",
|
||||
} # 값 빈 품목
|
||||
flow = {
|
||||
"boxes": [
|
||||
{
|
||||
"id": "m",
|
||||
"kind": "input",
|
||||
"name": "재료",
|
||||
"input": {"mode": "group", "group": "재료", "elements": [el], "variables": []},
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
}
|
||||
if dv._price("재료", "MT030715").get("blank"):
|
||||
got = dc.run(flow, {}, lambda k: {}, [], ["t"])
|
||||
assert got["errors"] == [] and any("바퀴숫돌" in n for n in got["notes"])
|
||||
|
||||
|
||||
def test_검색어_조건_찾기는_옛_검색어_찾기() -> None:
|
||||
old = _old("GF000185") # 레미콘 {굵은골재최대치수}-{강도}-{슬럼프}
|
||||
item = next(
|
||||
x
|
||||
for x in old["row"]["호표"]
|
||||
if isinstance(x["요소"], dict) and "{" in str(x["요소"].get("검색어"))
|
||||
)
|
||||
want = next(x for x in old["got"]["lines"] if x["이름"] == item["이름"])["단가"]
|
||||
text = store.cm.mf.fill(item["요소"]["검색어"], old["given"])
|
||||
slot = {"mode": "rule", "field": "검색어", "op": "같음", "unit": item["단위"],
|
||||
"scope": {k: item["요소"][k] for k in ("구분", "상세구분") if item["요소"].get(k)},
|
||||
"ref": json.dumps(store._dec(item["요소"]), ensure_ascii=False, default=str), "inputs": old["given"]} # fmt: skip
|
||||
assert dv.slot_price(slot, text)["value"] == want
|
||||
assert dv.slot_price({**slot, "ref": None}, text)["value"] == want # 묶음 없이 범위 + 검색어
|
||||
assert dv._filled("레미콘 {a}-{b}-{c}", "레미콘 25-18-080") == "레미콘 25-18-080"
|
||||
assert dv._filled("싱글코아바렐 {규격}", "BX") == "싱글코아바렐 BX"
|
||||
|
||||
|
||||
def test_고르기_글_값은_글_그대로() -> None:
|
||||
assert sflow._value("080", {"080"}) == "080" and sflow._value("080") == Decimal(80)
|
||||
assert sflow._value("3") == Decimal(3) and sflow._value("토사") == "토사"
|
||||
Reference in New Issue
Block a user