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

# Conflicts:
#	B09_Estimation/B09_Estimation_UI_Page.css
#	B09_Estimation/B09_Estimation_UI_Shell.ts
#	B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts
This commit is contained in:
2026-10-10 12:04:39 +09:00
15 changed files with 413 additions and 58 deletions
@@ -14,7 +14,7 @@ SPOTS = {
"B09_Estimation/B09_Estimation_Calc_Bill.ts": ['"수기 줄 — 연결 없음(우클릭 [연결])"'],
"B09_Estimation/B09_Estimation_Calc_Bill.py": ['"수기 줄 — 연결 없음(우클릭 [연결])"'],
"B09_Estimation/B09_Estimation_Sangeun.py": ['f"수기 줄 {'],
"B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가 · 면 = 수기 입력"],
"B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가 = 수기 입력"],
"B09_Estimation/B09_Estimation_UI_Tabs_Total.ts": ['"수기 입력"', '"수기 입력 = 안전관리비'],
"ui_template/ui_template_locale_b2.ts": [
"수량 원천: 수기 입력(직접비)",
@@ -0,0 +1,29 @@
"""183-2 자재단가대비표 머리 — 견적 「면」 뺌 · 다른 출처 「면」 → 「출처」(값 그대로) · 견적서 = 견적 아래.
소스 시험(열 정의 · 고칠 칸) — 셈 · 저장(`quote_page` 남음)은 안 바뀜(test_166_4).
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SRC = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Base.ts").read_text(encoding="utf-8")
def _compare() -> str:
start = SRC.index("export const buildCompare")
return SRC[start : SRC.index("export const buildMaterial")]
def test_견적은_단가_견적서_그_밖은_단가_출처():
body = _compare()
assert (
's === "견적" ? text("files", "견적서", 72, [s]) : text(`g:${s}`, "출처", 64, [s])' in body
)
assert '"면"' not in body # 면 머리 없음
assert body.count('text("files", "견적서"') == 1 # 맨 끝 견적서 열 지움
def test_견적_면_수기_칸_없앰():
body = _compare()
assert 'key === "g:견적"' not in body and "quote_page" not in body
assert "견적 단가 = 수기 입력" in body and "견적 단가 · 면" not in body
@@ -0,0 +1,113 @@
"""183-3 자재단가대비표 적용 단가 — 칸 누름 고르기 메뉴 · 고른 출처 칸 강조 · 기본 최저.
- 공용 표 `cellClass` 옵션(칸 class · 안 주면 같은 글) — Node 로 `renderDataTableHtml`
- 고르기 항목 `appliedChoices`(값 있는 출처 · 견적 · 최저(자동) · ✓ 표시) — Node 로 함수만 떼어 돌림
- 배선: 수기 적용 칸 · 출처 칸 돌림 없앰 · Shell 이 cellClass · choices 를 넘김 · B09 메뉴 한 벌
셈 · 저장(pick › applied › 최저)은 그대로(test_144 · test_166_4).
"""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
TS = ROOT / "config" / "node_modules" / "typescript"
def _src(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
def _node(script: str):
out = subprocess.run(
["node", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60
)
assert out.returncode == 0, out.stderr
return json.loads(out.stdout)
PRELUDE = f"""
const fs = require("fs");
const ts = require({json.dumps(str(TS))});
const js = (code) => ts.transpileModule(code, {{
compilerOptions: {{ module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }},
}}).outputText;
"""
def test_공용_표_cellClass_칸_class_안_주면_같은_글():
table = json.dumps(str(ROOT / "ui_template" / "ui_template_data_table.ts"))
got = _node(
PRELUDE
+ f"""
const box = {{ exports: {{}} }};
new Function("exports", "module", "require", js(fs.readFileSync({table}, "utf8")))(
box.exports, box, (p) => (p.endsWith(".css") ? {{}} : require(p)));
const m = box.exports;
const o = {{ columns: [{{ key: "a", label: "A" }}, {{ key: "b", label: "B" }}],
rows: [{{ a: 1, b: 2 }}, {{ a: 3, b: 4 }}] }};
const marked = m.renderDataTableHtml({{ ...o,
cellClass: (i, k) => (i === 0 && k === "b" ? "is-picked" : null) }});
console.log(JSON.stringify({{
plain: m.renderDataTableHtml(o),
none: m.renderDataTableHtml({{ ...o, cellClass: () => null }}),
count: marked.split("is-picked").length - 1,
row0: marked.split("<tbody>")[1].split("</tr>")[0],
}}));
"""
)
assert got["plain"] == got["none"]
assert got["count"] == 1 and '<td class="is-picked">2</td>' in got["row0"]
def test_고르기_항목_값_있는_출처_견적_최저_자동():
base = _src("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts")
start = base.index("function appliedChoices")
body = base[start : base.index("\n}\n", start) + 3]
sources = _src("B09_Estimation/B09_Estimation_Calc_Base.ts")
at = sources.index("export const SOURCES")
line = sources[at : sources.index(";", at) + 1] + "\n"
got = _node(
PRELUDE
+ f"""
const appliedChoices = new Function(js({json.dumps(line.replace("export ", "") + body)}) + "; return appliedChoices;")();
const r = {{ prices: {{ 관급: null, 물가정보: 1200, 물가자료: 0, 유통물가: 1000, 견적: 1500 }},
lowest_from: "유통물가", applied_from: "유통물가", pick: null }};
const seen = [];
const items = appliedChoices(r, (s) => seen.push(s));
items.forEach((x) => Array.isArray(x) && x[1]());
const pick = {{ ...r, applied_from: "견적", pick: "견적" }};
console.log(JSON.stringify({{
labels: items.map((x) => (Array.isArray(x) ? x[0] : x)),
seen,
picked: appliedChoices(pick, () => 0).map((x) => (Array.isArray(x) ? x[0] : x)),
}}));
"""
)
assert got["labels"] == [
"물가정보 1,200",
"✓ 유통물가 1,000 · 최저",
"견적 1,500",
"-",
"✓ 최저(자동)",
]
assert got["seen"] == ["물가정보", "유통물가", "견적", None]
assert got["picked"][2] == "✓ 견적 1,500" and got["picked"][-1] == "최저(자동)"
def test_배선_수기_적용_칸_출처_돌림_없앰():
base = _src("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts")
compare = base[base.index("export const buildCompare") : base.index("function usesOf")]
assert 'editable: (_i, key) => key === "p:견적",' in compare
assert "nextPick" not in base and 'key === "applied_from"' not in compare
assert 'key === `p:${rows[i]?.applied_from}` ? "is-picked"' in compare
assert 'if (key !== "applied" || !r) return null;' in compare
assert "delete cell.applied" in compare
shell = _src("B09_Estimation/B09_Estimation_UI_Shell.ts")
assert (
"cellClass: spec.cellClass," in shell and "openMenuAt(box.left, box.bottom, items)" in shell
)
edit = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts")
assert "export function openMenuAt(" in edit and "const ctxMenu = sharedMenu();" in edit
css = _src("B09_Estimation/B09_Estimation_UI_Page.css")
assert "td.is-picked" in css and "var(--color-accent)" in css
@@ -0,0 +1,88 @@
"""183-1 재료비목록표 = 연결된 일위대가 · 산근 · 중기가 쓰는 자재만 · 수량 · 단가 한 줄 · 쓰는 곳.
- 수요: 사본 일위대가 자재 · 굳힌 사전조합 자재 코드도 든다(`Linked.used_keys`)
- 거름: 대가 · 산근 · 중기 블록 재료 줄 · B08 줄 · 관급 · 별도 줄만 남김(`Summary.used_materials`)
- 수량 = 입력 `material_qty`(1차 역전개 총수량) — `Calc_Base` 짝 거울 · 두 번 셈(`Router.compute_twice`)
- 화면 비고 = 쓰는 곳(`UI_Tabs_Base.usesOf`)
"""
import json
from pathlib import Path
from B09_Estimation import B09_Estimation_Router as router_mod
from B09_Estimation.B09_Estimation_Calc_Base import compute_base
from B09_Estimation.B09_Estimation_Linked import used_keys
from B09_Estimation.B09_Estimation_Summary import used_materials
from resources.tester.test_144_b09_base_calc import _run_ts
ROOT = Path(__file__).resolve().parents[2]
MATERIALS = [
{"key": "MT1", "name": "쓰는 자재", "prices": {"물가자료": 1000}},
{"key": "MT2", "name": "고르기 후보", "prices": {"물가자료": 500}},
{"key": "MT3", "name": "관급 표시", "prices": {"물가자료": 300}},
{"key": "MO1", "name": "경유", "prices": {"유가": 1800}},
{"key": "b08:pipe_800", "name": "파형강관", "prices": {}, "qty": 8, "supply": "관급"},
]
USER = {"materials": {"MT3": {"supply": "관급"}}}
SG = [{"key": "SG1", "no": 1, "lines": [{"kind": "material", "ref": {"key": "MT1"}, "qty": 2}]}]
MACHINE = [{"key": "EQ1", "lines": [{"kind": "material", "ref": {"key": "MO1"}, "qty": None}]}]
def test_사본_일위대가_자재_사전조합_자재_코드도_수요(tmp_path):
labor = {
"줄": {"담은로직": [], "자재": [{"코드": "MT000053"}, {"코드": "PM000001"}]},
"참조사본": {"참조|system||SC-000001": {"담은로직": [], "자재": [{"코드": "MT000008"}]}},
}
(tmp_path / "일위대가.json").write_text(json.dumps(labor, ensure_ascii=False), "utf-8")
assert used_keys(tmp_path) == ["MT000053", "MT000008"] # 개인 자재(PM) = 원가 표 밖
def test_쓰는_자재만_남김():
sheets = {"sg": SG, "daega_blocks": [], "machine_blocks": MACHINE}
inputs = {"materials": MATERIALS}
assert used_materials(sheets, inputs, USER) == {"MT1", "MO1", "MT3", "b08:pipe_800"}
def test_수량_원천_material_qty_거울():
inputs = {"labor": [], "machines": [], "materials": MATERIALS}
old = {r["key"]: r["qty"] for r in compute_base(inputs, USER)["material"]}
assert old["b08:pipe_800"] == 8 and old["MT1"] is None # 없으면 입력 줄 수량(지금 길)
got = compute_base({**inputs, "material_qty": {"MT1": 6, "b08:pipe_800": 8}}, USER)
rows = {r["key"]: r for r in got["material"]}
assert (rows["MT1"]["qty"], rows["MT1"]["amount"]) == (6, 6000)
assert rows["MT2"]["qty"] is None and rows["MT2"]["amount"] is None # 없는 키 = 빈칸
for case in (inputs, {**inputs, "material_qty": {"MT1": 6}}):
assert _run_ts(case, USER) == compute_base(case, USER)
def test_두_번_셈_후보_줄_빠지고_수량_역전개(monkeypatch):
calls = []
def fake(root, inputs, user):
calls.append([m["key"] for m in inputs["materials"]])
base = compute_base(inputs, user)
bill = {"rows": [{"kind": "item", "qty": 3, "ref": {"tab": "sg", "key": "SG1"}}]}
return {**base, "sg": SG, "daega_blocks": [], "machine_blocks": MACHINE, "bill": bill}
monkeypatch.setattr(router_mod, "compute_sheets", fake)
inputs = {"labor": [], "machines": [], "materials": list(MATERIALS)}
sheets, summary = router_mod.compute_twice(Path("."), inputs, USER)
assert calls[0] == [m["key"] for m in MATERIALS]
assert calls[1] == ["MT1", "MT3", "MO1", "b08:pipe_800"] # 후보 MT2 뺌
assert inputs["material_qty"] == {"MT1": 6.0} # 설계내역서 3 × 호표 2
rows = {r["key"]: r for r in sheets["material"]}
assert (rows["MT1"]["qty"], rows["MT1"]["amount"]) == (6.0, 6000)
assert [r["qty"] for r in summary["sum_material"]] == [rows["MT1"]["qty"]] # 집계표와 같은 값
def test_화면_비고_쓰는_곳():
src = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Base.ts").read_text(encoding="utf-8")
for label in (
"`대가 ${b.no}호표`",
"`산근 ${b.no}호표`",
"`중기 ${n + 1}호표`",
'"내역 자재대"',
):
assert label in src
assert 'text("uses", "비고(쓰는 곳)"' in src and 'text("origin", "비고"' not in src