feat(828-2): STmate 엑셀 일위대가표 숨은 칸 → 구성 줄 — 읽는 곳 하나 · 율 줄 · 닫기 · 일식 · 별도계상 · 판에 일위 재료
- M02_San_Xlsx_Ilwi(새) = 일위대가표 읽기 한 곳 — N 열 메타(COD · IPR · PRI · PRO · EQC · KWN · DVD) · O 열 차수 꼬리 기준 · 줄 꼴 = 828-1 M02_San_Ilwi 꼴 + 엑셀 칸(행 · 차수 · 비고 · 전체 · 율.코드 · 메타) · 숨은 칸 없음 = 옛 코드 찾기 · 단산 길 일위 빈 목록 + 표지 - 5 건 호표 136 · 줄 706 · STC 구성 줄과 코드 · 갈래 · 수량 · 율 · 밑줄 같음(다른 5 줄 = STC 별도계상 규칙이 비고 별산 표시를 놓침) - 호표 길(M02_Hopyo_Stmate._hopyos) · 단산 길(M02_San_Xlsx_Stmate.read 일위) · 단서(M02_San_MatFind.from_book) 모두 공용 읽기 · 단서 · 산근 · 단가 전후 같음 - M02_Hopyo_Fixed._ratio = 숨은 칸 율 밑 · 대상 비목 먼저(글 짐작 대신) — 호표 길 전후 바뀜 = 영덕 제11호표 산악할증 0% 한 줄(금액만 → 비율 노무비 · 금액 0 그대로) · 단산 항목 1564 판정 그대로 - 재료 판(census) = 일위 구성 줄 자재도 셈(M02_San_MatFind.ilwi_found · 종 칸 일위 = 호표 코드 · 화면 「일위대가 N 호표」) - 시험: 새 test_828_2_xlsx_ilwi_meta · test_827_2 단서 시트 보기에 「제N호표」 줄 넣음(실제 시트는 늘 있음 · 공용 읽기가 호표 머리부터 셈) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -59,13 +59,19 @@ def _parts(money: Any) -> dict[str, Decimal]:
|
||||
|
||||
|
||||
def _ratio(line: dict[str, Any], others: list[dict[str, Any]]) -> dict[str, str] | None:
|
||||
"""% 줄 → `{기준, 비목}` — 밑수 = 다른 줄 엑셀 금액[기준] 합(끝수 1 원 안) · 금액 비목 하나."""
|
||||
hit = _BASE.search(f"{line.get('규격') or ''} {line.get('이름') or ''}")
|
||||
"""% 줄 → `{기준, 비목}` — 밑수 = 다른 줄 엑셀 금액[기준] 합(끝수 1 원 안) · 금액 비목 하나 ·
|
||||
STmate 숨은 칸 율(`율` {밑, 대상} · 828-2)이 있으면 글 「노무비의」 · 금액 칸 짐작 대신 그것."""
|
||||
under = _d((line.get("단가") or {}).get("계"))
|
||||
paid = [k for k, v in _parts(line.get("금액")).items() if v]
|
||||
if not hit or under is None or len(paid) != 1:
|
||||
return None
|
||||
base = "노무비" if hit.group(1) == "인건비" else hit.group(1)
|
||||
if rate := line.get("율"):
|
||||
base, paid = rate.get("밑"), [rate.get("대상")]
|
||||
if base not in COSTS or paid[0] not in COSTS or under is None:
|
||||
return None # 전체(닫기 뒤) 율 · 빈 칸 = 금액만
|
||||
else:
|
||||
hit = _BASE.search(f"{line.get('규격') or ''} {line.get('이름') or ''}")
|
||||
paid = [k for k, v in _parts(line.get("금액")).items() if v]
|
||||
if not hit or under is None or len(paid) != 1:
|
||||
return None
|
||||
base = "노무비" if hit.group(1) == "인건비" else hit.group(1)
|
||||
total = sum((_parts(x.get("금액"))[base] for x in others), Decimal(0))
|
||||
return {"기준": base, "비목": paid[0]} if abs(total - under) <= GAP else None
|
||||
|
||||
|
||||
@@ -32,12 +32,13 @@ from typing import Any
|
||||
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel # 서로 부름 — 부를 때만 씀
|
||||
from M02_MasterTemplete import M02_Hopyo_ReadEst as hopyo_est
|
||||
from M02_MasterTemplete import M02_Hopyo_UnitLine as unit_line
|
||||
from M02_MasterTemplete import M02_San_Xlsx_Ilwi as ilwi
|
||||
|
||||
SHEET = "일위대가표"
|
||||
CALC = "단가산출근거"
|
||||
LISTS = ("단가산출근거목록표", "노무비목록표", "중기목록표", "재료비목록표", "경비목록표")
|
||||
SHEETS = (SHEET, CALC, *LISTS)
|
||||
_CODE = re.compile(r"^(?:COD\|)?([A-Z]{1,2}\d{5}|PR[A-Z]?\d+|[TC]\d+)(?:_\d+)?(?: →)?$")
|
||||
_CODE = ilwi.CODE
|
||||
_META = re.compile(r"(?<![A-Z])COD\|([A-Z]{1,2}\d{5})") # 숨은 칸 「`COD|L00001」
|
||||
_HEAD_B = re.compile(r"BDCOD\|(B\d{5})")
|
||||
_HEAD_D = re.compile(r"BDCOD\|(D\d{5})")
|
||||
@@ -51,6 +52,7 @@ _CALC_PRICE = {"합계": 8, "노무비": 9, "재료비": 10, "경비": 11} # I~
|
||||
NOTE, CODE_COLS, RES_COL, QTY_COL, NAME_COL = 12, range(12, 26), 12, 7, 23
|
||||
FORMULA_COL, OUT_COL = 6, 13 # G 식 · N 합 갈 곳(C = 셈 밖 「소계(제외금액)」)
|
||||
COSTS = ("노무비", "재료비", "경비")
|
||||
_PART = {"M": "재료비", "L": "노무비", "S": "경비", "T": "전체"} # 숨은 칸 율 비목(828-2)
|
||||
_KIND = {"L": "L", "X": "X", "M": "M", "S": "E"} # 자원 코드 머리 → 단위 줄 묶음(S 경비 = 값)
|
||||
_RES_CODE = re.compile(r"^[LMXS]\d{5}$")
|
||||
_RATE = re.compile(r"^(?:PR|RR)[A-Z]?_") # 율 행 「PR_1」 · 「RRS_(8)_1」
|
||||
@@ -296,64 +298,59 @@ def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str
|
||||
|
||||
# ── 일위대가표 ────────────────────────────────────────────────────────
|
||||
def _hopyos(rows: list[list[Any]]) -> tuple[list[dict[str, Any]], list[list[dict[str, Any]]]]:
|
||||
"""호표 · 구성 줄 = 공용 읽기(`M02_San_Xlsx_Ilwi.read` · 828-2) → HP 줄 꼴 · 설명 줄 = 근거글 ·
|
||||
닫기 · 구분 줄 뺌 · 율 줄 코드 = PR + 율 코드(「PRA01」) · 숨은 칸 율 = `율` {밑, 대상}(비목 이름)."""
|
||||
hopyos: list[dict[str, Any]] = []
|
||||
raw: list[list[dict[str, Any]]] = []
|
||||
want_head = done = False
|
||||
for at, row in enumerate(rows, 1):
|
||||
first = _s(_cell(row, 0))
|
||||
hit = hopyo_excel._HOPYO.fullmatch(_flat(first))
|
||||
if hit:
|
||||
code = next((m.group(1) for v in row[1:26] if (m := _HEAD_B.search(_s(v)))), None)
|
||||
hopyos.append({"번": int(hit.group(1)), "코드": code, "행": [at, at], "근거글": []})
|
||||
raw.append([])
|
||||
want_head, done = True, False
|
||||
continue
|
||||
if not hopyos or done:
|
||||
continue
|
||||
hopyo = hopyos[-1]
|
||||
if want_head:
|
||||
hopyo.update(이름=first, 규격=_s(_cell(row, 1)), 단위=_s(_cell(row, 3)))
|
||||
hopyo["금액"] = {}
|
||||
want_head = False
|
||||
continue
|
||||
if _flat(first).startswith("합계") or _flat(first) == "계":
|
||||
hopyo["금액"] = hopyo_excel._money(row, _COLS, 1)
|
||||
done = True # 끝 — 다음 호표까지 줄 안 받음
|
||||
continue
|
||||
if not first:
|
||||
continue
|
||||
code = _code(row)
|
||||
hopyo["행"][1] = at
|
||||
if not code:
|
||||
if re.search(r"[0-9A-Za-z가-힣]", first):
|
||||
hopyo["근거글"].append(f"{first} {_s(_cell(row, 1))}".strip())
|
||||
continue
|
||||
if code[0] in "TC" and code[1].isdigit():
|
||||
continue # 계 · 소계 · 계약단가
|
||||
line = {
|
||||
"차례": len(raw[-1]),
|
||||
"이름": first,
|
||||
"규격": _s(_cell(row, 1)),
|
||||
"수량": hopyo_excel._num(_cell(row, 2)),
|
||||
"단위": _s(_cell(row, 3)),
|
||||
"단가": hopyo_excel._money(row, _COLS, 0),
|
||||
"금액": hopyo_excel._money(row, _COLS, 1),
|
||||
"비고": _s(_cell(row, NOTE)),
|
||||
"코드": code,
|
||||
"행": at,
|
||||
}
|
||||
line["갈래"] = hopyo_excel.kind_of(line) # 갈래 한 규칙(776-2)
|
||||
line["참조"] = (
|
||||
{"종류": "단산", "번": int(code[1:]), "코드": code}
|
||||
if code[0] == "D"
|
||||
else {"종류": "일위", "번": None, "코드": code}
|
||||
if code[0] == "B"
|
||||
else None
|
||||
)
|
||||
raw[-1].append(line)
|
||||
for got in ilwi.read(rows)["호표"]:
|
||||
hopyo = {k: got[k] for k in ("번", "코드", "행")} | {"근거글": []}
|
||||
if "머리행" in got:
|
||||
hopyo |= {k: got[k] for k in ("이름", "규격", "단위")} | {"금액": {}}
|
||||
if "끝행" in got:
|
||||
hopyo["금액"] = hopyo_excel._money(rows[got["끝행"] - 1], _COLS, 1)
|
||||
hopyos.append(hopyo)
|
||||
raw.append([])
|
||||
for one in got["줄"]:
|
||||
if not one["코드"]: # 설명 줄 · 코드 없는 글
|
||||
if re.search(r"[0-9A-Za-z가-힣]", one["이름"]):
|
||||
hopyo["근거글"].append(f"{one['이름']} {one['규격']}".strip())
|
||||
continue
|
||||
if one["갈래"] in ("닫기", "구분"):
|
||||
continue # 계 · 소계 · 계약단가
|
||||
raw[-1].append(_hopyo_line(rows[one["행"] - 1], one, len(raw[-1])))
|
||||
return hopyos, raw
|
||||
|
||||
|
||||
def _hopyo_line(row: list[Any], one: dict[str, Any], at: int) -> dict[str, Any]:
|
||||
rate = one.get("율") or {}
|
||||
code = one["코드"] + rate.get("코드", "") if rate else one["코드"]
|
||||
line = {
|
||||
"차례": at,
|
||||
"이름": one["이름"],
|
||||
"규격": one["규격"],
|
||||
"수량": hopyo_excel._num(_cell(row, 2)),
|
||||
"단위": one["단위"],
|
||||
"단가": hopyo_excel._money(row, _COLS, 0),
|
||||
"금액": hopyo_excel._money(row, _COLS, 1),
|
||||
"비고": one["비고"],
|
||||
"코드": code,
|
||||
"행": one["행"],
|
||||
}
|
||||
if rate.get(
|
||||
"밑"
|
||||
): # 828-2 숨은 칸 율 밑 · 대상 비목(글 「노무비의 %」 짐작보다 먼저 · `M02_Hopyo_Fixed._ratio`)
|
||||
line["율"] = {k: _PART.get(rate.get(k) or "", "") for k in ("밑", "대상")}
|
||||
line["갈래"] = hopyo_excel.kind_of(line) # 갈래 한 규칙(776-2)
|
||||
line["참조"] = (
|
||||
{"종류": "단산", "번": int(code[1:]), "코드": code}
|
||||
if code[0] == "D"
|
||||
else {"종류": "일위", "번": None, "코드": code}
|
||||
if code[0] == "B"
|
||||
else None
|
||||
)
|
||||
return line
|
||||
|
||||
|
||||
def read(sheets: dict[str, list[list[Any]]]) -> dict[str, Any]:
|
||||
"""STmate 시트 → HP 꼴 `{꼴, 시트, 호표, 단산, 검산}`(값은 JSON 수)."""
|
||||
hopyos, raw = _hopyos(sheets.get(SHEET) or [])
|
||||
|
||||
@@ -42,6 +42,8 @@ interface MatKind {
|
||||
별도계상: boolean;
|
||||
블록: number[];
|
||||
줄: number;
|
||||
/** 828-2 이 종이 든 일위대가 구성 줄의 호표 코드(STmate 엑셀 숨은 칸 · 원파일 구성표) */
|
||||
일위?: string[];
|
||||
고름?: MatCand;
|
||||
}
|
||||
|
||||
@@ -133,11 +135,13 @@ export function createMatPick(opt: { scope: HopyoScope; key: string; target: Hop
|
||||
|
||||
function kindRow(k: MatKind): HTMLElement {
|
||||
const what = `${label(k)}${k.단위 ? ` (${k.단위})` : ""}` || "—";
|
||||
const sub = [k.방법, `${k.블록.length} 블록 · ${k.줄} 줄`, ...k.단서]
|
||||
const hops = k.일위?.length ? ` · 일위대가 ${k.일위.length} 호표` : "";
|
||||
const sub = [k.방법, `${k.블록.length} 블록${hops} · ${k.줄} 줄`, ...k.단서]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
const badge = createBadge(k.별도계상 ? `별도계상 · ${k.판정}` : k.판정, TONE[k.판정] ?? "warning", { size: 18 }); // prettier-ignore
|
||||
const row = createListRow({ label: what, sub, trailing: badge, size: 36, title: `단산 ${k.블록.join(" · ")}` }); // prettier-ignore
|
||||
const title = `단산 ${k.블록.join(" · ")}${hops ? ` / 일위대가 ${k.일위?.join(" · ")}` : ""}`;
|
||||
const row = createListRow({ label: what, sub, trailing: badge, size: 36, title }); // prettier-ignore
|
||||
const box = el("div", { attrs: { "data-mat-kind": k.종 }, children: [row, controls(k)] });
|
||||
if (edits.has(k.종)) box.dataset.matEdited = "";
|
||||
return box;
|
||||
|
||||
@@ -37,7 +37,6 @@ from M02_MasterTemplete import M02_Hopyo_MatSame as ms
|
||||
LINKED, NO_SPEC, MANY, NONE = "이어짐", "규격 없음 — 고르기", "후보 여럿", "우리 표에 없음"
|
||||
TOP = 8
|
||||
_CTX: ContextVar[dict] = ContextVar("재료단서", default={})
|
||||
_B = re.compile(r"B\d+")
|
||||
_LEAD = re.compile(r"^[\s\-·\d.)]+")
|
||||
_RANGE = re.compile(r"[A-Za-z]*\d+(?:\.\d+)?\s*[~∼]\s*[A-Za-z]*\d+(?:\.\d+)?")
|
||||
|
||||
@@ -59,24 +58,40 @@ def from_boq(rows: Any) -> list[dict]:
|
||||
|
||||
|
||||
def from_book(rows: Any) -> list[dict]:
|
||||
"""STmate 엑셀 「일위대가표」 시트 줄 → 자재 줄 단서(숨은 메타 COD M… · 대가 머리 = 이름 · 규격)."""
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
"""STmate 엑셀 「일위대가표」(시트 줄 · 또는 `M02_San_Xlsx_Ilwi.read` 답) → 자재 줄 단서(숨은 메타
|
||||
COD M… · 대가 머리 = 이름 · 규격) — 읽기 = 공용(828-2)."""
|
||||
from M02_MasterTemplete import M02_San_Xlsx_Ilwi as ilwi
|
||||
|
||||
out, head = [], ""
|
||||
for r in rows or []:
|
||||
r = list(r) + [None] * 16
|
||||
if _B.fullmatch(str(r[14] or "").strip()):
|
||||
head = " ".join(str(x or "").strip() for x in r[:2]).strip()
|
||||
continue
|
||||
code = book.meta(r[13]).get("COD", "")
|
||||
if code.startswith("M") and r[0]:
|
||||
out.append(
|
||||
{"곳": "일위대가표", "코드": code, "이름": str(r[0]), "규격": str(r[1] or "")}
|
||||
)
|
||||
out[-1] |= {"머리": head} if head else {}
|
||||
sheet = rows if isinstance(rows, dict) else ilwi.read(rows)
|
||||
out = []
|
||||
for hopyo in sheet.get("호표") or []:
|
||||
head = f"{hopyo.get('이름') or ''} {hopyo.get('규격') or ''}".strip()
|
||||
for one in hopyo["줄"]:
|
||||
if "메타" in one and one["코드"].startswith("M") and one["이름"]:
|
||||
out.append({"곳": "일위대가표", "코드": one["코드"], "이름": one["이름"]})
|
||||
out[-1] |= {"규격": one["규격"]} | ({"머리": head} if head else {})
|
||||
return _uniq(out)
|
||||
|
||||
|
||||
def ilwi_found(saved: dict) -> list[tuple[dict, dict]]:
|
||||
"""둘 꼴 「일위」 구성 줄(828 · 엑셀 일위대가표 숨은 칸 · STC 구성표) 자재 줄 → [(일위 블록, 자원)] ·
|
||||
자원 `재료찾기` = 산근 줄과 같은 판정(`M02_San_Link.m01` · 단서 = 파일 단서 + 대가 머리)."""
|
||||
from M02_MasterTemplete.M02_San_Link import m01
|
||||
|
||||
out = []
|
||||
for blk in saved.get("일위") or []:
|
||||
head = f"{blk.get('이름') or ''} {blk.get('규격') or ''}"
|
||||
with clues_on(saved.get("단서"), head):
|
||||
for ln in blk.get("줄") or []:
|
||||
if ln.get("갈래") != "M" or not ln.get("이름"):
|
||||
continue
|
||||
res = {"꼴": saved.get("꼴"), "갈래": "재료", "코드": ln.get("코드") or ""}
|
||||
res |= {k: ln.get(k) or "" for k in ("이름", "규격", "단위")}
|
||||
m01("재료", res)
|
||||
out.append((blk, res))
|
||||
return out
|
||||
|
||||
|
||||
def _uniq(rows: list[dict]) -> list[dict]:
|
||||
seen, out = set(), []
|
||||
for r in rows:
|
||||
|
||||
@@ -179,8 +179,10 @@ def entry(item: dict, sym: str, excel: Any) -> dict | None:
|
||||
|
||||
# ── 판(그 파일 재료 종) ──────────────────────────────────────────────────
|
||||
def census(folder: str | Path, key: str) -> list[dict]:
|
||||
"""HP 블록마다 재료 줄 판정 → 종 [{종, 이름, 규격, 단위, 판정, 방법, 키, 후보, 단서, 별도계상, 블록, 줄}]."""
|
||||
"""HP 블록마다 재료 줄 판정 → 종 [{종, 이름, 규격, 단위, 판정, 방법, 키, 후보, 단서, 별도계상, 블록, 줄,
|
||||
일위?}] · 일위 = 그 종이 든 일위대가 구성 줄의 호표 코드(828-2 · 고름은 산근 블록 만들기에만 얹음)."""
|
||||
from M02_MasterTemplete import M02_Doc_FromNative as fn
|
||||
from M02_MasterTemplete import M02_San_MatFind as mf
|
||||
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
|
||||
|
||||
hopyo_store.read(folder, key) # 없는 HP = 404
|
||||
@@ -206,21 +208,34 @@ def census(folder: str | Path, key: str) -> list[dict]:
|
||||
found = res.get("재료찾기")
|
||||
if not found:
|
||||
continue
|
||||
k = kind_of(res)
|
||||
g = groups.setdefault(
|
||||
k,
|
||||
{"종": k, **{x: _text(res.get(x)) for x in ("이름", "규격", "단위")},
|
||||
**{x: found.get(x) for x in ("판정", "방법", "키", "후보", "단서")},
|
||||
"별도계상": _apart(res), "블록": [], "줄": 0},
|
||||
) # fmt: skip
|
||||
g = _group(groups, res)
|
||||
g["줄"] += 1
|
||||
if no not in g["블록"]:
|
||||
g["블록"].append(no)
|
||||
for blk, res in (
|
||||
mf.ilwi_found(src) if src else []
|
||||
): # 828-2 일위대가 구성 줄 재료(일위 = 호표 코드)
|
||||
if not res.get("재료찾기"):
|
||||
continue
|
||||
g = _group(groups, res)
|
||||
g["줄"] += 1
|
||||
if blk["코드"] not in (hops := g.setdefault("일위", [])):
|
||||
hops.append(blk["코드"])
|
||||
out_ = sorted(groups.values(), key=lambda g: (KINDS.index(g["판정"]) if g["판정"] in KINDS else 9, -len(g["블록"]), g["이름"])) # fmt: skip
|
||||
atomic_write_json(path, {**saved, CENSUS: out_})
|
||||
return out_
|
||||
|
||||
|
||||
def _group(groups: dict[str, dict], res: dict) -> dict:
|
||||
found, k = res["재료찾기"], kind_of(res)
|
||||
return groups.setdefault(
|
||||
k,
|
||||
{"종": k, **{x: _text(res.get(x)) for x in ("이름", "규격", "단위")},
|
||||
**{x: found.get(x) for x in ("판정", "방법", "키", "후보", "단서")},
|
||||
"별도계상": _apart(res), "블록": [], "줄": 0},
|
||||
) # fmt: skip
|
||||
|
||||
|
||||
def fingerprint(folder: str | Path, key: str) -> tuple[str, str]:
|
||||
hp = hopyo_store.read(folder, key)
|
||||
return str(hp.get("지문") or ""), str(hp.get("파일") or "")
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""STmate 출력 엑셀 「일위대가표」 → 호표 · 구성 줄(PLAN 828-2 · 요구 813 · 814 ㉮ · 읽기만 · 서버 단독).
|
||||
|
||||
읽는 곳 하나 — 일위대가 불러오기(`M02_Hopyo_Stmate.read`) · 단가산출 불러오기
|
||||
(`M02_San_Xlsx_Stmate.read` 「일위」) · 재료 단서(`M02_San_MatFind.from_book`).
|
||||
호표 = A 「제N호표」 줄(숨은 칸 `BDCOD|B…`) → 다음 줄 이름 · 규격 · 단위 → 구성 줄 → 「합계」 · 「계」.
|
||||
구성 줄 = 숨은 칸 기준(표시대로 · 글 짐작 안 함 · 원파일 구성표 BDQTY 와 같은 칸) —
|
||||
N 열 메타 `COD|M00131 ␅ EXI DVD BMK IPR KWN BLA EQC PRI PRO UNI TPR` · O 열 차수 꼬리 `M00131_1`.
|
||||
율 줄 = 밑줄(IPR 1) 밑 비목(PRI) × 율 → 대상 비목(PRO) · 비목 수 1 M · 2 L · 3 S · 4 T(전체) ·
|
||||
율 코드 EQC · 닫기(C) 뒤 % 줄(켜쌓기적용 「전체금액의 %」) = `전체`.
|
||||
별도계상 = 비고 「별산…」(STmate 표기) · 규격 「별도계상」 · 관급 = KWN ≠ 0 · 나누기 = DVD T.
|
||||
숨은 칸 없는 엑셀(값만 복사) = 코드는 M~Z 열 코드 꼴 첫 것(옛 길) · 시트 `숨은칸` 거짓 ·
|
||||
단가산출 길 「일위」 = 빈 목록(물러남).
|
||||
|
||||
줄 꼴 = 828-1 `M02_San_Ilwi` 일위 구성 줄 꼴 {차례, 코드, 갈래(M L S X B D W · 율 · 닫기 · 구분 · 글),
|
||||
이름, 규격, 단위, 수량, 단가 {T L M S}, 금액 {T L M S}, 율? {밑, 대상, 곱}, 밑줄?, 관급?, 별도계상?}
|
||||
+ 엑셀 칸 {행, 차수, 비고, 나누기?, 전체?, 율.코드, 메타}.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
SHEET = "일위대가표"
|
||||
CODE = re.compile(r"^(?:COD\|)?([A-Z]{1,2}\d{5}|PR[A-Z]?\d+|[TC]\d+)(?:_\d+)?(?: →)?$")
|
||||
CODE_COLS = range(12, 26) # M ~ Z 열(옛 길 · 판마다 열 수 다름)
|
||||
META_COL, CODE_COL, NOTE_COL = 13, 14, 12 # N 메타 · O 코드(차수 꼬리) · M 비고
|
||||
_HOPYO = re.compile(r"제\s*(\d+)\s*호표")
|
||||
_HEAD = re.compile(r"BDCOD\|(B\d{5})")
|
||||
_TAIL = re.compile(r"^[A-Z]{1,2}\d*_(\d+)$")
|
||||
_X7, _X5 = "_x0007_", "_x0005_"
|
||||
KIND = "MLSXBDW" # 자원 코드 머리(L 노임 · M 자재 · S 경비 · X 기계 · D 산근 · B 일위 · W 일식)
|
||||
PART = {"1": "M", "2": "L", "3": "S", "4": "T"} # PRI · PRO 비목 수
|
||||
_MONEY = {"T": 4, "L": 6, "M": 8, "S": 10} # 단가 열 · 금액 = +1
|
||||
|
||||
|
||||
def _cell(row: list[Any], col: int) -> Any:
|
||||
return row[col] if col < len(row) else None
|
||||
|
||||
|
||||
def _s(v: Any) -> str:
|
||||
if isinstance(v, float) and v == int(v):
|
||||
v = int(v)
|
||||
return str(v if v is not None else "").strip()
|
||||
|
||||
|
||||
def _num(v: Any) -> float | None:
|
||||
if isinstance(v, bool) or v in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return float(str(v).replace(",", "").strip())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def meta(text: Any) -> dict[str, str]:
|
||||
"""숨은 메타 칸 「`COD|L00003 ␅ EXI|0 …」 → dict(`M02_San_Book.meta` 와 같은 풀이)."""
|
||||
s = str(text or "").replace(_X7, "").replace(_X5, "\x05")
|
||||
out = {}
|
||||
for part in s.split("\x05"):
|
||||
part = part.strip("`")
|
||||
if "|" in part:
|
||||
k, v = part.split("|", 1)
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def scan_code(row: list[Any]) -> str | None:
|
||||
"""옛 길 — M~Z 열에서 코드 꼴 첫 것(숨은 칸 없는 엑셀 · `M02_Hopyo_Stmate` 와 같음)."""
|
||||
for col in CODE_COLS:
|
||||
hit = CODE.match(_s(_cell(row, col)))
|
||||
if hit:
|
||||
return hit.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def kind_of(code: str) -> str:
|
||||
"""갈래(`M02_San_Ilwi` 꼴) — 자원 머리 글자 M L S X B D W · 율 · 닫기 · 구분 · 글(빈 코드)."""
|
||||
if code.startswith(("PR", "RR")):
|
||||
return "율"
|
||||
if code[:1] in "CT" and code[1:2].isdigit():
|
||||
return "닫기" if code[0] == "C" else "구분"
|
||||
return code[:1] if code[:1] and code[0] in KIND else "글"
|
||||
|
||||
|
||||
def _money(row: list[Any], offset: int) -> dict[str, float | None]:
|
||||
return {k: _num(_cell(row, c + offset)) for k, c in _MONEY.items()}
|
||||
|
||||
|
||||
def _line(row: list[Any], at: int, closed: bool) -> dict[str, Any] | None:
|
||||
"""구성 줄 하나 — 숨은 메타 있으면 그것 · 없으면 옛 코드 찾기(코드 없으면 None)."""
|
||||
raw = _cell(row, META_COL)
|
||||
got = meta(raw) if isinstance(raw, str) and "COD|" in raw else {}
|
||||
tail = _TAIL.match(_s(_cell(row, CODE_COL)))
|
||||
if "COD" in got:
|
||||
code = got["COD"].strip()
|
||||
else:
|
||||
code = scan_code(row)
|
||||
if code is None:
|
||||
return None
|
||||
one: dict[str, Any] = {
|
||||
"행": at,
|
||||
"코드": code,
|
||||
"갈래": kind_of(code),
|
||||
"차수": int(tail.group(1)) if tail else None,
|
||||
"이름": _s(_cell(row, 0)),
|
||||
"규격": _s(_cell(row, 1)),
|
||||
"단위": _s(_cell(row, 3)),
|
||||
"수량": _num(_cell(row, 2)),
|
||||
"단가": _money(row, 0),
|
||||
"금액": _money(row, 1),
|
||||
"비고": _s(_cell(row, NOTE_COL)),
|
||||
}
|
||||
flags = {
|
||||
"나누기": got.get("DVD") == "T",
|
||||
"밑줄": got.get("IPR") == "1",
|
||||
"별도계상": one["비고"].startswith("별산") or "별도계상" in one["규격"],
|
||||
"관급": got.get("KWN", "0").strip() not in ("0", ""),
|
||||
}
|
||||
if code.startswith(("PR", "RR")) and got:
|
||||
one["율"] = {"밑": PART.get(got.get("PRI", "")), "대상": PART.get(got.get("PRO", ""))}
|
||||
one["율"] |= {"곱": code.startswith("RR"), "코드": got.get("EQC") or ""}
|
||||
pct = one["단위"] == "%" or got.get("UNI") == "%"
|
||||
flags["전체"] = closed and pct and one["갈래"] in ("율", "W")
|
||||
one |= {k: True for k, v in flags.items() if v}
|
||||
if got:
|
||||
one["메타"] = {k: v for k, v in got.items() if k != "COD"}
|
||||
return one
|
||||
|
||||
|
||||
def read(rows: Any) -> dict[str, Any]:
|
||||
"""시트 줄 → {숨은칸, 호표 [{번, 코드, 이름, 규격, 단위, 행 [처음,끝], 머리행, 저장 {T L M S}, 줄}]}.
|
||||
끝 줄(합계 · 계)에서 다음 「제N호표」 까지는 안 받음 · 이름 빈 줄은 건너뜀(옛 길과 같음)."""
|
||||
hopyos: list[dict[str, Any]] = []
|
||||
want_head = done = closed = False
|
||||
hidden = False
|
||||
for at, row in enumerate(rows or [], 1):
|
||||
row = list(row)
|
||||
first = _s(_cell(row, 0))
|
||||
flat = re.sub(r"\s+", "", first)
|
||||
if hit := _HOPYO.fullmatch(flat):
|
||||
code = next((m.group(1) for v in row[1:26] if (m := _HEAD.search(_s(v)))), None)
|
||||
hopyos.append({"번": int(hit.group(1)), "코드": code, "행": [at, at], "줄": []})
|
||||
want_head, done, closed = True, False, False
|
||||
continue
|
||||
if not hopyos or done:
|
||||
continue
|
||||
hopyo = hopyos[-1]
|
||||
if want_head:
|
||||
hopyo |= {"이름": first, "규격": _s(_cell(row, 1)), "단위": _s(_cell(row, 3))}
|
||||
hopyo["머리행"] = at
|
||||
want_head = False
|
||||
continue
|
||||
if flat.startswith("합계") or flat == "계":
|
||||
hopyo["저장"] = _money(row, 1)
|
||||
hopyo["끝행"] = at
|
||||
done = True
|
||||
continue
|
||||
if not first:
|
||||
continue
|
||||
hopyo["행"][1] = at
|
||||
one = _line(row, at, closed)
|
||||
if one is None: # 숨은 칸 없는 엑셀의 코드 없는 글 줄(옛 길 근거글)
|
||||
if re.search(r"[0-9A-Za-z가-힣]", first):
|
||||
one = {"행": at, "코드": "", "갈래": "글", "이름": first, "규격": _s(_cell(row, 1))}
|
||||
hopyo["줄"].append({"차례": len(hopyo["줄"]), **one})
|
||||
continue
|
||||
hidden = hidden or "메타" in one
|
||||
closed = closed or one["갈래"] == "닫기"
|
||||
hopyo["줄"].append({"차례": len(hopyo["줄"]), **one})
|
||||
for hopyo in hopyos:
|
||||
for key in ("이름", "규격", "단위"):
|
||||
hopyo.setdefault(key, "")
|
||||
return {"숨은칸": hidden, "호표": hopyos}
|
||||
|
||||
|
||||
def blocks(sheet: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""`read` 답 → 단가산출 불러오기 「일위」 블록(원파일 `M02_San_Stc.read` 「일위」 와 같은 머리 칸 + 줄) ·
|
||||
숨은 칸 없는 시트 = 빈 목록(구성 줄을 글로 짐작하지 않음)."""
|
||||
if not sheet.get("숨은칸"):
|
||||
return []
|
||||
return [
|
||||
{k: h.get(k) for k in ("코드", "번", "이름", "규격", "단위", "저장")}
|
||||
| {"엑셀행": h["행"][0], "줄": h["줄"], "줄합": None, "표지": []}
|
||||
for h in sheet["호표"]
|
||||
]
|
||||
@@ -41,7 +41,8 @@ def vote(blocks: list[dict], prices: dict) -> tuple[str, dict[str, int]]:
|
||||
|
||||
|
||||
def read(sheets: dict[str, list]) -> dict[str, Any]:
|
||||
"""STmate 엑셀 시트 → {꼴, 산근, 일위, 자원, 내역, 단가, 옵션, 표지}."""
|
||||
"""STmate 엑셀 시트 → {꼴, 산근, 일위, 자원, 내역, 단가, 옵션, 표지} · 일위 = 일위대가표 숨은 칸
|
||||
구성 줄(828-2 `M02_San_Xlsx_Ilwi` · 숨은 칸 없으면 빈 목록 + 표지)."""
|
||||
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 = {
|
||||
@@ -71,10 +72,15 @@ def read(sheets: dict[str, list]) -> dict[str, Any]:
|
||||
out["옵션"] = {"SAN_TYP": RULE[rule]}
|
||||
out["내역"] = None
|
||||
from M02_MasterTemplete import M02_San_MatFind as mf
|
||||
from M02_MasterTemplete import M02_San_Xlsx_Ilwi as ilwi
|
||||
|
||||
out["단서"] = mf.from_book(sheets.get("일위대가표")) # 827-2 재료 단서
|
||||
sheet = ilwi.read(sheets.get(ilwi.SHEET)) # 828-2 일위대가표 숨은 칸 → 구성 줄(읽는 곳 하나)
|
||||
out["일위"] = ilwi.blocks(sheet)
|
||||
out["단서"] = mf.from_book(sheet) # 827-2 재료 단서
|
||||
out["표지"] = [
|
||||
f"{sx.NOTE}일위대가 구성 · 내역은 호표 길(산근만 읽음)",
|
||||
f"{sx.NOTE}일위대가 구성 = 일위대가표 숨은 칸 · 내역은 안 읽음"
|
||||
if sheet["숨은칸"]
|
||||
else f"{sx.NOTE}일위대가표 숨은 칸 없음(값만 복사) — 구성 줄 안 읽음 · 일위대가는 호표 길",
|
||||
f"{sx.NOTE}끝전 = 파일 투표 {rule}(비목버림 {got['비목버림']} · 합버림 {got['합버림_큰나머지']} 블록)",
|
||||
]
|
||||
return out
|
||||
|
||||
@@ -125,6 +125,7 @@ def test_clue_head_and_boq_apart():
|
||||
def test_clue_book_sheet():
|
||||
meta = "_x0007_`COD|{}_x0005_`EXI|0_x0005_`"
|
||||
rows = [
|
||||
[" 제 17 호표", *[None] * 12, "_x0005_`BDCOD|B01217_x0007_`POSS|3_x0007_`POSE|4_x0007_`"],
|
||||
["파형강관부설", "D=800mm", None, "m", *[None] * 8, "대가 17", None, "B01217"],
|
||||
[
|
||||
"파형강관(800mm)",
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""828-2 STmate 출력 엑셀 「일위대가표」 숨은 칸 → 호표 · 구성 줄(`M02_San_Xlsx_Ilwi` · 요구 813 · 814 ㉮).
|
||||
|
||||
5 건 호표 136 · 줄 706 · 율 줄 밑 · 대상 비목 · 닫기 뒤 전체 % · 일식 · 별도계상 · 숨은 칸 없는 파일 물러남 ·
|
||||
원파일 STC 구성 줄(828-1)과 같은 꼴 · 읽는 곳 하나(호표 길 · 단산 길 · 단서) · 판 일위 줄 재료.
|
||||
실무 파일이 없으면 건너뜀.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path[:0] = [str(ROOT), str(ROOT / "resources" / "tester"), str(ROOT / "resources" / "master_data" / "scripts")] # fmt: skip
|
||||
|
||||
import helper_m02_hopyo_golden as golden # noqa: E402
|
||||
|
||||
from M02_MasterTemplete import M02_Doc_FromNative as fn # noqa: E402
|
||||
from M02_MasterTemplete import M02_Hopyo_Excel as hx # noqa: E402
|
||||
from M02_MasterTemplete import M02_Hopyo_Fixed as hfix # noqa: E402
|
||||
from M02_MasterTemplete import M02_San_Book as book # noqa: E402
|
||||
from M02_MasterTemplete import M02_San_MatFind as mf # noqa: E402
|
||||
from M02_MasterTemplete import M02_San_Xlsx as sx # noqa: E402
|
||||
from M02_MasterTemplete import M02_San_Xlsx_Ilwi as il # noqa: E402
|
||||
|
||||
TAGS = ("봉화", "동부청영월", "울진대흥", "영덕계류", "울진소광")
|
||||
HOPYOS = {"봉화": 45, "동부청영월": 23, "울진대흥": 34, "영덕계류": 9, "울진소광": 25}
|
||||
LINES = {"봉화": 216, "동부청영월": 138, "울진대흥": 184, "영덕계류": 39, "울진소광": 129}
|
||||
APART = {"봉화": 7, "동부청영월": 18, "울진대흥": 16, "영덕계류": 0, "울진소광": 9}
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _sheets(tag: str) -> dict:
|
||||
path = golden.FILES[tag]
|
||||
if not path.exists():
|
||||
pytest.skip(f"실무 엑셀 없음 {tag}")
|
||||
return book.sheets(path.name, path.read_bytes())
|
||||
|
||||
|
||||
def _lines(got: dict) -> list[dict]:
|
||||
return [x for h in got["호표"] for x in h["줄"]]
|
||||
|
||||
|
||||
def _bare(rows: list) -> list:
|
||||
"""값만 복사한 엑셀 — 숨은 메타(N 열) 지움."""
|
||||
return [[None if j == il.META_COL else v for j, v in enumerate(r)] for r in rows]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", TAGS)
|
||||
def test_hopyo_lines_by_meta(tag):
|
||||
got = il.read(_sheets(tag)[il.SHEET])
|
||||
lines = _lines(got)
|
||||
assert got["숨은칸"] and len(got["호표"]) == HOPYOS[tag] and len(lines) == LINES[tag]
|
||||
assert all("메타" in x for x in lines)
|
||||
assert all(h["코드"] and h["코드"].startswith("B") and h.get("저장") for h in got["호표"])
|
||||
assert sum(bool(x.get("별도계상")) for x in lines) == APART[tag]
|
||||
for x in lines:
|
||||
if x["갈래"] == "율":
|
||||
assert (
|
||||
x["율"]["밑"] in "MLST" and x["율"]["대상"] in "MLST" and x["율"]["코드"] == "A01"
|
||||
)
|
||||
if x["코드"] and x["갈래"] not in ("율", "닫기"):
|
||||
assert x["차수"] == 1
|
||||
|
||||
|
||||
def test_total_count():
|
||||
got = [il.read(_sheets(t)[il.SHEET]) for t in TAGS]
|
||||
assert sum(len(g["호표"]) for g in got) == 136 and sum(len(_lines(g)) for g in got) == 706
|
||||
|
||||
|
||||
def test_rate_close_lump():
|
||||
bong = _lines(il.read(_sheets("봉화")[il.SHEET]))
|
||||
first = next(x for x in bong if x["갈래"] == "율")
|
||||
assert (first["이름"], first["율"]["밑"], first["율"]["대상"]) == ("잡재료비", "L", "M")
|
||||
marked = [x["행"] for x in bong if x.get("밑줄")]
|
||||
assert 72 in marked and 73 in marked # 포장공 · 보통인부 = 잡재료비 밑수 줄
|
||||
yd = _lines(il.read(_sheets("영덕계류")[il.SHEET]))
|
||||
closes = [x for x in yd if x["갈래"] == "닫기"]
|
||||
whole = [x for x in yd if x.get("전체")]
|
||||
assert len(closes) == 2 and [(x["코드"], x["수량"]) for x in whole] == [("W00005", 90.0)] * 2
|
||||
assert sum(x["갈래"] == "W" for x in _lines(il.read(_sheets("울진소광")[il.SHEET]))) == 3
|
||||
|
||||
|
||||
def test_bare_sheet_falls_back():
|
||||
rows = _bare(_sheets("봉화")[il.SHEET])
|
||||
got = il.read(rows)
|
||||
assert not got["숨은칸"] and il.blocks(got) == [] and mf.from_book(rows) == []
|
||||
out = sx.read_sheets({**_sheets("봉화"), il.SHEET: rows})
|
||||
assert out["일위"] == [] and any("숨은 칸 없음" in t for t in out["표지"])
|
||||
# 호표 길 = 옛 코드 찾기(O 열) — 숨은 칸 율 · 호표 코드만 빠지고 줄은 같음
|
||||
from M02_MasterTemplete import M02_Hopyo_Stmate as st
|
||||
|
||||
a = st._hopyos(_sheets("봉화")[il.SHEET])
|
||||
b = st._hopyos(rows)
|
||||
strip = [[{k: v for k, v in x.items() if k != "율"} for x in h] for h in a[1]]
|
||||
heads = [[{k: v for k, v in h.items() if k != "코드"} for h in x[0]] for x in (a, b)]
|
||||
assert heads[0] == heads[1] and strip == b[1] # 호표 코드(BDCOD)도 숨은 칸이라 빠짐
|
||||
|
||||
|
||||
def test_same_form_as_stc():
|
||||
import test_810_1_stc as stc
|
||||
|
||||
path = stc.FILES["봉화"]
|
||||
if not path.exists():
|
||||
pytest.skip("원파일 없음")
|
||||
xl = sx.read_sheets(_sheets("봉화"))
|
||||
nat = {b["코드"]: b for b in stc.parsed("봉화")["일위"]}
|
||||
|
||||
def key(x):
|
||||
r = x.get("율") or {}
|
||||
flags = tuple(bool(x.get(k)) for k in ("밑줄", "관급", "별도계상"))
|
||||
return (
|
||||
x["코드"],
|
||||
x["갈래"],
|
||||
round(float(x["수량"] or 0), 6),
|
||||
r.get("밑"),
|
||||
r.get("대상"),
|
||||
flags,
|
||||
)
|
||||
|
||||
assert len(xl["일위"]) == 45
|
||||
for b in xl["일위"]:
|
||||
assert [key(x) for x in b["줄"]] == [key(x) for x in nat[b["코드"]]["줄"]], b["코드"]
|
||||
|
||||
|
||||
def test_one_reader_three_users():
|
||||
sh = _sheets("봉화")
|
||||
clues = mf.from_book(sh[il.SHEET])
|
||||
assert len(clues) == 41 and clues == mf.from_book(il.read(sh[il.SHEET]))
|
||||
assert all(c["곳"] == "일위대가표" and c["코드"].startswith("M") for c in clues)
|
||||
path = golden.FILES["영덕계류"]
|
||||
hp = hx.read_book(path.name, path.read_bytes())
|
||||
lines = hp["호표"][6]["줄"] # 제11호표 산악할증 — 숨은 칸 율(노무비 → 노무비 · 0 %)
|
||||
rate = next(x for x in lines if x["코드"] == "PRA01")
|
||||
assert rate["율"] == {"밑": "노무비", "대상": "노무비"}
|
||||
assert hfix._ratio(rate, [x for x in lines if x is not rate]) == {
|
||||
"기준": "노무비",
|
||||
"비목": "노무비",
|
||||
}
|
||||
|
||||
|
||||
def test_census_ilwi_materials():
|
||||
path = golden.FILES["영덕계류"]
|
||||
saved = fn.pack(sx.read_sheets(_sheets("영덕계류")), path.name)
|
||||
got = mf.ilwi_found(saved)
|
||||
assert len(got) == 4 and all(r["재료찾기"]["판정"] for _, r in got)
|
||||
assert {b["코드"] for b, _ in got} == {"B01107"}
|
||||
Reference in New Issue
Block a user