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

This commit is contained in:
2026-10-02 00:25:50 +09:00
6 changed files with 104 additions and 64 deletions
+13 -5
View File
@@ -332,14 +332,16 @@ def _rounded(value: Any, r: dict) -> Any:
def _lookup(rule: dict, block: Any, conds: dict) -> tuple[Any, str | None]:
"""표에서 찾기 — 같음 · 범위(아래 < 값 ≤ 위 · 빈 끝 열림 · 아래 = 위면 같음) · 맞은 줄 하나
· 빈 조건 칸 = 무엇이든 · 꺼낼 칸이 빈 줄은 뺌(옛 엔진 find 와 같음 · Rule lookup 과 같음)."""
· 빈 조건 칸 = 무엇이든 · 꺼낼 칸이 빈 줄은 뺌 · 여럿이면 범위 위 끝이 가장 작은 줄
(옛 엔진 find 와 같음 · Rule lookup 과 같음)."""
if not isinstance(block, list) or len(block) < 2:
return None, "오류"
head = [str(h if h is not None else "") for h in block[0]]
out = rule.get("col")
if out not in head:
two = out not in head and {out + dv.RANGE_LOW, out + dv.RANGE_HIGH} <= set(head)
if out not in head and not two:
return None, "오류"
tests = []
tests, ups = [], []
for c in rule.get("conds") or []:
col, want = str(c.get("col")), _cond_of(conds, c.get("from") or {})
if col in head:
@@ -351,6 +353,7 @@ def _lookup(rule: dict, block: Any, conds: dict) -> tuple[Any, str | None]:
head.index(col + dv.RANGE_HIGH),
_num_of(want),
)
ups.append(hi)
def fits(row, lo=lo, hi=hi, x=x) -> bool:
low, high = _num_of(row[lo]), _num_of(row[hi])
@@ -365,13 +368,18 @@ def _lookup(rule: dict, block: Any, conds: dict) -> tuple[Any, str | None]:
tests.append(fits)
else:
return None, "오류"
at = head.index(out)
at = head.index(out + dv.RANGE_LOW if two else out) # 값 칸이 범위로 갈림 — 아래 = 위면 그 값
rows = [list(r) + [None] * len(head) for r in block[1:]]
hits = [r for r in rows if r[at] not in (None, "") and all(t(r) for t in tests)]
if not hits:
return None, "표에 없는 조건"
for hi in ups: # 위 끝만 적힌 범위(「20 이하」 「40 이하」 …) — 위 끝이 가장 작은 줄
tops = [_num_of(r[hi]) for r in hits if _num_of(r[hi]) is not None]
hits = [r for r in hits if r[hi] is None or not tops or _num_of(r[hi]) == min(tops)]
if len(hits) > 1:
return None, "표에 여럿 맞음"
if two and not _same(hits[0][at], hits[0][head.index(out + dv.RANGE_HIGH)]):
return None, "오류"
return hits[0][at], None
@@ -594,7 +602,7 @@ def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | N
for f, book, calc in zip(funcs, books, calcs):
sheet = book["시트"][0]
for cell, v in (calc.get(sheet.get("id")) or {}).items():
if "오류" in v:
if "오류" in v and (sheet.get("칸") or {}).get(cell, {}).get("식") != "NA()":
errors.append(f"「{f.get('name')}」 {cell} 칸 계산 오류 {v['오류']}")
out = f["func"].get("output") or {}
at = _at(out.get("cell") or "A1")[2]
+15 -9
View File
@@ -139,14 +139,15 @@ class _Flow:
if len(texts) > 1:
raise Unsupported(f"자리 표시가 여러 칸에 낀 요소 「{ref}」")
((at, text),) = texts.items()
holes = list(dict.fromkeys(_HOLE.findall(text)))
if len(holes) == 1:
src, hole = self.hole(holes[0]), holes[0]
else: # 「레미콘 {a}-{b}-{c}」 — 함수 상자가 글을 이음
src = self.func(f"{label} 찾는 말", ("@text", text))
hole = src["name"]
text = "{" + hole + "}"
tpl = text if at is None else json.dumps(plain(ref | {at: text}), ensure_ascii=False)
# 찾는 값 = 원문번호(「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}
return ("@port", self.element(group, tpl, unit, f"{label} 자리", rule))
@@ -295,6 +296,9 @@ class _Flow:
group = GROUP_OF.get(base[1][:2], "기계")
node2 = self.priced(group, base[1], "", f"{base[1]} {col}", col)
return self.excel(node2, take, scope)
if base[0] == "logic" and col == "계": # 다른 로직 출력은 비목 셋 — 계 = 그 합
bid = self.logic(base)
return self.excel(("@sum", [{"box": bid, "name": k} for k in COSTS]), take, scope)
if base[0] == "logic":
return take({"box": self.logic(base), "name": col}, f"{base[1]} {col}")
if base[0] == "name" and base[1] == "줄" and f"줄.{col}" in scope:
@@ -356,7 +360,9 @@ def _body(f: _Flow, row: dict) -> None:
lines.setdefault(f"줄.{name}", []).append(part)
continue
unit = str(item.get("단위") or "")
price = f.priced(str(item.get("종류")), item["요소"], unit, f"{name} 단가")
ref = item["요소"] # 그룹 = 키 앞 두 글자(「MO:경유:{지역}」 = 유가전력) · 없으면 줄 종류
group = GROUP_OF.get(ref[:2], str(item.get("종류"))) if isinstance(ref, str) else "재료"
price = f.priced(group, ref, unit, f"{name} 단가")
part = f.func(f"{name} 금액", ("bin", "*", qty, price))
parts[item["비목"]].append(part)
lines.setdefault(f"줄.{name}", []).append(part)
+1 -1
View File
@@ -241,7 +241,7 @@ function runFunc(
if (v.cell) put(sheet.칸, v.cell, v.name in given ? given[v.name] : v.example);
const values = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [sheet] });
for (const [cell, v] of Object.entries(values[sheet.id] ?? {}))
if ("오류" in v) {
if ("오류" in v && sheet.칸[cell]?.식 !== "NA()") {
const err = v.오류 === "#CYCLE!" ? `${v.오류} ${tl("Cycle")}` : v.오류;
errors.push(tl("CellError", { box: box.name, cell, err }));
}
+21 -5
View File
@@ -181,9 +181,12 @@ function lookup(
const i = head.indexOf(name);
return i < 0 ? null : colName(i);
};
const out = at(rule.col);
// 값 칸이 범위로 갈림(「이름·아래」 「이름·위」) — 아래 = 위면 그 값
const two = !at(rule.col) && !!at(`${rule.col}·아래`) && !!at(`${rule.col}·위`);
const out = two ? at(`${rule.col}·아래`) : at(rule.col);
// 조건 값 = 1행 · 블록 오른쪽 한 칸 띄고
const tests: ((r: number) => string)[] = out ? [(r) => `NOT(ISBLANK(${out}${r}))`] : [];
const ups: string[] = [];
let bad = !out || rows < 1;
rule.conds.forEach((c, i) => {
const ref = `$${colName(W + 1 + i)}$1`;
@@ -193,13 +196,14 @@ function lookup(
const low = at(`${c.col}·아래`);
const high = at(`${c.col}·위`);
if (same) tests.push((r) => `OR(ISBLANK(${same}${r}),${same}${r}=${ref})`);
else if (low && high)
else if (low && high) {
ups.push(high);
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})))`,
);
else bad = true;
} else bad = true;
});
const hc = colName(W + 1 + rule.conds.length);
const last = rows + 1;
@@ -207,13 +211,25 @@ function lookup(
칸[`${hc}${r}`] = {
식: tests.length ? `IF(AND(${tests.map((t) => t(r)).join(",")}),1,0)` : "1",
};
const range = `${hc}2:${hc}${Math.max(2, last)}`;
// 여럿이면 범위 위 끝이 가장 작은 줄(옛 find · 「20 이하」 「40 이하」 …) — 둘째 맞음 칸
const hc2 = colName(W + 3 + rule.conds.length);
const span = (c: string): string => `${c}2:${c}${Math.max(2, last)}`;
for (let r = 2; r <= last; r++) {
const top = ups.map((h) => `OR(ISBLANK(${h}${r}),${h}${r}=MINIFS(${span(h)},${span(hc)},1))`);
칸[`${hc2}${r}`] = {
식: top.length ? `IF(AND(${hc}${r}=1,${top.join(",")}),1,0)` : `${hc}${r}`,
};
}
const range = span(hc2);
const count = `${colName(W + 2 + rule.conds.length)}1`;
const formula = out ? `INDEX(${out}2:${out}${Math.max(2, last)},MATCH(1,${range},0))` : "NA()";
칸[count] = { 식: `SUM(${range})` };
칸.ZZ1 = { 식: formula };
const up = at(`${rule.col}·위`);
if (two && up) 칸.ZZ2 = { 식: formula.replace(`INDEX(${out}2:${out}`, `INDEX(${up}2:${up}`) };
if (bad) return { value: null, why: "오류", formula };
const [n, got] = run(칸, [count, "ZZ1"]);
const [n, got, top] = run(칸, [count, "ZZ1", "ZZ2"]);
if (two && n === 1 && got !== top) return { value: null, why: "오류", formula };
if (n === 0) return { value: null, why: "표에 없는 조건", formula };
if (typeof n === "number" && n > 1) return { value: null, why: "표에 여럿 맞음", formula };
if (isError(got)) return { value: null, why: "오류", formula };
+17 -44
View File
@@ -4,9 +4,8 @@
· 전수 대조 — 사본 폴더(tmp/master_copy_36 · AISLO_MASTER_DIR)의 로직 1,472 마다
옛 셈(master_formula.run) = 변환 흐름도 셈(Compute.run) · 입력 = check_master.시험입력(정함 ①)
· 기본 입력(줄의 시험입력 뺌)도 같이 · 맞는 비율 · 안 맞는 까닭 → tmp/convert_report_36.json
· 조건 찾기 field 「키」(입력값으로 요소 고르기 · 정함 ②) · 표 값 식 글을 수로(정함 ③)는 sub4 몫
— 대조는 같은 뜻 대역(ref 의 {…} 에 알약 값을 끼워 dv.one_value · 식 글은 옛 파서로 셈)
· 같은 물음은 한 번만 셈
· 조건 찾기 field 「키」(입력값으로 요소 고르기 · 정함 ②) · 표 값 식 글 → 수(정함 ③)는 서버 길로
(Compute · Values · sub4) · 값 찾기(dv.one_value)는 같은 물음이면 한 번만 셈
"""
from __future__ import annotations
@@ -26,8 +25,7 @@ WORKERS = 6
# 일꾼(따로 뜬 파이썬 · 사본 폴더) — 표준 입력 = 키 목록 JSON · 표준 출력 = 줄마다 결과 JSON
WORKER = r"""
import json, os, re, sys
from decimal import Decimal
import json, os, sys
sys.path.insert(0, os.getcwd())
from M01_MasterData import M01_MasterData_Store as store
from M02_MasterTemplete.DataFlow import M02_DF_Compute as cp, M02_DF_Convert as cv
@@ -40,41 +38,8 @@ def cached(item, local=None):
k = json.dumps(item, ensure_ascii=False, sort_keys=True, default=str)
if k not in seen:
seen[k] = one_value(item, local)
if seen[k].get("rows"): # 표 값 식 글 → 수(Values._table 대역)
seen[k]["rows"] = [[arith(x) for x in r] for r in seen[k]["rows"]]
return seen[k]
def arith(x):
if isinstance(x, str) and mf._ARITH.fullmatch(x):
try:
return mf.evaluate(mf.parse(x), {}, master)
except (mf.FormulaError, ArithmeticError):
return x
return x
dv.one_value = cached
element_value = cp._element_value
HOLE = re.compile(r"\{([^{}]+)\}")
def stand_in(group, el, given, ports, errors, box, ctx):
rule = el.get("rule") or {}
if el.get("mode") != "rule" or rule.get("field") != "키":
return element_value(group, el, given, ports, errors, box, ctx)
src = rule.get("from") or {}
v = ports.get(src.get("box"), {}).get(src.get("name"))
if v is None:
errors.append(f"「{el.get('name')}」 자리 값 없음")
return None
text = format(Decimal(str(v)).normalize(), "f") if isinstance(v, (int, float)) else str(v)
ref, _, field = HOLE.sub(lambda m: text, el["ref"]).partition("#")
try:
ref = ref if ref.lstrip().startswith("{") else str(master.get(ref).get("키"))
except mf.FormulaError as e:
errors.append(f"「{el.get('name')}」 {e}")
return None
ref += f"#{field}" if field else ""
got = cached({"group": group, "ref": ref, "unit": el.get("unit")})
if not got["ok"]:
errors.append(f"「{el.get('name')}」 {got.get('reason')}")
return got.get("value") if got["ok"] else None
cp._element_value = stand_in
def load(key):
if key not in flows:
flows[key] = cv.convert(master.logic(key))["flow"]
@@ -95,7 +60,8 @@ def check(row, given):
got = cv.convert(row)
flows[row["키"]] = got["flow"]
try:
design = {"in-design": {k: cv.plain(v) for k, v in given.items()}}
env = mf._inputs(row, given) # 옛 엔진처럼 글로 적힌 수 → 수(화면은 수로 넘김)
design = {"in-design": {k: cv.plain(v) for k, v in env.items()}}
new = cp.run(got["flow"], design, load, [])
except Exception as e:
return {"state": "흐름 셈 멈춤", "why": repr(e)[:200], "notes": got["notes"]}
@@ -156,17 +122,24 @@ def _keys() -> list[str]:
return json.loads(_run(code))
REASONS = [ # (알림에 든 말, 까닭) — 앞이 먼저
("표에 여럿 맞음", "표 범위 규칙 다름 — 여럿 맞음(이상·이하 · 이상·미만 경계)"),
("표에 없는 조건", "표 범위 규칙 다름 — 없는 조건(이상·이하 · 이상·미만 경계)"),
("같음", "재료 검색어 자리 — 서버 조건 찾기(키 같음) ≠ 옛 검색어 찾기"),
("맞는 재료 없음", "재료 빈 검색어 — 서버 재료 값 찾기 다름"),
("비어 있음", "단가 빈 품목 — 서버 처리 다름"),
("값 없음", "단가 빈 품목 — 서버 처리 다름"),
]
def _reason(r: dict) -> str:
if r["state"] != "다름":
return r["state"]
moved = [n for n in r.get("notes") or [] if n.startswith("못 옮김")]
if moved:
return "못 옮김 — " + moved[0].split("—", 1)[1].strip().split("「")[0].strip()
for e in r.get("errors") or []:
tail = e.rsplit("」", 1)[-1].strip()
if tail:
return "흐름 셈 알림 — " + tail.split("—")[0].strip()[:40]
return "값 다름(알림 없음)"
text = " | ".join(r.get("errors") or [])
return next((why for word, why in REASONS if word in text), "값 다름(알림 없음)")
# ── 꼴 시험 ──────────────────────────────────────────────────────────
+37
View File
@@ -858,3 +858,40 @@ def test_표_찾기_빈_조건칸_무엇이든_빈_값줄_건너뜀_실패는_
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))
assert [b["kind"] for b in srv["breaks"]] == screen_breaks
def test_표_찾기_값_칸이_범위로_갈림_아래_같으면_그_값() -> None:
from M02_MasterTemplete.DataFlow import M02_DF_Compute as cp
block = [["구분", "값·아래", "값·위"], ["가", 3, 3], ["나", 1, 2]]
rule = {
"kind": "lookup",
"conds": [{"col": "구분", "from": {"box": "v", "name": "구분"}}],
"col": "값",
}
got = _run(
RULE
+ f"const block = {json.dumps(block, ensure_ascii=False)};\n"
+ f"const rule = {json.dumps(rule, ensure_ascii=False)};\n"
"out(['가', '나'].map(g => { const r = R.applyRule(rule, block, { 구분: g }); return [r.value, r.why]; }));"
)
assert got == [[3, None], [None, "오류"]]
assert [list(cp._lookup(rule, block, {"구분": g})) for g in ("가", "나")] == got
def test_표_찾기_여럿이면_위_끝_가장_작은_줄() -> None:
from M02_MasterTemplete.DataFlow import M02_DF_Compute as cp
block = [["거리·아래", "거리·위", "Q"], [None, 20, 11], [None, 40, 10], [None, 60, 9]]
rule = {
"kind": "lookup",
"conds": [{"col": "거리", "from": {"box": "v", "name": "x"}}],
"col": "Q",
}
got = _run(
RULE
+ f"const block = {json.dumps(block)};\nconst rule = {json.dumps(rule, ensure_ascii=False)};\n"
"out([1, 30, 60, 70].map(x => { const r = R.applyRule(rule, block, { x }); return [r.value, r.why]; }));"
)
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