Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cd7baea5e |
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -50,19 +50,27 @@ def read(name: str, data: bytes) -> dict[str, Any]:
|
||||
def pack(got: dict, name: str) -> dict[str, Any]:
|
||||
"""파서 답 → 둘 꼴(셈 · 변환에 쓰는 칸만 · JSON)."""
|
||||
out: dict[str, Any] = {"꼴": got["꼴"], "파일": name, "표지": got.get("표지") or []}
|
||||
head = ("코드", "이름", "규격", "단위", "저장")
|
||||
out |= {"엑셀": True} if got.get("엑셀") else {} # 812-1 엑셀 읽기기(`M02_San_Xlsx`) — 같은 꼴
|
||||
head = ("코드", "번", "이름", "규격", "단위", "저장")
|
||||
if got.get("엑셀"): # 엑셀 읽기기 — 줄 행 = 시트 행(원문 카드 · 옛 엑셀 길과 같은 자리)
|
||||
got = {**got, "산근": [_rows(b) for b in got["산근"]]}
|
||||
if got["꼴"] == "EST":
|
||||
out["산근"] = [
|
||||
{**{k: b.get(k) for k in head}, "줄": [_est_line(ln) for ln in b["줄"]]}
|
||||
{**{k: b.get(k) for k in head if k in b}, "줄": [_est_line(ln) for ln in b["줄"]]}
|
||||
for b in got["산근"]
|
||||
]
|
||||
out["일위"] = [{**{k: b.get(k) for k in head}, "줄": b["줄"]} for b in got["일위"]]
|
||||
out["일위"] = [
|
||||
{**{k: b.get(k) for k in head if k in b}, "줄": b["줄"]} for b in got["일위"]
|
||||
]
|
||||
return out
|
||||
out["산근"] = [
|
||||
{**{k: b.get(k) for k in head}, "줄": [{"행": ln["행"], "글": ln["글"]} for ln in b["줄"]]}
|
||||
{
|
||||
**{k: b.get(k) for k in head if k in b},
|
||||
"줄": [{"행": ln["행"], "글": ln["글"]} for ln in b["줄"]],
|
||||
}
|
||||
for b in got["산근"]
|
||||
]
|
||||
out["일위"] = [{k: b.get(k) for k in head} for b in got["일위"]]
|
||||
out["일위"] = [{k: b.get(k) for k in head if k in b} for b in got["일위"]]
|
||||
res = got.get("자원") or {}
|
||||
out["단가"] = {
|
||||
code: {**p, **{k: (res.get(code) or {}).get(k) for k in ("이름", "규격", "단위")}}
|
||||
@@ -72,6 +80,10 @@ def pack(got: dict, name: str) -> dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
def _rows(b: dict) -> dict:
|
||||
return {**b, "줄": [{**ln, "행": ln.get("엑셀행") or ln["행"]} for ln in b["줄"]]}
|
||||
|
||||
|
||||
def _est_line(ln: dict) -> dict:
|
||||
one = {k: ln.get(k) for k in _EST_KEYS if ln.get(k) not in (None, [], False, "")}
|
||||
if one.get("자원"):
|
||||
@@ -81,9 +93,9 @@ def _est_line(ln: dict) -> dict:
|
||||
|
||||
# ── 블록 ──────────────────────────────────────────────────────────────
|
||||
def numbers(saved: dict) -> dict[int, dict]:
|
||||
"""블록 번 → 산근 블록(엑셀 길 번과 같음)."""
|
||||
"""블록 번 → 산근 블록(엑셀 길 번과 같음 · 엑셀 읽기기 EST = 시트 「N.」 번)."""
|
||||
if saved["꼴"] == "EST":
|
||||
return {i: b for i, b in enumerate(saved["산근"], 1)}
|
||||
return {int(b.get("번") or i): b for i, b in enumerate(saved["산근"], 1)}
|
||||
return {int(str(b["코드"])[1:]): b for b in saved["산근"] if str(b["코드"])[1:].isdigit()}
|
||||
|
||||
|
||||
@@ -91,10 +103,10 @@ def emit(saved: dict, no: int, keys: bool = True) -> dict[str, Any]:
|
||||
"""원파일 산근 블록 하나 → `{로직, 원본}`(원본 `원파일` True · 코드 · 확정 수) · 없는 번 = KeyError."""
|
||||
blk = numbers(saved)[int(no)]
|
||||
if saved["꼴"] == "EST":
|
||||
names = {b["코드"]: i for i, b in enumerate(saved["산근"], 1)}
|
||||
names = {b["코드"]: n for n, b in numbers(saved).items()}
|
||||
out = ne.block(blk, int(no), keys, saved["파일"], names)
|
||||
out["원본"] |= {"원코드": blk["코드"]} | ({"규격": blk["규격"]} if blk.get("규격") else {})
|
||||
return out
|
||||
return _excel(saved, out)
|
||||
from M02_MasterTemplete import M02_San_Stmate as stm
|
||||
|
||||
nodes = [{"행": ln["행"], "글": ln["글"], **stm.parse_line(ln["글"])} for ln in blk["줄"]]
|
||||
@@ -106,6 +118,15 @@ def emit(saved: dict, no: int, keys: bool = True) -> dict[str, Any]:
|
||||
marks = sum(1 for one in out["원본"]["글줄"] if one.get("표지"))
|
||||
sure = len(out["로직"]["호표"]) + len(out["로직"].get("덧줄") or []) + len(out["로직"]["중간"])
|
||||
out["원본"] |= {"원파일": True, "갈래": "STmate", "확정": {"확정": sure, "표지": marks}}
|
||||
return _excel(saved, out)
|
||||
|
||||
|
||||
def _excel(saved: dict, out: dict) -> dict[str, Any]:
|
||||
"""엑셀 읽기기 꼴(812-1) — 원본 `원파일` 거짓 · `엑셀` 참(화면 꼬리표 「엑셀」 · 원코드 = 단산 번)."""
|
||||
if saved.get("엑셀"):
|
||||
out["원본"] |= {"원파일": False, "엑셀": True}
|
||||
out["원본"] |= {"시트": "단가산출"} if saved["꼴"] == "EST" else {}
|
||||
out["원본"].pop("원코드", None)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -304,11 +304,16 @@ def derive(sk: dict, ctx: tuple | None = None, whole: Any = None) -> dict:
|
||||
doc, notes = dc.cv.convert(_Row(master, key, row), row, given)
|
||||
_attach(doc, row, ids, src)
|
||||
_numbers(doc, keep)
|
||||
if (meta.get("원본") or {}).get("원파일"): # 원파일(810-2) — 소계 비목 칸 비움 · 값 변수 출처
|
||||
_native(doc)
|
||||
native = bool((meta.get("원본") or {}).get("원파일"))
|
||||
if native or (meta.get("원본") or {}).get(
|
||||
"엑셀"
|
||||
): # 810-2 · 812-1 — 소계 비목 칸 비움 · 값 변수 출처
|
||||
_native(doc, native)
|
||||
head = {k: (meta.get("원본") or {}).get(k) for k in HEAD if (meta.get("원본") or {}).get(k)}
|
||||
marks = list((meta.get("원본") or {}).get("표지") or [])
|
||||
if (meta.get("원본") or {}).get("원파일"): # 810-3 — 품셈 조건 글(값 없음) = 화면 「추정」 칸
|
||||
if native or (meta.get("원본") or {}).get(
|
||||
"엑셀"
|
||||
): # 810-3 — 품셈 조건 글(값 없음) = 「추정」 칸
|
||||
head["추정"] = [
|
||||
{"행": x.get("행"), "글": " ".join(str(x.get("글") or "").split())}
|
||||
for x in meta["원본"].get("글줄") or []
|
||||
@@ -322,13 +327,14 @@ def derive(sk: dict, ctx: tuple | None = None, whole: Any = None) -> dict:
|
||||
return {**doc, "키": str(sk.get("키") or key), "이름": str(sk.get("이름") or doc.get("이름"))}
|
||||
|
||||
|
||||
def _native(doc: dict) -> None:
|
||||
"""원파일 문서 — 소계 덧줄(합 밖) 비목 칸 비움(금액만) · 수 하나 변수(옵션 칸) 출처 「원파일」."""
|
||||
def _native(doc: dict, native: bool = True) -> None:
|
||||
"""원파일 · 엑셀 읽기기 문서 — 소계 덧줄(합 밖) 비목 칸 비움(금액만) · 수 하나 변수(옵션 칸) 출처
|
||||
「원파일」(엑셀 = 출처 없음 → 화면 꼬리표 「엑셀」)."""
|
||||
for line in doc.get("금액") or []:
|
||||
if line.get("출처") == SUB and line.get("합밖"):
|
||||
line.pop("비목", None)
|
||||
for var in doc.get("변수") or []:
|
||||
if (var.get("연결") or {}).get("갈래") == "숫자" and XL not in var["연결"]:
|
||||
if native and (var.get("연결") or {}).get("갈래") == "숫자" and XL not in var["연결"]:
|
||||
var["출처"] = "원파일"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""경쟁사 산근 엑셀(.xlsx · .xls) 읽기기 — 810-1 원파일 파서와 같은 「구조 줄」 꼴(PLAN 812-1 · 요구 711-2 ·
|
||||
712 · 713 · 읽기만 · 서버 단독).
|
||||
|
||||
`read(name, data)` = `M02_San_Estx.read` · `M02_San_Stc.read` 와 같은 칸 이름 · 같은 뜻
|
||||
(칸 뜻 `resources/master_data/_원파일파서_틀.md`) → 그 뒤는 원파일 길(`M02_Doc_FromNative.pack` →
|
||||
`emit` → `M02_Doc_NativeEst` · `M02_San_Emit.stmate` → 수식 문서) 그대로.
|
||||
EST = `M02_San_Xlsx_Est`(단가산출 시트 글 · 금액 칸 · 같은 책 단가표) ·
|
||||
STmate = `M02_San_Xlsx_Stmate`(숨은 G 열 원문 = 원파일 원문 · 단가 = 목록표 · 끝전 = 파일 투표).
|
||||
엑셀이 못 주는 칸(자원 코드 · LinkIndex · 자리 Diget)은 비움 + 표지 「엑셀 — …」 · 글에서 읽어 정한 것은
|
||||
`연결뜻` 에 적고 줄 `추정` 참 + 블록 표지 「엑셀 추정」.
|
||||
여기 = 공통(시트 → 글 줄 · 금액 칸 · 자원 표).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
|
||||
GUESS = "엑셀 추정"
|
||||
NOTE = "엑셀 — "
|
||||
|
||||
|
||||
class XlsxError(ValueError):
|
||||
"""산근 시트가 없는 엑셀."""
|
||||
|
||||
|
||||
def flat(v: Any) -> str:
|
||||
return re.sub(r"\s+", "", str(v or ""))
|
||||
|
||||
|
||||
def number(v: Any) -> float | None:
|
||||
"""칸 값 → 수(「3,442」 · 「8.」 · 수 칸) · 아니면 None."""
|
||||
if isinstance(v, bool) or v is None:
|
||||
return None
|
||||
if isinstance(v, (int, float)):
|
||||
return float(v)
|
||||
s = str(v).strip().replace(",", "")
|
||||
if not re.fullmatch(r"-?\d+(?:\.\d*)?|-?\.\d+", s):
|
||||
return None
|
||||
return float(s.rstrip(".") or 0)
|
||||
|
||||
|
||||
def _cell(v: Any) -> str:
|
||||
if isinstance(v, float) and v == int(v):
|
||||
return str(int(v))
|
||||
return "" if v is None else str(v)
|
||||
|
||||
|
||||
def _width(ch: str) -> int:
|
||||
return 2 if unicodedata.east_asian_width(ch) in "WFA" else 1
|
||||
|
||||
|
||||
def layout(cells: list[tuple[int, str]], base: int) -> str:
|
||||
"""글자 칸 나눔 줄(거창 꼴 · 열 = 화면 자리) → 한 줄 글(열 차례 · 모자란 자리 = 빈칸 · 겹친 자리(수 끝 점)
|
||||
= 빈칸 하나)."""
|
||||
out, end = "", 0
|
||||
for col, text in cells:
|
||||
want = col - base
|
||||
if want > end:
|
||||
out += " " * (want - end)
|
||||
elif out and want < end and not out.endswith(" "):
|
||||
out += " "
|
||||
out += text
|
||||
end = want + sum(_width(c) for c in text)
|
||||
return out
|
||||
|
||||
|
||||
# ── 시트 머리 ──────────────────────────────────────────────────────────
|
||||
MONEY = ("합계", "노무비", "재료비", "경비")
|
||||
|
||||
|
||||
def money_cols(rows: list[list[Any]]) -> list[int] | None:
|
||||
"""머리 줄의 맨 오른쪽 「합계 · 노무비 · 재료비 · 경비」 네 칸 열 → [T L M S]."""
|
||||
for r in rows[:6]:
|
||||
names = [flat(v) for v in r]
|
||||
hits = [j for j in range(len(names) - 3) if tuple(names[j : j + 4]) == MONEY]
|
||||
if hits:
|
||||
return list(range(hits[-1], hits[-1] + 4))
|
||||
return None
|
||||
|
||||
|
||||
def money_of(row: list[Any], cols: list[int]) -> dict[str, float] | None:
|
||||
got = {c: number(row[j]) if j < len(row) else None for c, j in zip("TLMS", cols)}
|
||||
got = {c: v for c, v in got.items() if v is not None}
|
||||
return got or None
|
||||
|
||||
|
||||
def head_col(rows: list[list[Any]], *words: str) -> int | None:
|
||||
for r in rows[:6]:
|
||||
for j, v in enumerate(r):
|
||||
if flat(v) in words:
|
||||
return j
|
||||
return None
|
||||
|
||||
|
||||
# ── 자원 표(EST) ───────────────────────────────────────────────────────
|
||||
def _table(rows: list[list[Any]], price: tuple[str, ...]) -> list[dict[str, Any]]:
|
||||
"""단가 시트 → [{번, 이름, 규격, 단위, 줄 (원 줄)}](품명 · 규격 · 단위 · 값 열 = 머리 글)."""
|
||||
c_no, c_name = head_col(rows, "No"), head_col(rows, "품명")
|
||||
c_size, c_unit = head_col(rows, "규격"), head_col(rows, "단위")
|
||||
if c_name is None:
|
||||
return []
|
||||
out = []
|
||||
for r in rows:
|
||||
no = str(r[c_no]).strip() if c_no is not None and c_no < len(r) else ""
|
||||
if not no.isdigit() or c_name >= len(r) or not str(r[c_name] or "").strip():
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"번": int(no),
|
||||
"이름": str(r[c_name]).strip(),
|
||||
"규격": str(r[c_size] or "").strip() if c_size is not None else "",
|
||||
"단위": str(r[c_unit] or "").strip() if c_unit is not None else "",
|
||||
"줄": r,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def resources(sheets: dict[str, list]) -> dict[str, list[dict[str, Any]]]:
|
||||
"""같은 책 단가표 → {종류: [{번 · 이름 · 규격 · 단위 · 표 · 값 {T L M S}}]}(노임 · 자재 = 적용단가 ·
|
||||
기계 · 견적 · 산근 = 적용 칸(합이 안 맞으면 당초 · `M02_San_Book.applied`))."""
|
||||
out: dict[str, list[dict[str, Any]]] = {}
|
||||
for kind, sheet, comp in (("노임", "노임단가", "L"), ("자재", "자재단가", "M")):
|
||||
rows = sheets.get(sheet) or []
|
||||
c = head_col(rows, "적용단가")
|
||||
got = []
|
||||
for one in _table(rows, ("적용단가",)):
|
||||
v = number(one["줄"][c]) if c is not None and c < len(one["줄"]) else None
|
||||
if v is not None:
|
||||
got.append({**_drop(one), "표": sheet, "값": {"T": v, comp: v}})
|
||||
out[kind] = got
|
||||
for kind, sheet in (
|
||||
("기계", "기계경비총괄표"),
|
||||
("견적", "견적단가"),
|
||||
("산근", "단가산출총괄표"),
|
||||
):
|
||||
got = []
|
||||
for one in _table(sheets.get(sheet) or [], ()):
|
||||
r = list(one["줄"]) + [None] * 30
|
||||
vals = {k: float(v or 0) for k, v in book.applied(r).items()}
|
||||
got.append({**_drop(one), "표": sheet, "값": vals})
|
||||
out[kind] = got
|
||||
return out
|
||||
|
||||
|
||||
def _drop(one: dict) -> dict:
|
||||
return {k: v for k, v in one.items() if k != "줄"}
|
||||
|
||||
|
||||
def kind_of(sheets: dict[str, list]) -> str | None:
|
||||
return book.kind_of(sheets)
|
||||
|
||||
|
||||
def read(name: str, data: bytes) -> dict[str, Any]:
|
||||
"""엑셀 → {꼴, 산근, 일위, 자원, 내역, 표지(, 단가 · 옵션 STmate)} — 산근 시트 없음 = XlsxError."""
|
||||
sheets = book.sheets(name, data)
|
||||
return read_sheets(sheets)
|
||||
|
||||
|
||||
def read_sheets(sheets: dict[str, list]) -> dict[str, Any]:
|
||||
kind = kind_of(sheets)
|
||||
if kind == "EST":
|
||||
from M02_MasterTemplete import M02_San_Xlsx_Est as xe
|
||||
|
||||
return xe.read(sheets) | {"엑셀": True}
|
||||
if kind == "STmate":
|
||||
from M02_MasterTemplete import M02_San_Xlsx_Stmate as xs
|
||||
|
||||
return xs.read(sheets) | {"엑셀": True}
|
||||
raise XlsxError("STmate · EST 산근 시트 없음")
|
||||
@@ -0,0 +1,621 @@
|
||||
"""EST 산근 엑셀 → 810-1 구조 줄(PLAN 812-1 · `M02_San_Estx.cost_line` 과 같은 칸 · 읽기만 · 서버 단독).
|
||||
|
||||
엑셀 「단가산출」 시트 = 원파일 LineData 를 보인 글(제어문자 표지 없음) + 줄 금액 칸(원파일 줄 TC NC GC JC ·
|
||||
0 = 빈칸 · 소계 61 = 비목 칸만) → 표지를 글 · 금액 칸 · 같은 책 단가표(노임 · 자재 · 기계 · 견적 · 단가산출
|
||||
총괄표)로 다시 정함(`연결뜻` · 표지 「엑셀 추정」 · 자원 코드 · 자리 = 비움):
|
||||
변수 줄 「이름 = 식 = 값」 → 값 = 결과(`\\x12`) · 「이름 = 값」 → 대입(결과 없음) · 금액 줄 첫 식 조각의 수가
|
||||
앞 대입 값과 같으면 변수(`\\x11` · 상수 아님) ·
|
||||
합계 머리(「합 계」) 아래 노무비 · 재료비 · 경비 · 계 줄 = 합계(`\\x1a` · 비목 L M S T) ·
|
||||
「* 단산 N 참조 *」 · 「* 견적 N 참조 *」 · 기계 이름(+규격) = 세 줄 머리(104 · 101 · 105) → 아래
|
||||
「노무비 · 재료비 · 경비 : 단가 …」(단가 = 그 자원 비목 값) = 세 줄 · 「소 계」 = 계 줄 ·
|
||||
노임 · 자재 이름 + 그 단가 수 = 자원 줄(5 · 6 · 비목 = 금액 칸) ·
|
||||
「…소계 : 수」(금액 칸 = 비목만) = 소계 61(줄참조) · 「수 × n% = 값」 + 금액 칸 = 율 58(줄참조) ·
|
||||
「(A 수 + 수) × n%」 = 율 62(구간 합) · 「= A수」 = 구간 표지(`\\x06A\\x06`) · 그 밖 = 글 줄.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_San_Xlsx as sx
|
||||
|
||||
SHEET = "단가산출"
|
||||
LINK = {"자재": 5, "노임": 6, "견적": 101, "산근": 104, "기계": 105, "소계": 61, "율": 58}
|
||||
MEAN = {5: "자재", 6: "노임", 101: "견적", 104: "산근", 105: "기계", 61: "소계", 58: "율"}
|
||||
MEAN[62] = "율"
|
||||
LABEL = {"노무비": "L", "재료비": "M", "경비": "S", "계": "T"}
|
||||
NUM = r"\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?|\.\d+" # 「.1」 = 엑셀 꼴 0.1
|
||||
_NUM = re.compile(r"(?<![A-Za-z0-9_.,])(-?(?:" + NUM + r"))\.?(?![\d,])")
|
||||
_HEAD = re.compile(r"^(\d+)\.(.*?)\[([^\[\]]*)\]\s*$")
|
||||
_HEADING = re.compile(r"^\s*(?:\(\d+\)|(?:\d+|[가-하])\s*[.)])\s*")
|
||||
_REF = re.compile(r"\*\s*(단산|견적)\s*(\d+)\s*참조\s*\*")
|
||||
_ASSIGN = re.compile(r"(?<![A-Za-z0-9_])([A-Za-z][A-Za-z0-9]*)\s*(?:\([^)=]*\))?\s*=(?!=)")
|
||||
_GROUP62 = re.compile(r"^\s*\(?\s*([A-Z])\s*(" + NUM + r")((?:\s*\+\s*(?:" + NUM + r"))*)")
|
||||
_UNITS = {"hr", "m", "km", "kg", "ton", "sec", "min", "mm", "cm", "ea", "t"}
|
||||
_COLON = re.compile(r"[::]")
|
||||
|
||||
|
||||
def _v(s: str) -> float:
|
||||
return float(s.replace(",", "").rstrip("."))
|
||||
|
||||
|
||||
def _close(a: float, b: float) -> bool:
|
||||
return abs(a - b) < 1e-6 * max(1.0, abs(b))
|
||||
|
||||
|
||||
def _span(text: str, name: str, start: int = 0) -> tuple[int, int] | None:
|
||||
"""빈칸을 건너 같은 글자 차례 자리(이름 · 규격) · 없음 None."""
|
||||
chars = [re.escape(c) for c in sx.flat(name)]
|
||||
if not chars:
|
||||
return None
|
||||
m = re.compile(r"\s*".join(chars)).search(text, start)
|
||||
return (m.start(), m.end()) if m else None
|
||||
|
||||
|
||||
# ── 시트 → 블록 ───────────────────────────────────────────────────────
|
||||
def blocks(rows: list[list[Any]]) -> list[dict[str, Any]]:
|
||||
"""단가산출 시트 → [{번, 머리, 행, 저장, 줄 [{행, 엑셀행, 보임, 돈}]}](머리 = B 열 「N.이름[단위]」)."""
|
||||
cols = sx.money_cols(rows) or [2, 3, 4, 5]
|
||||
split = cols[0] > 6 # 거창 꼴 — 글자 칸 나눔(열 = 화면 자리 · B 열 = 머리만)
|
||||
out: list[dict[str, Any]] = []
|
||||
for i, r in enumerate(rows):
|
||||
head = r[1] if len(r) > 1 else None
|
||||
m = _HEAD.match(head) if isinstance(head, str) and not head[:1].isspace() else None
|
||||
if m and i > 2:
|
||||
out.append({"번": int(m.group(1)), "머리": head, "행": i + 1, "줄": []})
|
||||
out[-1]["저장"] = sx.money_of(r, cols)
|
||||
continue
|
||||
if not out:
|
||||
continue
|
||||
if split:
|
||||
cells = [(j, sx._cell(v)) for j, v in enumerate(r[2 : cols[0]], 2)]
|
||||
text = sx.layout([(j, v) for j, v in cells if v.strip()], 2)
|
||||
else:
|
||||
text = sx._cell(head)
|
||||
one = {"행": len(out[-1]["줄"]) + 1, "엑셀행": i + 1, "보임": text, "칸": split}
|
||||
out[-1]["줄"].append(one | {"돈": sx.money_of(r, cols)})
|
||||
for b in out: # 끝 빈 줄(시트 꼬리)
|
||||
while b["줄"] and not b["줄"][-1]["보임"].strip() and not b["줄"][-1]["돈"]:
|
||||
b["줄"].pop()
|
||||
return out
|
||||
|
||||
|
||||
# ── 줄 ────────────────────────────────────────────────────────────────
|
||||
def _label(text: str) -> str | None:
|
||||
"""「노무비 :」 · 「경 비 :」 · 「계 :」 → 비목 글자 · 아니면 None."""
|
||||
m = _COLON.search(text)
|
||||
if not m:
|
||||
return None
|
||||
return LABEL.get(sx.flat(_HEADING.sub("", text[: m.start()])))
|
||||
|
||||
|
||||
def _nums(text: str, lo: int = 0, hi: int | None = None) -> list[tuple[int, int, float]]:
|
||||
hi = len(text) if hi is None else hi
|
||||
return [(m.start(1), m.end(1), _v(m.group(1))) for m in _NUM.finditer(text, lo, hi)]
|
||||
|
||||
|
||||
def _result(text: str) -> tuple[int, int, float, int | None] | None:
|
||||
"""마지막 「=」 뒤 첫 수(구간 글자 「A」 붙음 허용) → (시작, 끝, 값, 글자 자리)."""
|
||||
eq = text.rfind("=")
|
||||
if eq < 0:
|
||||
return None
|
||||
m = re.match(r"\s*(?:([A-Z])\s*)?(-?(?:" + NUM + r"))\.?(?![\d,])", text[eq + 1 :])
|
||||
if not m:
|
||||
return None
|
||||
at = eq + 1
|
||||
return at + m.start(2), at + m.end(2), _v(m.group(2)), at + m.start(1) if m.group(1) else None
|
||||
|
||||
|
||||
class Lines:
|
||||
"""블록 한 벌 줄 → 구조 줄."""
|
||||
|
||||
def __init__(self, res: dict[str, list[dict]], tags: list[str]) -> None:
|
||||
self.res, self.tags = res, tags
|
||||
self.vars: dict[str, float] = {} # 이름 → 마지막 대입 값
|
||||
self.ctx: dict | None = None # 세 줄 머리 자원 {종류, 자원}
|
||||
self.triple = False # 바로 앞이 세 줄
|
||||
self.heads: list[dict] = [] # 세 줄 머리 줄들
|
||||
self.lines: list[dict[str, Any]] = []
|
||||
|
||||
# ── 꼴 ─────────────────────────────────────────────────────────
|
||||
def blank(self, src: dict) -> dict[str, Any]:
|
||||
return {
|
||||
"행": src["행"],
|
||||
"엑셀행": src["엑셀행"],
|
||||
"글": src["보임"],
|
||||
"보임": src["보임"],
|
||||
"품명": None,
|
||||
"규격": None,
|
||||
"단위": None,
|
||||
"단": 0,
|
||||
"색인": 0,
|
||||
"연결": 0,
|
||||
"연결뜻": None,
|
||||
"비목": None,
|
||||
"자원": None,
|
||||
"연결단가": [],
|
||||
"변수": [],
|
||||
"줄참조": [],
|
||||
"결과": [],
|
||||
"합계": False,
|
||||
"자리": {"자리수": None, "방법": None},
|
||||
"표지": None,
|
||||
"기계": None,
|
||||
"산근": None,
|
||||
"저장": src.get("돈"),
|
||||
"추정": False,
|
||||
"_marks": [],
|
||||
"_칸": src.get("칸"),
|
||||
}
|
||||
|
||||
def resource(self, kind: str, one: dict) -> dict[str, Any]:
|
||||
code = (
|
||||
f"단산{one['번']}" if kind == "산근" else f"{one['표']}#{one['번']}"
|
||||
) # 원 코드 없음 = 표 줄 번
|
||||
out = {"코드": code, "종류": kind, "표": one["표"]}
|
||||
return out | {k: one[k] for k in ("이름", "규격", "단위")} | {"저장": dict(one["값"])}
|
||||
|
||||
def link(self, ln: dict, kind: str, one: dict | None) -> None:
|
||||
ln["연결"] = LINK[kind] if kind in LINK else 62
|
||||
ln["연결뜻"] = MEAN.get(ln["연결"])
|
||||
ln["추정"] = True
|
||||
if one is not None:
|
||||
ln["자원"] = self.resource(kind, one)
|
||||
if kind == "산근":
|
||||
ln["산근"] = ln["자원"]["코드"]
|
||||
|
||||
@staticmethod
|
||||
def money_part(ln: dict) -> str | None:
|
||||
"""금액 칸 비목 — 노 · 재 · 경 중 값 하나 · 셋 다 = T · 없음 = None."""
|
||||
got = [c for c in "LMS" if (ln["저장"] or {}).get(c)]
|
||||
return got[0] if len(got) == 1 else ("T" if got else None)
|
||||
|
||||
@staticmethod
|
||||
def closed(text: str, end: int) -> bool:
|
||||
"""칸 나눔 꼴 — 표지 붙은 수 뒤는 닫는 표지 2 칸(빈칸 · 수 끝 점 2 이상 · 줄 끝)."""
|
||||
gap = len(text[end:]) - len(text[end:].lstrip(" ."))
|
||||
return gap >= 2 or not text[end:].strip()
|
||||
|
||||
def wrap(self, ln: dict, s: int, e: int, ch: str) -> None:
|
||||
ln["_marks"].append((s, e, ch))
|
||||
|
||||
# ── 자원 찾기 ─────────────────────────────────────────────────
|
||||
def names(self, ln: dict, one: dict, start: int = 0) -> int | None:
|
||||
"""이름 · 규격 · 단위 자리 표지(`\\x0e` · `\\x13` · `\\x15`) · 끝 자리."""
|
||||
text = ln["보임"]
|
||||
sp = _span(text, one["이름"], start)
|
||||
if not sp:
|
||||
return None
|
||||
self.wrap(ln, *sp, "\x0e")
|
||||
ln["품명"] = text[sp[0] : sp[1]]
|
||||
end = sp[1]
|
||||
if one.get("규격"):
|
||||
m = re.match(r"\s*", text[end:])
|
||||
at = end + m.end()
|
||||
size = _span(text, one["규격"], at)
|
||||
if size and size[0] - at <= 1:
|
||||
lo = size[0] - (1 if text[size[0] - 1 : size[0]] in "([" and size[0] > at else 0)
|
||||
hi = size[1] + (1 if text[size[1] : size[1] + 1] in ")]" and lo < size[0] else 0)
|
||||
self.wrap(ln, lo, hi, "\x13")
|
||||
ln["규격"] = text[lo:hi]
|
||||
end = hi
|
||||
unit = re.match(r"\s*(\[[^\[\]]*\])", text[end:])
|
||||
if unit:
|
||||
self.wrap(ln, end + unit.start(1), end + unit.end(1), "\x15")
|
||||
ln["단위"] = unit.group(1)
|
||||
end += unit.end(1)
|
||||
return end
|
||||
|
||||
def find_item(self, ln: dict) -> tuple[str, dict, tuple[int, int, float]] | None:
|
||||
"""노임 · 자재 — 글 속 수가 그 단가이고 이름이 그 수 앞에 있음(가장 긴 이름 · 규격 맞음 먼저)."""
|
||||
text, body = ln["보임"], sx.flat(ln["보임"])
|
||||
best = None
|
||||
for hit in _nums(text):
|
||||
for kind, one in self.res["_값"].get(round(hit[2], 4), ()):
|
||||
name = sx.flat(one["이름"])
|
||||
if not name or name not in body:
|
||||
continue
|
||||
sp = _span(text, one["이름"])
|
||||
if not sp or sp[1] > hit[0]:
|
||||
continue
|
||||
key = (not one["규격"] or sx.flat(one["규격"]) in body, len(name))
|
||||
if best is None or key > best[0]:
|
||||
best = (key, kind, one, hit)
|
||||
return best[1:] if best else None
|
||||
|
||||
def find_head(self, ln: dict) -> tuple[str, dict] | None:
|
||||
"""세 줄 머리 — 「* 단산 N 참조 *」 · 「* 견적 N 참조 *」 · 기계 이름(+규격)."""
|
||||
text = ln["보임"]
|
||||
m = _REF.search(text)
|
||||
if m:
|
||||
kind = "산근" if m.group(1) == "단산" else "견적"
|
||||
one = self.res["_번"].get((kind, int(m.group(2))))
|
||||
if kind == "견적" and (not one or sx.flat(one["이름"]) not in sx.flat(text)):
|
||||
body = sx.flat(text[: m.start()]) # 견적 번 ≠ 표 차례 — 이름으로(가장 긴 것)
|
||||
hits = [x for x in self.res.get("견적") or [] if sx.flat(x["이름"]) in body]
|
||||
one = (
|
||||
max(hits, key=lambda x: len(sx.flat(x["이름"]) + sx.flat(x["규격"])))
|
||||
if hits
|
||||
else None
|
||||
)
|
||||
return (kind, one) if one else None
|
||||
if "=" in text or self.res["_기계틀"] is None or text.lstrip()[:1] in ("※", "*"):
|
||||
return None
|
||||
body, best = sx.flat(text), None
|
||||
for at in self.res["_기계틀"].finditer(body):
|
||||
name = at.group(1)
|
||||
for one in self.res["_기계"][name]:
|
||||
size = sx.flat(one["규격"])
|
||||
tail = body[at.start() + len(name) : at.start() + len(name) + len(size) + 2]
|
||||
if size and size not in tail:
|
||||
continue
|
||||
key = len(name) + len(size)
|
||||
if best is None or key > best[0]:
|
||||
best = (key, one)
|
||||
return ("기계", best[1]) if best else None
|
||||
|
||||
# ── 줄 갈래 ───────────────────────────────────────────────────
|
||||
def run(self, src: dict) -> dict[str, Any]:
|
||||
ln = self.blank(src)
|
||||
text = ln["보임"]
|
||||
if not text.strip() and not ln["저장"]:
|
||||
ln["연결"] = None
|
||||
return self.done(ln)
|
||||
heading = bool(_HEADING.match(text))
|
||||
part = _label(text)
|
||||
if heading:
|
||||
ln["단"] = 1
|
||||
title = sx.flat(_HEADING.sub("", text))
|
||||
if re.search(r"[합소총]계|^계", title):
|
||||
self.heads = [] # 합계 절 — 세 줄 머리 닫음
|
||||
self.triple = False
|
||||
if self.triple and "소계" in sx.flat(text.split(":")[0]) and self.ctx:
|
||||
return self.done(self.triple_total(ln))
|
||||
self.triple = False
|
||||
if part in ("L", "M", "S") and not heading and self.triple_line(ln, part):
|
||||
return self.done(ln)
|
||||
got = self.find_item(ln)
|
||||
total = ln["저장"] if part == "T" else not ln["저장"] # 합계 = 계 줄만 금액 칸
|
||||
if part and not heading and got is None and total:
|
||||
return self.done(self.total_line(ln, part))
|
||||
if got is not None:
|
||||
return self.done(self.item(ln, *got))
|
||||
if (head := self.find_head(ln)) is not None:
|
||||
kind, one = head
|
||||
self.link(ln, kind, one)
|
||||
end = self.names(ln, one)
|
||||
loose = end is None or (one["규격"] and ln["규격"] is None) # 규격이 이름 곁에 없음
|
||||
loose = loose or text[end or 0 :].lstrip()[:1] in (":", ":")
|
||||
if ln["단"] == 0 and kind == "기계" and loose: # 설명 글 속 장비 이름
|
||||
ln |= {"연결": 0, "연결뜻": None, "자원": None, "추정": False, "품명": None}
|
||||
ln |= {"규격": None, "단위": None, "_marks": []}
|
||||
self.plain(ln)
|
||||
return self.done(ln)
|
||||
self.heads.append({"종류": kind, "자원": one, "줄": ln, "씀": False})
|
||||
self.ctx = self.heads[-1]
|
||||
return self.done(ln)
|
||||
if self.subtotal(ln) or self.rate62(ln) or self.rate(ln):
|
||||
return self.done(ln)
|
||||
self.plain(ln)
|
||||
return self.done(ln)
|
||||
|
||||
def unused(self) -> None:
|
||||
"""머리 아닌 줄의 기계 이름 — 아래 세 줄이 안 쓰면 글 줄(설명 속 장비 이름)."""
|
||||
for h in self.heads:
|
||||
ln = h["줄"]
|
||||
if h["종류"] == "기계" and not h["씀"] and ln["단"] == 0:
|
||||
ln |= {"연결": 0, "연결뜻": None, "자원": None, "추정": False, "품명": None}
|
||||
ln |= {"규격": None, "단위": None, "_marks": []}
|
||||
|
||||
def done(self, ln: dict) -> dict[str, Any]:
|
||||
self.lines.append(ln)
|
||||
return ln
|
||||
|
||||
def total_line(self, ln: dict, part: str) -> dict:
|
||||
"""합계 줄 — 「노무비 : 식 = 값」 · 「재료비 : 값」 · 「계 : 값」(계 = 결과)."""
|
||||
text = ln["보임"]
|
||||
ln |= {"합계": True, "비목": part, "단": -1}
|
||||
lead = len(text) - len(text.lstrip())
|
||||
res = _result(text)
|
||||
if res:
|
||||
self.wrap(ln, lead, text.rfind("=") + 1, "\x1a")
|
||||
self.result(ln, res)
|
||||
elif part == "T":
|
||||
colon = _COLON.search(text).end()
|
||||
self.wrap(ln, lead, colon, "\x1a")
|
||||
nums = _nums(text, colon)
|
||||
if nums:
|
||||
self.wrap(ln, nums[0][0], nums[0][1], "\x12")
|
||||
ln["결과"] = [nums[0][2]]
|
||||
else:
|
||||
self.wrap(ln, lead, len(text.rstrip()), "\x1a")
|
||||
return ln
|
||||
|
||||
def result(self, ln: dict, res: tuple) -> None:
|
||||
s, e, v, letter = res
|
||||
if letter is not None:
|
||||
self.wrap(ln, letter, letter + 1, "\x06")
|
||||
self.wrap(ln, s, e, "\x12")
|
||||
ln["결과"].append(v)
|
||||
|
||||
def triple_line(self, ln: dict, part: str) -> bool:
|
||||
"""세 줄 — 단가 = 머리 자원(기계 · 산근 · 견적)의 그 비목 값."""
|
||||
text = ln["보임"]
|
||||
colon = _COLON.search(text).end()
|
||||
nums = _nums(text, colon)
|
||||
if not nums or not self.heads:
|
||||
return False
|
||||
price = nums[0]
|
||||
tail = text[price[1] :]
|
||||
if ("=" not in tail and tail.strip()) or tail.lstrip()[:1] == "+":
|
||||
return False
|
||||
fit = [
|
||||
h
|
||||
for h in reversed(self.heads)
|
||||
if _close(price[2], float(h["자원"]["값"].get(part) or 0))
|
||||
]
|
||||
if not fit:
|
||||
return False
|
||||
self.ctx = fit[0]
|
||||
self.ctx["씀"] = True
|
||||
one = self.ctx["자원"]
|
||||
self.link(ln, self.ctx["종류"], one)
|
||||
ln["비목"] = part
|
||||
if self.ctx["종류"] == "기계":
|
||||
ln["기계"] = True
|
||||
self.money(ln, price)
|
||||
self.triple = True
|
||||
return True
|
||||
|
||||
def triple_total(self, ln: dict) -> dict:
|
||||
one = self.ctx["자원"]
|
||||
self.link(ln, self.ctx["종류"], one)
|
||||
ln["비목"] = "T"
|
||||
text = ln["보임"]
|
||||
res = _result(text)
|
||||
if res:
|
||||
self.result(ln, res)
|
||||
else:
|
||||
m = re.search(r"[::]\s*(?:([A-Z])\s*)?(" + NUM + r")", text)
|
||||
if m:
|
||||
self.result(
|
||||
ln, (m.start(2), m.end(2), _v(m.group(2)), m.start(1) if m.group(1) else None)
|
||||
)
|
||||
money = ln["저장"] or {}
|
||||
rows = []
|
||||
for one in reversed(self.lines):
|
||||
if one["연결"] != ln["연결"] or one["비목"] not in ("L", "M", "S"):
|
||||
break
|
||||
rows.append(one)
|
||||
cols = [c for c in "LMS" if money.get(c)]
|
||||
total = sum((one["결과"] or [0])[-1] for one in rows)
|
||||
if len(cols) == 1 and money.get("T") and abs(total - money["T"]) < 1:
|
||||
for one in rows: # 계 줄 금액 칸 비목 하나 = 값 있는 세 줄 모두 그 비목으로 옮김
|
||||
if (one["결과"] or [0])[-1]:
|
||||
one["비목"] = cols[0]
|
||||
elif money:
|
||||
for one in rows: # 계 줄 금액 칸이 빈 비목 = 합에 안 든 줄(비목 없음)
|
||||
if not money.get(one["비목"]) and one["결과"] and one["결과"][-1]:
|
||||
one["비목"] = None
|
||||
self.triple = False
|
||||
return ln
|
||||
|
||||
def item(self, ln: dict, kind: str, one: dict, price: tuple) -> dict:
|
||||
self.link(ln, kind, one)
|
||||
self.names(ln, one)
|
||||
part = self.money_part(ln)
|
||||
ln["비목"] = part if part in ("L", "M", "S") else None
|
||||
self.money(ln, price)
|
||||
return ln
|
||||
|
||||
def money(self, ln: dict, price: tuple[int, int, float]) -> None:
|
||||
"""금액 줄 — 단가(`\\x03`) · 첫 식 조각의 앞 대입 값 = 변수(`\\x11`) · 끝 = 결과."""
|
||||
text = ln["보임"]
|
||||
s, e, v = price
|
||||
self.wrap(ln, s, e, "\x03")
|
||||
ln["연결단가"] = [v]
|
||||
self.var(ln, e)
|
||||
res = _result(text)
|
||||
if res and res[0] > e:
|
||||
self.result(ln, res)
|
||||
|
||||
def var(self, ln: dict, start: int) -> None:
|
||||
"""첫 식 조각(start ~ 첫 「=」)의 수 중 가장 늦게 대입한 변수 값 하나 = 변수(`\\x11` · 원파일 = 줄마다 하나) ·
|
||||
칸 나눔 꼴은 닫는 표지 자리(2 칸)가 있는 수만."""
|
||||
text = ln["보임"]
|
||||
eq = text.find("=", start)
|
||||
order = list(self.vars.values())
|
||||
best = None
|
||||
for a, b, val in _nums(text, start, eq if eq >= 0 else None):
|
||||
rank = max((i for i, x in enumerate(order) if _close(val, x)), default=-1)
|
||||
if ln["_칸"] and not self.closed(text, b):
|
||||
continue
|
||||
if rank >= 0 and (best is None or rank > best[0]):
|
||||
best = (rank, a, b, val)
|
||||
if best:
|
||||
self.wrap(ln, best[1], best[2], "")
|
||||
ln["변수"].append(best[3])
|
||||
|
||||
def subtotal(self, ln: dict) -> bool:
|
||||
"""「…소계 : 수」 — 금액 칸 계 없음 · 비목 하나(61) · 금액 칸 없음 = 비목 없음."""
|
||||
text = ln["보임"]
|
||||
m = _COLON.search(text)
|
||||
if not m or "소계" not in sx.flat(text[: m.start()]):
|
||||
return False
|
||||
money = ln["저장"] or {}
|
||||
parts = [c for c in "LMS" if money.get(c)]
|
||||
if len(parts) != 1 or (
|
||||
money.get("T") is not None and not _close(money["T"], money[parts[0]])
|
||||
):
|
||||
return False # 소계 61 = 금액 칸 비목 하나(계 칸 없음 · 있으면 같은 값)
|
||||
got = re.match(r"\s*(?:([A-Z])\s*)?(" + NUM + r")\.?(?![\d,])", text[m.end() :])
|
||||
if not got:
|
||||
return False
|
||||
at = m.end()
|
||||
if got.group(1):
|
||||
self.wrap(ln, at + got.start(1), at + got.end(1), "\x06")
|
||||
self.wrap(ln, at + got.start(2), at + got.end(2), "\x0c")
|
||||
ln["줄참조"] = [_v(got.group(2))]
|
||||
ln["저장"] = {**money, "T": money[parts[0]]}
|
||||
self.link(ln, "소계", None)
|
||||
ln["비목"] = self.money_part(ln)
|
||||
ln["표지"] = "소계"
|
||||
self.var(ln, at + got.end(2))
|
||||
res = _result(text)
|
||||
if res and res[0] > at + got.end(2):
|
||||
self.result(ln, res)
|
||||
return True
|
||||
|
||||
def rate62(self, ln: dict) -> bool:
|
||||
"""「(A 수 + 수) × n% = 값」 · 「A 수 + 수 = 합 × n% = 값」 — 구간 합 율(62 · 합계 표지)."""
|
||||
text = ln["보임"]
|
||||
m = _GROUP62.search(text)
|
||||
if not m or not re.search(r"[×xX*]", text[m.end() :]) or not ln["저장"]:
|
||||
return False
|
||||
self.wrap(ln, m.start(1), m.end(1), "\x07")
|
||||
self.wrap(ln, m.start(2), m.end(3), "\x1a")
|
||||
ln |= {"합계": True}
|
||||
self.link(ln, "율62", None)
|
||||
ln["비목"] = self.money_part(ln)
|
||||
ln["표지"] = "율"
|
||||
for r in re.finditer(r"=\s*(?:([A-Z])\s*)?(" + NUM + r")\.?(?![\d,])", text):
|
||||
self.result(
|
||||
ln, (r.start(2), r.end(2), _v(r.group(2)), r.start(1) if r.group(1) else None)
|
||||
)
|
||||
return True
|
||||
|
||||
def rate(self, ln: dict) -> bool:
|
||||
"""「수 × n% = 값」 + 금액 칸 — 율(58 · 첫 수 = 줄참조)."""
|
||||
text = ln["보임"]
|
||||
res = _result(text)
|
||||
if not ln["저장"] or not res:
|
||||
return False
|
||||
colon = _COLON.search(text)
|
||||
nums = _nums(text, colon.end() if colon else 0, text.rfind("="))
|
||||
seen = [v for x in self.lines for v in x["결과"] + x["줄참조"]]
|
||||
before = any(_close(nums[0][2], v) for v in seen) if nums else False
|
||||
lead = text[colon.end() if colon else 0 :].lstrip(" (")
|
||||
before = before and lead[:1].isdigit()
|
||||
if not nums or ("%" not in text[: text.rfind("=")] and not before):
|
||||
return False
|
||||
self.wrap(ln, nums[0][0], nums[0][1], "\x0c")
|
||||
ln["줄참조"] = [nums[0][2]]
|
||||
self.link(ln, "율", None)
|
||||
ln["비목"] = self.money_part(ln)
|
||||
ln["표지"] = "율"
|
||||
self.result(ln, res)
|
||||
return True
|
||||
|
||||
def plain(self, ln: dict) -> None:
|
||||
"""글 · 변수 줄 — 대입마다 「이름 = 식 = 값」 = 결과 · 그 밖 「… = 값」(식 있는 줄) = 결과."""
|
||||
text = ln["보임"]
|
||||
ln["연결"] = 0
|
||||
part = self.money_part(ln)
|
||||
ln["비목"] = part
|
||||
hits: list[re.Match] = []
|
||||
for m in _ASSIGN.finditer(text):
|
||||
if m.group(1).lower() in _UNITS and re.search(r"\d\.?\s?$", text[: m.start()]):
|
||||
continue # 「8 hr =」 = 단위
|
||||
gap = text[hits[-1].end() : m.start()] if hits else ""
|
||||
if not hits or (re.search(r"\d", gap) and not re.search(r"[×÷+\-*/(=]\s*$", gap)):
|
||||
hits.append(m)
|
||||
lead = text[: hits[0].start()].strip(" ○●·-{") if hits else ""
|
||||
if hits and (not lead or re.search(r"[::]$", lead)):
|
||||
for n, m in enumerate(hits):
|
||||
end = hits[n + 1].start() if n + 1 < len(hits) else len(text)
|
||||
seg = text[m.end() : end]
|
||||
parts = seg.split("=")
|
||||
val = re.match(r"\s*(?:[A-Z](?=\d))?(" + NUM + r")", parts[-1])
|
||||
if not val:
|
||||
continue
|
||||
at = m.end() + len(seg) - len(parts[-1])
|
||||
if len(parts) >= 2:
|
||||
res = _result(text[:end])
|
||||
if res and res[0] >= at:
|
||||
self.result(ln, res)
|
||||
self.vars.pop(m.group(1), None)
|
||||
self.vars[m.group(1)] = _v(val.group(1))
|
||||
return
|
||||
eq = text.rfind("=")
|
||||
seg = re.split(r"[=::]", text[:eq])[-1]
|
||||
if eq > 0 and re.search(r"[×÷+*]|\d\s*/|/\s*\d", seg):
|
||||
res = _result(text)
|
||||
if res:
|
||||
self.result(ln, res)
|
||||
|
||||
|
||||
# ── 책 ────────────────────────────────────────────────────────────────
|
||||
def _glyph(text: str, marks: list[tuple[int, int, str]]) -> str:
|
||||
"""보인 글 + 표지 자리 → 원파일 LineData 꼴(닫는 표지 먼저)."""
|
||||
ins: list[tuple[int, int, str]] = []
|
||||
for s, e, ch in marks:
|
||||
ins.append((s, 1, ch))
|
||||
ins.append((e, 0, ch))
|
||||
out, last = [], 0
|
||||
for at, _, ch in sorted(ins, key=lambda x: (x[0], x[1])):
|
||||
out.append(text[last:at])
|
||||
out.append(ch)
|
||||
last = at
|
||||
out.append(text[last:])
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def index(res: dict[str, list[dict]]) -> dict[str, Any]:
|
||||
"""자원 표 → 찾기 색인(단가 → 노임 · 자재 · 이름 → 기계 · (종류, 번) → 산근 · 견적)."""
|
||||
out: dict[str, Any] = dict(res)
|
||||
out["_값"] = {}
|
||||
for kind in ("노임", "자재"):
|
||||
for one in res.get(kind) or []:
|
||||
out["_값"].setdefault(round(one["값"]["T"], 4), []).append((kind, one))
|
||||
out["_기계"] = {}
|
||||
for one in res.get("기계") or []:
|
||||
if sx.flat(one["이름"]):
|
||||
out["_기계"].setdefault(sx.flat(one["이름"]), []).append(one)
|
||||
names = sorted(out["_기계"], key=len, reverse=True)
|
||||
out["_기계틀"] = re.compile("(?=(" + "|".join(map(re.escape, names)) + "))") if names else None
|
||||
out["_번"] = {(k, one["번"]): one for k in ("산근", "견적") for one in res.get(k) or []}
|
||||
return out
|
||||
|
||||
|
||||
def read(sheets: dict[str, list]) -> dict[str, Any]:
|
||||
"""EST 엑셀 시트 → {꼴, 산근, 일위, 자원, 내역, 표지}(`M02_San_Estx.read` 꼴 · 일위 = 호표 길)."""
|
||||
res = sx.resources(sheets)
|
||||
look = index(res)
|
||||
total = {one["번"]: one for one in res.get("산근") or []}
|
||||
out: dict[str, Any] = {"꼴": "EST", "산근": [], "일위": [], "내역": None}
|
||||
for b in blocks(sheets.get(SHEET) or []):
|
||||
tags: list[str] = []
|
||||
one = total.get(b["번"])
|
||||
m = _HEAD.match(b["머리"])
|
||||
head = {
|
||||
"코드": f"단산{b['번']}",
|
||||
"이름": one["이름"] if one else m.group(2).strip(),
|
||||
"규격": one["규격"] if one else "",
|
||||
"단위": one["단위"] if one else m.group(3).strip(),
|
||||
"저장": b["저장"] or (dict(one["값"]) if one else None),
|
||||
}
|
||||
if not one:
|
||||
tags.append(f"{sx.NOTE}총괄표에 단산{b['번']} 없음")
|
||||
work = Lines(look, tags)
|
||||
for src in b["줄"]:
|
||||
work.run(src)
|
||||
work.unused()
|
||||
lines = []
|
||||
for ln in work.lines:
|
||||
marks = ln.pop("_marks")
|
||||
ln.pop("_칸", None)
|
||||
ln["글"] = _glyph(ln["보임"], marks)
|
||||
lines.append(ln)
|
||||
out["산근"].append({**head, "번": b["번"], "엑셀행": b["행"], "줄": lines, "표지": tags})
|
||||
out["자원"] = {
|
||||
f"{kind}:{one['번']}": {k: one[k] for k in ("이름", "규격", "단위", "표")}
|
||||
| {"저장": one["값"]}
|
||||
for kind, rows in res.items()
|
||||
for one in rows
|
||||
}
|
||||
out["표지"] = [
|
||||
f"{sx.NOTE}자원 코드 · LinkIndex · 자리(Diget) 없음 — 연결뜻 = 글 · 금액 칸 · 단가표로 정함({sx.GUESS})",
|
||||
f"{sx.NOTE}자원 코드 = 단가표 이름#No(산근 = 단산 번)",
|
||||
]
|
||||
return out
|
||||
@@ -0,0 +1,70 @@
|
||||
"""STmate 산근 엑셀 → 810-1 구조 줄(PLAN 812-1 · `M02_San_Stc.read` 와 같은 꼴 · 읽기만 · 서버 단독).
|
||||
|
||||
엑셀 「단가산출근거」 숨은 G 열 = 원파일 COSTN.SMT 원문 그대로 → 줄 = `M02_San_Stc.stc_line`(풀이 =
|
||||
`M02_San_Stmate.parse_line` · 다시 짜지 않음) · 블록 머리 금액(C~F) = 원파일 저장(TAMT1 …) ·
|
||||
단가 = 목록표(노무비 · 재료비 · 경비 · 중기 · 단가산출근거 · 숨은 메타 = COSTN 칸) ·
|
||||
끝전 = 파일 투표(`_원파일파서_틀.md` 4 장 SAN_TYP 두 갈래 중 블록 계가 맞는 수가 많은 쪽 · 파일에 하나 ·
|
||||
같으면 비목버림).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
from M02_MasterTemplete import M02_San_Eval as ev
|
||||
from M02_MasterTemplete import M02_San_Stc as stc
|
||||
from M02_MasterTemplete import M02_San_Xlsx as sx
|
||||
|
||||
RULE = {"비목버림": "1", "합버림_큰나머지": "0"} # 끝전 갈래 → SAN_TYP
|
||||
|
||||
|
||||
def _amt(head: dict) -> dict[str, float]:
|
||||
return {c: float(head.get(k) or 0) for c, k in zip("TLMS", "CDEF")}
|
||||
|
||||
|
||||
def vote(blocks: list[dict], prices: dict) -> tuple[str, dict[str, int]]:
|
||||
"""파일 끝전 — 블록마다 두 갈래로 셈해(낙찰 · 조정 뒤) 엑셀 머리 계(C)와 맞는 수 · 많은 쪽(같으면
|
||||
비목버림)."""
|
||||
got = dict.fromkeys(RULE, 0)
|
||||
for blk in blocks:
|
||||
nodes = [ln["풀이"] for ln in blk["줄"]]
|
||||
want = (blk.get("저장") or {}).get("T")
|
||||
for rule in RULE:
|
||||
try:
|
||||
total = ev.stmate_block({"코드": blk["코드"]}, nodes, prices, rule)["금액"]["T"]
|
||||
except Exception: # noqa: BLE001 — 못 셈 = 표 없음
|
||||
continue
|
||||
got[rule] += want is not None and abs(float(total) - float(want)) < 0.5
|
||||
best = max(RULE, key=lambda r: (got[r], r == "비목버림"))
|
||||
return best, got
|
||||
|
||||
|
||||
def read(sheets: dict[str, list]) -> dict[str, Any]:
|
||||
"""STmate 엑셀 시트 → {꼴, 산근, 일위, 자원, 내역, 단가, 옵션, 표지}."""
|
||||
found = book.stmate_prices(sheets)
|
||||
prices = {c: {k: p.get(k) for k in ("T", "L", "M", "S", "meta")} for c, p in found.items()}
|
||||
res = {
|
||||
c: {"종류": stc.KIND.get(c[:1]), **{k: p.get(k) for k in ("이름", "규격", "단위")}}
|
||||
| {"저장": {k: p.get(k) for k in "TLMS"}}
|
||||
for c, p in found.items()
|
||||
}
|
||||
out: dict[str, Any] = {"꼴": "STmate", "산근": [], "일위": [], "자원": res, "단가": prices}
|
||||
for blk in book.stmate_blocks(sheets):
|
||||
tags: list[str] = []
|
||||
lines = [stc.stc_line(i, ln["G"], res, tags) for i, ln in enumerate(blk["몸"], 1)]
|
||||
for one, src in zip(lines, blk["몸"]):
|
||||
one["엑셀행"] = src["행"]
|
||||
me = found.get(blk["코드"]) or {}
|
||||
head = {"코드": blk["코드"], "이름": blk.get("이름") or me.get("이름")}
|
||||
head |= {"규격": me.get("규격"), "단위": me.get("단위"), "저장": _amt(blk["머리"])}
|
||||
_, tot = ev.run_stmate([ln["풀이"] for ln in lines], prices)
|
||||
out["산근"].append({**head, "줄": lines, "줄합": tot, "표지": tags, "엑셀행": blk["행"]})
|
||||
rule, got = vote(out["산근"], prices)
|
||||
out["옵션"] = {"SAN_TYP": RULE[rule]}
|
||||
out["내역"] = None
|
||||
out["표지"] = [
|
||||
f"{sx.NOTE}일위대가 구성 · 내역은 호표 길(산근만 읽음)",
|
||||
f"{sx.NOTE}끝전 = 파일 투표 {rule}(비목버림 {got['비목버림']} · 합버림 {got['합버림_큰나머지']} 블록)",
|
||||
]
|
||||
return out
|
||||
@@ -1,14 +1,13 @@
|
||||
"""M02 불러오기 단산 블록 → 가져온 식 단가산출(PLAN 793-3b · 요구 646 · 647 · 652) — 서버 단독.
|
||||
|
||||
올림 때 경쟁사 산근 시트(`M02_San_Book.sheets` · STmate 숨은 G 열 포함)를
|
||||
`실무자료/산근/<HP>.json` 에 둠
|
||||
(원본 엑셀은 안 둠 · HP 지울 때 같이 지움) → [가져온 식으로 열기] = 그 블록을 793-2a 내보내기
|
||||
(`M02_San_Emit` · 이음 = 1차 줄 판정) → 793-2 만들기(`M02_Sangeun_Imported.create` · 개인 층 ·
|
||||
올림 때 경쟁사 산근 시트(`M02_San_Book.sheets` · STmate 숨은 G 열 포함)와 그 엑셀 읽기기 둘 꼴
|
||||
(812-1 `M02_San_Xlsx` → `M02_Doc_FromNative.pack` · 칸 `엑셀`)을 `실무자료/산근/<HP>.json` 에 둠
|
||||
(원본 엑셀은 안 둠 · HP 지울 때 같이 지움) → [가져온 식으로 열기] = 그 블록을 원파일과 같은 길
|
||||
(`M02_Doc_FromNative.emit` · 원본 `엑셀` 참) → 793-2 만들기(`M02_Sangeun_Imported.create` · 개인 층 ·
|
||||
엑셀 단가 그대로 · 경쟁사 로직 인정 — 근거 기초 제안 없음 · 809-1 · 요구 703) ·
|
||||
같은 내용 SK 가 있으면 그것(다시씀).
|
||||
블록 번 = 판정 블록 번(STmate = 코드 「D00102」 숫자 · EST = 단가산출 블록 번).
|
||||
STmate 블록 끝전(비목버림 | 합버림_큰나머지)은 파일 설정 — 참조 평가기로 둘 다 셈해
|
||||
엑셀 계와 맞는 것.
|
||||
STmate 블록 끝전 = 파일 투표(`M02_San_Xlsx_Stmate.vote`).
|
||||
원파일(.estx · .stc · 810-2 `M02_Doc_FromNative`) = 같은 자리 칸 `원파일`(둘 꼴) — 있으면 원파일 우선
|
||||
(그 블록 번이 원파일에 있을 때) · 엑셀 올림 때 같은 공사 원파일이 다른 HP 에 있으면 붙임 · 원파일 올림 =
|
||||
같은 공사 엑셀 HP 에 붙임(없으면 원파일로 새 HP · `upload_native`).
|
||||
@@ -17,7 +16,6 @@ STmate 블록 끝전(비목버림 | 합버림_큰나머지)은 파일 설정 —
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -30,7 +28,6 @@ from M02_MasterTemplete import M02_San_Emit as em
|
||||
|
||||
DIRNAME = "산근"
|
||||
RELOAD = "가져온 식 자료 없음 — 엑셀을 다시 올림"
|
||||
RULES = ("비목버림", "합버림_큰나머지")
|
||||
MAX_INNER = 6 # 안쪽 산근 깊이 상한
|
||||
|
||||
|
||||
@@ -60,14 +57,33 @@ def keep(folder: str | Path, key: str, name: str, data: bytes) -> str | None:
|
||||
return f"엑셀 형식 오류 — {type(e).__name__}: {str(e)[:80]}"
|
||||
if not book.kind_of(sheets):
|
||||
return None
|
||||
try:
|
||||
excel = _excel(name, sheets)
|
||||
except Exception as e: # noqa: BLE001 — 산근 읽기 실패 = 사유(올리기는 됨)
|
||||
return f"산근 시트 읽기 오류 — {type(e).__name__}: {str(e)[:80]}"
|
||||
path = _path(folder, key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
got = {"파일": name, "시트": sheets}
|
||||
got = {"파일": name, "시트": sheets, "엑셀": excel}
|
||||
native = next((n for n in _natives(folder) if fn.same(n, sheets)), None)
|
||||
atomic_write_json(path, got | ({"원파일": native} if native else {}))
|
||||
return None
|
||||
|
||||
|
||||
def _excel(name: str, sheets: dict) -> dict:
|
||||
"""산근 시트 → 원파일과 같은 둘 꼴(812-1 엑셀 읽기기 `M02_San_Xlsx` → `M02_Doc_FromNative.pack`)."""
|
||||
from M02_MasterTemplete import M02_San_Xlsx as sx
|
||||
|
||||
return fn.pack(sx.read_sheets(sheets), name)
|
||||
|
||||
|
||||
def _excel_of(path: Path, saved: dict) -> dict:
|
||||
"""저장 HP 의 엑셀 둘 꼴 — 옛 저장(시트만)은 한 번 지어 그 자리에 둠."""
|
||||
if not isinstance(saved.get("엑셀"), dict):
|
||||
saved["엑셀"] = _excel(str(saved.get("파일") or ""), saved["시트"])
|
||||
atomic_write_json(path, saved)
|
||||
return saved["엑셀"]
|
||||
|
||||
|
||||
# ── 원파일(810-2) ──────────────────────────────────────────────────────
|
||||
def _saved(folder: str | Path) -> list[tuple[Path, dict]]:
|
||||
out = []
|
||||
@@ -114,32 +130,12 @@ def has(folder: str | Path, key: str) -> bool:
|
||||
return _path(folder, key).is_file()
|
||||
|
||||
|
||||
def _rule(blk: dict, nodes: list, prices: dict) -> str:
|
||||
"""STmate 블록 끝전 — 엑셀 계(꼬리 · 없으면 머리)와 맞는 쪽 · 둘 다 아니면 비목버림."""
|
||||
from M02_MasterTemplete import M02_San_Eval as ev
|
||||
|
||||
tails = [t for t in blk["꼬리"] if t["A"].startswith("계") and not t["A"].startswith("계약")]
|
||||
want = (tails or [blk["머리"]])[0].get("C")
|
||||
for rule in RULES:
|
||||
try:
|
||||
got = ev.stmate_block(blk, nodes, prices, rule)["계"]["T"]
|
||||
except Exception: # noqa: BLE001 — 못 셈 = 다음 갈래
|
||||
continue
|
||||
if want is not None and abs(float(got) - float(want)) < 0.5:
|
||||
return rule
|
||||
return RULES[0]
|
||||
|
||||
|
||||
def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]:
|
||||
"""HP 단산 블록 하나 → `{로직, 원본}`(793-2a 꼴 · 이음 = M01 키)."""
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
from M02_MasterTemplete import M02_San_Emit as em
|
||||
from M02_MasterTemplete import M02_San_Est as est
|
||||
from M02_MasterTemplete import M02_San_Stmate as stm
|
||||
|
||||
"""HP 단산 블록 하나 → `{로직, 원본}`(원파일 우선 · 아니면 엑셀 읽기기 같은 길 · 이음 = M01 키)."""
|
||||
hopyo_store.read(folder, key) # 없는 HP = 404
|
||||
path = _path(folder, key)
|
||||
try:
|
||||
saved = json.loads(_path(folder, key).read_bytes())
|
||||
saved = json.loads(path.read_bytes())
|
||||
except (OSError, ValueError) as e:
|
||||
raise store.StoreError(409, RELOAD) from e
|
||||
native = saved.get("원파일")
|
||||
@@ -147,52 +143,22 @@ def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]:
|
||||
return fn.emit(native, int(no))
|
||||
if not saved.get("시트"):
|
||||
raise store.StoreError(404, f"없는 단산{no}")
|
||||
sheets, name = saved["시트"], str(saved.get("파일") or "")
|
||||
if book.kind_of(sheets) == "STmate":
|
||||
blk = next((b for b in book.stmate_blocks(sheets) if int(b["코드"][1:]) == int(no)), None)
|
||||
if blk is None:
|
||||
raise store.StoreError(404, f"없는 단산{no}")
|
||||
prices = book.stmate_prices(sheets)
|
||||
nodes = stm.parse_block(blk["몸"])
|
||||
return em.stmate(blk, nodes, prices, _rule(blk, nodes, prices), True, name)
|
||||
prices, total = book.est_prices(sheets)
|
||||
blk = next((b for b in book.est_blocks(sheets) if int(b["번"]) == int(no)), None)
|
||||
if blk is None or blk["번"] not in total:
|
||||
excel = _excel_of(path, saved)
|
||||
if int(no) not in fn.numbers(excel):
|
||||
raise store.StoreError(404, f"없는 단산{no}")
|
||||
return em.est(blk, est.parse_block(blk["글"], prices), total[blk["번"]], True, name)
|
||||
return fn.emit(excel, int(no))
|
||||
|
||||
|
||||
def _inner(
|
||||
source: Path, key: str, logic: dict, native: bool = False
|
||||
) -> list[tuple[dict, int | None, str | None]]:
|
||||
"""안쪽 산근 줄 `[(호표 줄, 같은 엑셀 안쪽 블록 번, 못 잇는 까닭)]` — STmate = 줄 코드가 블록 코드 ·
|
||||
EST = 줄 이름 「단산N」(N 은 총괄표에 있는 블록). 비목 하나만 부른 줄은 그 비목이 그대로(옮김 없음)일 때만."""
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
|
||||
saved = json.loads(_path(source, key).read_bytes())
|
||||
"""안쪽 산근 줄 `[(호표 줄, 같은 파일 안쪽 블록 번, 못 잇는 까닭)]` — 원파일 · 엑셀 둘 꼴 같은 길
|
||||
(`M02_Doc_FromNative.inner` · 줄 자원 코드 = 블록 코드)."""
|
||||
path = _path(source, key)
|
||||
saved = json.loads(path.read_bytes())
|
||||
if native and isinstance(saved.get("원파일"), dict):
|
||||
return fn.inner(saved["원파일"], logic)
|
||||
sheets = saved["시트"]
|
||||
stmate = book.kind_of(sheets) == "STmate"
|
||||
have = {b["코드"]: int(b["코드"][1:]) for b in book.stmate_blocks(sheets)} if stmate else {}
|
||||
total = set() if stmate else set(book.est_prices(sheets)[1])
|
||||
out = []
|
||||
for line in logic["호표"]:
|
||||
res = line.get("자원") or {}
|
||||
if res.get("갈래") != "단가산출":
|
||||
continue
|
||||
if stmate:
|
||||
n = have.get(str(res.get("코드")))
|
||||
else:
|
||||
hit = re.fullmatch(r"단산(\d+)", str(res.get("이름")))
|
||||
n = int(hit.group(1)) if hit and int(hit.group(1)) in total else None
|
||||
if n is None:
|
||||
out.append((line, None, "같은 엑셀에 안쪽 블록 없음"))
|
||||
elif res.get("옮김") or (res.get("꼴") == "EST" and res.get("열쇠비목") not in em.COMP):
|
||||
out.append((line, n, "비목이 바뀐 부름"))
|
||||
else:
|
||||
out.append((line, n, None))
|
||||
return out
|
||||
return fn.inner(_excel_of(path, saved), logic)
|
||||
|
||||
|
||||
def _link_inner(source: Path, key: str, no: int, out: dict, trail: tuple) -> None:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
@@ -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["연결"]
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user