diff --git a/M02_MasterTemplete/M02_Hopyo_Marks.py b/M02_MasterTemplete/M02_Hopyo_Marks.py index aefdb82a1..06797c1c7 100644 --- a/M02_MasterTemplete/M02_Hopyo_Marks.py +++ b/M02_MasterTemplete/M02_Hopyo_Marks.py @@ -14,6 +14,10 @@ | 흐림 밖 | % · 뺌 | 판정 None (안 불리는 블록) | | 띠 내가 고름 | 화면 캐시(서버 아님) | — | 최종 판정은 화면(`SheetMap`)이 이 재료 + 캐시 고름으로 내림 — 여기는 상태 이름을 그대로 실음. + +`어긋남`(778-5 D-6 · 요구 623 · 624) = 불리는 블록의 엑셀 셈 어긋남(엑셀 = 과거 값 · 사용자가 보고 판단 · +칠 · 판정에 안 씀) — `{줄합, 금액, 차, 덮음, 줄 [{행, 이름, 묶음, 셈, 금액, 차, 까닭}], 까닭}` · 없으면 None. +줄 = 재현 못 한 단위 줄(`UnitLine.redo` — 엑셀 단가 × 매개값 ≠ 엑셀 줄 금액 · 셈 밖 뺌) · 덮음 실패 = 줄 합 ≠ 블록 금액. """ from __future__ import annotations @@ -25,6 +29,7 @@ from typing import Any from M02_MasterTemplete import M02_Hopyo_Fit as fit from M02_MasterTemplete import M02_Hopyo_Sangeun as sangeun from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_Hopyo_UnitLine as unit_line from M02_MasterTemplete import M02_Labor_Ref as refs @@ -36,8 +41,54 @@ def _origin(to: Path, sk: str, mark: str, here: refs.Here) -> dict[str, Any]: return sangeun.origin_of(doc, mark, here) +def _won(v: float) -> str: + return f"{v:,.1f}".removesuffix(".0") + + +def _calc(one: dict[str, Any]) -> float | None: + """엑셀 단가 × 매개값(× 계수) — `UnitLine.redo` 와 같은 셈 · 매개값 · 단가 없으면 None.""" + q, price = one.get("품"), one.get("단가") or {} + if q is None: + return None + if one["묶음"] == "R": + return float(one.get("기준값") or 0) * q + if one["묶음"] == "X": + ratio = one.get("계수") or {} + return sum(float(price[c]) * q * ratio.get(c, 1) for c in unit_line.COSTS if price.get(c)) + unit = unit_line.money(price) + return None if unit is None else unit * q + + +def misses(block: dict[str, Any]) -> dict[str, Any] | None: + """블록 엑셀 셈 어긋남 — 재현 못 한 줄 · 덮음 실패 · 없으면 None.""" + lines = [] + for one in block.get("항목") or []: + if one.get("재현") or one.get("밖"): + continue + calc, got = _calc(one), one.get("금액") + if calc is None or got is None: + why = "매개값 · 단가를 못 읽음(글 꼴 밖)" if got is not None else "줄 금액 없음" + else: + why = f"엑셀 단가 × 매개값 = {_won(calc)} ≠ 엑셀 줄 금액 {_won(float(got))}" + diff = round(float(got) - calc, 1) if calc is not None and got is not None else None + name = one.get("이름") or "" + row = {"행": one.get("행"), "이름": name, "묶음": one["묶음"], "셈": calc, "금액": got} + lines.append({**row, "차": diff, "까닭": why}) + total, have = unit_line.money(block.get("금액")), block.get("줄합") + cover = block.get("덮음") + if not lines and cover is not False: + return None + diff = round(float(have) - total, 1) if total is not None and have is not None else None + why = ( + f"줄 합 {_won(float(have))} ≠ 엑셀 블록 금액 {_won(total)} — 엑셀 합이 줄을 뺐거나 더함" + if cover is False and diff is not None + else f"재현 못 한 줄 {len(lines)}" + ) + return {"줄합": have, "금액": total, "차": diff, "덮음": cover, "줄": lines, "까닭": why} + + def blocks(folder: Path, key: str, to: Path | None = None) -> list[dict[str, Any]]: - """블록마다 `{번, 판정, SK, 갈래, 못옮김}` — `to` = 만든 SK 를 볼 층(없으면 파일 층).""" + """블록마다 `{번, 판정, SK, 갈래, 못옮김, 어긋남}` — `to` = 만든 SK 를 볼 층(없으면 파일 층).""" folder, to = Path(folder), Path(to or folder) hp = hopyo_store.read(folder, key) here = refs.where(folder) @@ -58,6 +109,7 @@ def blocks(folder: Path, key: str, to: Path | None = None) -> list[dict[str, Any "SK": sk, "갈래": origin.get("갈래"), "못옮김": origin.get("못옮김") or [], + "어긋남": misses(all_[no]) if line else None, } ) return out diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetNav.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetNav.ts index 077feaf88..45fa5c4b3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetNav.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetNav.ts @@ -8,9 +8,12 @@ * · 다시 칠 = 목록 다시 읽음(`marks`) · 확인 창 캐시 바뀜(`HOPYO_DRAFT`) · 만들기(`HOPYO_BUILT` → 목록 · 블록 다시 읽음) * · 블록을 여러 줄이 부르면 [▸ 부르는 줄] 이 차례로 돌림 · 뛸 때마다 그 앞 자리를 쌓고 [◂ 뒤로] 가 꺼냄 · 두 번 누름 = 첫 단추 * · 779-6 — 마지막 격자 다시 씀(`gridOf`) · 열린 창 앞으로 꺼냄 · 닫기 = 제자리로(`raise` · `lower`) + * · 778-5 D-6 — 머리 막대 아래 띠(부품 `createBanner`) 「엑셀 셈 어긋남 n」 + [보기 i/m] = 상세 시트 그 행으로 차례로 뛰기 · + * 그 행 까닭은 막대 글에 덧붙임 · 칠은 안 바꿈 * 블록 재료 = 서버 `GET …/hopyo-marks/{키}`(779-2 · 읽기만). * ========================================================================== */ +import { createBanner } from "@ui/ui_template_banner"; import { createButton, el } from "@ui/ui_template_elements"; import { createLegend } from "@ui/ui_template_flow_legend"; import { t as L } from "@ui/ui_template_locale"; @@ -28,6 +31,7 @@ import { import { indexOf, jumpsAt, + missesOf, paintOf, ROLES, type BlockMark, @@ -136,7 +140,11 @@ export function createSheetNav(opt: SheetNavOptions) { { label: L("M02_ToneMine"), color: "var(--color-accent)", kind: "line" }, ]); const bar = el("div", { className: "ui-toolbar m02-hopyo__bar", children: [buttons, source, why] }); // prettier-ignore - const root = el("div", { className: "m02-hopyo-sheet__nav", children: [bar, legend] }); + const band = el("div", { className: "m02-hopyo-sheet__miss" }); + band.setAttribute("data-hopyo-miss", ""); + const root = el("div", { className: "m02-hopyo-sheet__nav", children: [bar, band, legend] }); + let misses: ReturnType = []; + let missAt = 0; root.setAttribute("data-hopyo-nav", ""); /** 뛰기 — 지금 자리를 쌓고 감 · 행 하나면 [n, n] */ @@ -188,7 +196,33 @@ export function createSheetNav(opt: SheetNavOptions) { const role = roleOf.get(here.시트); const name = role ? grid.원본?.[role] : undefined; source.textContent = name ? L("M02_SheetSource").replace("{name}", name) : ""; - why.textContent = paint.get(here.시트)?.get(here.행)?.글 ?? ""; + const miss = here.시트 === ix.ids.단가산출상세 ? misses.filter((m) => m.행 === here.행) : []; + const text = [paint.get(here.시트)?.get(here.행)?.글, ...miss.map((m) => `${L("M02_SheetMissOne")} ${m.글}`)]; // prettier-ignore + why.textContent = text.filter(Boolean).join(" · "); + } + + /** 엑셀 셈 어긋남 띠 — 없으면 비움 · [보기] = 다음 자리로 */ + function paintBand(): void { + misses = missesOf(ix, blocks); + const detail = ix.ids.단가산출상세; + if (!misses.length || !detail) return void band.replaceChildren(); + missAt %= misses.length; + const go = createButton({ + label: L("M02_SheetMissGo").replace("{i}", String(missAt + 1)).replace("{all}", String(misses.length)), // prettier-ignore + variant: "ghost", + size: "sm", + onClick: () => { + const m = misses[missAt]; + missAt = (missAt + 1) % misses.length; + jumpTo(detail, m.행); + paintBand(); + }, + }); + go.setAttribute("data-hopyo-miss-go", ""); + const n = new Set(misses.map((m) => m.번)).size; + band.replaceChildren( + createBanner({ tone: "warning", text: L("M02_SheetMiss").replace("{n}", String(n)), actions: [go] }), // prettier-ignore + ); } function repaint(): void { @@ -199,6 +233,7 @@ export function createSheetNav(opt: SheetNavOptions) { async function readBlocks(): Promise { blocks = await readHopyoMarks(opt.scope, opt.key, opt.target).catch(() => blocks); + paintBand(); repaint(); } const onDraft = (): void => repaint(); diff --git a/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SheetMap.ts b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SheetMap.ts index 50ade240b..60d706d9b 100644 --- a/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SheetMap.ts +++ b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SheetMap.ts @@ -7,6 +7,7 @@ * · 「이어짐」 = 확인 창과 한 규칙(`Hopyo_Check` `isChecked && checkable` · 캐시 고름이 이김) — 캐시에 대상이 없으면 * 서버 체크 제안(서버가 배 확실할 때만 켬)을 그대로 믿음 * · 호표 머리 행 = 그 호표 줄 칠 중 가장 나쁜 것 · 블록 = 만든 SK 갈래 › 없으면 부르는 첫 줄 판정 「예상」 · 안 불림 = 밖 + * · 엑셀 셈 어긋남(778-5 D-6) = 블록 `어긋남`(Marks) → 상세 시트 행 차례 목록 — 칠 · 판정에 안 씀(띠 · 글만) * · 뛸 곳 = 일위대가 단산 줄 → 상세 블록 · 목록 행 · (여럿이면) 다른 부르는 줄 / 일위 줄 → 하위 호표 / 목록 행 → 상세 · 부르는 줄 / 상세 → 부르는 줄 · 목록 * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). * ========================================================================== */ @@ -44,6 +45,16 @@ export interface BlockMark { SK: string | null; 갈래: string | null; 못옮김: unknown[]; + /** 778-5 D-6 엑셀 셈 어긋남(불리는 블록 · 없으면 null) */ + 어긋남?: Miss | null; +} +export interface Miss { + 줄합: number | null; + 금액: number | null; + 차: number | null; + 덮음: boolean | null; + 줄: { 행: number | null; 이름: string; 묶음: string; 차: number | null; 까닭: string }[]; + 까닭: string; } /** 확인 창 캐시 고름(`Hopyo_Check` `CheckDraft` 중 칠에 쓰는 것) */ export interface PickDraft { @@ -223,3 +234,20 @@ export function paintOf( } return out; } + +/** 엑셀 셈 어긋남 자리 — 상세 시트 행 차례 · 덮음 실패 = 블록 머리 행 · 재현 못 한 줄 = 그 줄 행 */ +export function missesOf( + ix: SheetIndex, + blocks: BlockMark[], +): { 행: number; 번: number; 글: string }[] { + const out: { 행: number; 번: number; 글: string }[] = []; + for (const b of blocks) { + const m = b.어긋남; + const head = ix.block.get(b.번)?.[0]; + if (!m || !head) continue; + if (m.덮음 === false) out.push({ 행: head, 번: b.번, 글: `단산${b.번} · ${m.까닭}` }); + for (const l of m.줄) + out.push({ 행: l.행 ?? head, 번: b.번, 글: `단산${b.번} · ${l.이름} · ${l.까닭}` }); + } + return out.sort((a, b) => a.행 - b.행); +} diff --git a/resources/tester/helper_779_4_sheet_map.cjs b/resources/tester/helper_779_4_sheet_map.cjs index 98237a5ee..547a0b4b7 100644 --- a/resources/tester/helper_779_4_sheet_map.cjs +++ b/resources/tester/helper_779_4_sheet_map.cjs @@ -27,6 +27,7 @@ process.stdin.on("end", () => { input.blocks ?? [], (no) => (input.drafts ?? {})[no] ?? null, ); + out.misses = map.missesOf(ix, input.blocks ?? []); // 778-5 D-6 out.paint = Object.fromEntries( [...paint].map(([sheet, rows]) => [ sheet, diff --git a/resources/tester/test_778_5_hopyo_excel_misses.py b/resources/tester/test_778_5_hopyo_excel_misses.py new file mode 100644 index 000000000..33378a3fe --- /dev/null +++ b/resources/tester/test_778_5_hopyo_excel_misses.py @@ -0,0 +1,72 @@ +"""778-5 D-6 엑셀 셈 어긋남(`M02_Hopyo_Marks.misses` · `SheetMap.missesOf`) — 불리는 블록의 재현 못 한 +줄 · 덮음 실패 · 금액 차 · 까닭 · 상세 시트 행 차례. 북부청 임도 2 · 33 = 엑셀 합이 진동로울러 노무를 뺌 · +81 = 이제 맞음 · 칠은 안 바꿈(블록 칠 같음) · `hopyo-marks` 길에 실림(읽기만).""" + +import warnings + +import pytest + +from M02_MasterTemplete import M02_Hopyo_Excel as hx +from M02_MasterTemplete import M02_Hopyo_Grid as hg +from M02_MasterTemplete import M02_Hopyo_Marks as mk +from M02_MasterTemplete import M02_Hopyo_Sangeun as sg +from resources.tester.helper_m02_hopyo_golden import FILES +from resources.tester.test_779_4_sheet_map import _node + +warnings.filterwarnings("ignore", category=UserWarning, module="openpyxl") + + +@pytest.fixture(scope="module") +def north(): + path = FILES["북부청임도"] + data = path.read_bytes() + parsed = hx.read_book(path.name, data) + grid = hg.read(path.name, data, parsed) + grid.pop("책") + return parsed, grid + + +def _misses(parsed) -> dict[int, dict]: + blocks, first = sg.calls(parsed) + got = {n: mk.misses(blocks[n]) for n in first} + return {n: m for n, m in got.items() if m} + + +def test_북부청_임도_어긋남(north): + got = _misses(north[0]) + assert sorted(got) == [2, 33, 64, 95] + for no, diff in ((2, 2637.1), (33, 3418.7)): # 엑셀 합이 진동로울러(핸드가이드식) 노무 뺌 + m = got[no] + assert m["덮음"] is False and m["차"] == pytest.approx(diff, abs=0.1) + assert m["까닭"].startswith("줄 합") and "엑셀 블록 금액" in m["까닭"] + [line] = got[33]["줄"] # 엑셀 글 「36,248 ÷ 0.096 = 3,418.3」 + assert line["행"] == 1733 and line["묶음"] == "X" and "진동로울러" in line["이름"] + assert line["금액"] == 3897.1 and "≠ 엑셀 줄 금액 3,897.1" in line["까닭"] + assert got[2]["줄"] == [] + for no in (64, 95): # 글 꼴 밖(잔디 · 신나) = 매개값 못 읽음 · 블록은 덮음 + m = got[no] + assert m["덮음"] is True and m["줄"] and all(x["셈"] is None for x in m["줄"]) + assert all("못 읽음" in x["까닭"] for x in m["줄"]) + blocks, _ = sg.calls(north[0]) + assert mk.misses(blocks[81]) is None # 이제 맞음 + + +def test_다른_파일_어긋남_수(): + path = FILES["동부청영월"] + assert _misses(hx.read_book(path.name, path.read_bytes())) == {} + + +def test_상세_행_차례_칠_안_바꿈(north): + parsed, grid = north + got = _misses(parsed) + nos = sorted({b["번"] for b in grid["단산"]}) + plain = [{"번": n, "판정": "맞음", "SK": None, "갈래": None, "못옮김": []} for n in nos] + marked = [{**b, "어긋남": got.get(b["번"])} for b in plain] + a, b = _node({"grid": grid, "blocks": plain}), _node({"grid": grid, "blocks": marked}) + assert a["paint"] == b["paint"] and a["misses"] == [] # 칠 같음 · 띠만 + rows = [(m["행"], m["번"]) for m in b["misses"]] + head = {x["번"]: x["행"][0] for x in grid["단산"]} + assert rows == sorted(rows) + assert (head[2], 2) in rows and (head[33], 33) in rows and (1733, 33) in rows + assert len(rows) == 1 + 2 + 1 + 2 # 2 머리 · 33 머리 + 줄 · 64 줄 · 95 줄 둘 + assert all(m["글"].startswith(f"단산{m['번']} · ") for m in b["misses"]) diff --git a/resources/tester/test_779_2_hopyo_marks.py b/resources/tester/test_779_2_hopyo_marks.py index 018a93957..f04290f11 100644 --- a/resources/tester/test_779_2_hopyo_marks.py +++ b/resources/tester/test_779_2_hopyo_marks.py @@ -64,6 +64,8 @@ def test_marks_읽기만_판정_캐시_같음_SK(client): assert judged and all(r["판정"] in ("맞음", "다른 점", "후보", "없음") for r in judged) assert {r["판정"] for r in judged} <= {e["판정"]["상태"] for e in cache.values()} assert all(r["SK"] is None and r["갈래"] is None and r["못옮김"] == [] for r in rows) + # 778-5 D-6 — 엑셀 셈 어긋남 덧칸(불리는 블록) · 2 · 33 = 엑셀 합이 줄 뺌 + assert {r["번"] for r in rows if r["어긋남"]} == {2, 33, 64, 95} # [단가산출 만들기] 뒤 = SK · 갈래 · 못옮김 실림 made = client.post(f"{base}/{KEY}/sangeun") assert made.status_code == 200, made.text diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 59f804caf..aac17cd7a 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -356,6 +356,12 @@ export const ui_locales_m2 = { M02_SheetToList: ["▸ 단가산출표 · 단산{n}", "▸ Basis list · {n}"], M02_SheetToHopyo: ["▸ 제{n}호표", "▸ Sheet {n}"], M02_SheetToCaller: ["▸ 부르는 줄 {i}/{all}", "▸ Calling line {i}/{all}"], + M02_SheetMiss: [ + "엑셀 셈 어긋남 {n} — 엑셀 자체 셈이 맞지 않는 블록(칠과 무관 · 판단은 사용자)", + "Excel math mismatch {n} — blocks whose own Excel math does not add up (no effect on colors)", + ], + M02_SheetMissGo: ["보기 {i}/{all}", "Show {i}/{all}"], + M02_SheetMissOne: ["⚠ 엑셀 셈 어긋남", "⚠ Excel mismatch"], M02_ToneSuccess: ["이어짐", "Linked"], M02_ToneWarning: ["이어짐 · 엑셀과 다름", "Linked · differs"], M02_ToneInfo: ["고를 것", "To pick"],