fix(B09 설계내역서): 엑셀 내역 불러오기 화면 검증 흠 — 단가만 빈칸 · 운반 줄 오붙음 · 차례 · 표 높이 (PLAN 707-1)
- 단가만 갈래 = 붙인 자동 줄에 B08 수량이 없으면 엑셀 수량(빈칸 금액 막음) - 운반 자동 줄(「모래」 · 「시멘트」 = 자재대 줄 이름) = 붙이지 않음 · 지우기만 - 불러온 공종 = 관급자재대 앞(엑셀 차례 · 표 끝에 묻히지 않음) - 결과 창 = 표를 다시 그린 뒤 따로 엶(열린 창 가림막을 표 아래 자리로 재어 표가 한 줄로 줄던 것) · 어긋난 줄 표 칸 폭 줄임 - 못 읽은 파일(빈 · DRM · 내역 시트 없음) = 프로젝트 import 폴더에 안 둠 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,16 @@
|
||||
"""B09 경쟁사 내역 줄 불러오기 짜기(PLAN 707-1) — 읽은 내역(`Bill_Excel.read_bill`) → 설계내역서
|
||||
덮개 v2(`user.bill` · 계약 = `B09_Estimation_Calc_Bill` 머리). 파일 · 저장 안 만짐(부르는 쪽 몫).
|
||||
|
||||
먼저 싣고 나중 잇기 — 빠짐 0:
|
||||
먼저 싣고 나중 잇기 — 빠짐 0 · 실은 줄 = 표 차례에서 관급자재대(`sec:G`) 앞:
|
||||
묶음 줄 → 새 묶음(`hand:x{표지}-{행}` · 제외 맨 위 = 관급자재대 `sec:G` 아래)
|
||||
항목 줄 → 새 줄 + 고정 단가 칸 `price`(엑셀 단가) + `src_qty`(엑셀 수량) + `note`(엑셀 비고) ·
|
||||
연결 = 같은 책 일위대가 사본(`links` {대가 번: ref}) — 없으면 고정 단가로 금액
|
||||
같은 파일을 다시 부르면(같은 표지) 앞서 실은 줄을 갈아 끼움.
|
||||
수량 갈래 — `take`(가 넘겨받기) 붙인 자동 줄 수량 = 엑셀(B08 = `auto_qty` 로 나란히) ·
|
||||
`price`(나 단가만) 붙인 자동 줄 수량 = B08 · 엑셀 수량 = 비교 칸만. 새 줄 수량 = 늘 엑셀.
|
||||
`price`(나 단가만) 붙인 자동 줄 수량 = B08(B08 수량 없으면 엑셀) · 엑셀 수량 = 비교 칸.
|
||||
새 줄 수량 = 늘 엑셀.
|
||||
두 번 셈 막음 — `attach` = 이름(빈칸 뺌)이 같은 자동 항목 줄에 붙임(그 줄 = 불러온 자리로 옮기고
|
||||
단가 = 연결 · 고정 단가 칸 · 자재 줄 · 조달수수료 뺌) ·
|
||||
단가 = 연결 · 고정 단가 칸 · 자재 줄 · 운반 줄(이름이 자재와 같음 「모래」) · 조달수수료 뺌) ·
|
||||
`drop` = 안 붙은 자동 항목 줄 지움(`gone`).
|
||||
조달수수료율 = 사용자 값이 비었을 때만 엑셀 율.
|
||||
"""
|
||||
@@ -24,6 +25,8 @@ from typing import Any
|
||||
MODES = ("take", "price")
|
||||
FIELDS = ("name", "spec", "unit", "qty", "ref")
|
||||
PARTS = ("labor", "material", "expense")
|
||||
#: 붙이지도 지우지도 않는 자동 줄 머리 — 자재(대비표 주인) · 조달수수료(운반 = 지움만)
|
||||
SKIP = ("mat", "fee")
|
||||
|
||||
|
||||
def tag_of(data: bytes) -> str:
|
||||
@@ -73,19 +76,21 @@ def _price(line: dict[str, Any]) -> dict[str, float]:
|
||||
return {p: float(line["단가"].get(p) or 0) for p in PARTS}
|
||||
|
||||
|
||||
def _autos(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""붙일 수 있는 자동 항목 줄 — 자재 줄 · 조달수수료 뺌."""
|
||||
def _autos(rows: list[dict[str, Any]], attach: bool = False) -> list[dict[str, Any]]:
|
||||
"""지울 수 있는 자동 항목 줄(자재 · 조달수수료 뺌) ·
|
||||
`attach` = 붙일 수 있는 줄(운반도 뺌 — 「모래」 = 자재대 줄 이름)."""
|
||||
skip = (*SKIP, "haul") if attach else SKIP
|
||||
return [
|
||||
r
|
||||
for r in rows
|
||||
if r["kind"] == "item" and r.get("auto") and r["key"] != "fee" and r["key"][:4] != "mat:"
|
||||
if r["kind"] == "item" and r.get("auto") and r["key"].split(":")[0] not in skip
|
||||
]
|
||||
|
||||
|
||||
def matches(parsed: dict[str, Any], rows: list[dict[str, Any]]) -> dict[str, str]:
|
||||
"""{불러온 줄 행(글): 자동 줄 키} — 이름(빈칸 뺌)이 같은 첫 남은 자동 줄 · 제외 줄 뺌."""
|
||||
free: dict[str, list[str]] = {}
|
||||
for row in _autos(rows):
|
||||
for row in _autos(rows, attach=True):
|
||||
free.setdefault(_flat(row["name"]), []).append(row["key"])
|
||||
out = {}
|
||||
for line in parsed["줄"]:
|
||||
@@ -144,6 +149,7 @@ def apply(
|
||||
kept.append(e)
|
||||
bill["rows"] = kept
|
||||
pairs = matches(parsed, rows) if attach else {}
|
||||
by_key = {r["key"]: r for r in rows}
|
||||
keys = {line["행"]: f"{head}{line['행']:05d}" for line in parsed["줄"]}
|
||||
put, report = [], {"실음": 0, "붙임": 0, "지움": 0, "이음": 0, "줄": {}}
|
||||
for line in parsed["줄"]:
|
||||
@@ -162,8 +168,8 @@ def apply(
|
||||
else:
|
||||
bill["rows"].remove(entry)
|
||||
entry.update({**cells, "ref": link})
|
||||
if mode == "take":
|
||||
entry["qty"] = line["수량"]
|
||||
if mode == "take" or by_key[auto].get("auto_qty", by_key[auto]["qty"]) is None:
|
||||
entry["qty"] = line["수량"] # 단가만 · B08 수량 없음 = 엑셀
|
||||
entry.pop("parent", None)
|
||||
put.append({**entry, **({"parent": parent} if parent else {})})
|
||||
report["붙임"] += 1
|
||||
@@ -185,7 +191,8 @@ def apply(
|
||||
bill["gone"].append(row["key"])
|
||||
report["지움"] += 1
|
||||
bill["rows"] = [e for e in bill["rows"] if e["key"] not in bill["gone"]]
|
||||
bill["rows"] += put
|
||||
at = next((n for n, e in enumerate(bill["rows"]) if e["key"] == "sec:G"), len(bill["rows"]))
|
||||
bill["rows"][at:at] = put # 불러온 공종 = 관급자재대 앞(엑셀 차례)
|
||||
out = {**user, "bill": bill}
|
||||
estimate = dict(user.get("estimate") or {})
|
||||
if estimate.get("procurement_fee_pct") is None and parsed.get("조달수수료율") is not None:
|
||||
|
||||
@@ -66,9 +66,9 @@ def _keep(root: Path, name: str, data: bytes) -> str:
|
||||
return key
|
||||
|
||||
|
||||
def _parse(path: Path) -> dict[str, Any]:
|
||||
def _parse(name: str, data: bytes) -> dict[str, Any]:
|
||||
try:
|
||||
return read_bill(path.name, path.read_bytes())
|
||||
return read_bill(name, data)
|
||||
except BillExcelError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
|
||||
@@ -137,11 +137,13 @@ async def import_bill(
|
||||
data = await file.read(MAX_BYTES + 1)
|
||||
if len(data) > MAX_BYTES:
|
||||
raise HTTPException(status_code=400, detail="파일이 너무 큼(50MB 넘음)")
|
||||
parsed = await asyncio.to_thread(_parse, file.filename or "", data) # 읽힌 파일만 둠
|
||||
file_key = await asyncio.to_thread(_keep, root, file.filename or "", data)
|
||||
if not file_key:
|
||||
raise HTTPException(status_code=400, detail="파일을 고름")
|
||||
path = _saved(root, file_key)
|
||||
parsed = await asyncio.to_thread(_parse, path)
|
||||
if file is None:
|
||||
parsed = await asyncio.to_thread(_parse, path.name, path.read_bytes())
|
||||
rows = (await payload(project_id, root, _draft(user), True))["sheets"]["bill"]["rows"]
|
||||
found = await asyncio.to_thread(_found, session, path.name, _numbers(parsed))
|
||||
return {
|
||||
@@ -170,7 +172,7 @@ async def apply_bill(
|
||||
raise HTTPException(status_code=400, detail="수량 갈래는 take · price 중 하나")
|
||||
root = await _project_root(project_id)
|
||||
path = _saved(root, body.file_key)
|
||||
parsed = await asyncio.to_thread(_parse, path)
|
||||
parsed = await asyncio.to_thread(_parse, path.name, path.read_bytes())
|
||||
draft = _draft(body.user) or await asyncio.to_thread(store.read_user, root)
|
||||
rows = (await payload(project_id, root, draft, True))["sheets"]["bill"]["rows"]
|
||||
found = await asyncio.to_thread(_found, session, path.name, _numbers(parsed))
|
||||
|
||||
@@ -51,11 +51,11 @@ function merged(user: Row, made: Row): Row {
|
||||
function differences(rows: Row[]): HTMLElement {
|
||||
return createDataTable({
|
||||
columns: [
|
||||
{ key: "행", label: "엑셀 행", kind: "number", width: 56 },
|
||||
{ key: "이름", label: "명칭", width: 200 },
|
||||
{ key: "엑셀", label: "엑셀 금액", kind: "number", width: 110 },
|
||||
{ key: "우리", label: "우리 금액", kind: "number", width: 110 },
|
||||
{ key: "까닭", label: "까닭", width: 160 },
|
||||
{ key: "행", label: "엑셀 행", kind: "number", width: 48 },
|
||||
{ key: "이름", label: "명칭", width: 150 },
|
||||
{ key: "엑셀", label: "엑셀 금액", kind: "number", width: 96 },
|
||||
{ key: "우리", label: "우리 금액", kind: "number", width: 96 },
|
||||
{ key: "까닭", label: "까닭", width: 130 },
|
||||
],
|
||||
rows,
|
||||
}).root;
|
||||
@@ -123,20 +123,27 @@ export function createBillImport(
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const got = await post(projectId, "bill/apply", JSON.stringify(ask), true);
|
||||
// 창 닫고 다시 그림 — 표 높이(fillViewport)가 열린 창 가림막을 아래 자리로 셈
|
||||
close();
|
||||
done(got.payload, merged(draft, got.user));
|
||||
const n = got.알림 || {};
|
||||
const head = `새 줄 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 이음 ${n.이음} — [저장]해야 남음`;
|
||||
const off: Row[] = got.어긋남 || [];
|
||||
if (!off.length) {
|
||||
close();
|
||||
return void showToast(`${head} · 엑셀 금액과 모두 같음`, "success");
|
||||
}
|
||||
run.remove();
|
||||
body.replaceChildren(
|
||||
createText(head, "success", 14),
|
||||
createText(`엑셀 금액과 다른 줄 ${off.length}`, "warning", 14),
|
||||
differences(off),
|
||||
);
|
||||
if (!off.length) return void showToast(`${head} · 엑셀 금액과 모두 같음`, "success");
|
||||
// 어긋난 줄 = 표 높이를 잰 뒤(다음 화면) 보기 창
|
||||
const result = (): void =>
|
||||
void openModal({
|
||||
title: "엑셀 내역 불러오기 — 결과",
|
||||
closeLabel: "닫기",
|
||||
width: "40rem",
|
||||
mount: (box) =>
|
||||
box.append(
|
||||
createText(head, "success", 14),
|
||||
createText(`엑셀 금액과 다른 줄 ${off.length}`, "warning", 14),
|
||||
differences(off),
|
||||
),
|
||||
});
|
||||
requestAnimationFrame(() => requestAnimationFrame(result));
|
||||
} catch (error) {
|
||||
showToast(`불러오지 못함 — ${error instanceof Error ? error.message : error}`, "error");
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user