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

This commit is contained in:
2026-10-02 01:04:13 +09:00
13 changed files with 146 additions and 36 deletions
@@ -70,7 +70,7 @@ const plain = (v: unknown): string =>
export function flowRows(
lg: CfLogic,
ev: CfEvalResult,
round: { item: CfRound; total: CfRound },
round: { item: CfRound; total: CfRound | null },
): FlowRows {
const outs: CfBox[] = lg.boxes.filter((b) => b.kind === "output");
const groups = GROUP_ORDER.map((key): FlowGroup => {
@@ -92,7 +92,7 @@ export function flowRows(
key,
lines,
sub,
subText: sub === null ? "" : showRounded(sub, round.total),
subText: sub === null ? "" : showRounded(sub, round.total ?? undefined),
};
}).filter((g) => g.lines.length);
const total = roundBy(round.total, ev.cost.계);
@@ -115,7 +115,7 @@ export function flowRows(
return {
groups,
total,
totalText: showRounded(total, round.total),
totalText: showRounded(total, round.total ?? undefined),
funcs,
errors: ev.errors,
};
@@ -34,6 +34,8 @@ export interface CfQuery {
};
value?: unknown;
pick?: string;
/** 다른 로직 입력 상자 — 서버가 그 로직을 받은 설계 값으로 셈(/logic-value · 화면 queriesOf 가 만듦 · /values 로는 안 감) */
logic?: { key: string; designs: Record<string, unknown> };
}
/** 입력 값 답 — 표는 `rows` · 그 밖은 `value` · 못 구하면 ok false + 쉬운 말 */
@@ -647,7 +647,9 @@ def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | N
at = _at(out.get("cell") or "A1")[2]
hit = (calc.get(sheet.get("id")) or {}).get(at)
value = (
_plain(hit)
hit["오류"] # 오류 값을 넘김(Eval fromCalc 와 같음 · 받는 칸은 그 글/NA())
if isinstance(hit, dict) and "오류" in hit
else _plain(hit)
if hit is not None
else ((sheet.get("칸") or {}).get(at) or {}).get("값")
)
@@ -87,11 +87,17 @@ class _Flow:
self.links.append(one | ({"rule": rule} if rule else {}))
def design(self) -> None:
"""설계 값 상자 — 예시값 = 줄의 시험입력 · 없으면 check_master.시험입력 기본."""
given = store.cm.시험입력(self.row)
try:
given = mf._inputs(self.row, given) # 글로 적힌 수 → 수(옛 엔진과 같음)
except mf.FormulaError:
pass
variables = []
for s in self.row.get("입력") or []:
pick = s.get("고르기")
one = {"name": s["이름"], "type": "choice" if pick else "number"}
one |= {"unit": str(s.get("단위") or "-"), "example": plain(pick[0]) if pick else None}
one |= {"unit": str(s.get("단위") or "-"), "example": plain(given.get(s["이름"]))}
if pick:
one["choices"] = plain(pick)
if "범위" in s:
@@ -329,6 +335,7 @@ def convert(row: dict, ctx: dict | None = None) -> dict:
flow = {"key": row.get("키"), "name": row.get("이름") or ""}
flow |= {"resultUnit": row.get("결과단위") or "", "boxes": list(f.boxes.values())}
flow |= {"links": f.links, "userElements": []}
flow["totalRound"] = None # 옛 계는 끝수 없음(줄.끝수 묶음은 출력 상자 끝수로 실음)
return {"flow": flow, "notes": list(dict.fromkeys(f.notes))}
@@ -170,8 +170,12 @@ export async function unitChoices(): Promise<string[]> {
return units.length ? units : ["원/㎡"];
}
/** 조건 찾기를 쓰는 그룹 — 재료 밖은 인력 · 기계 · 유가전력(field 키 · 서버 Values.GROUP_RULE) */
const RULE_GROUPS = ["인력", "기계", "유가전력"];
/** 입력 값 물음 — 그룹 입력 상자의 요소(알약 = 요소 이름) · 재료 자리(알약 = 자리 이름 · 조건 찾기는 이어진 알약 값을
* pill 로 읽어 넘김) · 표(알약 「표」) · 사용자 요소 · 변수는 Eval 이 스스로 봄 */
* pill 로 읽어 넘김 · 인력 · 기계 · 유가 조건 찾기도) · 표(알약 「표」) · 다른 로직(서버가 받은 설계 값으로 셈 · port 「*」)
* · 사용자 요소 · 변수는 Eval 이 스스로 봄 */
export function queriesOf(
lg: CfLogic,
pill: (p: CfPort) => unknown = () => null,
@@ -179,10 +183,25 @@ export function queriesOf(
): { q: CfQuery; box: string; port: string }[] {
return lg.boxes.flatMap((b) => {
const inp = b.input;
if (b.kind === "input" && inp?.mode === "logic" && inp.logic?.key) {
const ref = inp.logic;
const designs: Record<string, unknown> = {};
for (const d of ref.designs) {
const l = lg.links.find((x) => x.to.box === b.id && x.to.name === d.name);
const v = l ? pill(l.from) : ref.examples?.[d.name];
if (v !== null && v !== undefined) designs[d.name] = v;
}
const q: CfQuery = { id: `${b.id}*`, group: "기계", logic: { key: ref.key, designs } };
return [{ q, box: b.id, port: "*" }];
}
if (b.kind !== "input" || !inp || inp.mode !== "group" || !inp.group) return [];
const group = inp.group;
const slots = inp.elements
.filter((e) => group === "재료" && e.mode && e.mode !== "fixed")
.filter(
(e) =>
(group === "재료" && e.mode && e.mode !== "fixed") ||
(RULE_GROUPS.includes(group) && e.mode === "rule"),
)
.map((e) => {
const port = elementPort(e);
const q: CfQuery = {
@@ -222,14 +241,35 @@ export async function fetchValues(
pill?: (p: CfPort) => unknown,
elementPort?: (e: CfElementRef) => string,
): Promise<{ values: CfValues; errors: string[] }> {
const asks = queriesOf(lg, pill, elementPort);
if (!asks.length) return { values: {}, errors: [] };
const all = queriesOf(lg, pill, elementPort);
const asks = all.filter((a) => !a.q.logic);
const values: CfValues = {};
const errors: string[] = [];
// 다른 로직 — 서버가 받은 설계 값으로 셈(그 안 조건 찾기 · 단가까지) → 출력 이름마다 값
const logicAsks = all.filter((a) => a.q.logic);
const logicDone = logicAsks.length
? import("./M02_DF_Api").then(({ cfLogicValue }) =>
Promise.all(
logicAsks.map((a) =>
cfLogicValue(a.q.logic!.key, a.q.logic!.designs).then(
(got) => {
values[a.box] = { ...(values[a.box] ?? {}), ...(got.outputs as CfValues[string]) };
},
(e: unknown) => errors.push(`${a.q.logic!.key} — ${String(e)}`),
),
),
),
)
: Promise.resolve();
if (!asks.length) {
await logicDone;
return { values, errors };
}
const answers = await ask(
asks.map((a) => a.q),
lg.userElements as never,
);
const values: CfValues = {};
const errors: string[] = [];
await logicDone;
for (const a of asks) {
const got = answers.find((x) => x.id === a.q.id);
if (!got?.ok) errors.push(`${a.port}${got?.reason ? ` — ${got.reason}` : ""}`);
@@ -413,6 +413,11 @@ function otherLogic(
): Record<string, CfValue> {
const ref = box.input?.logic;
const out: Record<string, CfValue> = {};
// 서버가 받은 설계 값으로 셈한 출력(Entry_Detail 다른 로직 물음)이 있으면 그것 — 그 안 단가 · 조건 찾기까지 정본
if (ref?.outputs.some((p) => given[p.name] !== undefined)) {
for (const p of ref.outputs) out[p.name] = given[p.name] ?? null;
return out;
}
const other = ref ? opts.logics?.[ref.key] : undefined;
if (!other || (opts.depth ?? 0) > 8) {
if (ref && opts.logics && !other) errors.push(tl("NoLogic", { box: box.name }));
@@ -208,6 +208,8 @@ export interface CfLogic {
boxes: CfBox[];
links: CfLink[];
userElements: CfUserElement[];
/** 원가 합계 끝수 — 없으면 기본(일위대가표 계금 1원 미만 버림) · null = 끝수 없음(값 그대로 · 옛 로직 변환) */
totalRound?: CfRound | null;
}
/** 알약 하나의 모습 */
+10 -11
View File
@@ -70,7 +70,8 @@ export async function defaultTotalRound(): Promise<CfRound> {
}
/** 끝수 적용 — 소수 자리 · 방법(부동소수 오차는 아주 작은 값으로 눌러 줌) */
export function roundBy(r: CfRound, v: number): number {
export function roundBy(r: CfRound | null | undefined, v: number): number {
if (!r) return v;
const k = 10 ** r.places;
const x = v * k;
const eps = Math.abs(x) * 1e-12 + 1e-9;
@@ -84,7 +85,8 @@ export function roundBy(r: CfRound, v: number): number {
}
/** 「0.1원 미만 버림」 꼴 쉬운 말 */
export function roundText(r: CfRound): string {
export function roundText(r: CfRound | null): string {
if (!r) return "끝수 없음(값 그대로)";
const step = r.places > 0 ? Number((10 ** -r.places).toFixed(r.places)) : 1;
return `${step}원 미만 ${r.method}`;
}
@@ -108,14 +110,11 @@ let totalNow: CfRound = { places: 0, method: "버림" };
void defaultTotalRound().then((r) => (totalNow = r));
export const totalRoundNow = (): CfRound => totalNow;
/** 그 로직의 합계 끝수 — 비용 출력 상자에 끝수가 실려 있으면 그것(옛 로직을 옮긴 흐름은 자신의 끝수) ·
* 없으면 기본(새 로직 = 일위대가표 계금 1원 미만 버림) */
/** 그 로직의 합계 끝수 — 정본 = CfLogic.totalRound(옛 로직을 옮긴 흐름 = 자신의 끝수 · null = 끝수 없음) ·
* 정하지 않았으면 fallback(새 로직 = 일위대가표 계금 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;
lg: { totalRound?: CfRound | null },
fallback: CfRound = totalNow,
): CfRound | null {
return lg.totalRound === undefined ? fallback : lg.totalRound;
}
+5 -1
View File
@@ -321,7 +321,11 @@ def one_value(item: dict, local: list[dict] | None = None) -> dict:
if group == "기계" and isinstance(ref, str) and MACHINE_SEP in ref:
ref, _, field = ref.partition(MACHINE_SEP)
try:
if isinstance(item.get("slot"), dict): # 재료 자리(고정 · 선택 · 조건 찾기)
if (
isinstance(item.get("slot"), dict) and group in GROUP_RULE
): # 인력 · 기계 · 유가 조건 찾기
got = group_find(str(group), item["slot"], item.get("value"))
elif isinstance(item.get("slot"), dict): # 재료 자리(고정 · 선택 · 조건 찾기)
got = slot_price(item["slot"], item.get("value"), item.get("pick"))
elif isinstance(ref, str) and _UKEY.match(ref):
got = _user(ref, local or [])
+8 -8
View File
@@ -186,22 +186,22 @@ def test_missing_value_shows_dash():
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", "인건비", "노무비", round_=own), out_box("o2", "작업량", "값")]
)
fresh = logic([out_box("o1", "인건비", "노무비")])
only_plain = logic([out_box("o3", "작업량", "값", round_={"places": 3, "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},
{"op": "totalRound", "lg": only_plain, "fallback": default},
]
)
assert got == [own, default, default] # 값(비용 아님)의 끝수는 합계 끝수가 아님
assert got == [own, None, default]
def test_subtotal_and_total_use_converted_round():
+15 -1
View File
@@ -125,7 +125,8 @@ def _keys() -> list[str]:
REASONS = [ # (알림에 든 말, 까닭) — 앞이 먼저
("표에 여럿 맞음", "표 범위 규칙 다름 — 여럿 맞음(이상·이하 · 이상·미만 경계)"),
("표에 없는 조건", "표 범위 규칙 다름 — 없는 조건(이상·이하 · 이상·미만 경계)"),
("같음", "재료 검색어 자리 — 서버 조건 찾기(키 같음) ≠ 옛 검색어 찾기"),
("검색어", "재료 검색어 틀 — 서버 검색어 찾기 대기(sub4)"),
("같음", "조건 찾기 못 찾음(키 같음)"),
("맞는 재료 없음", "재료 빈 검색어 — 서버 재료 값 찾기 다름"),
("비어 있음", "단가 빈 품목 — 서버 처리 다름"),
("값 없음", "단가 빈 품목 — 서버 처리 다름"),
@@ -270,3 +271,16 @@ def test_전수_대조_옛_셈_같음() -> None:
r for r in rows if r["시험입력"]["state"] == "흐름 셈 멈춤"
][:3]
assert report["시험입력"]["맞는 비율"] >= 0.5, report["시험입력"]
def test_변수_예시값_합계_끝수() -> None:
_copy()
got = _shape("GF000007", "GF000226")
for key, one in got.items():
flow = one["flow"]
design = next(b for b in flow["boxes"] if b["id"] == "in-design")
assert all(v["example"] is not None for v in design["input"]["variables"]), key
assert flow["totalRound"] is None # 옛 계는 끝수 없음
box = next(b for b in got["GF000226"]["flow"]["boxes"] if b["id"] == "in-design")
vars226 = {v["name"]: v["example"] for v in box["input"]["variables"]}
assert vars226["직경"] == 60 # 줄의 시험입력(글 「60.0」 → 수)
+37 -3
View File
@@ -854,9 +854,8 @@ def test_표_찾기_빈_조건칸_무엇이든_빈_값줄_건너뜀_실패는_
monkeypatch.setattr(cp.dv, "one_value", lambda item, local=None: {"ok": True, "rows": BLOCK})
for v, (screen_out, screen_breaks) in zip(vals, got):
srv = cp.run(lg, {"v": v["v"]}, lambda key: {}, [])
out = srv["outputs"]["o"]
want = None if isinstance(screen_out, str) and screen_out.startswith("#") else screen_out
assert (None if out is None else float(out)) == (None if want is None else float(want))
out = srv["outputs"]["o"] # 오류 값은 화면과 같은 글(「#N/A」)
assert out == screen_out if isinstance(screen_out, str) else float(out) == float(screen_out)
assert [b["kind"] for b in srv["breaks"]] == screen_breaks
@@ -915,3 +914,38 @@ def test_표_찾기_범위_경계_규칙_칸() -> None:
)
assert got == [[1, None], [2, None], [3, None], [3, None], [None, "표에 없는 조건"]]
assert [list(cp._lookup(rule, block, {"x": x})) for x in xs] == got
def test_화면_값_물음_조건_찾기_인력기계유가_검색어_틀_다른_로직() -> None:
lg = {
"key": None, "name": "t", "resultUnit": "", "userElements": [],
"boxes": [
{"id": "v", "kind": "input", "name": "설계", "input": {"mode": "variable", "elements": [], "variables": []}},
{"id": "g", "kind": "input", "name": "기계", "input": {"mode": "group", "group": "기계", "variables": [], "elements": [
{"ref": "EQ:{기계}#가격", "name": "가격 자리", "slot": "가격 자리", "spec": "", "unit": "-", "mode": "rule",
"rule": {"field": "키", "op": "같음", "from": {"box": "v", "name": "기계"}}}]}},
{"id": "m", "kind": "input", "name": "재료", "input": {"mode": "group", "group": "재료", "variables": [], "elements": [
{"ref": "{}", "name": "레미콘 자리", "slot": "레미콘 자리", "spec": "", "unit": "㎥", "mode": "rule", "scope": {"구분": "레미콘"},
"rule": {"field": "검색어", "op": "같음", "from": {"box": "v", "name": "a"}, "template": "레미콘 {a}-{b}",
"slots": [{"name": "a", "from": {"box": "v", "name": "a"}}, {"name": "b", "from": {"box": "v", "name": "b"}}]}}]}},
{"id": "o", "kind": "input", "name": "로직", "input": {"mode": "logic", "elements": [], "variables": [], "logic": {
"key": "GC000270", "outputs": [{"name": "노무비", "unit": "원"}], "designs": [{"name": "기계", "unit": "-"}, {"name": "지역", "unit": "-"}],
"examples": {"지역": "전국평균"}}}},
],
"links": [{"id": "l1", "from": {"box": "v", "name": "기계"}, "to": {"box": "o", "name": "기계"}}],
} # fmt: skip
pills = {"기계": "0201-0020", "a": 25, "b": "18"}
got = _run(
"cache.set(path.resolve(path.join(ROOT, 'M01_MasterData', 'M01_MasterData_UI_Logic_Api.ts')), { exports: { fetchLogics: async () => [] } });\n"
"const D = loadTs(path.join(ROOT, 'M02_MasterTemplete', 'DataFlow', 'M02_DF_Entry_Detail.ts'));\n"
f"const lg = {json.dumps(lg, ensure_ascii=False)};\nconst pills = {json.dumps(pills, ensure_ascii=False)};\n"
"const qs = D.queriesOf(lg, (p) => pills[p.name] ?? null, M.elementPort)"
".map(a => [a.port, a.q.group, a.q.value ?? null, a.q.logic ?? null, !!a.q.slot]);\n"
"out([qs, E.evalLogic(lg, { o: { 노무비: 123 } }, { logics: {} }).ports.o]);"
)
qs, server = got
assert ["가격 자리", "기계", "0201-0020", None, True] in qs
assert ["레미콘 자리", "재료", "레미콘 25-18", None, True] in qs
designs = {"기계": "0201-0020", "지역": "전국평균"}
assert ["*", "기계", None, {"key": "GC000270", "designs": designs}, False] in qs
assert server == {"노무비": 123} # 서버가 셈한 다른 로직 출력이 있으면 그것
+2 -1
View File
@@ -574,7 +574,8 @@ def test_선_규칙_끝수_재료_조건_설계_때_정할_것(client: TestClien
assert any("맞는 재료 없음" in e for e in bad["errors"]) and any(
"표에 없는 조건" in e for e in bad["errors"]
)
assert bad["ports"]["f"]["합"] is None and _d(bad["cost"]["재료비"]) == 0
# 함수 결과 오류 = 오류 값(화면 Eval 과 같음 · 받는 칸으로 번짐)
assert bad["ports"]["f"]["합"] == "#N/A" and _d(bad["cost"]["재료비"]) == 0
def test_표준_표_읽기_쓰기_처음값(client: TestClient, folder: Path) -> None: