feat(B09 불러오기): .estx 고리 — 내역 줄 · 단가표 같은 길 · 요율 = 엑셀 안내 · 호표 목록 = 이음 후보 수 (PLAN 707-5)

- 길: 이름 끝 .estx = `B09_Estimation_Estx` read_bill · read_prices(같은 그릇) · 요율 = 400 「같은 공사 엑셀에서」
- 미리 보기 `호표` = 대가 · 산근 목록 수 + 내역 줄이 코드로 가리킨 수(구성 줄 귀속은 안 함)
- 창: 파일 고르기 *.estx · [단가표도 같이](받은 patch 를 캐시에 함께) · 지난 파일 요율 실패 = 까닭 알림 뒤 다시 고름
- 시험 test_707_5 8(길 갈림 · 호표 수 · 깨진 파일 400)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 16:59:53 +09:00
co-authored by Claude Opus 5.5
parent 6b6b1575eb
commit 56ebe76e7f
3 changed files with 82 additions and 14 deletions
+29 -4
View File
@@ -18,6 +18,9 @@
{file_key, name, sheets, counts, linked, missing,
patch{labor_cover, machine_cover, materials}}
file_key = 세 길 같은 표지(`_keep` · `_saved`) — 같은 파일을 다시 올리지 않음 ·
.estx(EST 원본 · 707-5 `B09_Estimation_Estx`) = 이름 끝으로 갈림 — 내역 줄 · 단가표 같은 그릇 ·
요율 = 400 「같은 공사 엑셀에서」 ·
미리 보기 `호표` = 대가 · 산근 목록 수 + 코드로 가리킨 줄(이음 후보만)
화면은 `patch` 를 캐시에 얹고 [저장] 때 서버가 정본으로 다시 셈.
`B09_Estimation_Router` 가 끼워 넣음(같은 머리 · 같은 권한).
"""
@@ -37,6 +40,7 @@ from pydantic import BaseModel, Field
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
from B09_Estimation import B09_Estimation_Bill_Import as importer
from B09_Estimation import B09_Estimation_Bill_Library as library
from B09_Estimation import B09_Estimation_Estx as estx
from B09_Estimation import B09_Estimation_Import_Prices as import_prices
from B09_Estimation import B09_Estimation_Import_Rates as import_rates
from B09_Estimation import B09_Estimation_Prices as prices
@@ -82,13 +86,28 @@ def _keep(root: Path, name: str, data: bytes) -> str:
return key
def _is_estx(name: str) -> bool:
return name.lower().endswith(".estx")
def _parse(name: str, data: bytes) -> dict[str, Any]:
try:
return read_bill(name, data)
return (estx.read_bill if _is_estx(name) else read_bill)(name, data)
except BillExcelError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
def _hopyo(name: str, data: bytes, parsed: dict[str, Any]) -> dict[str, int] | None:
""".estx 호표 목록 수 · 내역 줄이 코드로 가리킨 수(이음 후보 — 구성 줄은 `estx.UNKNOWN`)."""
if not _is_estx(name):
return None
got = estx.read_hopyo(name, data)
codes = {(k, x["코드"]) for k, v in got.items() for x in v}
hits = [h for line in parsed["줄"] if (h := line.get("호표"))]
return {**{k: len(v) for k, v in got.items()},
"가리킴": sum((h["종류"], h["코드"]) in codes for h in hits)} # fmt: skip
def _draft(raw: Any) -> dict[str, Any] | None:
if raw in (None, ""):
return None
@@ -158,8 +177,10 @@ async def import_bill(
if not file_key:
raise HTTPException(status_code=400, detail="파일을 고름")
path = _saved(root, file_key)
data = path.read_bytes()
if file is None:
parsed = await asyncio.to_thread(_parse, path.name, path.read_bytes())
parsed = await asyncio.to_thread(_parse, path.name, data)
hopyo = await asyncio.to_thread(_hopyo, path.name, data, parsed)
rows = (await payload(project_id, root, _draft(user), True))["sheets"]["bill"]["rows"]
found = await asyncio.to_thread(_found, session, path.name, _numbers(parsed))
return {
@@ -167,6 +188,7 @@ async def import_bill(
"파일": path.name,
**importer.preview(parsed, rows),
"이음": len(found),
"호표": hopyo,
}
@@ -234,6 +256,8 @@ async def import_rate_sheet(
root = await _project_root(project_id)
key, name, data = await _file(file, file_key, root)
draft = _draft(user)
if _is_estx(name):
raise HTTPException(400, "원가계산서 요율은 .estx 에서 못 읽음 — 같은 공사 엑셀에서 불러옴")
mine = (draft if draft is not None else await asyncio.to_thread(store.read_user, root))[
"estimate"
]
@@ -262,8 +286,9 @@ async def import_price_sheets(
draft = _draft(user)
mine = draft if draft is not None else await asyncio.to_thread(store.read_user, root)
try:
read = await asyncio.to_thread(import_prices.read_prices, name, data)
except ExcelError as error:
reader = estx.read_prices if _is_estx(name) else import_prices.read_prices
read = await asyncio.to_thread(reader, name, data)
except (ExcelError, BillExcelError) as error:
raise HTTPException(400, str(error)) from error
keys = await main._demand(root) # 화면 단가 목록(GET)과 같은 수요 — 사본 전부
b08 = await main._b08_for(project_id, [], True)
+39 -10
View File
@@ -14,6 +14,9 @@
* 길 = `B09_Estimation_Router_Import.py` import/rates · import/prices(file | file_key · user = 화면 캐시) →
* 확인 창(짝 표 · 식 다름 · 짝 없음 n) → [적용] = 답 `patch` 를 캐시에 얹음 · [저장] 때 서버 정본
* 올린 파일 = 내역 줄 · 요율 · 단가표 세 길이 같은 표지(file_key · `last`) — 같은 파일 다시 안 올림
*
* EST 원본(.estx · PLAN 707-5) = 같은 단추 · 같은 창 — 내역 줄 + [단가표도 같이](받은 patch 를 캐시에 함께) ·
* 요율 = 서버 400 「같은 공사 엑셀에서」 안내 · 호표 목록 = 수만(이음 후보 · 구성 줄 못 읽음)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
@@ -80,11 +83,17 @@ export function createBillImport(
const total = seen.합계 || {};
const lines = [
`${seen.파일} · ${seen.꼴} 「${seen.시트}」 · 공종 ${seen.묶음} · 항목 ${seen.항목}`,
`엑셀 직접비 합 ${won(total.직접)} 원 · 제외(관급 등) ${won(total.제외)} 원` +
`${seen.꼴 === "estx" ? ".estx" : "엑셀"} 직접비 합 ${won(total.직접)} 원 · 제외(관급 등) ${won(total.제외)} 원` +
(seen.조달수수료율 != null ? ` · 조달수수료율 ${seen.조달수수료율} %` : ""),
seen.이음
? `같은 파일로 들여온 일위대가 ${seen.이음} 호표 — 그 줄은 일위대가에 이음`
: "같은 파일로 들여온 일위대가 없음 — 모든 줄 = 엑셀 단가(고정)",
...(seen.호표
? [
`호표 목록 대가 ${seen.호표.대가} · 산근 ${seen.호표.산근} — 내역 줄 ${seen.호표.가리킴} 개가 코드로 가리킴(이음 후보 · 구성 줄은 못 읽음)`,
"원가계산서 요율은 .estx 에서 못 읽음 — 같은 공사 엑셀에서(총괄 · 원가계산서 탭)",
]
: []),
];
const take = createCheckbox("넘겨받기 — 불러온 수량을 씀(수량 산출 값은 곁에)", {
radio: "b09-import-mode",
@@ -106,6 +115,11 @@ export function createBillImport(
const drop = createCheckbox(`그 밖 자동 줄 ${seen.남는자동} 개 지움`, {
disabled: !seen.남는자동,
});
const withPrices = seen.호표
? createCheckbox("단가표도 같이 — 노임 · 자재 · 기계 단가를 이 공사 단가 책에 덮음", {
checked: true,
})
: null;
const run = createButton({ label: "불러오기", variant: "filled" });
run.dataset.b09ImportRun = "";
openModal({
@@ -117,6 +131,7 @@ export function createBillImport(
...lines.map((t) => createText(t, "muted", 14)),
el("div", { className: "b09-import__choices", children: [take.root, price.root] }),
el("div", { className: "b09-import__choices", children: [attach.root, drop.root] }),
...(withPrices ? [withPrices.root] : []),
);
actions.append(run);
run.addEventListener("click", async () => {
@@ -132,11 +147,21 @@ export function createBillImport(
showLoadingOverlay();
try {
const got = await post(projectId, "bill/apply", JSON.stringify(ask), true);
let made = merged(draft, got.user);
let tail = "";
if (withPrices?.input.checked) {
const sheet = await postSheet("prices", made, null);
made = { ...made, ...sheet.patch };
const c: Row = sheet.counts || {};
tail = ` · 단가표 이음 ${Object.entries(PART)
.map(([k, name]) => `${name} ${c[k]?.linked ?? 0}`)
.join(" · ")}`;
}
// 창 닫고 다시 그림 — 표 높이(fillViewport)가 열린 창 가림막을 아래 자리로 셈
close();
done(got.payload, merged(draft, got.user));
done(got.payload, made);
const n = got.알림 || {};
const head = `새 줄 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 이음 ${n.이음} — [저장]해야 남음`;
const head = `새 줄 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 이음 ${n.이음}${tail} — [저장]해야 남음`;
const off: Row[] = got.어긋남 || [];
if (!off.length) return void showToast(`${head} · 엑셀 금액과 모두 같음`, "success");
// 어긋난 줄 = 표 높이를 잰 뒤(다음 화면) 보기 창
@@ -167,7 +192,7 @@ export function createBillImport(
label: "엑셀 내역 불러오기",
variant: "ghost",
onClick: () =>
pickFiles({ accept: ".xls,.xlsx" }, ([file]) => {
pickFiles({ accept: ".xls,.xlsx,.estx" }, ([file]) => {
const form = new FormData();
form.append("file", file);
form.append("user", JSON.stringify(user()));
@@ -206,11 +231,11 @@ const PART: Record<string, string> = { labor: "노임", material: "자재", mach
/** 마지막에 올린 파일(서버 기억 열쇠) — 같은 파일 다시 씀 */
let last: { key: string; name: string } | null = null;
async function postSheet(kind: Kind, ctx: TabContext, file: File | null): Promise<Row> {
async function postSheet(kind: Kind, user: Row, file: File | null): Promise<Row> {
const form = new FormData();
if (file) form.append("file", file);
else if (last) form.append("file_key", last.key);
form.append("user", JSON.stringify(ctx.user));
form.append("user", JSON.stringify(user));
const url = `${API_BASE_URL}/projects/${currentProjectId()}/estimation/import/${kind}`;
const response = await fetch(url, { method: "POST", body: form, credentials: "include" });
const body = (await response.json().catch(() => null)) as Row | null;
@@ -332,8 +357,9 @@ const fail = (error: unknown): void => showToast(`불러오지 못함 — ${Stri
function pick(kind: Kind, ctx: TabContext): void {
pickFiles(
{ accept: ".xlsx,.xls" },
([file]) => void postSheet(kind, ctx, file).then((got) => openResult(kind, ctx, got), fail),
{ accept: ".xlsx,.xls,.estx" },
([file]) =>
void postSheet(kind, ctx.user, file).then((got) => openResult(kind, ctx, got), fail),
);
}
@@ -347,9 +373,12 @@ export function importTools(tab: TabId, ctx: TabContext): HTMLElement[] {
size: "sm",
onClick: () =>
last
? void postSheet(kind, ctx, null).then(
? void postSheet(kind, ctx.user, null).then(
(got) => openResult(kind, ctx, got),
() => pick(kind, ctx),
(error: unknown) => {
fail(error); // 지난 파일이 .estx(요율) · 없음 = 까닭 알리고 다시 고름
pick(kind, ctx);
},
)
: pick(kind, ctx),
});
+14
View File
@@ -125,3 +125,17 @@ def test_깨진_파일_표_없음_막힘():
z.writestr("Other.dat", b"")
with pytest.raises(ex.EstxError, match="EstMainDat"):
ex.read_bill("a.estx", buf.getvalue())
def test_불러오기_길_estx_갈림(data):
"""707-5 고리 — 이름 끝 .estx = 원본 읽기 · 호표 = 목록 수 + 코드로 가리킨 줄(이음 후보)."""
from B09_Estimation import B09_Estimation_Router_Import as route
name = "거창.ESTX"
parsed = route._parse(name, data["거창"])
assert parsed["꼴"] == "estx" and parsed["합계"]["직접"] == 384916227
assert route._hopyo(name, data["거창"], parsed) == {"대가": 14, "산근": 61, "가리킴": 48}
assert route._hopyo("a.xlsx", data["거창"], parsed) is None
with pytest.raises(route.HTTPException) as got:
route._parse("a.estx", b"not a zip")
assert got.value.status_code == 400