feat(778-5 D-6): 엑셀 셈 어긋남 띠 — 불리는 블록 재현 못 한 줄 · 덮음 실패 · 금액 차 · 까닭
- Marks 블록 덧칸 `어긋남 {줄합, 금액, 차, 덮음, 줄 [{행, 이름, 묶음, 셈, 금액, 차, 까닭}], 까닭}` · 읽기만
- SheetMap `missesOf` = 상세 시트 행 차례(덮음 실패 = 블록 머리 행 · 재현 못 한 줄 = 그 줄 행) · 칠 안 바꿈
- SheetNav 머리 막대 아래 띠(부품 createBanner) 「엑셀 셈 어긋남 n」 + [보기 i/m] 차례로 뛰기 · 그 행 까닭은 막대 글
- 북부청 임도 = 2 · 33(엑셀 합이 진동로울러 노무 뺌 · 차 2,637.1 · 3,418.7) · 64 · 95(글 꼴 밖 줄) · 81 맞음 · 영월 0
- 시험 새 test_778_5_hopyo_excel_misses · test_779_2 어긋남 한 줄 · helper_779_4 missesOf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<typeof missesOf> = [];
|
||||
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<void> {
|
||||
blocks = await readHopyoMarks(opt.scope, opt.key, opt.target).catch(() => blocks);
|
||||
paintBand();
|
||||
repaint();
|
||||
}
|
||||
const onDraft = (): void => repaint();
|
||||
|
||||
@@ -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.행);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user