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:
2026-11-09 07:34:28 +09:00
co-authored by Claude Opus 5.5
parent 5531e5f39f
commit 8cd7baea5e
7 changed files with 1369 additions and 85 deletions
+30 -9
View File
@@ -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
+12 -6
View File
@@ -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["출처"] = "원파일"
+174
View File
@@ -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 산근 시트 없음")
+621
View File
@@ -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
+70
View File
@@ -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
+36 -70
View File
@@ -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: