diff --git a/M02_MasterTemplete/M02_Doc_FromNative.py b/M02_MasterTemplete/M02_Doc_FromNative.py index 5a7e819e1..207a061c3 100644 --- a/M02_MasterTemplete/M02_Doc_FromNative.py +++ b/M02_MasterTemplete/M02_Doc_FromNative.py @@ -7,9 +7,8 @@ STmate = 원문 그대로 `M02_San_Emit.stmate`(원파일 원문 = 엑셀 숨은 G 열 · 단가 = COSTN · 끝전 = 옵션 SAN_TYP 1 비목버림 / 0 합버림_큰나머지 · 810-1 저장값 대조로 확인). 블록 번 = 엑셀 길과 같음(EST = 산근 표 차례 1부터 = 「단산N」 · STmate = 코드 숫자). -같은 공사 = 엑셀 산근 블록이 모두 원파일 블록과 금액 · 이름이 같음(`same`) — 둘 다 있으면 원파일. -일위대가(EST HeadMainDat) → 실무자료 호표 꼴(`hopyo`) · STmate 일위대가 구성은 못 읽음(BDQTY 난독화) → -호표 없음(엑셀을 같이 올리면 엑셀 호표 + 원파일 산근). +올리기 = 파일 하나(814-1 · 요구 742-1 — 같은 공사 엑셀 붙이기 걷음) · 원파일 = 단가산출표 판(`grid` · +산출근거 머리) + 산출근거 · 일위대가 안 만듦(요구 743-2 · `hopyo` = 단산만). """ from __future__ import annotations @@ -154,89 +153,52 @@ def inner(saved: dict, logic: dict) -> list[tuple[dict, int | None, str | None]] return out -# ── 같은 공사 · 일위대가 ─────────────────────────────────────────────── -def same(saved: dict, sheets: dict) -> bool: - """엑셀 산근 블록이 모두 원파일 같은 번 블록과 계 금액(1 원 안)이 같음 = 같은 공사.""" - from M02_MasterTemplete import M02_San_Book as book - - have = numbers(saved) - kind = book.kind_of(sheets) - if not kind or (kind == "STmate") != (saved["꼴"] == "STmate"): - return False - if kind == "STmate": - excel = {int(b["코드"][1:]): b["머리"].get("C") for b in book.stmate_blocks(sheets)} - else: - excel = {int(n): t.get("T") for n, t in book.est_prices(sheets)[1].items()} - if not excel: - return False - for n, total in excel.items(): - blk = have.get(int(n)) - if blk is None or abs(float(total or 0) - float((blk.get("저장") or {}).get("T") or 0)) > 1: - return False - return True - - -GALLAE = {6: "노임", 5: "자재", 105: "기계", 104: "단가산출", 103: "호표", 21: "기타", 101: "기타"} +# ── 단가산출표 판(814-1 · 요구 742-2 · 743-2) ─────────────────────────── +LIST = "단가산출표" +_LIST_HEAD = ("코드", "이름", "규격", "단위", "재료비", "노무비", "경비", "합계") +_LIST_COST = ("M", "L", "S", "T") def hopyo(saved: dict) -> dict[str, Any]: - """EST 일위대가(HeadMainDat) → 실무자료 읽기 꼴(`M02_Hopyo_Excel.read_book` 짝 · 호표 · 단산) · - STmate = 호표 없음(구성 못 읽음).""" - blocks = numbers(saved) - names = {str(b.get("코드")): n for n, b in blocks.items()} - heads = {str(h.get("코드")): n for n, h in enumerate(saved.get("일위") or [], 1)} + """원파일 → 실무자료 읽기 꼴 — 단산(산출근거 머리)만 · 일위대가 안 만듦(요구 743-2 · HeadMainDat 안 씀).""" out: dict[str, Any] = {"꼴": "원파일", "시트": saved["꼴"], "호표": [], "검산": {"어긋남": []}} out["단산"] = [ {"번": n, "이름": b.get("이름"), "단위": b.get("단위"), "금액": _cost(b.get("저장"))} - for n, b in blocks.items() + for n, b in numbers(saved).items() ] - if saved["꼴"] != "EST": - return out - for n, h in enumerate(saved.get("일위") or [], 1): - lines = [] - for k, ln in enumerate(h.get("줄") or []): - link = ln.get("연결") - code = str((ln.get("자원") or {}).get("코드") or "") - ref = None - if link == 104 and code in names: - ref = {"종류": "단산", "번": names[code]} - elif link == 103 and code in heads: - ref = {"종류": "일위", "번": heads[code]} - kind = "%" if ln.get("연결") in (52, 58, 62) else GALLAE.get(link or 0, "기타") - if kind == "자재" and str(ln.get("단위") or "") == "인": - kind = "노임" - if not any((ln.get("단가") or {}).values()) and kind not in ("%",): - kind = "별도계상" if not ref else kind - lines.append( - { - "차례": k, - "이름": ln.get("품명") or "", - "규격": ln.get("규격") or "", - "수량": _dec(ln.get("수량")), - "단위": ln.get("단위") or "", - "단가": _cost(ln.get("단가")), - "금액": _cost(ln.get("저장")), - "비고": "", - "참조": ref, - "갈래": kind, - "행": ln.get("행"), - "코드": code or None, - } - ) - out["호표"].append( - { - "번": n, - "이름": h.get("이름"), - "규격": h.get("규격") or "", - "단위": h.get("단위") or "", - "금액": _cost(h.get("저장")), - "행": [n, n], - "줄": lines, - } - ) return out +def grid(saved: dict) -> dict[str, Any]: + """원파일 → 단가산출표 한 시트 격자(`M02_Hopyo_Grid.read` 꼴) — 산출근거 머리(코드 · 이름 · 규격 · + 단위 · 저장 금액) 한 줄씩 · 행 = 1 머리 + 블록 차례.""" + cells: dict[str, dict] = {} + cols = "ABCDEFGH" + for c, head in zip(cols, _LIST_HEAD): + cells[f"{c}1"] = {"값": head} + rows = [] + for at, (n, b) in enumerate(numbers(saved).items(), 2): + cost = b.get("저장") or {} + vals = [b.get("코드"), b.get("이름"), b.get("규격"), b.get("단위")] + vals += [_dec(cost.get(k)) for k in _LIST_COST] + for c, v in zip(cols, vals): + if v not in (None, ""): + cells[f"{c}{at}"] = {"값": v if isinstance(v, (int, float)) else str(v)} + rows.append({"번": n, "이름": b.get("이름"), "행": [at, at]}) + sheet = {"id": "s1", "이름": LIST, "칸": cells, "틀고정": {"행": 1}} + sheet["열"] = {c: {"폭": w} for c, w in zip(cols, (10, 40, 18, 6, 12, 12, 12, 12))} + book = {"종류": "통합문서", "판": 1, "열": "", "서식": [{}], "시트": [sheet], "활성": "s1"} + return { + "책": book, + "호표": [], + "단산": rows, + "보기": {LIST: "s1"}, + "원본": {LIST: LIST}, + "목록": {"호표": [], "단산": [{"번": r["번"], "행": r["행"][0]} for r in rows]}, + "줄": [], + } + + def _dec(v: Any) -> int | float | None: """수 → 엑셀 읽기와 같은 꼴(정수 = int · 아니면 float · 없음 = None).""" if v is None: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index cee5e0942..8e0f9632e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -275,7 +275,7 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody { const lastOf = (sid: string): string => lastColumn(book.시트.find((x) => x.id === sid) ?? first); const more = { ...opt, target, grid, last: lastOf, sheet: () => sheet, host: left }; - nav = ids.일위대가표 ? Nav.createSheetNav(more) : null; + nav = ids.일위대가표 || ids.단가산출표 ? Nav.createSheetNav(more) : null; // 원파일 = 단가산출표 판만(814-1) if (nav) warn.after(nav.root); sk = nav ? createSheetSk({ ...opt, target, nav, side: right, ...(opt.onSkChange ? { onChange: opt.onSkChange } : {}) }) : null; // prettier-ignore sheet = createSpreadsheet(left, book, { @@ -290,6 +290,7 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody { if (no !== null && no !== current) mark(no); }, }); + if (!ids.일위대가표 && ids.단가산출표) sk?.at({ 시트: first.id, 칸: "A1" }); // 일위대가 없음 = 단가산출 목록 먼저 nav?.marks(briefs); // 머리 행 칸을 눌러 뗌 → 그 호표 영역 고름(끌기 · 방향키 도중엔 안 바꿈 · 줄 행은 그 줄 그대로) left.querySelector(".ss-grid-host")?.addEventListener("mouseup", (e) => { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts index 7c62c78a3..4ac6a7a91 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Side.ts * 좌측 「일위대가 불러오기」 컨테이너(PLAN 324-4 · 718-1 한시 · 785-4) — - * · [불러오기](구조물 조합 층 단추 꼴 `m02-layers__btn`) = 바로 엑셀 고르기 창 → 올림 → 본 칸 불러오기 보기 + * · [불러오기](구조물 조합 층 단추 꼴 `m02-layers__btn`) = 바로 파일 하나 고르기 창(엑셀 · 경쟁사 .estx · .STC · 814-1) → 올림 → 본 칸 불러오기 보기 * (`M02_MasterTemplete_Hopyo_Import` · 엑셀 + 오른쪽 호표 목록 · 가운데 상세 페이지 없음 · 누름 하나) * · 결과 층 고름 = 작은 고름(쓸 수 있는 층 둘 이상일 때만 · 일반 = 개인 하나라 안 보임) * · 「작업 중」 줄 = 층마다 서버 목록(같은 엑셀 작업 중 이어서) · 누르면 본 칸 불러오기 보기 @@ -67,7 +67,7 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { }); start.classList.add("m02-layers__btn"); start.title = - "다른 프로그램 일위대가 엑셀 → 우리 일위대가(한시 · 끝내거나 떠나면 올린 파일 지움)"; + "파일 하나 — 경쟁사 엑셀(.xls · .xlsx) 또는 경쟁사 파일(.estx · .STC) → 우리 정보로 다시 만듦(한시 · 끝내거나 떠나면 올린 파일 지움)"; start.dataset.hopyoWizardOpen = ""; const seg = el("div", { className: "m02-side__row" }); seg.hidden = true; @@ -145,14 +145,10 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { if (!file) return; start.disabled = true; uploadHopyo(scopeOf(pick), file) - .then((got: HopyoFile & { 이어서?: boolean; 원파일?: boolean; 붙음?: string[] | null }) => { + .then((got: HopyoFile & { 이어서?: boolean; 원파일?: boolean }) => { const tool = /\.estx$/i.test(file.name) ? "EST" : "STmate"; // 810-3 원파일 올림 안내 - if (got.원파일) - showToast( - `원파일 · ${tool} — 호표 ${got.호표수}${got.붙음?.length ? ` · 같은 공사 엑셀 ${got.붙음.join(", ")} 에 붙음` : ""}`, - "success", - ); - else if (got.이어서) showToast(`같은 엑셀 작업 중 — ${got.키} 이어서`, "info"); + if (got.이어서) showToast(`같은 파일 작업 중 — ${got.키} 이어서`, "info"); + else if (got.원파일) showToast(`${tool} 파일 — 단가산출표 · 산출근거`, "success"); else showToast(`올림 — 호표 ${got.호표수}`, got.어긋남 ? "warning" : "success"); if (got.읽기불가) showToast(`가져온 식으로 열 수 없음 — ${got.읽기불가}`, "warning"); return show(pick, got, true); diff --git a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py index fa86b982b..6ea2aedd0 100644 --- a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py +++ b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py @@ -8,9 +8,9 @@ 같은 내용 SK 가 있으면 그것(다시씀). 블록 번 = 판정 블록 번(STmate = 코드 「D00102」 숫자 · EST = 단가산출 블록 번). STmate 블록 끝전 = 파일 투표(`M02_San_Xlsx_Stmate.vote`). -원파일(.estx · .stc · 810-2 `M02_Doc_FromNative`) = 같은 자리 칸 `원파일`(둘 꼴) — 있으면 원파일 우선 -(그 블록 번이 원파일에 있을 때) · 엑셀 올림 때 같은 공사 원파일이 다른 HP 에 있으면 붙임 · 원파일 올림 = -같은 공사 엑셀 HP 에 붙임(없으면 원파일로 새 HP · `upload_native`). +원파일(.estx · .stc · 810-2 `M02_Doc_FromNative`) = 같은 자리 칸 `원파일`(둘 꼴) · 올리기 = 파일 하나 +(814-1 · 요구 742-1 — 같은 공사 엑셀 · 원파일 붙이기 걷음) · 원파일 = 새 HP(단가산출표 판 격자 · 일위대가 없음 · +`upload_native`). """ from __future__ import annotations @@ -63,9 +63,7 @@ def keep(folder: str | Path, key: str, name: str, data: bytes) -> str | None: return f"산근 시트 읽기 오류 — {type(e).__name__}: {str(e)[:80]}" path = _path(folder, key) path.parent.mkdir(parents=True, exist_ok=True) - got = {"파일": name, "시트": sheets, "엑셀": excel} - native = next((n for n in _natives(folder) if fn.same(n, sheets)), None) - atomic_write_json(path, got | ({"원파일": native} if native else {})) + atomic_write_json(path, {"파일": name, "시트": sheets, "엑셀": excel}) return None @@ -85,23 +83,8 @@ def _excel_of(path: Path, saved: dict) -> dict: # ── 원파일(810-2) ────────────────────────────────────────────────────── -def _saved(folder: str | Path) -> list[tuple[Path, dict]]: - out = [] - for path in sorted((hopyo_store._dir(folder) / DIRNAME).glob("HP-*.json")): - try: - out.append((path, json.loads(path.read_bytes()))) - except (OSError, ValueError): - continue - return out - - -def _natives(folder: str | Path) -> list[dict]: - return [d["원파일"] for _, d in _saved(folder) if isinstance(d.get("원파일"), dict)] - - def upload_native(folder: str | Path, name: str, data: bytes, who: Any) -> dict[str, Any]: - """원파일 올림 — 같은 공사 엑셀 HP 에 원파일을 붙임(`붙음` 키들 · 첫 HP 답) · 없으면 원파일로 - 새 HP(EST = 일위대가 호표 · STmate = 호표 없음 · 산근만). 같은 파일 다시 = 그 HP(`이어서`).""" + """원파일 올림 — 새 HP(단가산출표 판 격자 · 산근만 · 일위대가 없음 · 814-1) · 같은 파일 다시 = 그 HP(`이어서`).""" mark = hopyo_store.fingerprint(data) if same := next((r for r in hopyo_store.rows(folder) if r.get("지문") == mark), None): return {**same, "이어서": True, "원파일": True} @@ -111,15 +94,7 @@ def upload_native(folder: str | Path, name: str, data: bytes, who: Any) -> dict[ native = fn.read(name, data) except fn.NativeError as e: raise store.StoreError(400, str(e)) from e - joined = [] - for path, got in _saved(folder): - if got.get("시트") and fn.same(native, got["시트"]): - atomic_write_json(path, {**got, "원파일": native}) - joined.append(path.stem) - rows = {r["키"]: r for r in hopyo_store.rows(folder)} - if joined and joined[0] in rows: - return {**rows[joined[0]], "원파일": True, "붙음": joined} - row = hopyo_store.save(folder, name, fn.hopyo(native), who, None, mark) + row = hopyo_store.save(folder, name, fn.hopyo(native), who, fn.grid(native), mark) path = _path(folder, row["키"]) path.parent.mkdir(parents=True, exist_ok=True) atomic_write_json(path, {"파일": name, "원파일": native}) diff --git a/resources/tester/test_810_2_native_doc.py b/resources/tester/test_810_2_native_doc.py index dd4be6446..a8005b3f8 100644 --- a/resources/tester/test_810_2_native_doc.py +++ b/resources/tester/test_810_2_native_doc.py @@ -6,8 +6,8 @@ (b) 꼴 — 소계 = 합 밖 · 비목 칸 비움(문서 셈 금액만) · 기계 세 줄 = 묶음 한 줄 · 변수 = 이름(상수 아님) · 분수 그림 공식 = 변수 식 · 수 하나 변수 = 숫자 연결 + 출처 「원파일」 · 품셈 조건 글 = 「추정」 표지 (c) 문서 셈 거울 — 비목 없는 합 밖 줄 파이썬 = TS -(d) 올림 길 — 원파일 = 같은 공사 엑셀 HP 에 붙음 · 가져온 식 = 원파일 · 원파일만 = 새 HP(EST 호표 · - STmate 호표 없음) · 원파일 HP 호표 → 일위대가(SC) 금액 = 원파일 · 엑셀만 = 엑셀 길 그대로 +(d) 올림 길(814-1 파일 하나) — 원파일 = 새 HP(단가산출표 판 · 일위대가 없음 · 엑셀 HP 에 안 붙음) · + 가져온 식 = 원파일 · 엑셀 HP = 엑셀 길 그대로 원파일 · 실무 엑셀 없으면 그 시험만 skip(다른 PC). """ @@ -33,7 +33,6 @@ from test_810_1_stc import FILES as STC # noqa: E402 from M02_MasterTemplete import M02_Doc_FromNative as fn # noqa: E402 from M02_MasterTemplete import M02_Doc_Import as dim # noqa: E402 -from M02_MasterTemplete import M02_Hopyo_Build as hb # noqa: E402 from M02_MasterTemplete import M02_Hopyo_Excel as hx # noqa: E402 from M02_MasterTemplete import M02_Hopyo_Store as hs # noqa: E402 from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402 @@ -200,53 +199,34 @@ def _excel_hp(folder: Path, tag: str) -> str: return row["키"] -def test_upload_native_joins_excel_and_wins(world) -> None: +def test_upload_native_one_file_list_only(world) -> None: + """814-1 — 파일 하나: 원파일은 같은 공사 엑셀 HP 에 안 붙고 새 HP(단가산출표 판 · 일위대가 없음).""" folder = world["personal"] - est = _file(EST["거창"]) + est, stc = _file(EST["거창"]), _file(STC["봉화"]) with store.at(folder): hp = _excel_hp(folder, "거창") - before = fh.emit(folder, hp, 15) - assert not before["원본"].get("원파일") # 엑셀만 = 엑셀 길 got = fh.upload_native(folder, est.name, est.read_bytes(), WHO) - assert got["붙음"] == [hp] and got["키"] == hp and got["원파일"] + assert got["원파일"] and got["키"] != hp and "붙음" not in got and got["호표수"] == 0 + assert not fh.emit(folder, hp, 15)["원본"].get("원파일") # 엑셀 HP = 엑셀 길 그대로 again = fh.upload_native(folder, est.name, est.read_bytes(), WHO) - assert again["붙음"] == [hp] # 다시 올려도 새 HP 없음 - assert len(hs.rows(folder)) == 1 - made = fh.make(folder, hp, 15) # 전석 메쌓기 — 안쪽 산근 둘 + 굴삭기 + assert again["이어서"] and again["키"] == got["키"] and len(hs.rows(folder)) == 2 + grid = hs.grid(folder, got["키"]) + assert grid["보기"] == {"단가산출표": "s1"} and not grid["호표"] + assert len(grid["목록"]["단산"]) == len(fn.numbers(native("EST", "거창"))) + made = fh.make(folder, got["키"], 15) # 전석 메쌓기 — 안쪽 산근 둘 + 굴삭기 sk = store.read("sangeun", made["이름"])["문서"] assert sk["가져온"]["원본"]["원파일"] and made["검산"]["같음"] - refs = [x for x in sk["가져온"]["로직"]["호표"] if x.get("참조")] - assert len(refs) == 2 # 산근 속 산근 = 형제 SK 참조(797-1) - doc = dim.of(sk) - assert doc["가져온"]["원파일"] is True - ans = dim.run(doc, sk) + assert len([x for x in sk["가져온"]["로직"]["호표"] if x.get("참조")]) == 2 + ans = dim.run(dim.of(sk), sk) assert (ans["노무비"], ans["재료비"], ans["경비"]) == (74880, 7029, 12306) - # 엑셀 먼저 · 원파일 나중의 반대 — 원파일 HP 가 있으면 엑셀 올림에 붙음 - other = world["company"] - with store.at(other): - fh.upload_native(other, est.name, est.read_bytes(), WHO) - hp2 = _excel_hp(other, "거창") - assert fh.emit(other, hp2, 1)["원본"].get("원파일") - - -def test_upload_native_alone_hopyo_to_sc(world) -> None: - folder = world["personal"] - est, stc = _file(EST["장수"]), _file(STC["봉화"]) - with store.at(folder): - got = fh.upload_native(folder, est.name, est.read_bytes(), WHO) - assert got["원파일"] and not got.get("붙음") and got["호표수"] == 3 - out = hb.build_all(folder, folder, got["키"], None, True, 1) # 제1호표 = SC + 부르는 SK - assert [x["번"] for x in out["만듦"]] == [1] and out["같음"] == 1 and not out["실패"] - assert out["단가산출"] and all( - store.read("sangeun", k)["문서"]["가져온"]["원본"].get("원파일") - for k in out["단가산출"] - ) st = fh.upload_native(folder, stc.name, stc.read_bytes(), WHO) - assert st["원파일"] and st["호표수"] == 0 # STmate 일위대가 구성 못 읽음 — 산근만 + assert st["원파일"] and st["호표수"] == 0 + head = hs.grid(folder, st["키"])["책"]["시트"][0]["칸"] + assert head["A1"]["값"] == "코드" and str(head["A2"]["값"]).startswith("D") made = fh.make(folder, st["키"], next(iter(fn.numbers(native("STC", "봉화"))))) assert made["검산"]["같음"] with pytest.raises(store.StoreError): - fh.upload_native(folder, "깨진.estx", b"PK\x03\x04garbage", WHO) + fh.upload_native(folder, "깨진.estx", b"PKgarbage", WHO) def test_router_branch_by_extension() -> None: