Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_5
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
@@ -81,11 +84,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",
|
||||
@@ -107,6 +116,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({
|
||||
@@ -118,6 +132,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 () => {
|
||||
@@ -133,12 +148,22 @@ 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);
|
||||
void openBillLink(user(), done, mode.value); // 707-4 다음: 우리 공종으로 잇기
|
||||
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");
|
||||
// 어긋난 줄 = 표 높이를 잰 뒤(다음 화면) 보기 창
|
||||
@@ -169,7 +194,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()));
|
||||
@@ -208,11 +233,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;
|
||||
@@ -334,8 +359,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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -349,9 +375,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),
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 # 닫을 때 표 거둠
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 포함)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user