Author SHA1 Message Date
eomsangdonandClaude Opus 5.5 8cd7baea5e 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>
2026-11-09 07:34:28 +09:00
17 changed files with 1376 additions and 844 deletions
+1 -4
View File
@@ -30,7 +30,6 @@ import {
newVar,
setRow,
shapeOf,
sourceOf,
unfold,
type StackItem,
type StackLine,
@@ -255,9 +254,7 @@ export function buildBlock(
const was = h.before?.get(id);
const freed = !iffy && raw !== undefined && !!was && hasIf(tryParse(was) ?? ["num", 0, "0"]);
const sub = link?.갈래 === "단가산출" && link.키 ? h.subDoc(link.키) : null;
const src = v ? sourceOf(h.doc, v) : null; // 가져온 값(812-3) = 옵션 가로 칸과 같은 값 · 꼬리표만
const srcTag = src === "원파일" ? lt("Src_Native") : src === "엑셀" ? lt("Src_Excel") : src;
const tag = expr ? null : srcTag ? clipTag(srcTag) : sourceTag(h, id, link);
const tag = expr ? null : sourceTag(h, id, link);
const tools: HTMLElement[] = [];
if (sub?.결과 && link?.키)
tools.push(
+1 -1
View File
@@ -51,7 +51,7 @@ export function buildSourced(
const n = Number(f.input.value.trim().replace(/,/g, ""));
if (f.input.value.trim() === "" || !Number.isFinite(n))
return void (f.input.value = String(v.연결?.값 ?? "")); // 수만 — 빈 값 · 글 = 그대로
set(id, v, n); // 수 그대로(글 「0.7」 = 셈 「—」 · 공식 묶음 값 칸 `setRow` 와 같은 꼴 · 812-3)
set(id, v, String(n));
});
f.root.dataset.option = id;
const tagText = tag === "원파일" ? lt("Src_Native") : tag === "엑셀" ? lt("Src_Excel") : tag;
+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["출처"] = "원파일"
@@ -161,10 +161,7 @@ def machines(doc: dict, title: dict[str, str]) -> None:
def shape(doc: dict, meta: dict) -> dict:
"""derive 문서 다듬기 — 원본 글줄 · 엔진 줄 자원 이름."""
from M02_MasterTemplete import M02_Doc_NativeLayout as lay
src = meta.get("원본") or {}
lay.tag(doc, meta) # 812-3 원파일 차림 — 금액 줄 ↔ 로직 줄(끝에 `apply`)
names(doc, list(src.get("글줄") or []))
title = {}
for item in (meta.get("로직") or {}).get("호표") or []:
@@ -172,5 +169,4 @@ def shape(doc: dict, meta: dict) -> dict:
if res.get("갈래") == "기계" and res.get("이름"):
title[str(item.get("이름"))] = " ".join(x for x in (res["이름"], res.get("규격")) if x)
machines(doc, title)
lay.apply(doc, meta)
return doc
-22
View File
@@ -19,7 +19,6 @@ from __future__ import annotations
import re
from M02_MasterTemplete import M02_Doc_NativeLayout as lay
from M02_MasterTemplete import M02_San_Emit as em
from M02_MasterTemplete import M02_San_Est as est
from M02_MasterTemplete import M02_San_Expr as ex
@@ -77,8 +76,6 @@ class Block:
self.groups: dict[str, list[tuple[str, float]]] = {} # 구간 글자 → (부름 글, 결과)
self.extra: dict[int, dict] = {} # 행 → 소계가 정한 비목(아래 줄)
self.got = {"확정": 0, "표지": 0, "추정": 0}
self.memo: list[int] = [] # 글 줄 행(812-3 차림 · 분수 그림 위아래 줄 뺌)
self.frac: set[int] = set()
# ── 작은 도구 ──────────────────────────────────────────────────
def mark(self, ln: dict, why: str) -> None:
@@ -185,7 +182,6 @@ class Block:
return re.sub(r"\s*([×÷·+*/−-])\s*", r"\1", t).strip().split()
tops, bottoms, at = cols(i - 1), cols(i + 1), [0]
self.frac |= {self.lines[k]["행"] for k in (i - 1, i + 1) if 0 <= k < len(self.lines)}
def one(_: re.Match) -> str:
k = at[0]
@@ -489,7 +485,6 @@ class Block:
def text_line(self, ln: dict) -> None:
"""글 줄 — 품셈 · 조건 이름만 있는 글(값 없음) = 「추정」 표지(연결 안 함 · 810-3 화면)."""
self.memo.append(ln["행"])
if _COND.search(ln["보임"]):
self.o.glyph(
ln["행"], ln["보임"], mark=f"{NOTE}{GUESS} — 품셈 조건 글(값 없음 · 연결 안 함)"
@@ -498,22 +493,6 @@ class Block:
return
self.o.glyph(ln["행"], ln["보임"])
def tags(self) -> None:
"""812-3 차림 표지(`M02_Doc_NativeLayout`) — 글줄 `차림` = 머리(단 > 0 ·
소계 구간 첫 글 · 번호 글) · 합계 · 글(근거 · 메모)."""
self.o.head[lay.TAG] = True
for ln in self.lines:
one = self.o.at.get(ln["행"])
if one is None or ln["행"] in self.frac or "자리" in one:
continue
head = (ln.get("단") or 0) > 0 or ln.get("색인") == -20
if ln.get("합계"):
one[lay.TAG] = "합계"
elif head or (ln["행"] in self.memo and lay.NUMBERED.match(one["글"])):
one[lay.TAG] = "머리"
elif ln["행"] in self.memo:
one[lay.TAG] = "글"
# ── 한 벌 ─────────────────────────────────────────────────────
def run(self) -> dict:
for k, ln in enumerate(self.lines): # 「노무비 : ‹단가›」(식 · 결과 없음) = 단가 × 1
@@ -565,7 +544,6 @@ class Block:
elif (ln.get("보임") or "").strip():
self.text_line(ln)
i += 1
self.tags()
saved = self.blk.get("저장") or {}
check = {COMP[c]: saved.get(c) or 0 for c in COMP} | {"계": saved.get("T") or 0}
out = self.o.done("비목버림", None, check, "산근", self.file)
-442
View File
@@ -1,442 +0,0 @@
"""가져온 단가산출 줄 쌓기 차림(PLAN 812-3 · 요구 713 · 669~677) — 경쟁사 산출근거 꼴.
금액 · 셈 그대로. `M02_Doc_Import_Shape.shape` 가 부름(`tag` = 다듬기 앞 · `apply` = 끝) ·
원본 `차림` 표지 있는 블록만(EST = `M02_Doc_NativeEst` · STmate = `M02_San_Emit.stmate`):
항목 = 머리 줄(글줄 `차림` 「머리」 · 「1. …」 「가. …」 글) · 번호 글은 떼고 화면이 매김 ·
가 · 나 = `가나다` · STmate 는 계 줄(`단계:`)마다 끊고 그 뒤 첫 글 줄 = 제목 ·
머리 앞 줄 = 제목 없는 항목 · 합계 줄(「합계」 · 「소계」 절)만 있는 머리 = 뺌
(화면 소계 · 합계가 냄)
금액 줄 = 그 자리(원본 행 차례) — EST 는 셈이 같을 때만 금액 차례를 원본 차례로
(단계 · 누계 이름 없고 기호 부름이 앞 줄일 때)
공식 묶음 = 변수 대입 줄(`mid:` · 쓰는 차례) · 글 줄 = 근거 · 메모(글줄 `차림` 「글」)
저장 = 금액 줄 `묶음` = 항목 제목 + 문서 `쌓기`(기본 차림과 다를 때만).
파이썬 · TS 짝 — `common_util/common_util_line_stack.ts` `stackOf` · `saveOf` ·
`hiddenOf`(안 놓인 기호 자리 · 기본 차림)의 거울 · 거울 시험
`resources/tester/test_812_3_native_layout.py`(TS 왕복). 고칠 때 둘을 같이 고침.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
from typing import Any
_SCRIPTS = Path(__file__).resolve().parents[1] / "resources" / "master_data" / "scripts"
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
import master_doc as md # noqa: E402
from M02_MasterTemplete import M02_Sangeun_Doc as sd # noqa: E402
TAG = "차림" # 글줄 칸 — 머리 | 합계 | 글 · 원본 칸 = 차림 낼 블록
KEEP = "_로직줄" # 문서 금액 줄 ↔ 로직 줄 차례(다듬기 사이 임시)
GANADA = "가나다라마바사아자차카타파하"
NUMBERED = re.compile(rf"^\s*'?\s*(\d{{1,2}}|[{GANADA}])\s*[.)]\s*(?!\d)(\S.*)$")
_TOTAL = {"합계", "소계", "계"}
def tag(doc: dict, meta: dict) -> None:
"""다듬기(이름 · 기계 합침) 앞 — 금액 줄마다 로직 줄 차례(호표 → 덧줄)를 적어 둠."""
row = meta.get("로직") or {}
n = len(row.get("호표") or []) + len(row.get("덧줄") or [])
money = doc.get("금액") or []
if (meta.get("원본") or {}).get(TAG) and money and len(money) == n:
for k, line in enumerate(money):
line[KEEP] = k
def title_of(text: str) -> tuple[str, bool]:
"""「 1. 기 계(100%)」 → (「기 계(100%)」, 거짓) · 「'가. 굴삭기'」 → (「굴삭기」, 가나다)."""
flat = " ".join(str(text or "").replace("'", " ").split())
m = NUMBERED.match(flat)
if not m:
return flat, False
return m.group(2).strip(), m.group(1) in GANADA
# ── 차림 ──────────────────────────────────────────────────────────────
def apply(doc: dict, meta: dict) -> None:
money = doc.get("금액") or []
keys = [line.pop(KEEP, None) for line in money]
src = meta.get("원본") or {}
if not src.get(TAG) or not money or None in keys:
return
from M02_MasterTemplete import M02_Sangeun_Imported as imp
row = meta.get("로직") or {}
ids = sd.line_ids(
imp.row_of({imp.KEY: {"로직": row}}) or {}
) # 글줄 자리 = 줄 정체(`imp._named`)
lines = [*(row.get("호표") or []), *(row.get("덧줄") or [])]
by: dict[str, int] = {}
for k, (cid, one) in enumerate(zip(ids, lines)):
by.setdefault(cid, k)
by.setdefault(str(one.get("이름")), k)
inv = {k: j for j, k in enumerate(keys)}
texts = sorted(
(t for t in src.get("글줄") or [] if isinstance(t.get("행"), int)), key=lambda t: t["행"]
)
at: dict[int, int] = {} # 문서 금액 줄 → 원본 행
for t in texts:
for spot in t.get("자리들") or ([t["자리"]] if t.get("자리") else []):
j = inv.get(by.get(str(spot), -1))
if j is not None:
at[j] = min(at.get(j, t["행"]), t["행"])
rows = _fill([at.get(j) for j in range(len(money))])
order = sorted(range(len(money)), key=lambda j: (rows[j], j))
if order != list(range(len(money))) and _safe(doc, order):
doc["금액"] = money = [money[j] for j in order]
rows = [rows[j] for j in order]
else:
for j in range(1, len(rows)): # 차례 그대로 — 앞 줄보다 위면 앞 줄 자리로
rows[j] = max(rows[j], rows[j - 1])
items = _items(doc, texts, rows, src.get("갈래") == "STmate")
for it in items:
for kind, j in it["줄"]:
if kind == "금액":
money[j].pop("묶음", None)
money[j] |= {"묶음": it["제목"]} if it["제목"] else {}
raw = [_save_item(it, money) for it in items]
doc.pop("쌓기", None)
plain = save_of(stack_of(doc), money)
got = save_of(stack_of({**doc, "쌓기": raw}), money)
if got != plain:
doc["쌓기"] = got
def _fill(rows: list[int | None]) -> list[int]:
"""원본 행 없는 줄(글줄에 안 붙은 소계 덧줄 등) = 다음 줄 행 · 없으면 앞 줄 행."""
out = list(rows)
for j in range(len(out) - 1, -1, -1):
if out[j] is None and j + 1 < len(out):
out[j] = out[j + 1]
for j in range(len(out)):
if out[j] is None:
out[j] = out[j - 1] if j else 0
return [int(x or 0) for x in out]
def _safe(doc: dict, order: list[int]) -> bool:
"""금액 차례를 바꿔도 셈이 같음 — 단계 없음 · 누계 · 번지 · 단계 이름 안 씀 ·
줄 기호 부름 = 앞 줄."""
if doc.get("단계"):
return False
money = doc.get("금액") or []
pos = {j: p for p, j in enumerate(order)}
sym = {str(x["기호"]): pos[j] for j, x in enumerate(money) if x.get("기호")}
for j, line in enumerate(money):
for text in (line.get("수량"), line.get("단가")):
if text is None or str(text).strip() == "":
continue
try:
got = md.names(md.parse(text))
except md.DocError:
return False
if any(n in md.MONEY_NAMES or n == "계" for n in got):
return False
if any(n in sym and sym[n] >= pos[j] for n in got):
return False
return True
def _items(doc: dict, texts: list[dict], rows: list[int], stm: bool) -> list[dict]:
"""원본 행 차례로 항목 · 줄을 쌓음 — 줄 = (금액, 문서 줄) | (공식, [기호]) | (글, 글)."""
have = set(symbols(doc))
hidden = hidden_of(doc)
ev: list[tuple[int, int, int, str, Any]] = []
for t in texts:
mark, text = t.get(TAG), str(t.get("글") or "")
spots = t.get("자리들") or ([t["자리"]] if t.get("자리") else [])
mids = [s[4:] for s in spots if str(s).startswith("mid:")]
if any(str(s).startswith("단계:") for s in spots):
ev.append((t["행"], 0, 0, "끝", None))
elif mark == "합계":
ev.append((t["행"], 0, 0, "합계", None))
elif not spots and (mark == "머리" or (mark == "글" and NUMBERED.match(text))):
ev.append((t["행"], 0, 0, "머리", title_of(text)))
elif not spots and mark == "글" and text.strip():
ev.append((t["행"], 1, 0, "글", " ".join(text.replace("'", " ").split())))
ids = [m for m in mids if m in have and m not in hidden]
if ids:
ev.append((t["행"], 1, 0, "공식", ids))
ev += [(r, 2, j, "금액", j) for j, r in enumerate(rows)]
ev.sort(key=lambda e: e[:3])
items: list[dict] = []
cur: dict | None = None
cut = False # STmate 계 줄 뒤 — 다음 첫 글 줄 = 제목
def new(title: str = "", sub: bool = False) -> dict:
it = {"제목": title, "가나다": sub, "줄": [], "합계": False}
items.append(it)
return it
for _, _, _, kind, val in ev:
if kind == "끝":
cur, cut = None, stm
elif kind == "합계":
if cur is not None:
cur["합계"] = True
elif kind == "머리" or (kind == "글" and cut and cur is None):
title, sub = val if kind == "머리" else title_of(val)
if cur is not None and not cur["줄"] and not cur["제목"]:
cur["제목"], cur["가나다"] = title, sub
else:
cur = new(title, sub)
cut = False
else:
cur = cur if cur is not None else new()
if kind == "공식" and cur["줄"] and cur["줄"][-1][0] == "공식":
cur["줄"][-1][1].extend(x for x in val if x not in cur["줄"][-1][1])
else:
cur["줄"].append((kind, list(val) if kind == "공식" else val))
keep = []
for it in items:
flat = re.sub(r"\s+", "", it["제목"])
if not it["줄"] and (it["합계"] or not flat or flat in _TOTAL):
continue
keep.append(it)
return keep
def _save_item(it: dict, money: list[dict]) -> dict:
out: dict[str, Any] = {}
if it["제목"]:
out["제목"] = it["제목"]
if it["가나다"]:
out["가나다"] = True
lines = []
for kind, val in it["줄"]:
if kind == "금액":
lines.append({"금액": val, "이름": money[val].get("이름") or ""})
elif kind == "공식":
lines.append({"공식": val})
else:
lines.append({"글": val})
return out | {"줄": lines}
# ── 줄 쌓기 모델 거울(`common_util_line_stack.ts`) ──────────────────────
def _names_in(text: Any) -> list[str]:
if text is None or str(text).strip() == "":
return []
try:
return md.names(md.parse(text))
except md.DocError:
return []
def symbols(doc: dict) -> list[str]:
out = [md.var_id(v) for v in doc.get("변수") or []]
for line in doc.get("식") or []:
sid = str(line.get("기호") or "")
if sid and sid not in out:
out.append(sid)
return out
def _is_option(v: dict | None) -> bool:
return bool(v and v.get("열쇠") and (v.get("연결") or {}).get("갈래") in ("선택", "입력"))
def uses_of(doc: dict) -> dict[str, list[str]]:
out: dict[str, list[str]] = {}
def add(sid: str, more: list[str]) -> None:
now = out.setdefault(sid, [])
now += [n for n in more if n not in now]
for v in doc.get("변수") or []:
add(md.var_id(v), md.link_names(v["연결"]) if v.get("연결") else [])
for line in doc.get("식") or []:
add(str(line.get("기호") or ""), _names_in(line.get("식")))
return out
def price_sym(line: dict) -> str | None:
text = line.get("단가")
if text is None or str(text).strip() == "":
return None
try:
n = md.parse(text)
except md.DocError:
return None
if n[0] == "var":
return n[1]
if n[0] == "field" and n[1][0] == "var":
return n[1][1]
return None
def owned_of(doc: dict) -> dict[int, str]:
count: dict[str, int] = {}
for ns in (
*(_names_in(x.get("수량")) + _names_in(x.get("단가")) for x in doc.get("금액") or []),
*uses_of(doc).values(),
*([[doc["결과"]]] if doc.get("결과") else []),
):
for n in ns:
count[n] = count.get(n, 0) + 1
vars_ = {md.var_id(v): v for v in doc.get("변수") or []}
lines = {str(x.get("기호") or "") for x in doc.get("식") or []}
out = {}
for i, line in enumerate(doc.get("금액") or []):
s = price_sym(line)
kind = ((vars_.get(s) or {}).get("연결") or {}).get("갈래") if s else None
if s and kind in ("요소", "단가산출") and count.get(s) == 1 and s not in lines:
out[i] = s
return out
def hidden_of(doc: dict) -> set[str]:
"""차림에 안 넣는 기호 — 옵션 · 금액 줄이 가진 연결(가져온 값 옵션 칸은 공식 묶음에도 보임)."""
out = set(owned_of(doc).values())
out |= {md.var_id(v) for v in doc.get("변수") or [] if _is_option(v)}
return out
def _post_order(doc: dict, roots: list[str], uses: dict[str, list[str]]) -> list[str]:
out: list[str] = []
seen: set[str] = set()
stack: list[tuple[str, int]] = []
for start in [*roots, *symbols(doc)]: # 되부름 없이(깊은 식 사슬)
if start in seen:
continue
seen.add(start)
stack.append((start, 0))
while stack:
sid, k = stack.pop()
kids = uses.get(sid) or []
if k < len(kids):
stack.append((sid, k + 1))
if kids[k] not in seen:
seen.add(kids[k])
stack.append((kids[k], 0))
else:
out.append(sid)
return out
def _reach(line: dict, uses: dict[str, list[str]]) -> set[str]:
out: set[str] = set()
todo = _names_in(line.get("수량")) + _names_in(line.get("단가"))
while todo:
sid = todo.pop()
if sid in out:
continue
out.add(sid)
todo += uses.get(sid) or []
return out
def stack_of(doc: dict) -> list[dict]:
"""`stackOf` — 저장 차림 + 안 놓인 것은 기본 자리 ·
줄 = {꼴 금액 i | 꼴 공식 기호 틀? | 꼴 글 글}."""
money = doc.get("금액") or []
all_ = set(symbols(doc))
hidden = hidden_of(doc)
items: list[dict] = []
where: dict[int, dict] = {}
placed: set[str] = set()
saved = doc.get("쌓기") if isinstance(doc.get("쌓기"), list) else []
taken: set[int] = set()
for s in saved:
s = s if isinstance(s, dict) else {}
it: dict[str, Any] = {
"제목": str(s.get("제목") if s.get("제목") is not None else ""),
"줄": [],
}
if s.get("가나다"):
it["가나다"] = True
for line in s.get("줄") if isinstance(s.get("줄"), list) else []:
if isinstance(line, dict) and "금액" in line:
i = int(line["금액"])
name = line.get("이름")
if not (
0 <= i < len(money) and (money[i].get("이름") or "") == name and i not in taken
):
free = (k for k, m in enumerate(money) if k not in taken)
i = next((k for k in free if (money[k].get("이름") or "") == name), -1)
if i < 0 or i in taken:
continue
taken.add(i)
where[i] = it
it["줄"].append({"꼴": "금액", "i": i})
elif isinstance(line, dict) and "공식" in line:
got = [
x
for x in line["공식"] or []
if x in all_ and x not in hidden and x not in placed
]
placed |= set(got)
one: dict[str, Any] = {"꼴": "공식", "기호": got}
if line.get("틀"):
one["틀"] = str(line["틀"])
if line.get("틀꼴"):
one["틀꼴"] = str(line["틀꼴"])
it["줄"].append(one)
elif isinstance(line, dict) and "글" in line:
it["줄"].append(
{"꼴": "글", "글": str(line["글"] if line["글"] is not None else "")}
)
items.append(it)
for i, m in enumerate(money):
if i in taken:
continue
title = str(m.get("묶음") if m.get("묶음") is not None else "")
prev = where.get(i - 1)
if prev is not None and prev["제목"] == title:
it = prev
at = next(k for k, x in enumerate(prev["줄"]) if x["꼴"] == "금액" and x["i"] == i - 1)
prev["줄"].insert(at + 1, {"꼴": "금액", "i": i})
else:
it = next((x for x in items if x["제목"] == title), None) if saved else None
if it is None:
it = {"제목": title, "줄": []}
items.append(it)
it["줄"].append({"꼴": "금액", "i": i})
taken.add(i)
where[i] = it
uses = uses_of(doc)
reach = [_reach(m, uses) for m in money]
for sid in _post_order(doc, [doc["결과"]] if doc.get("결과") else [], uses):
if sid not in all_ or sid in hidden or sid in placed:
continue
i = next((k for k, r in enumerate(reach) if sid in r), -1)
it = where.get(i) if i >= 0 else (items[0] if items else None)
if it is None:
it = {"제목": "", "줄": []}
items.append(it)
block = next((x for x in it["줄"] if x["꼴"] == "공식"), None)
if block is None:
block = {"꼴": "공식", "기호": []}
it["줄"].insert(0, block)
block["기호"].append(sid)
placed.add(sid)
return items
def save_of(items: list[dict], money: list[dict]) -> list[dict]:
"""`saveOf`(차례 그대로)."""
out = []
for it in items:
one: dict[str, Any] = {}
if it["제목"]:
one["제목"] = it["제목"]
if it.get("가나다"):
one["가나다"] = True
lines = []
for x in it["줄"]:
if x["꼴"] == "금액":
lines.append({"금액": x["i"], "이름": money[x["i"]].get("이름") or ""})
elif x["꼴"] == "글":
lines.append({"글": x["글"]})
else:
lines.append(
{"공식": list(x["기호"])}
| ({"틀": x["틀"]} if x.get("틀") else {})
| ({"틀꼴": x["틀꼴"]} if x.get("틀꼴") else {})
)
out.append(one | {"줄": lines})
return out
+1 -5
View File
@@ -486,15 +486,11 @@ def stmate(
me = prices.get(block["코드"]) or {}
meta = me.get("meta") or {}
o = Out(block.get("이름"), me.get("단위"), "STmate", block["코드"])
o.head["차림"] = True # 812-3 — 계 단으로 항목 · 글 줄(`M02_Doc_NativeLayout`)
st = _Stm(o, block, prices, keys)
for node in nodes:
kind, src = node["종류"], {"행": node.get("행"), "글": node.get("글") or ""}
if kind in ("빈", "글"):
if src["글"].strip().strip("'").strip():
o.glyph(src["행"], src["글"])
o.at[src["행"]]["차림"] = "글" # 812-3 줄 쌓기 글 줄 · 제목(`M02_Doc_NativeLayout`)
elif src["글"].strip():
if src["글"].strip():
o.glyph(src["행"], src["글"])
elif kind == "대입":
st.assign(node, src)
+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:
+2 -3
View File
@@ -150,11 +150,10 @@ export function sourceOf(doc: FormulaDoc, v: DocVar): string | null {
return String((v as { 출처?: string }).출처 ?? (head.원파일 ? "원파일" : "엑셀"));
}
/** 차림에 안 넣는 기호 — 옵션 · 금액 줄이 가진 연결(가져온 식 값 옵션 칸은 공식 묶음에도 보임 · 같은 값 · 812-3)
* 파이썬 거울 = `M02_Doc_NativeLayout.py`(stackOf · saveOf · hiddenOf · 가져온 단가산출 차림) — 고칠 때 같이 고침 */
/** 차림에 안 넣는 기호 — 옵션 · 금액 줄이 가진 연결 · 가져온 식 값 옵션 칸 */
export function hiddenOf(doc: FormulaDoc): Set<string> {
const out = new Set(ownedOf(doc).values());
for (const v of doc.변수 ?? []) if (isOption(v)) out.add(varId(v));
for (const v of doc.변수 ?? []) if (isOption(v) || sourceOf(doc, v)) out.add(varId(v));
return out;
}
-52
View File
@@ -1,52 +0,0 @@
/* 812-3 가져온 단가산출 차림 헬퍼 — 줄 쌓기 모델(common_util_line_stack.ts)을 그 자리에서 트랜스파일해 Node 로 돌림.
* stdin = [{키, 문서, 파이썬 [{제목, 가나다?, 줄 [{꼴, i | 기호 | 글}]}]}]
* stdout = [{키, 같음(문서 → 줄 → 문서 · 칸 차례 무시), 거울(TS stackOf = 파이썬 stack_of), 항목 · 제목 · 공식 · 글 · 쌓기}]
* 짝 = test_812_3_native_layout.py · 파이썬 거울 = M02_Doc_NativeLayout.py */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const cache = new Map();
function load(at) {
if (cache.has(at)) return cache.get(at);
const js = ts.transpileModule(fs.readFileSync(at, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(at, box.exports);
const need = (p) =>
p.startsWith(".") ? load(path.resolve(path.dirname(at), `${p}.ts`)) : require(p);
new Function("exports", "module", "require", js)(box.exports, box, need);
return box.exports;
}
const ls = load(path.join(__dirname, "..", "..", "common_util", "common_util_line_stack.ts"));
function canon(v) {
if (Array.isArray(v)) return v.map(canon);
if (v && typeof v === "object") {
const out = {};
for (const k of Object.keys(v).sort()) out[k] = canon(v[k]);
return out;
}
return v;
}
const same = (a, b) => JSON.stringify(canon(a)) === JSON.stringify(canon(b));
const input = JSON.parse(fs.readFileSync(0, "utf8"));
const out = input.map(({ 키, 문서, 파이썬 }) => {
const items = ls.stackOf(문서);
const back = ls.docOf(문서, items);
const lines = items.flatMap((it) => it.줄);
return {
키,
같음: same(back, 문서),
거울: same(items, 파이썬),
항목: items.length,
제목: items.filter((it) => it.제목.trim()).length,
공식: lines.filter((l) => l.꼴 === "공식" && l.기호.length).length,
글: lines.filter((l) => l.꼴 === "글").length,
쌓기: "쌓기" in 문서,
...(same(items, 파이썬) ? {} : { ts: items }),
};
});
process.stdout.write(JSON.stringify(out));
+2 -3
View File
@@ -4,7 +4,7 @@
(a) 화면 셈 거울 — EST 여섯 · STC 일곱 산근 블록 모두: 화면 길(TS importView → calcDoc) = 서버 길
(`M02_Doc_Import.run` 계산기) 답 전부 같음(줄 · 비목 · 합 · 표지)
(b) 옵션 칸 — 원파일 수 하나 변수(출처 「원파일」) = 옵션 가로 칸(`sourceOf`) ·
줄 쌓기 공식 묶음에도 보임(812-3) · 금액 줄 단가로 쓰는 수 · 엑셀 단가 기억 연결 = 옵션 칸 아님
줄 쌓기 공식 묶음에 안 들어감 · 금액 줄 단가로 쓰는 수 · 엑셀 단가 기억 연결 = 옵션 칸 아님
(c) 추정 칸 — 문서 머리 `가져온.추정` 줄 수 = 원본 확정 추정 수(EST) · 원코드 · 규격 머리
(d) 소계 줄 — 합 밖 · 비목 없음(화면 비목 칸 빈칸 · 종류 「소계」 —
`line_stack_edit` · `line_stack_out` 글 대조)
@@ -121,8 +121,7 @@ def test_screen_calc_mirror_and_option_cells(kind: str, tag: str) -> None:
and not any(str(m.get("단가")) == v["열쇠"] for m in doc.get("금액") or [])
]
assert set(nums) <= set(shape["옵션"]), (tag, key, nums, shape["옵션"])
# 옵션 칸 = 공식 묶음에도 보임(812-3 · 같은 값)
assert set(shape["옵션"]) <= set(shape["공식"]), (tag, key)
assert not set(shape["옵션"]) & set(shape["공식"]), (tag, key) # 옵션 칸 = 공식 묶음 밖
for v in doc["변수"]:
if v["열쇠"] in shape["옵션"]:
assert v["연결"]["갈래"] == "숫자" and "엑셀" not in v["연결"]
+426
View File
@@ -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["다름"])
@@ -1,223 +0,0 @@
"""812-3 지킴 — 가져온 단가산출(원파일) 줄 쌓기 차림 = 경쟁사 산출근거 꼴(요구 713).
차림 = `M02_Doc_NativeLayout`.
(a) 견본(거창 덤프 꼴) — 1 측구터파기(토사) 「기 계 … 굴삭기」 항목 + 공식 묶음
q · K · f · E · Cm · Q + 보조인부 항목 · 41 합판거푸집(4회) 근거 글 ·
재료비(재료 · 소계 · 율 · 글) · 인건비(인력 · 소계 · 율 · 글) ·
15 전석 메쌓기 별산 · 단산 참조 Q 직접 · 기계 · 인부 ·
2 가 · 나 소항목 · 봉화 STC = 계 단마다 항목
(b) 전수 — 원파일 EST · STC 산근 블록 모두: 차림 전후 셈(비목 · 계 · 줄 금액) 같음 ·
TS 왕복(문서 → 줄 → 문서) 같음 · TS `stackOf` = 파이썬 거울 `stack_of` ·
수 표 = tmp/s8123/sweep.json(일회 기록)
(c) M01 문서 1,518 — 차림 안 바뀜(쌓기 없음 · 왕복 같음 · 거울 같음)
원파일 없으면 그 시험만 skip(다른 PC).
"""
from __future__ import annotations
import json
import subprocess
import sys
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 test_810_2_native_doc as t2 # noqa: E402
from test_810_1_estx import FILES as EST # noqa: E402
from test_810_1_stc import FILES as STC # noqa: E402
from M02_MasterTemplete import M02_Doc_Copy as dc # noqa: E402
from M02_MasterTemplete import M02_Doc_Import as dim # noqa: E402
from M02_MasterTemplete import M02_Doc_NativeLayout as lay # noqa: E402
COSTS = ("노무비", "재료비", "경비", "계")
def _ts(cases: list[dict]) -> list[dict]:
proc = subprocess.run(
["node", str(HERE / "helper_812_3_layout.cjs")],
input=json.dumps(dc.ml._norm(cases), ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
)
assert proc.returncode == 0, proc.stderr[-3000:]
return json.loads(proc.stdout)
def _doc(kind: str, tag: str, no: int, laid: bool = True) -> tuple[dict, dict]:
sk = t2.sk_of(t2.fn.emit(t2.native(kind, tag), no))
keep = lay.apply
if not laid:
lay.apply = lambda doc, meta: [x.pop(lay.KEEP, None) for x in doc.get("금액") or []]
try:
return sk, dim.derive(sk, None, t2.whole())
finally:
lay.apply = keep
def _items(doc: dict) -> list[dict]:
return lay.stack_of(doc)
def _show(doc: dict) -> list[tuple]:
"""(제목, 가나다, [줄 글]) — 금액 = 이름 · 공식 = 「=기호…」 · 글 = 「#글」."""
money = doc["금액"]
out = []
for it in _items(doc):
lines = []
for x in it["줄"]:
if x["꼴"] == "금액":
lines.append(money[x["i"]]["이름"])
elif x["꼴"] == "공식":
lines.append("=" + " ".join(x["기호"]))
else:
lines.append("#" + x["글"])
out.append((it["제목"], bool(it.get("가나다")), lines))
return out
# ── (a) 견본 ─────────────────────────────────────────────────────────────
def test_sample_excavation_machine_and_formula() -> None:
_, doc = _doc("EST", "거창", 1)
got = _show(doc)
assert got == [
("기 계(100%) : 굴삭기(무한궤도) (0.7㎥)", False, ["=q K f E Cm Q", "굴삭기(무한궤도)"]),
("보조인부(1일당 1인)", False, ["보통인부"]),
], got
assert [x["묶음"] for x in doc["금액"]] == [got[0][0], got[1][0]]
def test_sample_formwork_text_rate_subtotal() -> None:
_, doc = _doc("EST", "거창", 41)
got = _show(doc)
assert got[0] == (
"",
False,
["#< 토목품셈 6-3-1 합판거푸집(2022개정) >", "#※ 사용횟수 4회 : 보통"],
)
assert got[1] == (
"재료비",
False,
[
"합판(내수),대전",
"외송각재,대전",
"주자재소계",
"#소모자재(박리재등) (주자재비의 9%)",
"율",
],
)
assert got[2][0] == "인건비 (24년 품셈 개정)" and got[2][2][:3] == [
"형틀목공",
"보통인부",
"인부소계",
]
assert got[2][2][3].startswith("#공구손료") and len(got) == 3
sub = next(x for x in doc["금액"] if x["이름"] == "주자재소계")
assert sub["합밖"] and "비목" not in sub # 소계 = 합 밖 · 비목 칸 빈칸 그대로(810-2)
def test_sample_stone_wall_reference_and_direct_q() -> None:
_, doc = _doc("EST", "거창", 15)
got = _show(doc)
titles = [g[0] for g in got]
assert titles[0] == "전석 구입운반 : 별산" and got[0][2] == []
assert (
titles[1].startswith("뒷채움돌 운반 및 채집")
and got[1][2][-1] == "뒷채움돌기초잡석채집운반"
)
assert got[1][2][0].split()[0] == "=Q" # 단산 참조 Q 직접 = 그 항목 공식 묶음
assert titles[3].startswith("전석쌓기 기계") and got[3][2] == ["=Q_3", "굴삭기(무한궤도)"]
assert got[4] == ("전석쌓기 인부", False, ["석공", "보통인부"])
assert "합" not in "".join(titles) # 합계 절 = 화면 자동 합계(두 번 안 보임)
def test_sample_stc_steps_are_items() -> None:
_, doc = _doc("STC", "봉화", 14)
got = [
(t, g, [x for x in ls if not x.startswith("=P_")]) for t, g, ls in _show(doc)
] # 손 단가 = 기본 자리
assert got == [
("", False, ["#▣ 토공의 비탈규준틀[개소]2-6-1"]),
("재 료 비", False, ["각재(거푸집용,외송)", "판재", "일반못(철못)"]),
("인 건 비", False, ["건축목공", "보통인부"]),
], got
def test_sample_ganada_sub_items() -> None:
_, doc = _doc("EST", "거창", 2)
got = _show(doc)
assert got[0] == ("기 계(100%)", False, [])
assert got[1][:2] == ("굴삭기(무한궤도) (0.7+브레이카)", True)
assert got[2] == ("치즐(0.7㎥용)", True, ["치즐(0.7㎥)"]) # 「다. 소 계」 절 = 뺌
# ── (b) 전수 ─────────────────────────────────────────────────────────────
def _money(sk: dict, doc: dict) -> tuple:
got = dim.run(doc, sk, t2.whole())
lines = sorted(str(x.get("금액")) for x in got.get("줄") or [])
return tuple(str(got.get(c)) for c in COSTS), lines
@lru_cache(maxsize=1)
def _sweep() -> dict:
stat = {"블록": 0, "금액같음": 0, "다름": [], "cases": []}
for kind, files in (("EST", EST), ("STC", STC)):
for tag, path in files.items():
if not path.exists():
continue
for no in t2.fn.numbers(t2.native(kind, tag)):
sk, doc = _doc(kind, tag, no)
_, plain = _doc(kind, tag, no, laid=False)
stat["블록"] += 1
if _money(sk, doc) == _money(sk, plain):
stat["금액같음"] += 1
else:
stat["다름"].append((kind, tag, no))
stat["cases"].append(
{"키": f"{kind}|{tag}|{no}", "문서": doc, "파이썬": _items(doc)}
)
return stat
def test_sweep_money_same_and_ts_round_trip() -> None:
stat = _sweep()
if not stat["블록"]:
pytest.skip("원파일 없음")
assert not stat["다름"] and stat["금액같음"] == stat["블록"], stat["다름"][:5]
got = _ts(stat["cases"])
bad = {f: [g["키"] for g in got if not g[f]] for f in ("같음", "거울")}
assert bad == {"같음": [], "거울": []}, {k: v[:5] for k, v in bad.items()}
by = {}
for g in got:
kind = g["키"].split("|")[0]
row = by.setdefault(
kind, dict.fromkeys(("블록", "항목", "제목", "공식", "글", "쌓기", "무제목만"), 0)
)
row["블록"] += 1
for f in ("항목", "제목", "공식", "글"):
row[f] += g[f]
row["쌓기"] += g["쌓기"]
row["무제목만"] += g["항목"] > 0 and g["제목"] == 0
out = ROOT / "tmp" / "s8123"
out.mkdir(parents=True, exist_ok=True)
(out / "sweep.json").write_text(json.dumps(by, ensure_ascii=False, indent=1), "utf-8")
assert by.get("EST", {}).get("제목", 0) > by.get("EST", {}).get("블록", 0)
# ── (c) M01 ──────────────────────────────────────────────────────────────
def test_m01_docs_layout_unchanged() -> None:
docs = dc.folder()
assert len(docs) >= 1518
cases = [{"키": k, "문서": d, "파이썬": _items(d)} for k, d in docs.items()]
got = _ts(cases)
bad = {f: [g["키"] for g in got if not g[f]] for f in ("같음", "거울")}
assert bad == {"같음": [], "거울": []}, {k: v[:5] for k, v in bad.items()}
assert not any(g["쌓기"] for g in got)