- 새 M02_Hopyo_UnitLine — 단위 줄 꼴 · 자체 검산(줄 재현 0.5 % · 줄합 = 블록 금액 덮음) · 값 줄 → 하위 가름 · 율 줄 기준꼴 · 기준줄 · 할증 풀이 · 일위대가 줄 투영(묶음 · 재현 · % 기준) · 두 어댑터 끝에 settle - ReadEst.items — 줄 금액 · 율 · 값 줄 · 합계 · 소계 절 = 밖 · 끝 점 「28,500. ×」 · 이름 속 = · / 자재 · 「(지주)」 · 「\/㎡」 금액 꼬리 · 「직종 : P」 선언 줄 뺌 · 자재 손료율 표지 · 기계 비목마다 매개값 계수 · 절 = 제목 - Stmate — 같은 기계 코드 두 묶음 따로(울진대흥 1129 · 2119 · 울진소광 1448 단가 두 배 흠) · 비목마다 H 다르면 계수(봉화 덤프 (Cm-t1)/Cm · 영월 물탱크 ×0.722) · PR · RR 율 행(기준 표지 PA01M 꼴) · 이름 없는 율 행 = 위 라벨 줄 이름 · 기준글 · G 열 「* N'%'」 자원 = 율 줄 · 자재 손료율 · D 행 = 하위 · N 열 C = 밖 - Store.BOOK 3 — 읽기 꼴 바뀜(옛 판 HP = 다시 올림) - 여덟 파일 불리는 블록: 줄 재현 811/836 → 836/842 · 덮음 201 → 204/206(남은 둘 = 엑셀 합이 진동로울러 노무 뺌) · 일위대가 줄 1,344/1,350 - 새 시험 test_778_3_hopyo_unit_lines Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
392 lines
18 KiB
Python
392 lines
18 KiB
Python
"""M02 일위대가 실무자료 EST(엑셀꼴) 단가산출 어댑터(PLAN 776-2 · 요구 621-1 ② 정규화) —
|
||
엑셀 「단가산출」 시트 → 블록 `{번, 이름, 단위, 행, 출처, 직종, 글, 금액, 항목}`.
|
||
형식(EST 글)을 아는 곳은 여기까지.
|
||
|
||
블록 = B열(머리 「산출근거」) 머리 줄 「N.이름[단위]」(앞 빈칸 없음)부터 다음 머리 앞까지 ·
|
||
출처 = 「…품셈」(오타 「품줌」) 뒤 20 글자 안 절 번호 — 앞 글에 산림 · 임도 = 산림품셈 ·
|
||
그 밖 = 건설품셈 ·
|
||
직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로) ·
|
||
글 = 머리 뺀 몸 줄 `GLINES` 줄 · 줄마다 `GCHARS` 자 안 · 산출근거 열이 빈 줄은 그 오른쪽 ~ 돈 머리
|
||
(맨 오른쪽 묶음) 앞 칸을 빈칸으로 이어 붙임(거창 꼴 · 돈 머리 없으면 `WIDE` 열) ·
|
||
금액 = 머리 줄 돈 칸(1 단위).
|
||
항목(Book 블록 항목 · 778-3) = 글 줄 → 단위 줄(꼴 = `M02_Hopyo_UnitLine`) · 줄 금액 = 「= 금액」 ·
|
||
끝 점(「28,500. ×」) 뗌 · 율(호표 줄 이름 · 규격)에 기대지 않는 것만(섞음 율은 지문 몫):
|
||
L 직종 줄 「직종 : 단가 × 식 = 금액」(못 풀면 금액 ÷ 단가 「역산」 · `직종` = 우리 이름) ·
|
||
X 장비 = 장비 절 · 장비 글 줄 뒤 「노무비 · 경비 · 재료비 : 단가 ÷ Q」 — 절 · 장비마다 하나
|
||
(품 = 노무비(단가 > 0) › 경비 › 재료비 줄 · 비목마다 매개값이 다르면 `계수`) ·
|
||
M 자재 = 「이름 단가 × 수량 = 금액」(이름 속 = · / 도) · 「이름 : 별도계상」(품 없음) ·
|
||
R 율 = 「기준 × N% = 금액」(라벨 = 이 줄 · 앞 줄 · 기준 = 「노무비의」 앞 낱말) ·
|
||
E 값 = 셈 글 아닌 「이름 : … = 금액」(하위 가름은 `UnitLine.settle`) ·
|
||
절 = 제목(작은 절 = 「윗 제목 · 작은 제목」) · 합계 · 소계 절 안 줄 = `밖`.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from functools import lru_cache
|
||
from typing import Any
|
||
|
||
from M01_MasterData import M01_MasterData_Store as m01
|
||
from M02_MasterTemplete import M02_Hopyo_UnitLine as unit_line
|
||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||
from M02_MasterTemplete.M02_Hopyo_Words import mtypes, parse_rhs
|
||
|
||
SHEET = "단가산출"
|
||
GLINES, GCHARS, WIDE = 200, 300, 140
|
||
_HEAD = re.compile(r"^(\d+)\.\s*(.*?)\s*\[([^\[\]]*)\]\s*$")
|
||
_BOOK = re.compile(r"품[셈줌]")
|
||
_TRADE = re.compile(r"^[\s*·\-]*([가-힣][가-힣\s]*?)\s*:\s*[\d,]")
|
||
_TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 「13-114」 = 13-11-4
|
||
_MONEY = {"합계": "계", "노무비": "노무비", "재료비": "재료비", "경비": "경비"}
|
||
# 항목 풀이 — 지문(`Finger`)과 같은 규칙
|
||
COST = ("노무비", "재료비", "경비")
|
||
SKIPW = frozenset(
|
||
"소계 계 합계 인부소계 자재소계 주자재소계 운반 상차 기계 다짐 적사 살수 집토 암절취 채집 인력 "
|
||
"장비 작업조건 1일시공량 작업면적 1시간작업량 기계채집 구조물터파기".split()
|
||
)
|
||
_COLON = re.compile(
|
||
r"^\s*(?:\d+\)|[0-9가-힣]\.)?\s*(?P<name>[^::=]{1,40}?)\s*[::]\s*(?P<rhs>.*)$"
|
||
)
|
||
_MAT = re.compile(
|
||
r"^\s*(?P<name>[^\d\s::=][^::=]*?[A-Za-z가-힣)\]])\s+(?P<rhs>[\d,]+(?:\.\d+)?\s*[×xX*÷/].*=.*)$"
|
||
)
|
||
_SEC = re.compile(r"^\s*(\d+)\.\s*(.*)$")
|
||
_SUB = re.compile(r"^\s*([가-하])\.\s*(.*)$")
|
||
_LASTNUM = re.compile(r"=\s*([\d,]+(?:\.\d+)?)\s*$")
|
||
# 단위 줄(778-3) — 줄 금액 · 끝 점 · 율 · 느슨한 자재 · 셈 글 · 합계 절
|
||
NUM = r"[\d,]+(?:\.\d+)?"
|
||
_AMOUNT = re.compile( # 끝 「= 금액」 — 원 · ₩ · 「\/㎡」 단위 · 「(지주)」 꼬리 허용
|
||
r"=\s*[A-Z]?\s*(" + NUM + r")\s*\.?\s*(?:원|₩)?\s*(?:\\?/\s*\S+)?\s*(?:\([^()]*\))?\s*$"
|
||
)
|
||
_DOT = re.compile(r"(\d)\.(?=\s|$|%)")
|
||
_DECR = re.compile(r"[×xX*]\s*(0?\.\d+)\s*=")
|
||
_PCTN = re.compile(r"(\d+(?:\.\d+)?)\s*%")
|
||
_BASEW = re.compile(r"([가-힣]+)의\s*[\d.]+\s*%")
|
||
_TOTAL = re.compile(r"(합\s*계|소\s*계|총\s*계)")
|
||
_NUMBERED = re.compile(r"^\s*(?:\d+|[가-하])\s*[.)]\s*") # 「3.」 · 「가.」 · 「1)」 머리
|
||
_TOP_L = re.compile(r"^\s*\d+\s*\.\s*(.*)$")
|
||
_SUB_L = re.compile(r"^\s*[가-하]\s*\.\s*(.*)$")
|
||
_DECIMAL = re.compile(r"^\s*\d+\.\d")
|
||
_LOOSE = re.compile(
|
||
r"^\s*(?P<name>[^\d\s::].*?[A-Za-z가-힣)\]])\s+(?P<rhs>" + NUM + r"\s*[×xX*÷/].*=.*)$"
|
||
)
|
||
_CALC = re.compile(
|
||
r"(^|[\s(])(Q\d?|q\w?|K|k|f\d?|E\w?|Cm|L|V\d|T|rt|n|t\d|A|N|W|V|S)\s*[=:]|─|㎥/hr|m3/hr|/hr\b"
|
||
)
|
||
|
||
|
||
def _flat(value: Any) -> str:
|
||
return re.sub(r"\s+", "", str(value or ""))
|
||
|
||
|
||
def _cell_text(value: Any) -> str:
|
||
if isinstance(value, float) and value == int(value):
|
||
value = int(value)
|
||
return str(value).strip() if value is not None else ""
|
||
|
||
|
||
# ── 이름 · 출처(두 어댑터 · 뒤 단계 공용) ────────────────────────────────
|
||
@lru_cache(maxsize=2)
|
||
def _trade_names(whole_id: int) -> dict[str, str]:
|
||
"""{빈칸 뺀 이름 · 옛이름: 지금 이름} — 우리 인력(LB)."""
|
||
out: dict[str, str] = {}
|
||
for row in m01.loaded()[1].numbers.get("LB", {}).values():
|
||
name = str(row.get("이름") or "")
|
||
out[_flat(name)] = name
|
||
for old in row.get("옛이름") or []:
|
||
out.setdefault(_flat(old), name)
|
||
return out
|
||
|
||
|
||
def trade_names() -> dict[str, str]:
|
||
return _trade_names(id(m01.loaded()[1]))
|
||
|
||
|
||
def trade(name: Any) -> str | None:
|
||
"""노임 이름 → 우리 인력 이름(빈칸 · 옛이름 · 괄호 글 풀이) · 아니면 None."""
|
||
names, flat = trade_names(), _flat(name)
|
||
return names.get(flat) or names.get(re.sub(r"\(.*?\)", "", flat))
|
||
|
||
|
||
def sources(text: str) -> list[list[str]]:
|
||
"""글 → [[책, 절 번호]] — 「건설공사표준품셈 3-2-1」 · 「산림사업표준품셈(9-14)」."""
|
||
out = []
|
||
for hit in _BOOK.finditer(text):
|
||
num = labor_inputs._NUMBER.search(text, hit.end())
|
||
if not num or num.start() - hit.end() > 20:
|
||
continue
|
||
before = text[max(0, hit.start() - 12) : hit.start()]
|
||
book = "산림품셈" if "산림" in before or "임도" in before else "건설품셈"
|
||
typo = _TYPO_CLAUSE.match(num.group())
|
||
number = "{}-{}{}-{}".format(*typo.groups()) if typo else num.group()
|
||
if [book, number] not in out:
|
||
out.append([book, number])
|
||
return out
|
||
|
||
|
||
def rates_in(text: Any) -> set[float]:
|
||
"""글 속 섞음 비율 후보 — (N%) · 8:2 · 9:1."""
|
||
t = str(text or "")
|
||
out = {round(float(n) / 100, 4) for n in re.findall(r"(\d+(?:\.\d+)?)\s*%", t)}
|
||
out = {v for v in out if 0 < v < 1}
|
||
for a, b in re.findall(r"(\d+)\s*:\s*(\d+)", t):
|
||
a, b = float(a), float(b)
|
||
if a and b and a + b <= 20:
|
||
out |= {round(a / (a + b), 4), round(b / (a + b), 4)}
|
||
return out
|
||
|
||
|
||
# ── 블록 읽기 ─────────────────────────────────────────────────────────
|
||
def _columns(rows: list[list[Any]]) -> tuple[int, int, dict[str, int]]:
|
||
"""(자료 첫 줄, 산출근거 열, {계 · 노무비 · 재료비 · 경비: 열}) — 머리 없으면 (0, 1, {}) ·
|
||
돈 머리가 둘 묶음이면 맨 오른쪽(거창 꼴 · 값이 든 쪽)."""
|
||
for at, row in enumerate(rows[:10]):
|
||
flat = [_flat(v) for v in row]
|
||
if "산출근거" in flat:
|
||
last = len(flat) - 1
|
||
money = {
|
||
key: last - flat[::-1].index(head) for head, key in _MONEY.items() if head in flat
|
||
}
|
||
return at + 1, flat.index("산출근거"), money
|
||
return 0, 1, {}
|
||
|
||
|
||
def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]:
|
||
"""단가산출 시트 줄 → `{N: {번, 이름, 단위, 행: [시작, 끝], 출처, 직종, 글, 금액, 항목}}`
|
||
(행 = 엑셀 1부터)."""
|
||
if not rows:
|
||
return {}
|
||
start, col, money = _columns(rows)
|
||
end = min(money.values()) if money and min(money.values()) > col + 1 else WIDE
|
||
names = trade_names()
|
||
blocks: dict[int, dict[str, Any]] = {}
|
||
block: dict[str, Any] | None = None
|
||
for at, row in enumerate(rows[start:], start + 1):
|
||
text = row[col] if col < len(row) else None
|
||
head = None
|
||
if isinstance(text, str) and text.strip():
|
||
head = None if text[:1].isspace() else _HEAD.match(text)
|
||
else:
|
||
text = " ".join(_cell_text(v) for v in row[col + 1 : end] if _cell_text(v))
|
||
if not text:
|
||
continue
|
||
if head:
|
||
block = blocks.setdefault(
|
||
int(head.group(1)),
|
||
{
|
||
"번": int(head.group(1)),
|
||
"이름": head.group(2),
|
||
"단위": head.group(3).strip(),
|
||
"행": [at, at],
|
||
"출처": [],
|
||
"직종": [],
|
||
"글": [],
|
||
"금액": {
|
||
key: v
|
||
for key, c in money.items()
|
||
for v in [row[c] if c < len(row) else None]
|
||
if isinstance(v, (int, float)) and not isinstance(v, bool)
|
||
},
|
||
"_글행": [],
|
||
},
|
||
)
|
||
continue
|
||
if block is None:
|
||
continue
|
||
block["행"][1] = at
|
||
if len(block["글"]) < GLINES:
|
||
block["글"].append(text.rstrip()[:GCHARS])
|
||
block["_글행"].append(at)
|
||
block["출처"] += [one for one in sources(text) if one not in block["출처"]]
|
||
trade_ = _TRADE.match(text)
|
||
name = names.get(_flat(trade_.group(1))) if trade_ else None
|
||
if name and name not in block["직종"]:
|
||
block["직종"].append(name)
|
||
for block in blocks.values():
|
||
block["항목"] = items(block["글"], block.pop("_글행"))
|
||
return blocks
|
||
|
||
|
||
# ── 글 → 단위 줄 ───────────────────────────────────────────────────────
|
||
def _ratio(rhs: str) -> float | None:
|
||
"""식을 못 풀 때만 — 금액 ÷ 단가(품 되찾기용)."""
|
||
m = re.match(r"^\s*([\d,]+(?:\.\d+)?)", rhs)
|
||
n = _LASTNUM.search(rhs)
|
||
if not m or not n:
|
||
return None
|
||
p, a = float(m.group(1).replace(",", "")), float(n.group(1).replace(",", ""))
|
||
return a / p if p else None
|
||
|
||
|
||
def _num(text: str) -> float | None:
|
||
try:
|
||
return float(text.replace(",", ""))
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def amount(row: str) -> float | None:
|
||
"""줄 끝 「= 금액」(원 · 끝 점 · 앞 글자 하나 허용)."""
|
||
m = _AMOUNT.search(row.replace("W", ""))
|
||
return _num(m.group(1)) if m else None
|
||
|
||
|
||
def _item(kind: str, name: str, got: dict[str, Any], how: str, **more: Any) -> dict[str, Any]:
|
||
keep = {"품": got.get("품"), "밑품": got.get("밑품")}
|
||
keep |= {"율": got.get("율") or [], "인자": got.get("인자") or []}
|
||
return {"묶음": kind, "이름": name, "규격": "", **keep, "어떻게": how, **more}
|
||
|
||
|
||
def _material(hit: Any, row: str, money: float | None, place: dict) -> dict[str, Any]:
|
||
"""자재 줄 「이름 단가 × 수량 [× 손료율] = 금액」 — 손료율 = 식 속 % · 끝 「× 0.xx =」."""
|
||
got = parse_rhs(hit.group("rhs"))
|
||
more = {"단가": {"재료비": got["단가"]} if got else {}, "금액": money, **place}
|
||
one = _item("M", hit.group("name").strip(), got or {}, "식", **more)
|
||
pct = [v for v, note in (got or {}).get("율") or [] if note == "%"]
|
||
dec = _DECR.search(row)
|
||
dec = float(dec.group(1)) if dec and 0.5 <= float(dec.group(1)) < 1 else None
|
||
if (
|
||
pct or dec
|
||
): # ponytail: 끝 「× 0.95 =」 = 손료율(0.5~1) 어림 — 글에 「손료」 표지가 생기면 그것으로
|
||
one["손료율"] = pct[-1] if pct else dec
|
||
return one
|
||
|
||
|
||
def _rate(row: str, prev: str, cm: Any) -> dict[str, Any]:
|
||
"""율 줄 「기준 × N% = 금액」(라벨 = 이 줄 이름 · 없으면 앞 줄) →
|
||
{이름, 품 = 율, 기준값, 기준}."""
|
||
pm = _PCTN.findall(row) or _PCTN.findall(prev)
|
||
rate = float(pm[-1]) / 100 if pm else None
|
||
dec = _DECR.search(row)
|
||
if dec and not _PCTN.findall(row):
|
||
rate = float(dec.group(1))
|
||
body = row.split(":", 1)[-1] if cm else row
|
||
par = re.search(r"\(([^()]*\d[^()]*)\)\s*[×xX*]", body)
|
||
if par and "+" in par.group(1):
|
||
base: float | None = sum(_num(x) or 0 for x in re.findall(NUM, par.group(1)))
|
||
else:
|
||
hit = re.search(r"(" + NUM + r")\s*[×xX*]\s*(?:" + NUM + r")\s*(?:%|=)", body)
|
||
hit = hit or re.search(r"(" + NUM + r")", body)
|
||
base = _num(hit.group(1)) if hit else None
|
||
word = _BASEW.search(row) or _BASEW.search(prev)
|
||
label = _NUMBERED.sub("", (cm.group("name") if cm else prev)).strip()[:40]
|
||
return {"이름": label, "품": rate, "기준값": base, "기준": word.group(1) if word else ""}
|
||
|
||
|
||
def items(text: list[str], rows: list[int] | None = None) -> list[dict[str, Any]]:
|
||
"""블록 몸 글(머리 뺌) → 단위 줄(글 차례 · 기계 = 절 · 장비마다 하나 · 첫 비목 줄 자리 ·
|
||
품 = 노무비(단가 > 0) › 경비 › 재료비 줄) · 합계 · 소계 절 안 = `밖` · 값 → 하위 가름은 책
|
||
단위(`M02_Hopyo_UnitLine.settle`)."""
|
||
rows = rows or [0] * len(text)
|
||
names = trade_names()
|
||
out: list[dict[str, Any]] = []
|
||
machines: dict[tuple, dict[str, Any]] = {}
|
||
sec, sub, mach, label, total, prev = 0, "", False, "", False, ""
|
||
top = path = "" # 절 제목 · 작은 절이면 「윗 제목 · 작은 제목」
|
||
whole_out = False # 합계 · 소계 맨 윗 절 안
|
||
sec_rates: set[float] = set()
|
||
for raw, at in zip(text, rows, strict=True):
|
||
row = str(raw).rstrip()
|
||
h, s = _SEC.match(row), _SUB.match(row)
|
||
if (lt := _TOP_L.match(row)) and not _DECIMAL.match(row): # 몫 절 · 셈 밖(띄어 쓴 「가 .」)
|
||
top = path = lt.group(1).strip()
|
||
whole_out = total = bool(_TOTAL.search(top))
|
||
elif ls := _SUB_L.match(row):
|
||
total = whole_out or bool(_TOTAL.search(ls.group(1)))
|
||
path = path if total else f"{top} · {ls.group(1).strip()}"
|
||
if h or s:
|
||
if h:
|
||
sec, sub = int(h.group(1)), ""
|
||
sec_rates = rates_in(h.group(2))
|
||
else:
|
||
sub = s.group(1)
|
||
sec_rates = sec_rates | rates_in(s.group(2))
|
||
title = (h or s).group(2)
|
||
ts = mtypes(title)
|
||
mach = bool(ts)
|
||
label = title.strip() if ts else ""
|
||
else:
|
||
row = _DOT.sub(r"\1", row) # 「28,500. ×」 끝 점
|
||
place = {
|
||
"절": path,
|
||
"윗절": top,
|
||
"절율": sorted(sec_rates),
|
||
"행": at,
|
||
**({"밖": True} if total else {}),
|
||
}
|
||
money = amount(row)
|
||
cm = _COLON.match(row)
|
||
name = re.sub(r"^[0-9가-힣]\.", "", _flat(cm.group("name"))) if cm else ""
|
||
who = names.get(name) if cm else None
|
||
if who:
|
||
rhs = cm.group("rhs")
|
||
raw_name, how = cm.group("name").strip(), {"직종": who, "금액": money, **place}
|
||
if got := parse_rhs(rhs):
|
||
out.append(_item("L", raw_name, got, "식", 단가={"노무비": got["단가"]}, **how))
|
||
elif r := _ratio(rhs):
|
||
got = {"품": r, "밑품": r}
|
||
out.append(_item("L", raw_name, got, "역산", 단가={}, **how))
|
||
elif money is not None: # 품 없는 「직종 : P」 선언 줄은 뺌(값은 뒤 줄)
|
||
out.append(_item("L", raw_name, {}, "식", 단가={}, **how))
|
||
prev = row
|
||
continue
|
||
if cm and name in COST:
|
||
got = parse_rhs(cm.group("rhs"))
|
||
if mach and (got or money is not None):
|
||
key = (sec, sub, label)
|
||
one = machines.get(key)
|
||
if one is None:
|
||
one = _item("X", label, {}, "식", 단가={}, 금액=0.0, _순=9, _q={}, **place)
|
||
machines[key] = one
|
||
out.append(one)
|
||
one["금액"] = round(one["금액"] + (money or 0), 6)
|
||
if got:
|
||
prio = (
|
||
0 if (name == "노무비" and got["단가"] > 0) else 1 if name == "경비" else 2
|
||
)
|
||
if prio < one["_순"]:
|
||
one.update({k: got[k] for k in ("품", "밑품", "율", "인자")}, _순=prio)
|
||
one["단가"][name] = got["단가"]
|
||
one["_q"][name] = got["품"]
|
||
prev = row
|
||
continue
|
||
if cm and name not in SKIPW and re.search(r"(별도|별산)", cm.group("rhs")):
|
||
more = {"단가": {}, "금액": 0.0, **place}
|
||
out.append(_item("M", cm.group("name").strip(), {}, "별도", **more))
|
||
prev = row
|
||
continue
|
||
mm = _MAT.match(row)
|
||
if mm and not cm:
|
||
out.append(_material(mm, row, money, place))
|
||
prev = row
|
||
continue
|
||
if not (h or s):
|
||
ts = mtypes(row)
|
||
if ts and "%" not in row and not cm:
|
||
mach, label = True, row.strip()
|
||
if (
|
||
money is None
|
||
or total
|
||
or (cm and (name in ("소계", "계", "합계") or name.endswith("소계")))
|
||
):
|
||
prev = row
|
||
continue
|
||
dec = _DECR.search(row)
|
||
loose = None if cm else _LOOSE.match(row)
|
||
if loose and (names.get(_flat(loose.group("name"))) or _BASEW.search(loose.group("name"))):
|
||
loose = None # 직종 · 「…의 N%」 이름 = 자재 아님
|
||
if loose: # 자재(이름 속 = · / · 손료율 % 도)
|
||
out.append(_material(loose, row, money, place))
|
||
elif "%" in row or (dec and "%" in prev):
|
||
got = {"묶음": "R", "규격": "", **_rate(row, prev, cm), "단가": {}, "금액": money}
|
||
out.append({**got, "어떻게": "율", **place})
|
||
elif cm and not _CALC.search(row):
|
||
first = re.search(r"(" + NUM + r")\s*[×xX*]", cm.group("rhs"))
|
||
price = {"계": _num(first.group(1))} if first else {}
|
||
one = {"묶음": "E", "이름": cm.group("name").strip()[:40], "규격": "", "품": None}
|
||
out.append({**one, "단가": price, "금액": money, "어떻게": "값", **place})
|
||
prev = row
|
||
for one in machines.values():
|
||
unit_line.machine_done(one)
|
||
return out
|