refactor(776-2): 엑셀 한 번 열기(격자 = 읽은 책) · 줄 갈래 한 규칙 · EST 블록 금액 · 항목 · 옛 판 HP 409 「다시 올림」
- Excel — read_book 이 연 책(xls = formatting_info · 목록 시트까지)을 Grid.read 가 이어 씀(opened) · 거창 여는 횟수 2 → 1 · 여덟 파일 올림 시간 17.8 → 7.8 초 - Excel.kind_of = 두 형식 · 붙여넣기 한 규칙(STmate 코드 머리 · EST 칸 · 끝에 노임 올림) · Stmate._kind 걷음 - Excel — EST 블록에 금액 · 항목(ReadEst) 실음 · Basis 대신 ReadEst 직접 - Grid — 읽은 책 그대로 · xlsx 열 폭은 보기 시트 머리 <cols> 만(sheetData 에서 멈춤) · 여덟 파일 격자 결과 전과 같음 - Store.read 다른 판 = 409 「다시 올림」 · Forget.finish 는 옛 판도 끝냄(old=True) - S1 · S2 해시 그대로 · test_718_1 옛 판 열기 409 · 끝내기 시험 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,11 +29,12 @@ import re
|
||||
from decimal import ROUND_FLOOR, Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
|
||||
from M02_MasterTemplete import M02_Hopyo_ReadEst as hopyo_basis # EST 단가산출 어댑터(776-2)
|
||||
from M02_MasterTemplete import M02_Hopyo_Stmate as hopyo_stmate
|
||||
|
||||
SHEET = "일위대가"
|
||||
SUMMARY = "일위대가총괄표"
|
||||
LISTS = ("단가산출총괄표", "일위대가목록표") # 엑셀 보기 목록 시트(779-1 격자) — 같이 읽음
|
||||
COSTS = ("노무비", "재료비", "경비")
|
||||
HEADS = ("호표", "품명", "규격", "수량", "단위", "비고")
|
||||
NO_SHEET = "일위대가 시트 없음"
|
||||
@@ -42,6 +43,16 @@ _REF = re.compile(r"^(?:(단산|산근|단가산출)|(일위대가|일위|대가
|
||||
DRM = "문서 보안(DRM)으로 잠긴 파일 — 보안을 푼 사본으로 저장해 올림"
|
||||
_OLE = b"\xd0\xcf\x11\xe0"
|
||||
_MACHINE_UNITS = ("hr", "시간", "h")
|
||||
_CODE_KIND = {
|
||||
"D": "단가산출",
|
||||
"B": "호표",
|
||||
"L": "노임",
|
||||
"X": "기계",
|
||||
"S": "별도계상",
|
||||
"W": "별도계상",
|
||||
}
|
||||
# ponytail: 마지막 연 책 하나(올림 길 read_book → Grid.read 가 같은 바이트) — 여럿 동시면 다시 엶
|
||||
_OPENED: tuple[bytes, dict[str, list[list[Any]]], Any] | None = None
|
||||
|
||||
|
||||
class ExcelError(ValueError):
|
||||
@@ -82,6 +93,22 @@ def _out(value: Decimal | None) -> int | float | None:
|
||||
# ── 파일 → 시트 표(줄마다 값 목록) ────────────────────────────────────────
|
||||
def _sheets(name: str, data: bytes, wanted: tuple[str, ...] = ()) -> dict[str, list[list[Any]]]:
|
||||
"""시트 이름 → 줄 목록 — 일위대가 · 총괄표 · 단가산출만 읽음(`wanted` = 더 읽을 시트)."""
|
||||
return _open(name, data, wanted)[0]
|
||||
|
||||
|
||||
def opened(name: str, data: bytes, wanted: tuple[str, ...] = ()) -> tuple[dict[str, Any], Any]:
|
||||
"""(시트 줄, xls 책 | None) — 방금 `read_book` 이 연 같은 바이트면 그 책(한 번 열기 · 776-2) ·
|
||||
아니면 엶. xls 책 = xlrd `formatting_info`(격자 서식)."""
|
||||
global _OPENED
|
||||
got, _OPENED = _OPENED, None
|
||||
if got is not None and got[0] is data: # read_book 은 목록 시트(LISTS)까지 읽어 둠
|
||||
return got[1], got[2]
|
||||
return _open(name, data, wanted)
|
||||
|
||||
|
||||
def _open(
|
||||
name: str, data: bytes, wanted: tuple[str, ...] = ()
|
||||
) -> tuple[dict[str, list[list[Any]]], Any]:
|
||||
keep = (SHEET, SUMMARY, hopyo_basis.SHEET, *wanted)
|
||||
if not data:
|
||||
raise ExcelError("빈 파일")
|
||||
@@ -100,15 +127,21 @@ def _sheets(name: str, data: bytes, wanted: tuple[str, ...] = ()) -> dict[str, l
|
||||
if ws.title.strip() in keep
|
||||
}
|
||||
book.close()
|
||||
return out
|
||||
return out, None
|
||||
import xlrd
|
||||
|
||||
book = xlrd.open_workbook(file_contents=data, logfile=io.StringIO())
|
||||
return {
|
||||
try:
|
||||
book = xlrd.open_workbook(
|
||||
file_contents=data, formatting_info=True, logfile=io.StringIO()
|
||||
)
|
||||
except Exception: # 서식 표가 깨진 파일 — 값만(격자는 그때 서식 없이 다시 엶)
|
||||
book = xlrd.open_workbook(file_contents=data, logfile=io.StringIO())
|
||||
rows = {
|
||||
sh.name.strip(): [sh.row_values(r) for r in range(sh.nrows)]
|
||||
for sh in book.sheets()
|
||||
if sh.name.strip() in keep
|
||||
}
|
||||
return rows, book
|
||||
except ExcelError:
|
||||
raise
|
||||
except Exception as error: # 라이브러리마다 다른 예외 — 깨진 파일 · 엑셀 아님
|
||||
@@ -143,7 +176,29 @@ def _money(row: list[Any], cols: dict[str, int], offset: int) -> dict[str, Decim
|
||||
|
||||
# ── 갈래 · 검산 ─────────────────────────────────────────────────────────
|
||||
def kind_of(line: dict[str, Any]) -> str:
|
||||
"""구성 줄 갈래 — 단가산출 · 자재 · 기계 · 호표 · % · 별도계상 · 기타."""
|
||||
"""구성 줄 갈래 한 규칙(776-2 · EST · STmate · 붙여넣기) — 단가산출 · 자재 · 기계 · 노임 ·
|
||||
호표 · % · 별도계상 · 기타. STmate 코드가 있으면 코드 머리 글자 · 없으면 칸(비고 참조 ·
|
||||
단위 · 단가) · 끝에 노임 올림(단위 「인」 + 우리 직종 · 괄호 글 풂)."""
|
||||
kind = _code_kind(line) if line.get("코드") else _cell_kind(line)
|
||||
if kind in ("기타", "자재", "노임") and _flat(line.get("단위")) == "인":
|
||||
return "노임" if hopyo_basis.trade(line.get("이름")) else kind
|
||||
return kind
|
||||
|
||||
|
||||
def _code_kind(line: dict[str, Any]) -> str:
|
||||
code, price = str(line["코드"]), line["단가"]
|
||||
if code.startswith("PR") or _flat(line.get("단위")) == "%":
|
||||
return "%"
|
||||
if code[0] == "M":
|
||||
apart = str(line.get("비고") or "").startswith("별산")
|
||||
apart = apart or "별도계상" in f"{line.get('규격')}{line.get('이름')}"
|
||||
return "별도계상" if apart or not any(price.values()) else "자재"
|
||||
if code[0] == "D" and price.get("재료비") and not price.get("노무비") and not price.get("경비"):
|
||||
return "자재" # 재료비만 = 자재(358-2 · 엑셀꼴과 같음)
|
||||
return _CODE_KIND.get(code[0], "기타")
|
||||
|
||||
|
||||
def _cell_kind(line: dict[str, Any]) -> str:
|
||||
ref = line.get("참조") or {}
|
||||
unit = _flat(line.get("단위")).lower()
|
||||
price = line["단가"]
|
||||
@@ -241,7 +296,9 @@ def _by_name(raw: list[list[dict[str, Any]]], blocks: dict[int, dict[str, Any]])
|
||||
# ── 읽기 ──────────────────────────────────────────────────────────────
|
||||
def read_book(name: str, data: bytes) -> dict[str, Any]:
|
||||
"""엑셀 → `{시트, 호표: [...], 검산: {어긋남: [글 …], 총괄표: 맞춘 수}}` — 값은 JSON 수."""
|
||||
sheets = _sheets(name, data)
|
||||
global _OPENED
|
||||
sheets, xls = _open(name, data, LISTS)
|
||||
_OPENED = (data, sheets, xls) # 격자(`Grid.read`)가 이어 씀 — 한 번 열기
|
||||
if hopyo_stmate.is_stmate(sheets):
|
||||
return hopyo_stmate.read(sheets)
|
||||
rows = sheets.get(SHEET)
|
||||
@@ -306,7 +363,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
|
||||
{**_json(hopyo), "줄": [_json(line) for line in lines]}
|
||||
for hopyo, lines in zip(hopyos, raw, strict=True)
|
||||
]
|
||||
keep = ("번", "이름", "단위", "행", "출처", "직종", "글")
|
||||
keep = ("번", "이름", "단위", "행", "출처", "직종", "글", "금액", "항목")
|
||||
basis = [{k: b[k] for k in keep} for b in blocks.values()]
|
||||
return {
|
||||
"꼴": "엑셀꼴",
|
||||
|
||||
@@ -138,7 +138,7 @@ def _caches(folder: Path, key: str) -> list[Path]:
|
||||
def finish(folder: str | Path, key: str) -> dict[str, Any]:
|
||||
"""불러오기 끝냄 — 만든 문서 사본 걷음 · HP · 격자 · 맞춤셈 캐시 지움 → `{지움, 만든}`."""
|
||||
folder = Path(folder)
|
||||
hopyo_store.read(folder, key) # 없으면 404
|
||||
hopyo_store.read(folder, key, old=True) # 없으면 404 · 옛 판도 끝냄(776-2)
|
||||
head = f"{key} "
|
||||
made = forget(folder, lambda mark: mark.startswith(head))
|
||||
caches = _caches(folder, key) # HP 지우기 전에 자리 셈(HP 지문)
|
||||
|
||||
@@ -24,13 +24,14 @@ import zipfile
|
||||
from typing import Any
|
||||
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.xml.functions import fromstring
|
||||
from openpyxl.xml.functions import fromstring, iterparse
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
|
||||
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel
|
||||
from M02_MasterTemplete import M02_Hopyo_ReadEst as hopyo_basis # EST 단가산출 시트 이름
|
||||
from M02_MasterTemplete import M02_Hopyo_Stmate as hopyo_stmate
|
||||
|
||||
LIST_E, LIST_S, HOPYO_S = "단가산출총괄표", hopyo_stmate.LISTS[0], "일위대가목록표"
|
||||
LIST_E, HOPYO_S = hopyo_excel.LISTS
|
||||
LIST_S = hopyo_stmate.LISTS[0]
|
||||
SHEETS = (
|
||||
hopyo_excel.SHEET,
|
||||
LIST_E,
|
||||
@@ -185,10 +186,8 @@ def _xls_sheet(book: Any, sh: Any, styles: _Styles, sid: str) -> dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
def _xls(data: bytes) -> dict[str, Any]:
|
||||
import xlrd
|
||||
|
||||
book = xlrd.open_workbook(file_contents=data, formatting_info=True, logfile=io.StringIO())
|
||||
def _xls(book: Any) -> dict[str, Any]:
|
||||
"""xlrd 책(`formatting_info` · 읽기가 연 것) → 격자."""
|
||||
styles = _Styles()
|
||||
found = {sh.name.strip(): sh for sh in book.sheets()}
|
||||
sheets = [
|
||||
@@ -203,8 +202,11 @@ def _xls(data: bytes) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _xlsx_widths(data: bytes) -> dict[str, dict[str, dict[str, Any]]]:
|
||||
"""시트 이름 → 열 글자 → `{폭, 숨김}` — 시트 XML `<cols>` 를 읽음(read_only 는 폭을 안 줌)."""
|
||||
def _xlsx_widths(
|
||||
data: bytes, names: tuple[str, ...] | None = None
|
||||
) -> dict[str, dict[str, dict[str, Any]]]:
|
||||
"""시트 이름 → 열 글자 → `{폭, 숨김}` — 시트 XML 머리 `<cols>` 만 읽음(`<sheetData>` 에서 멈춤 ·
|
||||
read_only 는 폭을 안 줌) · `names` = 이 시트만."""
|
||||
out: dict[str, dict[str, dict[str, Any]]] = {}
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
@@ -216,11 +218,15 @@ def _xlsx_widths(data: bytes) -> dict[str, dict[str, dict[str, Any]]]:
|
||||
for sheet in fromstring(zf.read("xl/workbook.xml")).iter():
|
||||
if tag(sheet) != "sheet":
|
||||
continue
|
||||
if names is not None and sheet.get("name", "").strip() not in names:
|
||||
continue
|
||||
rid = next((v for k, v in sheet.attrib.items() if k.endswith("}id")), None)
|
||||
path = (rels.get(rid) or "").lstrip("/")
|
||||
path = path if path.startswith("xl/") else f"xl/{path}"
|
||||
cols: dict[str, dict[str, Any]] = {}
|
||||
for col in fromstring(zf.read(path)).iter():
|
||||
for _, col in iterparse(zf.open(path), events=("start",)):
|
||||
if tag(col) == "sheetData":
|
||||
break
|
||||
if tag(col) != "col":
|
||||
continue
|
||||
first, last = int(col.get("min", 0)), min(int(col.get("max", 0)), 200)
|
||||
@@ -240,7 +246,7 @@ def _xlsx(
|
||||
) -> dict[str, Any]:
|
||||
# ponytail: xlsx 는 값 · 열 폭만(병합 · 서식은 openpyxl 비 read_only 로 무거움) — 필요하면 그때
|
||||
found = found or hopyo_excel._sheets(name, data, EXTRA)
|
||||
widths = _xlsx_widths(data)
|
||||
widths = _xlsx_widths(data, SHEETS)
|
||||
sheets = []
|
||||
for at, title in enumerate((n for n in SHEETS if n in found), 1):
|
||||
rows = found[title]
|
||||
@@ -319,9 +325,8 @@ def _lists(rows: dict[str, Any], parsed: dict[str, Any]) -> dict[str, list[dict[
|
||||
def read(name: str, data: bytes, parsed: dict[str, Any]) -> dict[str, Any]:
|
||||
"""엑셀 → `{책: Workbook, 호표, 단산, 보기, 원본, 목록, 줄}` — `parsed` =
|
||||
`M02_Hopyo_Excel.read_book` 결과(행 범위)."""
|
||||
xlsx = data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx")
|
||||
rows = hopyo_excel._sheets(name, data, EXTRA) # ponytail: xls 는 서식 없이 한 번 더 읽음
|
||||
book = _xlsx(name, data, rows) if xlsx else _xls(data)
|
||||
rows, xls = hopyo_excel.opened(name, data, EXTRA) # 읽기가 연 책 그대로(한 번 열기 · 776-2)
|
||||
book = _xlsx(name, data, rows) if xls is None else _xls(xls)
|
||||
keep = ("번", "이름", "행")
|
||||
ids = {s["이름"]: s["id"] for s in book["시트"]}
|
||||
on = {role: next((n for n in names if n in ids), None) for role, names in ROLES.items()}
|
||||
|
||||
@@ -45,7 +45,6 @@ _COLS = {"합계": 4, "노무비": 6, "재료비": 8, "경비": 10}
|
||||
_CALC_AMOUNT = {"합계": 2, "노무비": 3, "재료비": 4, "경비": 5} # 단가산출근거 C~F 금액
|
||||
_CALC_PRICE = {"합계": 8, "노무비": 9, "재료비": 10, "경비": 11} # I~L 단가(T · L · M · S)
|
||||
NOTE, CODE_COLS, RES_COL, QTY_COL, NAME_COL = 12, range(12, 26), 12, 7, 23
|
||||
_KIND = {"D": "단가산출", "B": "호표", "L": "노임", "X": "기계", "S": "별도계상", "W": "별도계상"}
|
||||
|
||||
|
||||
def is_stmate(names: Any) -> bool:
|
||||
@@ -199,18 +198,6 @@ def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str
|
||||
|
||||
|
||||
# ── 일위대가표 ────────────────────────────────────────────────────────
|
||||
def _kind(code: str, line: dict[str, Any]) -> str:
|
||||
if code.startswith("PR") or _flat(line["단위"]) == "%":
|
||||
return "%"
|
||||
price = line["단가"]
|
||||
if code[0] == "M":
|
||||
apart = line["비고"].startswith("별산") or "별도계상" in f"{line['규격']}{line['이름']}"
|
||||
return "별도계상" if apart or not any(price.values()) else "자재"
|
||||
if code[0] == "D" and price.get("재료비") and not price.get("노무비") and not price.get("경비"):
|
||||
return "자재" # 재료비만 = 자재(358-2 · 엑셀꼴과 같음)
|
||||
return _KIND.get(code[0], "기타")
|
||||
|
||||
|
||||
def _hopyos(rows: list[list[Any]]) -> tuple[list[dict[str, Any]], list[list[dict[str, Any]]]]:
|
||||
hopyos: list[dict[str, Any]] = []
|
||||
raw: list[list[dict[str, Any]]] = []
|
||||
@@ -258,7 +245,7 @@ def _hopyos(rows: list[list[Any]]) -> tuple[list[dict[str, Any]], list[list[dict
|
||||
"코드": code,
|
||||
"행": at,
|
||||
}
|
||||
line["갈래"] = _kind(code, line)
|
||||
line["갈래"] = hopyo_excel.kind_of(line) # 갈래 한 규칙(776-2)
|
||||
line["참조"] = (
|
||||
{"종류": "단산", "번": int(code[1:]), "코드": code}
|
||||
if code[0] == "D"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
(작업 중)만 · 옛 HP · 하루 지난 HP 는 목록에서 숨김(지움 = `M02_Hopyo_Forget`) ·
|
||||
`실무자료/불러옴.json` `{번}` = 낸 키 가장 큰 번(지운 뒤에도 키를 다시 안 씀 · 옛 `지문` 안 읽음).
|
||||
판(776-2) = 읽기 꼴(Book) 판 `BOOK` 을 HP 에 찍음 · 다른 판 = 지난 것(목록에서 숨김 ·
|
||||
다음 올림 때 끝냄 · 옛 꼴 읽기 갈래 없음 — 다시 올림).
|
||||
다음 올림 때 끝냄 · 작업 중 열기 = 409 「다시 올림」 · 옛 꼴 읽기 갈래 없음).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -30,6 +30,7 @@ MEMORY = "맞춤"
|
||||
GRID = "격자"
|
||||
DONE = "불러옴"
|
||||
NO_GRID = "격자 없음 — 다시 올리면 보임"
|
||||
RELOAD = "읽기 꼴이 바뀐 옛 불러오기 — 엑셀을 다시 올림"
|
||||
EXPIRE = timedelta(days=1) # 작업 중 HP 보관 — 올린 뒤 하루
|
||||
BOOK = 2 # 읽기 꼴(Book) 판 — 읽기 · 정규화 출력 꼴을 바꾸면 올림
|
||||
_KEY = re.compile(r"^HP-(\d{6})$")
|
||||
@@ -106,10 +107,13 @@ def _put_done(folder: str | Path, got: dict[str, Any]) -> None:
|
||||
atomic_write_json(_dir(folder) / f"{DONE}.json", got)
|
||||
|
||||
|
||||
def read(folder: str | Path, key: str) -> dict[str, Any]:
|
||||
def read(folder: str | Path, key: str, old: bool = False) -> dict[str, Any]:
|
||||
"""HP — 다른 판 = 409 「다시 올림」(`old` = 끝내기 · 정리만 옛 판도 읽음)."""
|
||||
doc = _load(_path(folder, key))
|
||||
if not isinstance(doc, dict):
|
||||
raise store.StoreError(404, f"없는 실무자료 「{key}」")
|
||||
if not old and doc.get("판") != BOOK:
|
||||
raise store.StoreError(409, RELOAD)
|
||||
return doc
|
||||
|
||||
|
||||
|
||||
@@ -98,7 +98,21 @@ def test_결과_단가산출_산근_블록_기초_SK_고름대기_겹침_없음(
|
||||
|
||||
|
||||
def test_판_다른_HP_지난_것():
|
||||
"""776-2 — 읽기 꼴 판이 다르면(옛 판 · 판 없음) 막 올렸어도 지난 것 = 목록 숨김 · 다음 올림 때 끝냄."""
|
||||
"""776-2 — 읽기 꼴 판이 다르면(옛 판 · 판 없음) 막 올렸어도 지난 것(숨김 · 올림 때 끝냄)."""
|
||||
now = {"올린때": "2999-01-01T00:00:00Z"}
|
||||
assert hs.stale({**now, "판": hs.BOOK}, now=None) is False
|
||||
assert hs.stale(now) and hs.stale({**now, "판": hs.BOOK - 1})
|
||||
|
||||
|
||||
def test_판_다른_HP_열기_409_끝내기는_됨(client):
|
||||
"""776-2 — 작업 중 옛 판 HP 를 열면 409 「다시 올림」 · [끝내기]는 옛 판도 지움."""
|
||||
folder = layers.personal_dir(7, 42)
|
||||
assert _post(client).json()["키"] == "HP-000001"
|
||||
path = folder / hs.DIRNAME / "HP-000001.json"
|
||||
doc = json.loads(path.read_bytes())
|
||||
path.write_text(json.dumps({**doc, "판": hs.BOOK - 1}, ensure_ascii=False), encoding="utf-8")
|
||||
res = client.get(URL.format("personal") + "/HP-000001")
|
||||
assert res.status_code == 409 and res.json()["detail"] == hs.RELOAD
|
||||
assert _keys(client) == [] # 목록 숨김
|
||||
assert client.delete(URL.format("personal") + "/HP-000001").status_code == 200
|
||||
assert not path.exists()
|
||||
|
||||
Reference in New Issue
Block a user