feat(812-1): 엑셀 읽기기 새로 — xlsx · xls → 810-1 구조 줄 꼴 → 원파일 길 한 벌 · 골든 시험 · 올림 길 교체
- M02_San_Xlsx(공통 · 글자 칸 배치 · 금액 칸 · 자원 표) · M02_San_Xlsx_Est(EST 글 → 표지: 변수 = 가장 늦은 대입 값 하나 · 칸 나눔 꼴은 닫는 표지 2 칸 · 합계 줄 · 세 줄 머리 · 노임 · 자재 단가 짝 · 소계 61 · 율 58 · 62 · 구간 글자) · M02_San_Xlsx_Stmate(숨은 G 열 원문 = stc_line · 끝전 파일 투표) - M02_Doc_FromNative: 엑셀 둘 꼴(번 · 시트 행 · 원본 엑셀 참 · 원파일 거짓) · M02_Doc_Import: 엑셀 길도 소계 비목 칸 비움 · 추정 칸(출처 = 엑셀 꼬리표) - M02_Sangeun_FromHopyo: 산근 블록 = 새 읽기기(옛 San_Est · Emit.est 부름 걷음 · 옛 저장은 한 번 지어 둠) - test_812_1: 실제 짝(거창 · STC 다섯) 같음 · 합성 배치 = 거창 실제 · 합성 EST 1,849 · STC 371 · 금액 실무 11(엇갈림 고정 목록) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -50,19 +50,27 @@ def read(name: str, data: bytes) -> dict[str, Any]:
|
||||
def pack(got: dict, name: str) -> dict[str, Any]:
|
||||
"""파서 답 → 둘 꼴(셈 · 변환에 쓰는 칸만 · JSON)."""
|
||||
out: dict[str, Any] = {"꼴": got["꼴"], "파일": name, "표지": got.get("표지") or []}
|
||||
head = ("코드", "이름", "규격", "단위", "저장")
|
||||
out |= {"엑셀": True} if got.get("엑셀") else {} # 812-1 엑셀 읽기기(`M02_San_Xlsx`) — 같은 꼴
|
||||
head = ("코드", "번", "이름", "규격", "단위", "저장")
|
||||
if got.get("엑셀"): # 엑셀 읽기기 — 줄 행 = 시트 행(원문 카드 · 옛 엑셀 길과 같은 자리)
|
||||
got = {**got, "산근": [_rows(b) for b in got["산근"]]}
|
||||
if got["꼴"] == "EST":
|
||||
out["산근"] = [
|
||||
{**{k: b.get(k) for k in head}, "줄": [_est_line(ln) for ln in b["줄"]]}
|
||||
{**{k: b.get(k) for k in head if k in b}, "줄": [_est_line(ln) for ln in b["줄"]]}
|
||||
for b in got["산근"]
|
||||
]
|
||||
out["일위"] = [{**{k: b.get(k) for k in head}, "줄": b["줄"]} for b in got["일위"]]
|
||||
out["일위"] = [
|
||||
{**{k: b.get(k) for k in head if k in b}, "줄": b["줄"]} for b in got["일위"]
|
||||
]
|
||||
return out
|
||||
out["산근"] = [
|
||||
{**{k: b.get(k) for k in head}, "줄": [{"행": ln["행"], "글": ln["글"]} for ln in b["줄"]]}
|
||||
{
|
||||
**{k: b.get(k) for k in head if k in b},
|
||||
"줄": [{"행": ln["행"], "글": ln["글"]} for ln in b["줄"]],
|
||||
}
|
||||
for b in got["산근"]
|
||||
]
|
||||
out["일위"] = [{k: b.get(k) for k in head} for b in got["일위"]]
|
||||
out["일위"] = [{k: b.get(k) for k in head if k in b} for b in got["일위"]]
|
||||
res = got.get("자원") or {}
|
||||
out["단가"] = {
|
||||
code: {**p, **{k: (res.get(code) or {}).get(k) for k in ("이름", "규격", "단위")}}
|
||||
@@ -72,6 +80,10 @@ def pack(got: dict, name: str) -> dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
def _rows(b: dict) -> dict:
|
||||
return {**b, "줄": [{**ln, "행": ln.get("엑셀행") or ln["행"]} for ln in b["줄"]]}
|
||||
|
||||
|
||||
def _est_line(ln: dict) -> dict:
|
||||
one = {k: ln.get(k) for k in _EST_KEYS if ln.get(k) not in (None, [], False, "")}
|
||||
if one.get("자원"):
|
||||
@@ -81,9 +93,9 @@ def _est_line(ln: dict) -> dict:
|
||||
|
||||
# ── 블록 ──────────────────────────────────────────────────────────────
|
||||
def numbers(saved: dict) -> dict[int, dict]:
|
||||
"""블록 번 → 산근 블록(엑셀 길 번과 같음)."""
|
||||
"""블록 번 → 산근 블록(엑셀 길 번과 같음 · 엑셀 읽기기 EST = 시트 「N.」 번)."""
|
||||
if saved["꼴"] == "EST":
|
||||
return {i: b for i, b in enumerate(saved["산근"], 1)}
|
||||
return {int(b.get("번") or i): b for i, b in enumerate(saved["산근"], 1)}
|
||||
return {int(str(b["코드"])[1:]): b for b in saved["산근"] if str(b["코드"])[1:].isdigit()}
|
||||
|
||||
|
||||
@@ -91,10 +103,10 @@ def emit(saved: dict, no: int, keys: bool = True) -> dict[str, Any]:
|
||||
"""원파일 산근 블록 하나 → `{로직, 원본}`(원본 `원파일` True · 코드 · 확정 수) · 없는 번 = KeyError."""
|
||||
blk = numbers(saved)[int(no)]
|
||||
if saved["꼴"] == "EST":
|
||||
names = {b["코드"]: i for i, b in enumerate(saved["산근"], 1)}
|
||||
names = {b["코드"]: n for n, b in numbers(saved).items()}
|
||||
out = ne.block(blk, int(no), keys, saved["파일"], names)
|
||||
out["원본"] |= {"원코드": blk["코드"]} | ({"규격": blk["규격"]} if blk.get("규격") else {})
|
||||
return out
|
||||
return _excel(saved, out)
|
||||
from M02_MasterTemplete import M02_San_Stmate as stm
|
||||
|
||||
nodes = [{"행": ln["행"], "글": ln["글"], **stm.parse_line(ln["글"])} for ln in blk["줄"]]
|
||||
@@ -106,6 +118,15 @@ def emit(saved: dict, no: int, keys: bool = True) -> dict[str, Any]:
|
||||
marks = sum(1 for one in out["원본"]["글줄"] if one.get("표지"))
|
||||
sure = len(out["로직"]["호표"]) + len(out["로직"].get("덧줄") or []) + len(out["로직"]["중간"])
|
||||
out["원본"] |= {"원파일": True, "갈래": "STmate", "확정": {"확정": sure, "표지": marks}}
|
||||
return _excel(saved, out)
|
||||
|
||||
|
||||
def _excel(saved: dict, out: dict) -> dict[str, Any]:
|
||||
"""엑셀 읽기기 꼴(812-1) — 원본 `원파일` 거짓 · `엑셀` 참(화면 꼬리표 「엑셀」 · 원코드 = 단산 번)."""
|
||||
if saved.get("엑셀"):
|
||||
out["원본"] |= {"원파일": False, "엑셀": True}
|
||||
out["원본"] |= {"시트": "단가산출"} if saved["꼴"] == "EST" else {}
|
||||
out["원본"].pop("원코드", None)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -304,11 +304,16 @@ def derive(sk: dict, ctx: tuple | None = None, whole: Any = None) -> dict:
|
||||
doc, notes = dc.cv.convert(_Row(master, key, row), row, given)
|
||||
_attach(doc, row, ids, src)
|
||||
_numbers(doc, keep)
|
||||
if (meta.get("원본") or {}).get("원파일"): # 원파일(810-2) — 소계 비목 칸 비움 · 값 변수 출처
|
||||
_native(doc)
|
||||
native = bool((meta.get("원본") or {}).get("원파일"))
|
||||
if native or (meta.get("원본") or {}).get(
|
||||
"엑셀"
|
||||
): # 810-2 · 812-1 — 소계 비목 칸 비움 · 값 변수 출처
|
||||
_native(doc, native)
|
||||
head = {k: (meta.get("원본") or {}).get(k) for k in HEAD if (meta.get("원본") or {}).get(k)}
|
||||
marks = list((meta.get("원본") or {}).get("표지") or [])
|
||||
if (meta.get("원본") or {}).get("원파일"): # 810-3 — 품셈 조건 글(값 없음) = 화면 「추정」 칸
|
||||
if native or (meta.get("원본") or {}).get(
|
||||
"엑셀"
|
||||
): # 810-3 — 품셈 조건 글(값 없음) = 「추정」 칸
|
||||
head["추정"] = [
|
||||
{"행": x.get("행"), "글": " ".join(str(x.get("글") or "").split())}
|
||||
for x in meta["원본"].get("글줄") or []
|
||||
@@ -322,13 +327,14 @@ def derive(sk: dict, ctx: tuple | None = None, whole: Any = None) -> dict:
|
||||
return {**doc, "키": str(sk.get("키") or key), "이름": str(sk.get("이름") or doc.get("이름"))}
|
||||
|
||||
|
||||
def _native(doc: dict) -> None:
|
||||
"""원파일 문서 — 소계 덧줄(합 밖) 비목 칸 비움(금액만) · 수 하나 변수(옵션 칸) 출처 「원파일」."""
|
||||
def _native(doc: dict, native: bool = True) -> None:
|
||||
"""원파일 · 엑셀 읽기기 문서 — 소계 덧줄(합 밖) 비목 칸 비움(금액만) · 수 하나 변수(옵션 칸) 출처
|
||||
「원파일」(엑셀 = 출처 없음 → 화면 꼬리표 「엑셀」)."""
|
||||
for line in doc.get("금액") or []:
|
||||
if line.get("출처") == SUB and line.get("합밖"):
|
||||
line.pop("비목", None)
|
||||
for var in doc.get("변수") or []:
|
||||
if (var.get("연결") or {}).get("갈래") == "숫자" and XL not in var["연결"]:
|
||||
if native and (var.get("연결") or {}).get("갈래") == "숫자" and XL not in var["연결"]:
|
||||
var["출처"] = "원파일"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""경쟁사 산근 엑셀(.xlsx · .xls) 읽기기 — 810-1 원파일 파서와 같은 「구조 줄」 꼴(PLAN 812-1 · 요구 711-2 ·
|
||||
712 · 713 · 읽기만 · 서버 단독).
|
||||
|
||||
`read(name, data)` = `M02_San_Estx.read` · `M02_San_Stc.read` 와 같은 칸 이름 · 같은 뜻
|
||||
(칸 뜻 `resources/master_data/_원파일파서_틀.md`) → 그 뒤는 원파일 길(`M02_Doc_FromNative.pack` →
|
||||
`emit` → `M02_Doc_NativeEst` · `M02_San_Emit.stmate` → 수식 문서) 그대로.
|
||||
EST = `M02_San_Xlsx_Est`(단가산출 시트 글 · 금액 칸 · 같은 책 단가표) ·
|
||||
STmate = `M02_San_Xlsx_Stmate`(숨은 G 열 원문 = 원파일 원문 · 단가 = 목록표 · 끝전 = 파일 투표).
|
||||
엑셀이 못 주는 칸(자원 코드 · LinkIndex · 자리 Diget)은 비움 + 표지 「엑셀 — …」 · 글에서 읽어 정한 것은
|
||||
`연결뜻` 에 적고 줄 `추정` 참 + 블록 표지 「엑셀 추정」.
|
||||
여기 = 공통(시트 → 글 줄 · 금액 칸 · 자원 표).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
|
||||
GUESS = "엑셀 추정"
|
||||
NOTE = "엑셀 — "
|
||||
|
||||
|
||||
class XlsxError(ValueError):
|
||||
"""산근 시트가 없는 엑셀."""
|
||||
|
||||
|
||||
def flat(v: Any) -> str:
|
||||
return re.sub(r"\s+", "", str(v or ""))
|
||||
|
||||
|
||||
def number(v: Any) -> float | None:
|
||||
"""칸 값 → 수(「3,442」 · 「8.」 · 수 칸) · 아니면 None."""
|
||||
if isinstance(v, bool) or v is None:
|
||||
return None
|
||||
if isinstance(v, (int, float)):
|
||||
return float(v)
|
||||
s = str(v).strip().replace(",", "")
|
||||
if not re.fullmatch(r"-?\d+(?:\.\d*)?|-?\.\d+", s):
|
||||
return None
|
||||
return float(s.rstrip(".") or 0)
|
||||
|
||||
|
||||
def _cell(v: Any) -> str:
|
||||
if isinstance(v, float) and v == int(v):
|
||||
return str(int(v))
|
||||
return "" if v is None else str(v)
|
||||
|
||||
|
||||
def _width(ch: str) -> int:
|
||||
return 2 if unicodedata.east_asian_width(ch) in "WFA" else 1
|
||||
|
||||
|
||||
def layout(cells: list[tuple[int, str]], base: int) -> str:
|
||||
"""글자 칸 나눔 줄(거창 꼴 · 열 = 화면 자리) → 한 줄 글(열 차례 · 모자란 자리 = 빈칸 · 겹친 자리(수 끝 점)
|
||||
= 빈칸 하나)."""
|
||||
out, end = "", 0
|
||||
for col, text in cells:
|
||||
want = col - base
|
||||
if want > end:
|
||||
out += " " * (want - end)
|
||||
elif out and want < end and not out.endswith(" "):
|
||||
out += " "
|
||||
out += text
|
||||
end = want + sum(_width(c) for c in text)
|
||||
return out
|
||||
|
||||
|
||||
# ── 시트 머리 ──────────────────────────────────────────────────────────
|
||||
MONEY = ("합계", "노무비", "재료비", "경비")
|
||||
|
||||
|
||||
def money_cols(rows: list[list[Any]]) -> list[int] | None:
|
||||
"""머리 줄의 맨 오른쪽 「합계 · 노무비 · 재료비 · 경비」 네 칸 열 → [T L M S]."""
|
||||
for r in rows[:6]:
|
||||
names = [flat(v) for v in r]
|
||||
hits = [j for j in range(len(names) - 3) if tuple(names[j : j + 4]) == MONEY]
|
||||
if hits:
|
||||
return list(range(hits[-1], hits[-1] + 4))
|
||||
return None
|
||||
|
||||
|
||||
def money_of(row: list[Any], cols: list[int]) -> dict[str, float] | None:
|
||||
got = {c: number(row[j]) if j < len(row) else None for c, j in zip("TLMS", cols)}
|
||||
got = {c: v for c, v in got.items() if v is not None}
|
||||
return got or None
|
||||
|
||||
|
||||
def head_col(rows: list[list[Any]], *words: str) -> int | None:
|
||||
for r in rows[:6]:
|
||||
for j, v in enumerate(r):
|
||||
if flat(v) in words:
|
||||
return j
|
||||
return None
|
||||
|
||||
|
||||
# ── 자원 표(EST) ───────────────────────────────────────────────────────
|
||||
def _table(rows: list[list[Any]], price: tuple[str, ...]) -> list[dict[str, Any]]:
|
||||
"""단가 시트 → [{번, 이름, 규격, 단위, 줄 (원 줄)}](품명 · 규격 · 단위 · 값 열 = 머리 글)."""
|
||||
c_no, c_name = head_col(rows, "No"), head_col(rows, "품명")
|
||||
c_size, c_unit = head_col(rows, "규격"), head_col(rows, "단위")
|
||||
if c_name is None:
|
||||
return []
|
||||
out = []
|
||||
for r in rows:
|
||||
no = str(r[c_no]).strip() if c_no is not None and c_no < len(r) else ""
|
||||
if not no.isdigit() or c_name >= len(r) or not str(r[c_name] or "").strip():
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"번": int(no),
|
||||
"이름": str(r[c_name]).strip(),
|
||||
"규격": str(r[c_size] or "").strip() if c_size is not None else "",
|
||||
"단위": str(r[c_unit] or "").strip() if c_unit is not None else "",
|
||||
"줄": r,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def resources(sheets: dict[str, list]) -> dict[str, list[dict[str, Any]]]:
|
||||
"""같은 책 단가표 → {종류: [{번 · 이름 · 규격 · 단위 · 표 · 값 {T L M S}}]}(노임 · 자재 = 적용단가 ·
|
||||
기계 · 견적 · 산근 = 적용 칸(합이 안 맞으면 당초 · `M02_San_Book.applied`))."""
|
||||
out: dict[str, list[dict[str, Any]]] = {}
|
||||
for kind, sheet, comp in (("노임", "노임단가", "L"), ("자재", "자재단가", "M")):
|
||||
rows = sheets.get(sheet) or []
|
||||
c = head_col(rows, "적용단가")
|
||||
got = []
|
||||
for one in _table(rows, ("적용단가",)):
|
||||
v = number(one["줄"][c]) if c is not None and c < len(one["줄"]) else None
|
||||
if v is not None:
|
||||
got.append({**_drop(one), "표": sheet, "값": {"T": v, comp: v}})
|
||||
out[kind] = got
|
||||
for kind, sheet in (
|
||||
("기계", "기계경비총괄표"),
|
||||
("견적", "견적단가"),
|
||||
("산근", "단가산출총괄표"),
|
||||
):
|
||||
got = []
|
||||
for one in _table(sheets.get(sheet) or [], ()):
|
||||
r = list(one["줄"]) + [None] * 30
|
||||
vals = {k: float(v or 0) for k, v in book.applied(r).items()}
|
||||
got.append({**_drop(one), "표": sheet, "값": vals})
|
||||
out[kind] = got
|
||||
return out
|
||||
|
||||
|
||||
def _drop(one: dict) -> dict:
|
||||
return {k: v for k, v in one.items() if k != "줄"}
|
||||
|
||||
|
||||
def kind_of(sheets: dict[str, list]) -> str | None:
|
||||
return book.kind_of(sheets)
|
||||
|
||||
|
||||
def read(name: str, data: bytes) -> dict[str, Any]:
|
||||
"""엑셀 → {꼴, 산근, 일위, 자원, 내역, 표지(, 단가 · 옵션 STmate)} — 산근 시트 없음 = XlsxError."""
|
||||
sheets = book.sheets(name, data)
|
||||
return read_sheets(sheets)
|
||||
|
||||
|
||||
def read_sheets(sheets: dict[str, list]) -> dict[str, Any]:
|
||||
kind = kind_of(sheets)
|
||||
if kind == "EST":
|
||||
from M02_MasterTemplete import M02_San_Xlsx_Est as xe
|
||||
|
||||
return xe.read(sheets) | {"엑셀": True}
|
||||
if kind == "STmate":
|
||||
from M02_MasterTemplete import M02_San_Xlsx_Stmate as xs
|
||||
|
||||
return xs.read(sheets) | {"엑셀": True}
|
||||
raise XlsxError("STmate · EST 산근 시트 없음")
|
||||
@@ -0,0 +1,621 @@
|
||||
"""EST 산근 엑셀 → 810-1 구조 줄(PLAN 812-1 · `M02_San_Estx.cost_line` 과 같은 칸 · 읽기만 · 서버 단독).
|
||||
|
||||
엑셀 「단가산출」 시트 = 원파일 LineData 를 보인 글(제어문자 표지 없음) + 줄 금액 칸(원파일 줄 TC NC GC JC ·
|
||||
0 = 빈칸 · 소계 61 = 비목 칸만) → 표지를 글 · 금액 칸 · 같은 책 단가표(노임 · 자재 · 기계 · 견적 · 단가산출
|
||||
총괄표)로 다시 정함(`연결뜻` · 표지 「엑셀 추정」 · 자원 코드 · 자리 = 비움):
|
||||
변수 줄 「이름 = 식 = 값」 → 값 = 결과(`\\x12`) · 「이름 = 값」 → 대입(결과 없음) · 금액 줄 첫 식 조각의 수가
|
||||
앞 대입 값과 같으면 변수(`\\x11` · 상수 아님) ·
|
||||
합계 머리(「합 계」) 아래 노무비 · 재료비 · 경비 · 계 줄 = 합계(`\\x1a` · 비목 L M S T) ·
|
||||
「* 단산 N 참조 *」 · 「* 견적 N 참조 *」 · 기계 이름(+규격) = 세 줄 머리(104 · 101 · 105) → 아래
|
||||
「노무비 · 재료비 · 경비 : 단가 …」(단가 = 그 자원 비목 값) = 세 줄 · 「소 계」 = 계 줄 ·
|
||||
노임 · 자재 이름 + 그 단가 수 = 자원 줄(5 · 6 · 비목 = 금액 칸) ·
|
||||
「…소계 : 수」(금액 칸 = 비목만) = 소계 61(줄참조) · 「수 × n% = 값」 + 금액 칸 = 율 58(줄참조) ·
|
||||
「(A 수 + 수) × n%」 = 율 62(구간 합) · 「= A수」 = 구간 표지(`\\x06A\\x06`) · 그 밖 = 글 줄.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_San_Xlsx as sx
|
||||
|
||||
SHEET = "단가산출"
|
||||
LINK = {"자재": 5, "노임": 6, "견적": 101, "산근": 104, "기계": 105, "소계": 61, "율": 58}
|
||||
MEAN = {5: "자재", 6: "노임", 101: "견적", 104: "산근", 105: "기계", 61: "소계", 58: "율"}
|
||||
MEAN[62] = "율"
|
||||
LABEL = {"노무비": "L", "재료비": "M", "경비": "S", "계": "T"}
|
||||
NUM = r"\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?|\.\d+" # 「.1」 = 엑셀 꼴 0.1
|
||||
_NUM = re.compile(r"(?<![A-Za-z0-9_.,])(-?(?:" + NUM + r"))\.?(?![\d,])")
|
||||
_HEAD = re.compile(r"^(\d+)\.(.*?)\[([^\[\]]*)\]\s*$")
|
||||
_HEADING = re.compile(r"^\s*(?:\(\d+\)|(?:\d+|[가-하])\s*[.)])\s*")
|
||||
_REF = re.compile(r"\*\s*(단산|견적)\s*(\d+)\s*참조\s*\*")
|
||||
_ASSIGN = re.compile(r"(?<![A-Za-z0-9_])([A-Za-z][A-Za-z0-9]*)\s*(?:\([^)=]*\))?\s*=(?!=)")
|
||||
_GROUP62 = re.compile(r"^\s*\(?\s*([A-Z])\s*(" + NUM + r")((?:\s*\+\s*(?:" + NUM + r"))*)")
|
||||
_UNITS = {"hr", "m", "km", "kg", "ton", "sec", "min", "mm", "cm", "ea", "t"}
|
||||
_COLON = re.compile(r"[::]")
|
||||
|
||||
|
||||
def _v(s: str) -> float:
|
||||
return float(s.replace(",", "").rstrip("."))
|
||||
|
||||
|
||||
def _close(a: float, b: float) -> bool:
|
||||
return abs(a - b) < 1e-6 * max(1.0, abs(b))
|
||||
|
||||
|
||||
def _span(text: str, name: str, start: int = 0) -> tuple[int, int] | None:
|
||||
"""빈칸을 건너 같은 글자 차례 자리(이름 · 규격) · 없음 None."""
|
||||
chars = [re.escape(c) for c in sx.flat(name)]
|
||||
if not chars:
|
||||
return None
|
||||
m = re.compile(r"\s*".join(chars)).search(text, start)
|
||||
return (m.start(), m.end()) if m else None
|
||||
|
||||
|
||||
# ── 시트 → 블록 ───────────────────────────────────────────────────────
|
||||
def blocks(rows: list[list[Any]]) -> list[dict[str, Any]]:
|
||||
"""단가산출 시트 → [{번, 머리, 행, 저장, 줄 [{행, 엑셀행, 보임, 돈}]}](머리 = B 열 「N.이름[단위]」)."""
|
||||
cols = sx.money_cols(rows) or [2, 3, 4, 5]
|
||||
split = cols[0] > 6 # 거창 꼴 — 글자 칸 나눔(열 = 화면 자리 · B 열 = 머리만)
|
||||
out: list[dict[str, Any]] = []
|
||||
for i, r in enumerate(rows):
|
||||
head = r[1] if len(r) > 1 else None
|
||||
m = _HEAD.match(head) if isinstance(head, str) and not head[:1].isspace() else None
|
||||
if m and i > 2:
|
||||
out.append({"번": int(m.group(1)), "머리": head, "행": i + 1, "줄": []})
|
||||
out[-1]["저장"] = sx.money_of(r, cols)
|
||||
continue
|
||||
if not out:
|
||||
continue
|
||||
if split:
|
||||
cells = [(j, sx._cell(v)) for j, v in enumerate(r[2 : cols[0]], 2)]
|
||||
text = sx.layout([(j, v) for j, v in cells if v.strip()], 2)
|
||||
else:
|
||||
text = sx._cell(head)
|
||||
one = {"행": len(out[-1]["줄"]) + 1, "엑셀행": i + 1, "보임": text, "칸": split}
|
||||
out[-1]["줄"].append(one | {"돈": sx.money_of(r, cols)})
|
||||
for b in out: # 끝 빈 줄(시트 꼬리)
|
||||
while b["줄"] and not b["줄"][-1]["보임"].strip() and not b["줄"][-1]["돈"]:
|
||||
b["줄"].pop()
|
||||
return out
|
||||
|
||||
|
||||
# ── 줄 ────────────────────────────────────────────────────────────────
|
||||
def _label(text: str) -> str | None:
|
||||
"""「노무비 :」 · 「경 비 :」 · 「계 :」 → 비목 글자 · 아니면 None."""
|
||||
m = _COLON.search(text)
|
||||
if not m:
|
||||
return None
|
||||
return LABEL.get(sx.flat(_HEADING.sub("", text[: m.start()])))
|
||||
|
||||
|
||||
def _nums(text: str, lo: int = 0, hi: int | None = None) -> list[tuple[int, int, float]]:
|
||||
hi = len(text) if hi is None else hi
|
||||
return [(m.start(1), m.end(1), _v(m.group(1))) for m in _NUM.finditer(text, lo, hi)]
|
||||
|
||||
|
||||
def _result(text: str) -> tuple[int, int, float, int | None] | None:
|
||||
"""마지막 「=」 뒤 첫 수(구간 글자 「A」 붙음 허용) → (시작, 끝, 값, 글자 자리)."""
|
||||
eq = text.rfind("=")
|
||||
if eq < 0:
|
||||
return None
|
||||
m = re.match(r"\s*(?:([A-Z])\s*)?(-?(?:" + NUM + r"))\.?(?![\d,])", text[eq + 1 :])
|
||||
if not m:
|
||||
return None
|
||||
at = eq + 1
|
||||
return at + m.start(2), at + m.end(2), _v(m.group(2)), at + m.start(1) if m.group(1) else None
|
||||
|
||||
|
||||
class Lines:
|
||||
"""블록 한 벌 줄 → 구조 줄."""
|
||||
|
||||
def __init__(self, res: dict[str, list[dict]], tags: list[str]) -> None:
|
||||
self.res, self.tags = res, tags
|
||||
self.vars: dict[str, float] = {} # 이름 → 마지막 대입 값
|
||||
self.ctx: dict | None = None # 세 줄 머리 자원 {종류, 자원}
|
||||
self.triple = False # 바로 앞이 세 줄
|
||||
self.heads: list[dict] = [] # 세 줄 머리 줄들
|
||||
self.lines: list[dict[str, Any]] = []
|
||||
|
||||
# ── 꼴 ─────────────────────────────────────────────────────────
|
||||
def blank(self, src: dict) -> dict[str, Any]:
|
||||
return {
|
||||
"행": src["행"],
|
||||
"엑셀행": src["엑셀행"],
|
||||
"글": src["보임"],
|
||||
"보임": src["보임"],
|
||||
"품명": None,
|
||||
"규격": None,
|
||||
"단위": None,
|
||||
"단": 0,
|
||||
"색인": 0,
|
||||
"연결": 0,
|
||||
"연결뜻": None,
|
||||
"비목": None,
|
||||
"자원": None,
|
||||
"연결단가": [],
|
||||
"변수": [],
|
||||
"줄참조": [],
|
||||
"결과": [],
|
||||
"합계": False,
|
||||
"자리": {"자리수": None, "방법": None},
|
||||
"표지": None,
|
||||
"기계": None,
|
||||
"산근": None,
|
||||
"저장": src.get("돈"),
|
||||
"추정": False,
|
||||
"_marks": [],
|
||||
"_칸": src.get("칸"),
|
||||
}
|
||||
|
||||
def resource(self, kind: str, one: dict) -> dict[str, Any]:
|
||||
code = (
|
||||
f"단산{one['번']}" if kind == "산근" else f"{one['표']}#{one['번']}"
|
||||
) # 원 코드 없음 = 표 줄 번
|
||||
out = {"코드": code, "종류": kind, "표": one["표"]}
|
||||
return out | {k: one[k] for k in ("이름", "규격", "단위")} | {"저장": dict(one["값"])}
|
||||
|
||||
def link(self, ln: dict, kind: str, one: dict | None) -> None:
|
||||
ln["연결"] = LINK[kind] if kind in LINK else 62
|
||||
ln["연결뜻"] = MEAN.get(ln["연결"])
|
||||
ln["추정"] = True
|
||||
if one is not None:
|
||||
ln["자원"] = self.resource(kind, one)
|
||||
if kind == "산근":
|
||||
ln["산근"] = ln["자원"]["코드"]
|
||||
|
||||
@staticmethod
|
||||
def money_part(ln: dict) -> str | None:
|
||||
"""금액 칸 비목 — 노 · 재 · 경 중 값 하나 · 셋 다 = T · 없음 = None."""
|
||||
got = [c for c in "LMS" if (ln["저장"] or {}).get(c)]
|
||||
return got[0] if len(got) == 1 else ("T" if got else None)
|
||||
|
||||
@staticmethod
|
||||
def closed(text: str, end: int) -> bool:
|
||||
"""칸 나눔 꼴 — 표지 붙은 수 뒤는 닫는 표지 2 칸(빈칸 · 수 끝 점 2 이상 · 줄 끝)."""
|
||||
gap = len(text[end:]) - len(text[end:].lstrip(" ."))
|
||||
return gap >= 2 or not text[end:].strip()
|
||||
|
||||
def wrap(self, ln: dict, s: int, e: int, ch: str) -> None:
|
||||
ln["_marks"].append((s, e, ch))
|
||||
|
||||
# ── 자원 찾기 ─────────────────────────────────────────────────
|
||||
def names(self, ln: dict, one: dict, start: int = 0) -> int | None:
|
||||
"""이름 · 규격 · 단위 자리 표지(`\\x0e` · `\\x13` · `\\x15`) · 끝 자리."""
|
||||
text = ln["보임"]
|
||||
sp = _span(text, one["이름"], start)
|
||||
if not sp:
|
||||
return None
|
||||
self.wrap(ln, *sp, "\x0e")
|
||||
ln["품명"] = text[sp[0] : sp[1]]
|
||||
end = sp[1]
|
||||
if one.get("규격"):
|
||||
m = re.match(r"\s*", text[end:])
|
||||
at = end + m.end()
|
||||
size = _span(text, one["규격"], at)
|
||||
if size and size[0] - at <= 1:
|
||||
lo = size[0] - (1 if text[size[0] - 1 : size[0]] in "([" and size[0] > at else 0)
|
||||
hi = size[1] + (1 if text[size[1] : size[1] + 1] in ")]" and lo < size[0] else 0)
|
||||
self.wrap(ln, lo, hi, "\x13")
|
||||
ln["규격"] = text[lo:hi]
|
||||
end = hi
|
||||
unit = re.match(r"\s*(\[[^\[\]]*\])", text[end:])
|
||||
if unit:
|
||||
self.wrap(ln, end + unit.start(1), end + unit.end(1), "\x15")
|
||||
ln["단위"] = unit.group(1)
|
||||
end += unit.end(1)
|
||||
return end
|
||||
|
||||
def find_item(self, ln: dict) -> tuple[str, dict, tuple[int, int, float]] | None:
|
||||
"""노임 · 자재 — 글 속 수가 그 단가이고 이름이 그 수 앞에 있음(가장 긴 이름 · 규격 맞음 먼저)."""
|
||||
text, body = ln["보임"], sx.flat(ln["보임"])
|
||||
best = None
|
||||
for hit in _nums(text):
|
||||
for kind, one in self.res["_값"].get(round(hit[2], 4), ()):
|
||||
name = sx.flat(one["이름"])
|
||||
if not name or name not in body:
|
||||
continue
|
||||
sp = _span(text, one["이름"])
|
||||
if not sp or sp[1] > hit[0]:
|
||||
continue
|
||||
key = (not one["규격"] or sx.flat(one["규격"]) in body, len(name))
|
||||
if best is None or key > best[0]:
|
||||
best = (key, kind, one, hit)
|
||||
return best[1:] if best else None
|
||||
|
||||
def find_head(self, ln: dict) -> tuple[str, dict] | None:
|
||||
"""세 줄 머리 — 「* 단산 N 참조 *」 · 「* 견적 N 참조 *」 · 기계 이름(+규격)."""
|
||||
text = ln["보임"]
|
||||
m = _REF.search(text)
|
||||
if m:
|
||||
kind = "산근" if m.group(1) == "단산" else "견적"
|
||||
one = self.res["_번"].get((kind, int(m.group(2))))
|
||||
if kind == "견적" and (not one or sx.flat(one["이름"]) not in sx.flat(text)):
|
||||
body = sx.flat(text[: m.start()]) # 견적 번 ≠ 표 차례 — 이름으로(가장 긴 것)
|
||||
hits = [x for x in self.res.get("견적") or [] if sx.flat(x["이름"]) in body]
|
||||
one = (
|
||||
max(hits, key=lambda x: len(sx.flat(x["이름"]) + sx.flat(x["규격"])))
|
||||
if hits
|
||||
else None
|
||||
)
|
||||
return (kind, one) if one else None
|
||||
if "=" in text or self.res["_기계틀"] is None or text.lstrip()[:1] in ("※", "*"):
|
||||
return None
|
||||
body, best = sx.flat(text), None
|
||||
for at in self.res["_기계틀"].finditer(body):
|
||||
name = at.group(1)
|
||||
for one in self.res["_기계"][name]:
|
||||
size = sx.flat(one["규격"])
|
||||
tail = body[at.start() + len(name) : at.start() + len(name) + len(size) + 2]
|
||||
if size and size not in tail:
|
||||
continue
|
||||
key = len(name) + len(size)
|
||||
if best is None or key > best[0]:
|
||||
best = (key, one)
|
||||
return ("기계", best[1]) if best else None
|
||||
|
||||
# ── 줄 갈래 ───────────────────────────────────────────────────
|
||||
def run(self, src: dict) -> dict[str, Any]:
|
||||
ln = self.blank(src)
|
||||
text = ln["보임"]
|
||||
if not text.strip() and not ln["저장"]:
|
||||
ln["연결"] = None
|
||||
return self.done(ln)
|
||||
heading = bool(_HEADING.match(text))
|
||||
part = _label(text)
|
||||
if heading:
|
||||
ln["단"] = 1
|
||||
title = sx.flat(_HEADING.sub("", text))
|
||||
if re.search(r"[합소총]계|^계", title):
|
||||
self.heads = [] # 합계 절 — 세 줄 머리 닫음
|
||||
self.triple = False
|
||||
if self.triple and "소계" in sx.flat(text.split(":")[0]) and self.ctx:
|
||||
return self.done(self.triple_total(ln))
|
||||
self.triple = False
|
||||
if part in ("L", "M", "S") and not heading and self.triple_line(ln, part):
|
||||
return self.done(ln)
|
||||
got = self.find_item(ln)
|
||||
total = ln["저장"] if part == "T" else not ln["저장"] # 합계 = 계 줄만 금액 칸
|
||||
if part and not heading and got is None and total:
|
||||
return self.done(self.total_line(ln, part))
|
||||
if got is not None:
|
||||
return self.done(self.item(ln, *got))
|
||||
if (head := self.find_head(ln)) is not None:
|
||||
kind, one = head
|
||||
self.link(ln, kind, one)
|
||||
end = self.names(ln, one)
|
||||
loose = end is None or (one["규격"] and ln["규격"] is None) # 규격이 이름 곁에 없음
|
||||
loose = loose or text[end or 0 :].lstrip()[:1] in (":", ":")
|
||||
if ln["단"] == 0 and kind == "기계" and loose: # 설명 글 속 장비 이름
|
||||
ln |= {"연결": 0, "연결뜻": None, "자원": None, "추정": False, "품명": None}
|
||||
ln |= {"규격": None, "단위": None, "_marks": []}
|
||||
self.plain(ln)
|
||||
return self.done(ln)
|
||||
self.heads.append({"종류": kind, "자원": one, "줄": ln, "씀": False})
|
||||
self.ctx = self.heads[-1]
|
||||
return self.done(ln)
|
||||
if self.subtotal(ln) or self.rate62(ln) or self.rate(ln):
|
||||
return self.done(ln)
|
||||
self.plain(ln)
|
||||
return self.done(ln)
|
||||
|
||||
def unused(self) -> None:
|
||||
"""머리 아닌 줄의 기계 이름 — 아래 세 줄이 안 쓰면 글 줄(설명 속 장비 이름)."""
|
||||
for h in self.heads:
|
||||
ln = h["줄"]
|
||||
if h["종류"] == "기계" and not h["씀"] and ln["단"] == 0:
|
||||
ln |= {"연결": 0, "연결뜻": None, "자원": None, "추정": False, "품명": None}
|
||||
ln |= {"규격": None, "단위": None, "_marks": []}
|
||||
|
||||
def done(self, ln: dict) -> dict[str, Any]:
|
||||
self.lines.append(ln)
|
||||
return ln
|
||||
|
||||
def total_line(self, ln: dict, part: str) -> dict:
|
||||
"""합계 줄 — 「노무비 : 식 = 값」 · 「재료비 : 값」 · 「계 : 값」(계 = 결과)."""
|
||||
text = ln["보임"]
|
||||
ln |= {"합계": True, "비목": part, "단": -1}
|
||||
lead = len(text) - len(text.lstrip())
|
||||
res = _result(text)
|
||||
if res:
|
||||
self.wrap(ln, lead, text.rfind("=") + 1, "\x1a")
|
||||
self.result(ln, res)
|
||||
elif part == "T":
|
||||
colon = _COLON.search(text).end()
|
||||
self.wrap(ln, lead, colon, "\x1a")
|
||||
nums = _nums(text, colon)
|
||||
if nums:
|
||||
self.wrap(ln, nums[0][0], nums[0][1], "\x12")
|
||||
ln["결과"] = [nums[0][2]]
|
||||
else:
|
||||
self.wrap(ln, lead, len(text.rstrip()), "\x1a")
|
||||
return ln
|
||||
|
||||
def result(self, ln: dict, res: tuple) -> None:
|
||||
s, e, v, letter = res
|
||||
if letter is not None:
|
||||
self.wrap(ln, letter, letter + 1, "\x06")
|
||||
self.wrap(ln, s, e, "\x12")
|
||||
ln["결과"].append(v)
|
||||
|
||||
def triple_line(self, ln: dict, part: str) -> bool:
|
||||
"""세 줄 — 단가 = 머리 자원(기계 · 산근 · 견적)의 그 비목 값."""
|
||||
text = ln["보임"]
|
||||
colon = _COLON.search(text).end()
|
||||
nums = _nums(text, colon)
|
||||
if not nums or not self.heads:
|
||||
return False
|
||||
price = nums[0]
|
||||
tail = text[price[1] :]
|
||||
if ("=" not in tail and tail.strip()) or tail.lstrip()[:1] == "+":
|
||||
return False
|
||||
fit = [
|
||||
h
|
||||
for h in reversed(self.heads)
|
||||
if _close(price[2], float(h["자원"]["값"].get(part) or 0))
|
||||
]
|
||||
if not fit:
|
||||
return False
|
||||
self.ctx = fit[0]
|
||||
self.ctx["씀"] = True
|
||||
one = self.ctx["자원"]
|
||||
self.link(ln, self.ctx["종류"], one)
|
||||
ln["비목"] = part
|
||||
if self.ctx["종류"] == "기계":
|
||||
ln["기계"] = True
|
||||
self.money(ln, price)
|
||||
self.triple = True
|
||||
return True
|
||||
|
||||
def triple_total(self, ln: dict) -> dict:
|
||||
one = self.ctx["자원"]
|
||||
self.link(ln, self.ctx["종류"], one)
|
||||
ln["비목"] = "T"
|
||||
text = ln["보임"]
|
||||
res = _result(text)
|
||||
if res:
|
||||
self.result(ln, res)
|
||||
else:
|
||||
m = re.search(r"[::]\s*(?:([A-Z])\s*)?(" + NUM + r")", text)
|
||||
if m:
|
||||
self.result(
|
||||
ln, (m.start(2), m.end(2), _v(m.group(2)), m.start(1) if m.group(1) else None)
|
||||
)
|
||||
money = ln["저장"] or {}
|
||||
rows = []
|
||||
for one in reversed(self.lines):
|
||||
if one["연결"] != ln["연결"] or one["비목"] not in ("L", "M", "S"):
|
||||
break
|
||||
rows.append(one)
|
||||
cols = [c for c in "LMS" if money.get(c)]
|
||||
total = sum((one["결과"] or [0])[-1] for one in rows)
|
||||
if len(cols) == 1 and money.get("T") and abs(total - money["T"]) < 1:
|
||||
for one in rows: # 계 줄 금액 칸 비목 하나 = 값 있는 세 줄 모두 그 비목으로 옮김
|
||||
if (one["결과"] or [0])[-1]:
|
||||
one["비목"] = cols[0]
|
||||
elif money:
|
||||
for one in rows: # 계 줄 금액 칸이 빈 비목 = 합에 안 든 줄(비목 없음)
|
||||
if not money.get(one["비목"]) and one["결과"] and one["결과"][-1]:
|
||||
one["비목"] = None
|
||||
self.triple = False
|
||||
return ln
|
||||
|
||||
def item(self, ln: dict, kind: str, one: dict, price: tuple) -> dict:
|
||||
self.link(ln, kind, one)
|
||||
self.names(ln, one)
|
||||
part = self.money_part(ln)
|
||||
ln["비목"] = part if part in ("L", "M", "S") else None
|
||||
self.money(ln, price)
|
||||
return ln
|
||||
|
||||
def money(self, ln: dict, price: tuple[int, int, float]) -> None:
|
||||
"""금액 줄 — 단가(`\\x03`) · 첫 식 조각의 앞 대입 값 = 변수(`\\x11`) · 끝 = 결과."""
|
||||
text = ln["보임"]
|
||||
s, e, v = price
|
||||
self.wrap(ln, s, e, "\x03")
|
||||
ln["연결단가"] = [v]
|
||||
self.var(ln, e)
|
||||
res = _result(text)
|
||||
if res and res[0] > e:
|
||||
self.result(ln, res)
|
||||
|
||||
def var(self, ln: dict, start: int) -> None:
|
||||
"""첫 식 조각(start ~ 첫 「=」)의 수 중 가장 늦게 대입한 변수 값 하나 = 변수(`\\x11` · 원파일 = 줄마다 하나) ·
|
||||
칸 나눔 꼴은 닫는 표지 자리(2 칸)가 있는 수만."""
|
||||
text = ln["보임"]
|
||||
eq = text.find("=", start)
|
||||
order = list(self.vars.values())
|
||||
best = None
|
||||
for a, b, val in _nums(text, start, eq if eq >= 0 else None):
|
||||
rank = max((i for i, x in enumerate(order) if _close(val, x)), default=-1)
|
||||
if ln["_칸"] and not self.closed(text, b):
|
||||
continue
|
||||
if rank >= 0 and (best is None or rank > best[0]):
|
||||
best = (rank, a, b, val)
|
||||
if best:
|
||||
self.wrap(ln, best[1], best[2], "")
|
||||
ln["변수"].append(best[3])
|
||||
|
||||
def subtotal(self, ln: dict) -> bool:
|
||||
"""「…소계 : 수」 — 금액 칸 계 없음 · 비목 하나(61) · 금액 칸 없음 = 비목 없음."""
|
||||
text = ln["보임"]
|
||||
m = _COLON.search(text)
|
||||
if not m or "소계" not in sx.flat(text[: m.start()]):
|
||||
return False
|
||||
money = ln["저장"] or {}
|
||||
parts = [c for c in "LMS" if money.get(c)]
|
||||
if len(parts) != 1 or (
|
||||
money.get("T") is not None and not _close(money["T"], money[parts[0]])
|
||||
):
|
||||
return False # 소계 61 = 금액 칸 비목 하나(계 칸 없음 · 있으면 같은 값)
|
||||
got = re.match(r"\s*(?:([A-Z])\s*)?(" + NUM + r")\.?(?![\d,])", text[m.end() :])
|
||||
if not got:
|
||||
return False
|
||||
at = m.end()
|
||||
if got.group(1):
|
||||
self.wrap(ln, at + got.start(1), at + got.end(1), "\x06")
|
||||
self.wrap(ln, at + got.start(2), at + got.end(2), "\x0c")
|
||||
ln["줄참조"] = [_v(got.group(2))]
|
||||
ln["저장"] = {**money, "T": money[parts[0]]}
|
||||
self.link(ln, "소계", None)
|
||||
ln["비목"] = self.money_part(ln)
|
||||
ln["표지"] = "소계"
|
||||
self.var(ln, at + got.end(2))
|
||||
res = _result(text)
|
||||
if res and res[0] > at + got.end(2):
|
||||
self.result(ln, res)
|
||||
return True
|
||||
|
||||
def rate62(self, ln: dict) -> bool:
|
||||
"""「(A 수 + 수) × n% = 값」 · 「A 수 + 수 = 합 × n% = 값」 — 구간 합 율(62 · 합계 표지)."""
|
||||
text = ln["보임"]
|
||||
m = _GROUP62.search(text)
|
||||
if not m or not re.search(r"[×xX*]", text[m.end() :]) or not ln["저장"]:
|
||||
return False
|
||||
self.wrap(ln, m.start(1), m.end(1), "\x07")
|
||||
self.wrap(ln, m.start(2), m.end(3), "\x1a")
|
||||
ln |= {"합계": True}
|
||||
self.link(ln, "율62", None)
|
||||
ln["비목"] = self.money_part(ln)
|
||||
ln["표지"] = "율"
|
||||
for r in re.finditer(r"=\s*(?:([A-Z])\s*)?(" + NUM + r")\.?(?![\d,])", text):
|
||||
self.result(
|
||||
ln, (r.start(2), r.end(2), _v(r.group(2)), r.start(1) if r.group(1) else None)
|
||||
)
|
||||
return True
|
||||
|
||||
def rate(self, ln: dict) -> bool:
|
||||
"""「수 × n% = 값」 + 금액 칸 — 율(58 · 첫 수 = 줄참조)."""
|
||||
text = ln["보임"]
|
||||
res = _result(text)
|
||||
if not ln["저장"] or not res:
|
||||
return False
|
||||
colon = _COLON.search(text)
|
||||
nums = _nums(text, colon.end() if colon else 0, text.rfind("="))
|
||||
seen = [v for x in self.lines for v in x["결과"] + x["줄참조"]]
|
||||
before = any(_close(nums[0][2], v) for v in seen) if nums else False
|
||||
lead = text[colon.end() if colon else 0 :].lstrip(" (")
|
||||
before = before and lead[:1].isdigit()
|
||||
if not nums or ("%" not in text[: text.rfind("=")] and not before):
|
||||
return False
|
||||
self.wrap(ln, nums[0][0], nums[0][1], "\x0c")
|
||||
ln["줄참조"] = [nums[0][2]]
|
||||
self.link(ln, "율", None)
|
||||
ln["비목"] = self.money_part(ln)
|
||||
ln["표지"] = "율"
|
||||
self.result(ln, res)
|
||||
return True
|
||||
|
||||
def plain(self, ln: dict) -> None:
|
||||
"""글 · 변수 줄 — 대입마다 「이름 = 식 = 값」 = 결과 · 그 밖 「… = 값」(식 있는 줄) = 결과."""
|
||||
text = ln["보임"]
|
||||
ln["연결"] = 0
|
||||
part = self.money_part(ln)
|
||||
ln["비목"] = part
|
||||
hits: list[re.Match] = []
|
||||
for m in _ASSIGN.finditer(text):
|
||||
if m.group(1).lower() in _UNITS and re.search(r"\d\.?\s?$", text[: m.start()]):
|
||||
continue # 「8 hr =」 = 단위
|
||||
gap = text[hits[-1].end() : m.start()] if hits else ""
|
||||
if not hits or (re.search(r"\d", gap) and not re.search(r"[×÷+\-*/(=]\s*$", gap)):
|
||||
hits.append(m)
|
||||
lead = text[: hits[0].start()].strip(" ○●·-{") if hits else ""
|
||||
if hits and (not lead or re.search(r"[::]$", lead)):
|
||||
for n, m in enumerate(hits):
|
||||
end = hits[n + 1].start() if n + 1 < len(hits) else len(text)
|
||||
seg = text[m.end() : end]
|
||||
parts = seg.split("=")
|
||||
val = re.match(r"\s*(?:[A-Z](?=\d))?(" + NUM + r")", parts[-1])
|
||||
if not val:
|
||||
continue
|
||||
at = m.end() + len(seg) - len(parts[-1])
|
||||
if len(parts) >= 2:
|
||||
res = _result(text[:end])
|
||||
if res and res[0] >= at:
|
||||
self.result(ln, res)
|
||||
self.vars.pop(m.group(1), None)
|
||||
self.vars[m.group(1)] = _v(val.group(1))
|
||||
return
|
||||
eq = text.rfind("=")
|
||||
seg = re.split(r"[=::]", text[:eq])[-1]
|
||||
if eq > 0 and re.search(r"[×÷+*]|\d\s*/|/\s*\d", seg):
|
||||
res = _result(text)
|
||||
if res:
|
||||
self.result(ln, res)
|
||||
|
||||
|
||||
# ── 책 ────────────────────────────────────────────────────────────────
|
||||
def _glyph(text: str, marks: list[tuple[int, int, str]]) -> str:
|
||||
"""보인 글 + 표지 자리 → 원파일 LineData 꼴(닫는 표지 먼저)."""
|
||||
ins: list[tuple[int, int, str]] = []
|
||||
for s, e, ch in marks:
|
||||
ins.append((s, 1, ch))
|
||||
ins.append((e, 0, ch))
|
||||
out, last = [], 0
|
||||
for at, _, ch in sorted(ins, key=lambda x: (x[0], x[1])):
|
||||
out.append(text[last:at])
|
||||
out.append(ch)
|
||||
last = at
|
||||
out.append(text[last:])
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def index(res: dict[str, list[dict]]) -> dict[str, Any]:
|
||||
"""자원 표 → 찾기 색인(단가 → 노임 · 자재 · 이름 → 기계 · (종류, 번) → 산근 · 견적)."""
|
||||
out: dict[str, Any] = dict(res)
|
||||
out["_값"] = {}
|
||||
for kind in ("노임", "자재"):
|
||||
for one in res.get(kind) or []:
|
||||
out["_값"].setdefault(round(one["값"]["T"], 4), []).append((kind, one))
|
||||
out["_기계"] = {}
|
||||
for one in res.get("기계") or []:
|
||||
if sx.flat(one["이름"]):
|
||||
out["_기계"].setdefault(sx.flat(one["이름"]), []).append(one)
|
||||
names = sorted(out["_기계"], key=len, reverse=True)
|
||||
out["_기계틀"] = re.compile("(?=(" + "|".join(map(re.escape, names)) + "))") if names else None
|
||||
out["_번"] = {(k, one["번"]): one for k in ("산근", "견적") for one in res.get(k) or []}
|
||||
return out
|
||||
|
||||
|
||||
def read(sheets: dict[str, list]) -> dict[str, Any]:
|
||||
"""EST 엑셀 시트 → {꼴, 산근, 일위, 자원, 내역, 표지}(`M02_San_Estx.read` 꼴 · 일위 = 호표 길)."""
|
||||
res = sx.resources(sheets)
|
||||
look = index(res)
|
||||
total = {one["번"]: one for one in res.get("산근") or []}
|
||||
out: dict[str, Any] = {"꼴": "EST", "산근": [], "일위": [], "내역": None}
|
||||
for b in blocks(sheets.get(SHEET) or []):
|
||||
tags: list[str] = []
|
||||
one = total.get(b["번"])
|
||||
m = _HEAD.match(b["머리"])
|
||||
head = {
|
||||
"코드": f"단산{b['번']}",
|
||||
"이름": one["이름"] if one else m.group(2).strip(),
|
||||
"규격": one["규격"] if one else "",
|
||||
"단위": one["단위"] if one else m.group(3).strip(),
|
||||
"저장": b["저장"] or (dict(one["값"]) if one else None),
|
||||
}
|
||||
if not one:
|
||||
tags.append(f"{sx.NOTE}총괄표에 단산{b['번']} 없음")
|
||||
work = Lines(look, tags)
|
||||
for src in b["줄"]:
|
||||
work.run(src)
|
||||
work.unused()
|
||||
lines = []
|
||||
for ln in work.lines:
|
||||
marks = ln.pop("_marks")
|
||||
ln.pop("_칸", None)
|
||||
ln["글"] = _glyph(ln["보임"], marks)
|
||||
lines.append(ln)
|
||||
out["산근"].append({**head, "번": b["번"], "엑셀행": b["행"], "줄": lines, "표지": tags})
|
||||
out["자원"] = {
|
||||
f"{kind}:{one['번']}": {k: one[k] for k in ("이름", "규격", "단위", "표")}
|
||||
| {"저장": one["값"]}
|
||||
for kind, rows in res.items()
|
||||
for one in rows
|
||||
}
|
||||
out["표지"] = [
|
||||
f"{sx.NOTE}자원 코드 · LinkIndex · 자리(Diget) 없음 — 연결뜻 = 글 · 금액 칸 · 단가표로 정함({sx.GUESS})",
|
||||
f"{sx.NOTE}자원 코드 = 단가표 이름#No(산근 = 단산 번)",
|
||||
]
|
||||
return out
|
||||
@@ -0,0 +1,70 @@
|
||||
"""STmate 산근 엑셀 → 810-1 구조 줄(PLAN 812-1 · `M02_San_Stc.read` 와 같은 꼴 · 읽기만 · 서버 단독).
|
||||
|
||||
엑셀 「단가산출근거」 숨은 G 열 = 원파일 COSTN.SMT 원문 그대로 → 줄 = `M02_San_Stc.stc_line`(풀이 =
|
||||
`M02_San_Stmate.parse_line` · 다시 짜지 않음) · 블록 머리 금액(C~F) = 원파일 저장(TAMT1 …) ·
|
||||
단가 = 목록표(노무비 · 재료비 · 경비 · 중기 · 단가산출근거 · 숨은 메타 = COSTN 칸) ·
|
||||
끝전 = 파일 투표(`_원파일파서_틀.md` 4 장 SAN_TYP 두 갈래 중 블록 계가 맞는 수가 많은 쪽 · 파일에 하나 ·
|
||||
같으면 비목버림).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
from M02_MasterTemplete import M02_San_Eval as ev
|
||||
from M02_MasterTemplete import M02_San_Stc as stc
|
||||
from M02_MasterTemplete import M02_San_Xlsx as sx
|
||||
|
||||
RULE = {"비목버림": "1", "합버림_큰나머지": "0"} # 끝전 갈래 → SAN_TYP
|
||||
|
||||
|
||||
def _amt(head: dict) -> dict[str, float]:
|
||||
return {c: float(head.get(k) or 0) for c, k in zip("TLMS", "CDEF")}
|
||||
|
||||
|
||||
def vote(blocks: list[dict], prices: dict) -> tuple[str, dict[str, int]]:
|
||||
"""파일 끝전 — 블록마다 두 갈래로 셈해(낙찰 · 조정 뒤) 엑셀 머리 계(C)와 맞는 수 · 많은 쪽(같으면
|
||||
비목버림)."""
|
||||
got = dict.fromkeys(RULE, 0)
|
||||
for blk in blocks:
|
||||
nodes = [ln["풀이"] for ln in blk["줄"]]
|
||||
want = (blk.get("저장") or {}).get("T")
|
||||
for rule in RULE:
|
||||
try:
|
||||
total = ev.stmate_block({"코드": blk["코드"]}, nodes, prices, rule)["금액"]["T"]
|
||||
except Exception: # noqa: BLE001 — 못 셈 = 표 없음
|
||||
continue
|
||||
got[rule] += want is not None and abs(float(total) - float(want)) < 0.5
|
||||
best = max(RULE, key=lambda r: (got[r], r == "비목버림"))
|
||||
return best, got
|
||||
|
||||
|
||||
def read(sheets: dict[str, list]) -> dict[str, Any]:
|
||||
"""STmate 엑셀 시트 → {꼴, 산근, 일위, 자원, 내역, 단가, 옵션, 표지}."""
|
||||
found = book.stmate_prices(sheets)
|
||||
prices = {c: {k: p.get(k) for k in ("T", "L", "M", "S", "meta")} for c, p in found.items()}
|
||||
res = {
|
||||
c: {"종류": stc.KIND.get(c[:1]), **{k: p.get(k) for k in ("이름", "규격", "단위")}}
|
||||
| {"저장": {k: p.get(k) for k in "TLMS"}}
|
||||
for c, p in found.items()
|
||||
}
|
||||
out: dict[str, Any] = {"꼴": "STmate", "산근": [], "일위": [], "자원": res, "단가": prices}
|
||||
for blk in book.stmate_blocks(sheets):
|
||||
tags: list[str] = []
|
||||
lines = [stc.stc_line(i, ln["G"], res, tags) for i, ln in enumerate(blk["몸"], 1)]
|
||||
for one, src in zip(lines, blk["몸"]):
|
||||
one["엑셀행"] = src["행"]
|
||||
me = found.get(blk["코드"]) or {}
|
||||
head = {"코드": blk["코드"], "이름": blk.get("이름") or me.get("이름")}
|
||||
head |= {"규격": me.get("규격"), "단위": me.get("단위"), "저장": _amt(blk["머리"])}
|
||||
_, tot = ev.run_stmate([ln["풀이"] for ln in lines], prices)
|
||||
out["산근"].append({**head, "줄": lines, "줄합": tot, "표지": tags, "엑셀행": blk["행"]})
|
||||
rule, got = vote(out["산근"], prices)
|
||||
out["옵션"] = {"SAN_TYP": RULE[rule]}
|
||||
out["내역"] = None
|
||||
out["표지"] = [
|
||||
f"{sx.NOTE}일위대가 구성 · 내역은 호표 길(산근만 읽음)",
|
||||
f"{sx.NOTE}끝전 = 파일 투표 {rule}(비목버림 {got['비목버림']} · 합버림 {got['합버림_큰나머지']} 블록)",
|
||||
]
|
||||
return out
|
||||
@@ -1,14 +1,13 @@
|
||||
"""M02 불러오기 단산 블록 → 가져온 식 단가산출(PLAN 793-3b · 요구 646 · 647 · 652) — 서버 단독.
|
||||
|
||||
올림 때 경쟁사 산근 시트(`M02_San_Book.sheets` · STmate 숨은 G 열 포함)를
|
||||
`실무자료/산근/<HP>.json` 에 둠
|
||||
(원본 엑셀은 안 둠 · HP 지울 때 같이 지움) → [가져온 식으로 열기] = 그 블록을 793-2a 내보내기
|
||||
(`M02_San_Emit` · 이음 = 1차 줄 판정) → 793-2 만들기(`M02_Sangeun_Imported.create` · 개인 층 ·
|
||||
올림 때 경쟁사 산근 시트(`M02_San_Book.sheets` · STmate 숨은 G 열 포함)와 그 엑셀 읽기기 둘 꼴
|
||||
(812-1 `M02_San_Xlsx` → `M02_Doc_FromNative.pack` · 칸 `엑셀`)을 `실무자료/산근/<HP>.json` 에 둠
|
||||
(원본 엑셀은 안 둠 · HP 지울 때 같이 지움) → [가져온 식으로 열기] = 그 블록을 원파일과 같은 길
|
||||
(`M02_Doc_FromNative.emit` · 원본 `엑셀` 참) → 793-2 만들기(`M02_Sangeun_Imported.create` · 개인 층 ·
|
||||
엑셀 단가 그대로 · 경쟁사 로직 인정 — 근거 기초 제안 없음 · 809-1 · 요구 703) ·
|
||||
같은 내용 SK 가 있으면 그것(다시씀).
|
||||
블록 번 = 판정 블록 번(STmate = 코드 「D00102」 숫자 · EST = 단가산출 블록 번).
|
||||
STmate 블록 끝전(비목버림 | 합버림_큰나머지)은 파일 설정 — 참조 평가기로 둘 다 셈해
|
||||
엑셀 계와 맞는 것.
|
||||
STmate 블록 끝전 = 파일 투표(`M02_San_Xlsx_Stmate.vote`).
|
||||
원파일(.estx · .stc · 810-2 `M02_Doc_FromNative`) = 같은 자리 칸 `원파일`(둘 꼴) — 있으면 원파일 우선
|
||||
(그 블록 번이 원파일에 있을 때) · 엑셀 올림 때 같은 공사 원파일이 다른 HP 에 있으면 붙임 · 원파일 올림 =
|
||||
같은 공사 엑셀 HP 에 붙임(없으면 원파일로 새 HP · `upload_native`).
|
||||
@@ -17,7 +16,6 @@ STmate 블록 끝전(비목버림 | 합버림_큰나머지)은 파일 설정 —
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -30,7 +28,6 @@ from M02_MasterTemplete import M02_San_Emit as em
|
||||
|
||||
DIRNAME = "산근"
|
||||
RELOAD = "가져온 식 자료 없음 — 엑셀을 다시 올림"
|
||||
RULES = ("비목버림", "합버림_큰나머지")
|
||||
MAX_INNER = 6 # 안쪽 산근 깊이 상한
|
||||
|
||||
|
||||
@@ -60,14 +57,33 @@ def keep(folder: str | Path, key: str, name: str, data: bytes) -> str | None:
|
||||
return f"엑셀 형식 오류 — {type(e).__name__}: {str(e)[:80]}"
|
||||
if not book.kind_of(sheets):
|
||||
return None
|
||||
try:
|
||||
excel = _excel(name, sheets)
|
||||
except Exception as e: # noqa: BLE001 — 산근 읽기 실패 = 사유(올리기는 됨)
|
||||
return f"산근 시트 읽기 오류 — {type(e).__name__}: {str(e)[:80]}"
|
||||
path = _path(folder, key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
got = {"파일": name, "시트": sheets}
|
||||
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 {}))
|
||||
return None
|
||||
|
||||
|
||||
def _excel(name: str, sheets: dict) -> dict:
|
||||
"""산근 시트 → 원파일과 같은 둘 꼴(812-1 엑셀 읽기기 `M02_San_Xlsx` → `M02_Doc_FromNative.pack`)."""
|
||||
from M02_MasterTemplete import M02_San_Xlsx as sx
|
||||
|
||||
return fn.pack(sx.read_sheets(sheets), name)
|
||||
|
||||
|
||||
def _excel_of(path: Path, saved: dict) -> dict:
|
||||
"""저장 HP 의 엑셀 둘 꼴 — 옛 저장(시트만)은 한 번 지어 그 자리에 둠."""
|
||||
if not isinstance(saved.get("엑셀"), dict):
|
||||
saved["엑셀"] = _excel(str(saved.get("파일") or ""), saved["시트"])
|
||||
atomic_write_json(path, saved)
|
||||
return saved["엑셀"]
|
||||
|
||||
|
||||
# ── 원파일(810-2) ──────────────────────────────────────────────────────
|
||||
def _saved(folder: str | Path) -> list[tuple[Path, dict]]:
|
||||
out = []
|
||||
@@ -114,32 +130,12 @@ def has(folder: str | Path, key: str) -> bool:
|
||||
return _path(folder, key).is_file()
|
||||
|
||||
|
||||
def _rule(blk: dict, nodes: list, prices: dict) -> str:
|
||||
"""STmate 블록 끝전 — 엑셀 계(꼬리 · 없으면 머리)와 맞는 쪽 · 둘 다 아니면 비목버림."""
|
||||
from M02_MasterTemplete import M02_San_Eval as ev
|
||||
|
||||
tails = [t for t in blk["꼬리"] if t["A"].startswith("계") and not t["A"].startswith("계약")]
|
||||
want = (tails or [blk["머리"]])[0].get("C")
|
||||
for rule in RULES:
|
||||
try:
|
||||
got = ev.stmate_block(blk, nodes, prices, rule)["계"]["T"]
|
||||
except Exception: # noqa: BLE001 — 못 셈 = 다음 갈래
|
||||
continue
|
||||
if want is not None and abs(float(got) - float(want)) < 0.5:
|
||||
return rule
|
||||
return RULES[0]
|
||||
|
||||
|
||||
def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]:
|
||||
"""HP 단산 블록 하나 → `{로직, 원본}`(793-2a 꼴 · 이음 = M01 키)."""
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
from M02_MasterTemplete import M02_San_Emit as em
|
||||
from M02_MasterTemplete import M02_San_Est as est
|
||||
from M02_MasterTemplete import M02_San_Stmate as stm
|
||||
|
||||
"""HP 단산 블록 하나 → `{로직, 원본}`(원파일 우선 · 아니면 엑셀 읽기기 같은 길 · 이음 = M01 키)."""
|
||||
hopyo_store.read(folder, key) # 없는 HP = 404
|
||||
path = _path(folder, key)
|
||||
try:
|
||||
saved = json.loads(_path(folder, key).read_bytes())
|
||||
saved = json.loads(path.read_bytes())
|
||||
except (OSError, ValueError) as e:
|
||||
raise store.StoreError(409, RELOAD) from e
|
||||
native = saved.get("원파일")
|
||||
@@ -147,52 +143,22 @@ def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]:
|
||||
return fn.emit(native, int(no))
|
||||
if not saved.get("시트"):
|
||||
raise store.StoreError(404, f"없는 단산{no}")
|
||||
sheets, name = saved["시트"], str(saved.get("파일") or "")
|
||||
if book.kind_of(sheets) == "STmate":
|
||||
blk = next((b for b in book.stmate_blocks(sheets) if int(b["코드"][1:]) == int(no)), None)
|
||||
if blk is None:
|
||||
raise store.StoreError(404, f"없는 단산{no}")
|
||||
prices = book.stmate_prices(sheets)
|
||||
nodes = stm.parse_block(blk["몸"])
|
||||
return em.stmate(blk, nodes, prices, _rule(blk, nodes, prices), True, name)
|
||||
prices, total = book.est_prices(sheets)
|
||||
blk = next((b for b in book.est_blocks(sheets) if int(b["번"]) == int(no)), None)
|
||||
if blk is None or blk["번"] not in total:
|
||||
excel = _excel_of(path, saved)
|
||||
if int(no) not in fn.numbers(excel):
|
||||
raise store.StoreError(404, f"없는 단산{no}")
|
||||
return em.est(blk, est.parse_block(blk["글"], prices), total[blk["번"]], True, name)
|
||||
return fn.emit(excel, int(no))
|
||||
|
||||
|
||||
def _inner(
|
||||
source: Path, key: str, logic: dict, native: bool = False
|
||||
) -> list[tuple[dict, int | None, str | None]]:
|
||||
"""안쪽 산근 줄 `[(호표 줄, 같은 엑셀 안쪽 블록 번, 못 잇는 까닭)]` — STmate = 줄 코드가 블록 코드 ·
|
||||
EST = 줄 이름 「단산N」(N 은 총괄표에 있는 블록). 비목 하나만 부른 줄은 그 비목이 그대로(옮김 없음)일 때만."""
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
|
||||
saved = json.loads(_path(source, key).read_bytes())
|
||||
"""안쪽 산근 줄 `[(호표 줄, 같은 파일 안쪽 블록 번, 못 잇는 까닭)]` — 원파일 · 엑셀 둘 꼴 같은 길
|
||||
(`M02_Doc_FromNative.inner` · 줄 자원 코드 = 블록 코드)."""
|
||||
path = _path(source, key)
|
||||
saved = json.loads(path.read_bytes())
|
||||
if native and isinstance(saved.get("원파일"), dict):
|
||||
return fn.inner(saved["원파일"], logic)
|
||||
sheets = saved["시트"]
|
||||
stmate = book.kind_of(sheets) == "STmate"
|
||||
have = {b["코드"]: int(b["코드"][1:]) for b in book.stmate_blocks(sheets)} if stmate else {}
|
||||
total = set() if stmate else set(book.est_prices(sheets)[1])
|
||||
out = []
|
||||
for line in logic["호표"]:
|
||||
res = line.get("자원") or {}
|
||||
if res.get("갈래") != "단가산출":
|
||||
continue
|
||||
if stmate:
|
||||
n = have.get(str(res.get("코드")))
|
||||
else:
|
||||
hit = re.fullmatch(r"단산(\d+)", str(res.get("이름")))
|
||||
n = int(hit.group(1)) if hit and int(hit.group(1)) in total else None
|
||||
if n is None:
|
||||
out.append((line, None, "같은 엑셀에 안쪽 블록 없음"))
|
||||
elif res.get("옮김") or (res.get("꼴") == "EST" and res.get("열쇠비목") not in em.COMP):
|
||||
out.append((line, n, "비목이 바뀐 부름"))
|
||||
else:
|
||||
out.append((line, n, None))
|
||||
return out
|
||||
return fn.inner(_excel_of(path, saved), logic)
|
||||
|
||||
|
||||
def _link_inner(source: Path, key: str, no: int, out: dict, trail: tuple) -> None:
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
"""812-1 골든 — 엑셀 읽기기(`M02_San_Xlsx`) = 810-1 원파일 파서와 같은 구조 줄(읽기만).
|
||||
|
||||
① 실제 짝 — 거창 xlsx ↔ 거창 .estx(블록 머리 · 줄마다 연결뜻 · 비목 · 변수 · 줄참조 · 결과 · 합계 · 저장 ·
|
||||
자원 이름 · 규격 · 단위) · STC 다섯 xlsx ↔ .stc(같은 블록 · 원문 · 줄 칸 · 자원 이름 · 규격 · 끝전 = SAN_TYP)
|
||||
② 합성 — 원파일 구조 줄 보임 글 + 저장 금액으로 경쟁사 내보내기 꼴 시트를 지어(EST = 거창 꼴 글자 칸 나눔 ·
|
||||
닫는 표지 2 칸 · 합계 표지 1 칸 · 소계 61 = 비목 칸만 — 거창 실제와 글 배치 같음(수 꼴 빼고) 먼저 확인) →
|
||||
읽기기 → 원파일 구조 줄과 같음(EST 1,849 · STC 371 블록) · 원파일 자체 엇갈림(같은 글 · 다른 표지)은
|
||||
`ODD` 로 고정
|
||||
③ 금액 — 실무 엑셀 11 = 읽기기 → `M02_Doc_FromNative` → 수식 문서 셈 = 엑셀 블록 금액(`ODD_MONEY` 고정)
|
||||
빠른 견본 기본 · 전수 = 환경 S8121_ALL=1 · 파일 없으면 skip(다른 PC).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
from collections import Counter
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HERE = Path(__file__).resolve().parent
|
||||
for p in (ROOT, HERE):
|
||||
if str(p) not in sys.path:
|
||||
sys.path.insert(0, str(p))
|
||||
|
||||
import helper_m02_hopyo_golden as golden # noqa: E402
|
||||
import test_797_1a_import_flags as practice # noqa: E402
|
||||
import test_810_1_estx as estx # noqa: E402
|
||||
import test_810_1_stc as stc # noqa: E402
|
||||
|
||||
from M02_MasterTemplete import M02_Doc_FromNative as fn # noqa: E402
|
||||
from M02_MasterTemplete import M02_San_Book as book # noqa: E402
|
||||
from M02_MasterTemplete import M02_San_Xlsx as sx # noqa: E402
|
||||
|
||||
ALL = os.environ.get("S8121_ALL") == "1"
|
||||
F = sx.flat
|
||||
FIELDS = ("연결뜻", "비목", "변수", "줄참조", "결과", "합계")
|
||||
#: 원파일 자체 엇갈림 — 같은 보인 글 · 금액 칸인데 원파일 표지가 다른 줄(엑셀이 못 가름 · 고정)
|
||||
ODD = {
|
||||
("main", 638, 30), # 산근 세 줄 뒤 「소 계」 연결 없음(같은 꼴 다른 곳 = 104)
|
||||
("main", 1249, 230), # 같음
|
||||
("main", 1199, 40), # 「0 × 1% = A0」 금액 0 — 비목 S 가 금액 칸에 없음
|
||||
("main", 1438, 4), # 「{ 골재장 상차도 }」 글 줄에 비목 M
|
||||
("main", 1286, 4), # 괄호 속 「L = … = 46.00 m」 결과 표지 없음(같은 꼴 다른 곳 = 결과)
|
||||
("main", 1391, 6), # 「= 5.714㎡ / 100㎡ =」 중간 값에 결과 표지
|
||||
*(("main", 1308, r) for r in (8, 16, 26)), # 금액 칸 없는 「소 계 : 수 kg」 = 61(861 = 글)
|
||||
*(("main", 1441, r) for r in (64, 65, 66, 67)), # 단가가 기계표와 다른 세 줄 · 비목 없음
|
||||
}
|
||||
#: 금액 — 셈 ≠ 엑셀 블록(까닭 고정)
|
||||
ODD_MONEY = {
|
||||
(
|
||||
"북부청임도",
|
||||
33,
|
||||
), # 노무비 줄 「36,248 ÷ 0.096」 오타 — 절 소계 덧셈이 그 줄을 뺌(글 덧셈만 앎)
|
||||
(
|
||||
"북부청임도",
|
||||
3,
|
||||
), # 아래 넷 = `M02_Doc_NativeEst.triple` 자리 규칙 dict 묶음 TypeError(812-3 몫)
|
||||
("북부청임도", 54),
|
||||
("북부청임도", 55),
|
||||
("북부청사방", 24),
|
||||
}
|
||||
|
||||
|
||||
# ── 합성(경쟁사 내보내기 꼴) ─────────────────────────────────────────────
|
||||
W, MC = 146, [141, 142, 143, 144]
|
||||
_OPEN = dict.fromkeys("\x03\x0e\x11\x12\x13\x15\x0c\x06\x07", 0) | {"\x1a": 1}
|
||||
_CLOSE = dict.fromkeys("\x03\x0e\x11\x12\x13\x15\x0c\x06\x07", 2) | {"\x1a": 1}
|
||||
|
||||
|
||||
def _w(ch: str) -> int:
|
||||
return 2 if unicodedata.east_asian_width(ch) in "WFA" else 1
|
||||
|
||||
|
||||
def _cls(ch: str) -> str:
|
||||
if ch.isdigit() or ch in ",.":
|
||||
return "n"
|
||||
o = ord(ch)
|
||||
if 0xAC00 <= o <= 0xD7A3 or 0x3131 <= o <= 0x318E or 0x3300 <= o <= 0x33FF:
|
||||
return "h"
|
||||
return "a" if ch.isascii() and ch.isalpha() else "s"
|
||||
|
||||
|
||||
def cells(text: str) -> list[tuple[int, str]]:
|
||||
"""LineData → 글자 칸(열 = 화면 자리 · 여는 표지 0 · 닫는 표지 2 · 합계 표지 1 칸 · 표지 안 = 한 칸)."""
|
||||
out: list[list] = []
|
||||
col, cur, opened, angle = 2, None, set(), False
|
||||
for i, ch in enumerate(text):
|
||||
if ord(ch) < 32:
|
||||
col += _CLOSE.get(ch, 0) if ch in opened else _OPEN.get(ch, 0)
|
||||
opened ^= {ch}
|
||||
cur = None
|
||||
continue
|
||||
whole = bool(opened & set("\x0e\x13\x15")) or angle
|
||||
if ch == "<" and ">" in text[i:]:
|
||||
angle, whole = True, False
|
||||
if ch == " " and not (whole and cur):
|
||||
cur, col = None, col + 1
|
||||
continue
|
||||
c = _cls(ch)
|
||||
joined = cur is not None and (
|
||||
whole
|
||||
or (c == cur[2] and c != "s")
|
||||
or (cur[2] == "a" and ch.isdigit())
|
||||
or (ch == ":" and cur[2] == "h")
|
||||
)
|
||||
if joined:
|
||||
cur[1] += ch
|
||||
else:
|
||||
cur = [col, ch, c]
|
||||
out.append(cur)
|
||||
angle = angle and ch != ">"
|
||||
col += _w(ch)
|
||||
return [(a, b.rstrip()) for a, b, _ in out]
|
||||
|
||||
|
||||
def _fmt(v: float | None) -> str | None:
|
||||
return None if not v else f"{v:,.6f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def _money(row: list, saved: dict | None, link: int | None) -> None:
|
||||
s = saved or {}
|
||||
if not any(s.get(c) for c in "TLMS"):
|
||||
return
|
||||
if link != 61 and s.get("T"):
|
||||
row[MC[0]] = int(s["T"]) if s["T"] == int(s["T"]) else s["T"]
|
||||
for c, j in zip("LMS", MC[1:]):
|
||||
row[j] = _fmt(s.get(c))
|
||||
|
||||
|
||||
def _table(title: str, head: dict, recs: list[dict]) -> list[list]:
|
||||
rows = [[None] * 30 for _ in range(3)]
|
||||
rows[0][1] = title
|
||||
for j, h in head.items():
|
||||
rows[2][j] = h
|
||||
for r in recs:
|
||||
rows.append([r.get(j) for j in range(30)])
|
||||
return rows
|
||||
|
||||
|
||||
def synth_est(d: dict) -> dict[str, list]:
|
||||
"""원파일 EST → 엑셀 꼴 시트(단가산출 · 총괄표 · 노임 · 자재 · 기계 · 견적)."""
|
||||
rows = [[None] * W for _ in range(3)]
|
||||
rows[0][1], rows[2][1] = "단가산출", "산 출 근 거"
|
||||
labels = ("합 계", "노무비", "재료비", "경 비")
|
||||
for j, h in [*zip(MC, labels), *zip((2, 3, 4, 5), labels)]:
|
||||
rows[2][j] = h
|
||||
tot = []
|
||||
for n, b in enumerate(d["산근"], 1):
|
||||
head = [None] * W
|
||||
size = f"({b['규격']})" if b.get("규격") else ""
|
||||
head[1] = f"{n}.{b['이름'] or ''}{size}[{b.get('단위') or ''}]"
|
||||
_money(head, b["저장"], None)
|
||||
rows.append(head)
|
||||
for ln in b["줄"]:
|
||||
row = [None] * W
|
||||
for j, v in cells(ln["글"]):
|
||||
if j < MC[0]:
|
||||
row[j] = v
|
||||
_money(row, ln["저장"], ln["연결"])
|
||||
rows.append(row)
|
||||
s = b["저장"] or {}
|
||||
one = {1: str(n), 2: b["이름"], 3: b.get("규격"), 4: b.get("단위"), 5: s.get("T")}
|
||||
one |= {6: _fmt(s.get("L")), 7: _fmt(s.get("M")), 8: _fmt(s.get("S"))}
|
||||
tot.append(one | {25: s.get("T"), 26: s.get("L"), 27: s.get("M"), 28: s.get("S")})
|
||||
head4 = {
|
||||
1: "No",
|
||||
2: "품명",
|
||||
3: "규격",
|
||||
4: "단위",
|
||||
5: "합계",
|
||||
6: "노무비",
|
||||
7: "재료비",
|
||||
8: "경비",
|
||||
}
|
||||
out = {"단가산출": rows, "단가산출총괄표": _table("단가산출총괄표", head4, tot)}
|
||||
recs: dict[str, list] = {"노임": [], "자재": [], "기계": [], "견적": []}
|
||||
for key, v in d["자원"].items():
|
||||
kind, s = key.split(":")[0], v["저장"] or {}
|
||||
base = {2: v["이름"], 3: v["규격"], 4: v["단위"]}
|
||||
if kind == "노임":
|
||||
recs[kind].append(base | {7: s.get("T")})
|
||||
elif kind == "자재":
|
||||
recs[kind].append(base | {19: s.get("T")})
|
||||
elif kind in ("기계", "견적"):
|
||||
one = base | {5: s.get("T"), 6: s.get("L"), 7: s.get("M"), 8: s.get("S")}
|
||||
recs[kind].append(
|
||||
one | {25: s.get("T"), 26: s.get("L"), 27: s.get("M"), 28: s.get("S")}
|
||||
)
|
||||
for kind in recs:
|
||||
for i, r in enumerate(recs[kind], 1):
|
||||
r[1] = str(i)
|
||||
one_price = {1: "No", 2: "품명", 3: "규격", 4: "단위"}
|
||||
out["노임단가"] = _table("노임단가", one_price | {7: "적용단가"}, recs["노임"])
|
||||
out["자재단가"] = _table("자재단가", one_price | {19: "적용단가"}, recs["자재"])
|
||||
out["기계경비총괄표"] = _table("기계경비총괄표", head4, recs["기계"])
|
||||
out["견적단가"] = _table("견적단가", head4, recs["견적"])
|
||||
return out
|
||||
|
||||
|
||||
def synth_stmate(n: dict) -> dict[str, list]:
|
||||
"""원파일 STC → 엑셀 꼴 시트(단가산출근거 숨은 G 열 원문 · 목록표 숨은 메타)."""
|
||||
rows: list[list] = [[None] * 12 for _ in range(3)]
|
||||
for b in n["산근"]:
|
||||
s, at = b["저장"], len(rows) + 3
|
||||
head = [b["코드"], None, s["T"], s["L"], s["M"], s["S"], None, None, None, None, None, None]
|
||||
head[6] = f"BDCOD|{b['코드']}\x05POSS|{at}\x05POSE|{at + len(b['줄']) - 1}"
|
||||
rows += [head, [None, b["이름"]] + [None] * 10]
|
||||
rows += [[None] * 6 + [ln["글"]] + [None] * 5 for ln in b["줄"]]
|
||||
out = {"단가산출근거": rows}
|
||||
lists: dict[str, list] = {k: [] for k in ("노무비", "재료비", "경비", "단가산출근거")}
|
||||
for code, v in n["자원"].items():
|
||||
p = n["단가"].get(code) or {}
|
||||
meta = "\x05".join(
|
||||
[f"COD|{code}"]
|
||||
+ [f"{k}|{x}" for k, x in (p.get("meta") or {}).items() if x is not None]
|
||||
)
|
||||
r = [None, v["이름"], v["규격"], v["단위"], p.get("T")] + [None] * 7
|
||||
if code[:1] in "LMS":
|
||||
r[6] = meta
|
||||
lists[{"L": "노무비", "M": "재료비", "S": "경비"}[code[:1]]].append(r)
|
||||
else:
|
||||
r[5:8], r[9] = [p.get("L"), p.get("M"), p.get("S")], meta
|
||||
lists["단가산출근거"].append(r)
|
||||
for k, v in lists.items():
|
||||
out[f"{k}목록표"] = [[None] * 12] * 3 + v
|
||||
return out
|
||||
|
||||
|
||||
# ── 맞대기 ─────────────────────────────────────────────────────────────
|
||||
def _res(r: dict | None, unit: bool = True) -> tuple | None:
|
||||
if not r:
|
||||
return None
|
||||
return (r.get("종류"), F(r.get("이름")), F(r.get("규격")), F(r.get("단위")) if unit else "")
|
||||
|
||||
|
||||
def _vals(v: list) -> list:
|
||||
return [round(x, 6) for x in v]
|
||||
|
||||
|
||||
def _saved(s: dict | None) -> tuple:
|
||||
return tuple(round(float((s or {}).get(c) or 0), 6) for c in "TLMS")
|
||||
|
||||
|
||||
def diff_est(tag: str, mine: dict, nat: dict) -> set[tuple]:
|
||||
"""EST 블록 · 줄 맞대기 → 다른 (파일, 블록, 행) · 블록 머리 다름 = 행 0."""
|
||||
bad = set()
|
||||
assert len(mine["산근"]) == len(nat["산근"])
|
||||
for n, (a, b) in enumerate(zip(mine["산근"], nat["산근"]), 1):
|
||||
if (F(a["이름"]), F(a["규격"]), a["단위"], _saved(a["저장"])[:4]) != (
|
||||
F(b["이름"]),
|
||||
F(b["규격"]),
|
||||
b["단위"],
|
||||
_saved(b["저장"])[:4],
|
||||
):
|
||||
bad.add((tag, n, 0))
|
||||
body = [x["보임"].strip() for x in b["줄"] if x["보임"].strip()]
|
||||
assert len([x for x in a["줄"] if x["보임"].strip()]) == len(body)
|
||||
for x, y in zip(a["줄"], b["줄"]):
|
||||
same = all(
|
||||
_vals(x[f]) == _vals(y[f]) if f in ("변수", "줄참조", "결과") else x[f] == y[f]
|
||||
for f in FIELDS
|
||||
)
|
||||
same = same and _saved(x["저장"]) == _saved(y["저장"])
|
||||
mine_r, want_r = _res(x["자원"]), _res(y["자원"])
|
||||
same = same and (mine_r == want_r or (want_r and want_r[0] == "산근" and mine_r))
|
||||
if not same:
|
||||
bad.add((tag, n, y["행"]))
|
||||
return bad
|
||||
|
||||
|
||||
def diff_stc(mine: dict, nat: dict) -> Counter:
|
||||
"""STmate — 같은 코드 블록 · 원문 · 줄 칸 · 자원 이름 · 규격(단위 = 원파일 COSTN 은 기호 글자) 다른 수."""
|
||||
got: Counter = Counter()
|
||||
have = {b["코드"]: b for b in nat["산근"]}
|
||||
for b in mine["산근"]:
|
||||
y = have.get(b["코드"])
|
||||
if y is None:
|
||||
got["원파일 없는 블록"] += 1
|
||||
continue
|
||||
got["블록"] += 1
|
||||
if [ln["글"].strip() for ln in b["줄"]] != [ln["글"].strip() for ln in y["줄"]]:
|
||||
got["원문 다름"] += 1
|
||||
continue
|
||||
got["저장 다름"] += _saved(b["저장"])[:4] != _saved(y["저장"])[:4]
|
||||
for x, z in zip(b["줄"], y["줄"]):
|
||||
got["줄"] += 1
|
||||
fields = ("연결뜻", "비목", "변수", "줄참조", "합계", "기계", "산근")
|
||||
got["줄 다름"] += any(x[f] != z[f] for f in fields)
|
||||
got["자원 다름"] += _res(x["자원"], False) != _res(z["자원"], False)
|
||||
return got
|
||||
|
||||
|
||||
# ── ① 실제 짝 ─────────────────────────────────────────────────────────
|
||||
@lru_cache(maxsize=4)
|
||||
def excel(tag: str) -> dict:
|
||||
path = practice.FILES.get(tag)
|
||||
if not path or not path.exists():
|
||||
pytest.skip(f"실무 엑셀 없음 {tag}")
|
||||
return sx.read(path.name, path.read_bytes())
|
||||
|
||||
|
||||
def test_real_pair_geochang_equals_estx() -> None:
|
||||
assert diff_est("거창", excel("거창"), estx.parsed("거창")) == set()
|
||||
|
||||
|
||||
STC_PAIRS = ("봉화", "동부청영월", "울진대흥", "영덕계류", "울진소광")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", STC_PAIRS)
|
||||
def test_real_pair_stmate_equals_stc(tag: str) -> None:
|
||||
mine, nat = excel(tag), stc.parsed(tag)
|
||||
got = diff_stc(mine, nat)
|
||||
assert got["블록"] and got["줄"]
|
||||
assert (got["원문 다름"], got["저장 다름"], got["줄 다름"], got["자원 다름"]) == (0, 0, 0, 0), (
|
||||
got
|
||||
)
|
||||
assert mine["옵션"]["SAN_TYP"] == nat["옵션"]["SAN_TYP"] # 끝전 파일 투표 = 원파일 옵션
|
||||
|
||||
|
||||
# ── ② 합성 ────────────────────────────────────────────────────────────
|
||||
def _num_norm(text: str) -> str:
|
||||
num = re.compile(r"\d[\d,]*(?:\.\d*)?|\.\d+")
|
||||
out = num.sub(lambda m: repr(float(m.group(0).replace(",", "").rstrip(".") or 0)), text)
|
||||
return re.sub(r"\s+", "", out)
|
||||
|
||||
|
||||
def test_synth_layout_equals_real_geochang() -> None:
|
||||
"""합성 규칙 검증 — 거창 실제 시트 줄 = 원파일 LineData 합성 칸(글 배치 · 수 꼴 「8.」 · 「.1」 빼고)."""
|
||||
path = golden.FILES["거창"]
|
||||
if not path.exists():
|
||||
pytest.skip("거창 엑셀 없음")
|
||||
rows = book.sheets(path.name, path.read_bytes())["단가산출"]
|
||||
heads = [
|
||||
i
|
||||
for i, r in enumerate(rows)
|
||||
if isinstance(r[1], str) and re.match(r"^\d+\.", r[1]) and r[141]
|
||||
]
|
||||
nat = estx.parsed("거창")["산근"]
|
||||
bad = total = 0
|
||||
for k, (h, b) in enumerate(zip(heads, nat)):
|
||||
end = heads[k + 1] if k + 1 < len(heads) else len(rows)
|
||||
for ln, r in zip(b["줄"], rows[h + 1 : end]):
|
||||
real = [(j, sx._cell(v)) for j, v in enumerate(r[2:141], 2) if sx._cell(v).strip()]
|
||||
mine = sx.layout(real, 2)
|
||||
total += 1
|
||||
bad += _num_norm(mine) != _num_norm(sx.layout(cells(ln["글"]), 2))
|
||||
got = sx.money_of(r, MC) or {}
|
||||
want = {c: v for c, v in (ln["저장"] or {}).items() if v and c in "TLMS"}
|
||||
if ln["연결"] == 61:
|
||||
want.pop("T", None)
|
||||
bad += {c: round(v, 6) for c, v in got.items()} != {
|
||||
c: round(v, 6) for c, v in want.items()
|
||||
}
|
||||
assert total > 2000 and bad == 0
|
||||
|
||||
|
||||
EST_SAMPLE = ("거창", "통영")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", list(estx.FILES) if ALL else EST_SAMPLE)
|
||||
def test_synth_est_equals_native(tag: str) -> None:
|
||||
nat = estx.parsed(tag)
|
||||
got = diff_est(tag, sx.read_sheets(synth_est(nat)), nat)
|
||||
assert got == {x for x in ODD if x[0] == tag}, sorted(got)[:10]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", list(stc.FILES) if ALL else ("봉화",))
|
||||
def test_synth_stmate_equals_native(tag: str) -> None:
|
||||
nat = stc.parsed(tag)
|
||||
mine = sx.read_sheets(synth_stmate(nat))
|
||||
got = diff_stc(mine, nat)
|
||||
assert got["블록"] == len(nat["산근"]) and got["원파일 없는 블록"] == 0
|
||||
assert (got["원문 다름"], got["저장 다름"], got["줄 다름"], got["자원 다름"]) == (0, 0, 0, 0), (
|
||||
got
|
||||
)
|
||||
assert mine["옵션"]["SAN_TYP"] == nat["옵션"]["SAN_TYP"]
|
||||
|
||||
|
||||
# ── ③ 금액 ────────────────────────────────────────────────────────────
|
||||
def sweep(tag: str) -> dict:
|
||||
"""엑셀 → 읽기기 → 둘 꼴 → 블록마다 수식 문서 셈 = 엑셀 블록 금액(낙찰률 = 계약단가 뒤)."""
|
||||
import test_810_2_native_doc as nd
|
||||
|
||||
path = practice.FILES[tag]
|
||||
saved = fn.pack(excel(tag), path.name)
|
||||
out = {"블록": 0, "같음": 0, "다름": set(), "변수줄": 0, "자원줄": 0, "자원추정": 0}
|
||||
for ln in (x for b in saved["산근"] for x in b["줄"]):
|
||||
out["변수줄"] += bool(ln.get("변수"))
|
||||
out["자원줄"] += bool(ln.get("자원"))
|
||||
for no, blk in fn.numbers(saved).items():
|
||||
out["블록"] += 1
|
||||
try:
|
||||
emitted = fn.emit(saved, no)
|
||||
_, _, got = nd.doc_and_money(emitted)
|
||||
except TypeError:
|
||||
out["다름"].add((tag, no))
|
||||
continue
|
||||
mine = {c: got.get(k) for c, k in nd.COST}
|
||||
nag = (emitted["원본"].get("계약") or {}).get("낙찰률")
|
||||
if nag:
|
||||
for c in "LMS":
|
||||
mine[c] = math.floor(round(float(mine[c]) * nag / 100, 6))
|
||||
mine["T"] = mine["L"] + mine["M"] + mine["S"]
|
||||
want = blk.get("저장") or {}
|
||||
if all(abs(float(mine[c] or 0) - float(want.get(c) or 0)) < 0.5 for c, _ in nd.COST):
|
||||
out["같음"] += 1
|
||||
else:
|
||||
out["다름"].add((tag, no))
|
||||
assert emitted["원본"]["엑셀"] is True and not emitted["원본"]["원파일"]
|
||||
return out
|
||||
|
||||
|
||||
MONEY_SAMPLE = ("거창", "봉화")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", list(practice.FILES) if ALL else MONEY_SAMPLE)
|
||||
def test_money_equals_excel(tag: str) -> None:
|
||||
got = sweep(tag)
|
||||
assert got["블록"] and got["다름"] == {x for x in ODD_MONEY if x[0] == tag}, sorted(got["다름"])
|
||||
Reference in New Issue
Block a user