Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_8

This commit is contained in:
2026-10-31 17:16:02 +09:00
11 changed files with 109 additions and 28 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),
});
+16 -9
View File
@@ -44,17 +44,19 @@ const sty = (n) => ({
paddingBottom: n.css.paddingBottom ?? "0px",
borderBottomWidth: n.css.borderBottomWidth ?? "0px",
marginBottom: n.css.marginBottom ?? "0px",
position: n.css.position ?? "static",
});
const box = { exports: {} };
new Function("exports", "module", "require", "window", "document", "getComputedStyle", "ResizeObserver", js)(
box.exports,
box,
(p) => (p.endsWith(".css") ? {} : require(p)),
win,
{ body: null },
sty,
RO,
);
new Function(
"exports",
"module",
"require",
"window",
"document",
"getComputedStyle",
"ResizeObserver",
js,
)(box.exports, box, (p) => (p.endsWith(".css") ? {} : require(p)), win, { body: null }, sty, RO);
const m = box.exports;
// 문서: body(padding-bottom 8) > card(padding-bottom 12 · gap 8) > [root][avg 20][info 30][hint 40]
@@ -81,4 +83,9 @@ hint.height = 60;
observers.forEach((o) => o.fn());
out.afterHintGrow = parseFloat(root.style.maxHeight);
out.firstMinusGrow = out.first - 20;
// 열린 창 가림막(body 끝 · position fixed · 창 높이) = 아래 자리에 안 셈(707-5)
link(body, card, el("backdrop", { position: "fixed" }, 900));
out.belowWithModal = m.spaceBelow(root);
out.belowAfterGrow = 3 * 8 + 20 + 30 + 60 + 12 + 8;
console.log(JSON.stringify(out));
+2 -1
View File
@@ -147,6 +147,7 @@ const { loadTs } = makeLoader({
root: ROOT,
requireFor: (full) => (request) => {
if (request in STUBS) return STUBS[request];
if (request.endsWith(".css")) return {}; // 부품 글꼴 import(ui_template_icons.css 등)
if (request.startsWith(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`);
return require(request);
},
@@ -178,7 +179,7 @@ const flush = () => new Promise((resolve) => setTimeout(resolve, 20));
const out = {};
void openItemPicker({ current: null, canCopy: false });
await flush();
const [, cols] = modal.body.children;
const [, , cols] = modal.body.children; // 위 표 · 규격 표(704-4) · 열
const [search, layerBar, treeHost] = cols.children[0].children;
const searchInput = search.children[0];
const active = () => treeHost.all().find((n) => n.classList.contains("is-active"))?.dataset.id;
@@ -119,6 +119,7 @@ const { loadTs } = makeLoader({
requireFor: (full) => (request) => {
if (CATALOG.isCatalogRequest(request)) return CATALOG;
if (STUBS[request]) return STUBS[request];
if (request.endsWith(".css")) return {}; // 부품 글꼴 import(ui_template_icons.css 등) — 트랜스파일 못 함
// 화면 공용 부품(@ui/…) — 이 시험이 안 쓰는 것이라 뭘 불러도 아무 일 안 하는 함수로 막아 둠
if (request.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} });
// 공용 계산(@util/…) — tsconfig 별칭대로 common_util 폴더에서 읽음
@@ -177,3 +177,4 @@ def test_창_끝까지_셈에_아래_안내_줄_포함():
assert r["below"] == r["belowExpected"] == 134 # 아래 줄 · 안내 줄 둘 + 여백
assert r["bottom"] == r["viewport"] # 문서 맨 아래 = 창 끝 → 페이지 스크롤 0
assert r["afterHintGrow"] == r["firstMinusGrow"] # 안내 줄이 늘면 표 칸이 그만큼 줄어듦
assert r["belowWithModal"] == r["belowAfterGrow"] # 열린 창 가림막(fixed) = 아래 자리 아님
@@ -117,7 +117,7 @@ def test_모달_배선_고르면_보이고_편집_화면에서는_숨김():
ts = _text(UI / "ui_template_item_picker.ts")
assert "createComboTable((colId) => pickColumn(colId))" in ts # 280-2 표 고름 → 나무
assert "paintTable();" in ts # 고른 항목 · 층이 바뀔 때마다(280-2)
assert "body.append(tableWrap, cols, editHost," in ts # 맨 위
assert "body.append(tableWrap, specHost, cols, editHost," in ts # 맨 위(704-4 규격 표 뒤)
assert ts.count("tableWrap.hidden = true;") == 1 # 편집 화면 열 때
assert ts.count("tableWrap.hidden = false;") == 1 # 돌아올 때
assert "comboTable.destroy();" in ts # 닫을 때 표 거둠
+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
+1 -1
View File
@@ -77,7 +77,7 @@ def test_사전조합_층_단추는_페이지를_다시_안_세움():
# 구조물 조합 층 단추는 그대로 해시 이동 한 벌
assert page.count('layerBar("') == 1
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
assert "precombo: { scope: () => pre, bar: preBar }," in page
assert "precombo: {\n scope: () => pre,\n bar: preBar," in page # 706-1 more 덧붙음
def test_고름에_owner_권한_서고_길에_명시_owner():
@@ -300,5 +300,7 @@ def test_스냅샷이_없으면_알림(world):
def test_초기화는_templates_를_안_건드리고_복제는_통째로():
assert not any(tree.startswith("templates") for tree in initial._FILE_TREES)
tool = (config_system.PROJECT_ROOT / "db_management/tools_clone_project.py").read_text("utf-8")
tool = (config_system.PROJECT_ROOT / "common_util/common_util_project_copy.py").read_text(
"utf-8"
) # 복제 도구 본체(tools_clone_project = CLI 껍데기)
assert '"robocopy",' in tool and '"/E",' in tool # 프로젝트 폴더 전체(templates 포함)
+2 -1
View File
@@ -410,7 +410,8 @@ export function spaceBelow(el: HTMLElement): number {
for (let s = node.nextElementSibling; s; s = s.nextElementSibling) {
const h = s.getBoundingClientRect().height;
const m = getComputedStyle(s); // 203 형제 위아래 바깥 여백도(B09 안내 글 margin-top)
if (h) sum += h + gap + px(m.marginTop) + px(m.marginBottom);
const out = m.position === "fixed" || m.position === "absolute"; // 열린 창 가림막 · 알림 = 자리 안 씀
if (h && !out) sum += h + gap + px(m.marginTop) + px(m.marginBottom);
}
sum += px(own.marginBottom) + px(around.paddingBottom) + px(around.borderBottomWidth);
}