Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
"""M02 일위대가 불러오기 — 못옮김 · 안 이은 기계 줄 → M01 요소 후보 셋(PLAN 746-1 · 요구 585).
|
||||
|
||||
후보 = `[{키, 갈래 노임|기계|자재, 이름, 규격, 단위, 단가}]` 셋까지 — 보여주기만(자동 이음 없음 · 고르면 지금
|
||||
잇기 길 `{노임|기계|자재: 키}`).
|
||||
후보 = `[{키, 갈래 노임|기계|자재|유가, 이름, 규격, 단위, 단가}]` 셋까지 — 보여주기만(자동 이음 없음 ·
|
||||
고르면 지금 잇기 길 `{노임|기계|자재|유가: 키}`).
|
||||
자재 = `M02_Hopyo_Material.candidates` 점수(이름 글자 묶음 · 규격) · 단가 = M01 자재 값(엑셀 줄 단위)
|
||||
노임 = LB 이름 글자 묶음 닮음 · 단가 = LB 값
|
||||
기계 = EQ 갈래 겹침 · 규격 수(같음 1 · 겹침 0.5) · 이름 낱말 덤 · 괄호 낱말 어긋남 깎음 ·
|
||||
단가 없음(기계경비는 로직 셈) · `machine` = 하나로 가름(778-1)
|
||||
유가(785-3) = MO 이름에 글 이름이 듦(경유 ⊂ 자동차용경유) · 전국 먼저 · 단가 = MO 값
|
||||
차례 = 점수(0.1 단위로 접음) › 엑셀 단가에 가까운 것(엑셀 단가 = 블록 단위 줄 단가 · 모르면 점수만).
|
||||
서버 단독 · 읽기만.
|
||||
"""
|
||||
@@ -95,6 +96,35 @@ def labor(name: str, want: float | None = None) -> list[dict[str, Any]]:
|
||||
return _order(rows, want)
|
||||
|
||||
|
||||
def oil_rows(name: Any) -> list[dict[str, Any]]:
|
||||
"""유가전력 MO 줄 — 정리 이름(빈칸 뺌)이 MO 이름에 듦 · 전국 먼저 · 키 차례."""
|
||||
me = re.sub(r"\s", "", nfkc(name))
|
||||
if len(me) < 2:
|
||||
return []
|
||||
rows = [
|
||||
r
|
||||
for r in m01.loaded()[1].numbers.get("MO", {}).values()
|
||||
if me in re.sub(r"\s", "", nfkc(r.get("이름")))
|
||||
]
|
||||
rows.sort(key=lambda r: (not str(r.get("상세구분") or "").startswith("전국"), str(r["키"])))
|
||||
return rows
|
||||
|
||||
|
||||
def oils(name: str) -> list[dict[str, Any]]:
|
||||
"""유가전력 후보 셋 — `{키, 갈래 유가, 이름, 규격 = 상세구분, 단위, 단가}`."""
|
||||
return [
|
||||
{
|
||||
"키": str(r["키"]),
|
||||
"갈래": "유가",
|
||||
"이름": r.get("이름"),
|
||||
"규격": r.get("상세구분") or "",
|
||||
"단위": r.get("단위"),
|
||||
"단가": _num(r.get("값")),
|
||||
} # fmt: skip
|
||||
for r in oil_rows(name)[:TOP]
|
||||
]
|
||||
|
||||
|
||||
def _form(text: str, name: Any) -> tuple[bool, bool]:
|
||||
"""괄호 낱말(778-1) — (글 형식 낱말이 EQ 이름에 다 있음, EQ 이름에 글에 없는 부속 있음)."""
|
||||
t, n = nfkc(text), nfkc(name)
|
||||
@@ -153,6 +183,8 @@ def lost(one: dict[str, Any], want: float | None = None) -> list[dict[str, Any]]
|
||||
kind, name = one.get("갈래"), str(one.get("이름") or "")
|
||||
if kind == "자재":
|
||||
return materials(name, str(one.get("단위") or ""), want)
|
||||
if kind == "유가":
|
||||
return oils(name)
|
||||
if kind == "노임":
|
||||
return labor(name, want)
|
||||
if kind == "기계":
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""M02 일위대가 실무자료 별칭 장부(PLAN 338-3 · 설계 3-5) — 층 `실무자료/맞춤.json` 을 키움.
|
||||
|
||||
줄 = {열쇠(품명|규격|단위 정규화): {원래 {품명, 규격, 단위},
|
||||
갈래 「짝」 · 「우리에 없음」 · 「안 씀」, 짝 [{로직|자재, 입력값, 배, 몫}],
|
||||
갈래 「짝」 · 「우리에 없음」 · 「안 씀」, 짝 [{로직|자재, 입력값, 배, 몫}
|
||||
| 고른 것 꼴 {갈래, 키?, 입력값, 배, 몫, 이름?, 단위?, 비목?, 기준?, 별도계상?}(785-3)],
|
||||
누가, 때, 처음파일, 엑셀조항, 판}} ·
|
||||
직접 줄 호표 통째 = 열쇠 「호표|품명|규격|단위」.
|
||||
옛 줄 {로직|자재|빼기, 입력값, 환산, 확정} = 읽을 때 새 꼴로 바꿔 읽음(빼기 = 「안 씀」 ·
|
||||
@@ -202,10 +203,68 @@ def input_names(code: str) -> set[str]:
|
||||
return {str(x.get("이름")) for x in (got[0]["입력"] if got else [])}
|
||||
|
||||
|
||||
# 고른 것 꼴(785-3 · `M02_Hopyo_Row.pick`) — 갈래 → 키 머리(None = 키 없음 · 기초 = 돈 로직 목록)
|
||||
PICK_HEADS: dict[str, tuple[str, ...] | None] = {
|
||||
"기초": ("GC", "GF"),
|
||||
"단가산출": ("SK-",),
|
||||
"일위대가": ("SC-",),
|
||||
"인력": ("LB",),
|
||||
"재료": ("MT", "PM"),
|
||||
"유가": ("MO",),
|
||||
"기계": ("EQ",),
|
||||
"율": None,
|
||||
"고정": None,
|
||||
}
|
||||
PICK_KEYS = {"갈래", "키", "입력값", "배", "몫", "이름", "단위", "비목", "기준", "별도계상"}
|
||||
|
||||
|
||||
def _check_pick(raw: dict, where: str, known: tuple[set[str], set[str]]) -> dict[str, Any]:
|
||||
"""고른 것 꼴 짝 재검사 — 갈래 · 키 머리 · M01 에 있음 · 입력값(기초만) · 율 비목 · 기준."""
|
||||
from M02_MasterTemplete import M02_Labor_Fixed as fixed # 고정 줄 규칙(율 기준) — 늦게 읽음
|
||||
|
||||
kind = raw.get("갈래")
|
||||
if kind not in PICK_HEADS or set(raw) - PICK_KEYS:
|
||||
raise _bad(f"짝 갈래 · 칸이 틀림 「{where}」")
|
||||
code, heads = str(raw.get("키") or ""), PICK_HEADS[kind]
|
||||
if heads is None and code or heads is not None and not code.startswith(heads):
|
||||
raise _bad(f"「{kind}」 키가 틀림 「{code}」 「{where}」")
|
||||
if (
|
||||
kind == "기초"
|
||||
and code not in known[0]
|
||||
or kind == "재료"
|
||||
and code[:2] == "MT"
|
||||
and code not in known[1]
|
||||
):
|
||||
raise _bad(f"없는 {kind} 「{code}」 「{where}」")
|
||||
if kind in ("인력", "유가", "기계") and not fixed._has(m01.loaded()[1], code):
|
||||
raise _bad(f"M01 에 없는 {kind} 「{code}」 「{where}」")
|
||||
values = raw.get("입력값") or {}
|
||||
if not isinstance(values, dict) or (values and kind != "기초"):
|
||||
raise _bad(f"입력값이 틀림 「{where}」")
|
||||
if unknown := set(values) - input_names(code) if values else set():
|
||||
raise _bad(f"모르는 입력 {sorted(unknown)} 「{where}」")
|
||||
if kind == "율" and not (raw.get("비목") in fixed.COSTS and fixed._basis(raw.get("기준"))):
|
||||
raise _bad(f"율 짝은 비목 · 기준 「{where}」")
|
||||
times, part = times_of(raw, where)
|
||||
out = {
|
||||
"갈래": kind,
|
||||
**({"키": code} if code else {}),
|
||||
"입력값": values,
|
||||
"배": times,
|
||||
"몫": part,
|
||||
}
|
||||
out.update({k: str(raw[k]) for k in ("이름", "단위", "비목") if raw.get(k)})
|
||||
if kind == "율":
|
||||
out["기준"] = raw["기준"]
|
||||
return {**out, **({"별도계상": True} if raw.get("별도계상") is True else {})}
|
||||
|
||||
|
||||
def check_pair(raw: Any, where: str, known: tuple[set[str], set[str]]) -> dict[str, Any]:
|
||||
"""짝 하나 재검사 → `{로직|자재, 입력값, 배, 몫}` — 틀리면 400."""
|
||||
"""짝 하나 재검사 → `{로직|자재, 입력값, 배, 몫}` · 고른 것 꼴(`갈래`) — 틀리면 400."""
|
||||
if not isinstance(raw, dict):
|
||||
raise _bad(f"짝 꼴이 틀림 「{where}」")
|
||||
if "갈래" in raw:
|
||||
return _check_pick(raw, where, known)
|
||||
extra = set(raw) - {"로직", "자재", "입력값", "배", "몫", "이름", "단위"}
|
||||
if extra:
|
||||
raise _bad(f"모르는 칸 {sorted(extra)} 「{where}」")
|
||||
|
||||
@@ -197,30 +197,10 @@ def _num_in(flat: str, pool: str, spec: dict[str, Any]) -> bool:
|
||||
|
||||
# ── 환산 ─────────────────────────────────────────────────────────────
|
||||
def factor(have: Any, want: Any, spec: Any = "") -> dict[str, Any]:
|
||||
"""`{배, 갈래}` — 우리 수량 = 엑셀 수량 × 배 · 배 None = 사용자 입력."""
|
||||
units = [u.strip() for u in str(want or "").split("·") if u.strip()]
|
||||
plain = mm.plain_unit(have)
|
||||
if len(units) > 1:
|
||||
ok = plain in {mm.plain_unit(u) for u in units}
|
||||
return {"배": 1 if ok else None, "갈래": "두 단위" if ok else "입력"}
|
||||
if plain.replace("/", "") == mm.plain_unit(want).replace("/", ""): # 공/㎥ ≡ 공㎥
|
||||
return {"배": 1, "갈래": "같음"}
|
||||
if str(want or "").strip() == "자재": # 결과단위 「원/자재 단위」 = 줄 단위 그대로
|
||||
return {"배": 1, "갈래": "자재 단위"}
|
||||
a, b = mp.kind_of(have), mp.kind_of(want)
|
||||
if a is not None and a == b:
|
||||
scale = mp.UNITS[a]
|
||||
ratio = Decimal(scale[plain]) / Decimal(scale[mm.plain_unit(want)])
|
||||
return {"배": _json(ratio), "갈래": "단위표"}
|
||||
found = numbers(spec)
|
||||
width, thick = _meters(found.get("B")), _meters(found.get("T"))
|
||||
by = {("넓이", "부피"): thick, ("길이", "넓이"): width}
|
||||
if (a, b) == ("길이", "부피") and width is not None and thick is not None:
|
||||
by[(a, b)] = width * thick
|
||||
got = by.get((a, b))
|
||||
if got:
|
||||
return {"배": _json(got), "갈래": "추정"}
|
||||
return {"배": None, "갈래": "입력"}
|
||||
"""`{배, 갈래}` — `M02_Hopyo_Row.factor` 로 옮김(785-3 · 옛 길이 부름)."""
|
||||
from M02_MasterTemplete import M02_Hopyo_Row as hopyo_row # Row 가 Match 를 부름 — 늦게 읽음
|
||||
|
||||
return hopyo_row.factor(have, want, spec)
|
||||
|
||||
|
||||
def fits(have: Any, want: Any) -> bool:
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
"""M02 엑셀 불러오기 1차 줄 판정(PLAN 785-3 · 요구 641 · 642 · 설계 642-A 2-3 · 분석 642-C 5장).
|
||||
|
||||
엑셀 사전 = 단가표 시트 → 갈래 + M01 키 후보(한 번 · `lexicon`) — JSON 꼴 `{꼴, 줄 [사전 줄]}` ·
|
||||
사전 줄 = `{갈래, 이름, 규격, 단위, 단가, 시트, 행, 코드?, 후보 [고른 것]}`.
|
||||
EST = 자재단가(재료 · 이름이 M01 유가전력이면 유가) · 노임단가(인력) · 기계경비총괄표(기계) ·
|
||||
기계경비적용기준(기본재료비 = 유가 · 환율 = 환율 · 줄 꼴 없음 → 꼬리표만) · 단가산출총괄표 ·
|
||||
일위대가총괄표 · 머리 줄 = 앞 열 줄 안 「품명」 칸(열은 머리 글로 찾음).
|
||||
STmate = 목록표(`Stmate._lists`) · 줄 코드가 열쇠(L 인력 · X 기계 · M 재료 · D 단가산출 ·
|
||||
S · W 고정).
|
||||
세부행 갈래(위가 먼저) = ① 비고 「단산N · 일위N참조」(이름으로 이은 줄 뺌) ② 품명 · 규격
|
||||
「별도계상 · 별도계산」 → 재료 ③ 단위 % · 규격 「…비의」 → 율 · hr · 시간 → 기계 · 인 → 인력
|
||||
④ 사전(코드 › 품명+규격 › 품명) · 갈래 여럿 = 금액 칸 꼴(재료비만 → 재료 · 유가)로 가름 ·
|
||||
그 밖 = 고정.
|
||||
후보 찾기 차례 = 장부(`Ledger.recall` · 고른 것 꼴) › M01 표마다(LB · MT · MO · EQ) › M02 층
|
||||
SK · SC 같은 이름(위 층까지 · 참고).
|
||||
판정 = 제안 있음(장부 짝 · 후보 하나 · 별도계상 · 하위 호표 · 율) · 2차 필요(단가산출 줄 =
|
||||
`Bind` · `Solve` 몫) · 후보 여럿 · 후보 없음(고정 금액 제안).
|
||||
고른 것 = `{갈래 기초|단가산출|일위대가|인력|재료|유가|기계|율|고정, 키, 이름, 규격, 단위, 단가,
|
||||
입력값, 출처, 몫, 비목?, 기준?, 별도계상?, 층?, 주인?}`(설계 2-1 · 만들기 = `Make` 몫).
|
||||
수량 = `{엑셀, 배, 갈래}` — 우리 수량 = 엑셀 수량 × 몫 × 배(`factor` · 옮김 · 같은 단위 = 1).
|
||||
서버 단독 · 읽기만(M01 · 층 문서 · 장부).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from decimal import Decimal
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Hopyo_Elem as elem
|
||||
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hm
|
||||
from M02_MasterTemplete import M02_Hopyo_Material as hmat
|
||||
from M02_MasterTemplete import M02_Hopyo_ReadEst as est
|
||||
from M02_MasterTemplete import M02_Hopyo_Stmate as stmate
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
mm, mp = hm.mm, hm.mp
|
||||
KINDS = ("기초", "단가산출", "일위대가", "인력", "재료", "유가", "기계", "율", "고정")
|
||||
VERDICTS = ("제안 있음", "2차 필요", "후보 여럿", "후보 없음")
|
||||
COSTS = ("노무비", "재료비", "경비")
|
||||
SHEET_KIND = {
|
||||
"자재단가": "재료",
|
||||
"노임단가": "인력",
|
||||
"기계경비총괄표": "기계",
|
||||
"기계경비적용기준": "유가",
|
||||
"단가산출총괄표": "단가산출",
|
||||
"일위대가총괄표": "일위대가",
|
||||
}
|
||||
SHEETS = (*SHEET_KIND, *stmate.LISTS)
|
||||
CODE_KIND = {"L": "인력", "X": "기계", "M": "재료", "D": "단가산출", "B": "일위대가"}
|
||||
APART = ("별도계상", "별도계산")
|
||||
MACHINE_UNITS = ("hr", "시간", "h")
|
||||
CURRENCY = (("달러", "USD"), ("$", "USD"), ("엔", "JPY"), ("유로", "EUR"), ("파운드", "GBP"))
|
||||
_RATE_BASE = re.compile(r"(노무비|재료비|경비|인건비|자재비|노임|합계)\s*의")
|
||||
_BASE_COST = {"노": "노무비", "인": "노무비", "품": "노무비", "재": "재료비", "자": "재료비"}
|
||||
TOP = 5
|
||||
|
||||
|
||||
def flat(value: Any) -> str:
|
||||
return re.sub(r"\s+", "", unicodedata.normalize("NFKC", str(value or ""))).lower()
|
||||
|
||||
|
||||
def _num(value: Any) -> float | None:
|
||||
if isinstance(value, bool) or value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return float(str(value).replace(",", "").strip())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _plain(value: Any) -> int | float | None:
|
||||
if value is None:
|
||||
return None
|
||||
f = float(value)
|
||||
return int(f) if f == int(f) else round(f, 6)
|
||||
|
||||
|
||||
# ── 엑셀 사전 ───────────────────────────────────────────────────────────
|
||||
def _header(rows: list[list[Any]]) -> tuple[int, dict[str, int]] | None:
|
||||
for at, row in enumerate(rows[:10]):
|
||||
heads = [flat(v) for v in row]
|
||||
if "품명" in heads:
|
||||
return at, {h: i for i, h in reversed(list(enumerate(heads))) if h}
|
||||
return None
|
||||
|
||||
|
||||
def _list_rows(rows: list[list[Any]], sheet: str, kind: str) -> list[dict[str, Any]]:
|
||||
"""단가표 시트 줄 — No(수 · 「제N호표」) · 품명 있는 줄 · 단가 = 적용단가 › 합계 › 단가."""
|
||||
got = _header(rows)
|
||||
if got is None:
|
||||
return []
|
||||
at, cols = got
|
||||
name = cols["품명"]
|
||||
no = cols.get("no", cols.get("호표", name - 1))
|
||||
price = next((cols[h] for h in ("적용단가", "합계", "단가") if h in cols), None)
|
||||
cell = lambda r, c: r[c] if c is not None and c < len(r) else None # noqa: E731
|
||||
out = []
|
||||
for i, row in enumerate(rows[at + 1 :], at + 2):
|
||||
label = flat(cell(row, no))
|
||||
if not flat(cell(row, name)) or not (label.isdigit() or re.fullmatch(r"제\d+호표", label)):
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"갈래": kind,
|
||||
"이름": str(cell(row, name)).strip(),
|
||||
"규격": str(cell(row, cols.get("규격")) or "").strip(),
|
||||
"단위": str(cell(row, cols.get("단위")) or "").strip(),
|
||||
"단가": _plain(_num(cell(row, price))),
|
||||
"시트": sheet,
|
||||
"행": i,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _rates(rows: list[list[Any]], sheet: str) -> list[dict[str, Any]]:
|
||||
"""기계경비적용기준 환율 칸 — 「화폐」 머리 아래 (화폐, 환율) 줄."""
|
||||
for at, row in enumerate(rows[:10]):
|
||||
heads = [flat(v) for v in row]
|
||||
if "화폐" in heads:
|
||||
c = heads.index("화폐")
|
||||
return [
|
||||
{
|
||||
"갈래": "환율",
|
||||
"이름": str(r[c]).strip(),
|
||||
"규격": "",
|
||||
"단위": "원",
|
||||
"단가": _plain(_num(r[c + 1])),
|
||||
"시트": sheet,
|
||||
"행": i,
|
||||
} # fmt: skip
|
||||
for i, r in enumerate(rows[at + 1 :], at + 2)
|
||||
if c + 1 < len(r) and flat(r[c]) and _num(r[c + 1])
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def lexicon(sheets: dict[str, list[list[Any]]], keys: bool = True) -> dict[str, Any]:
|
||||
"""시트 줄 → 엑셀 사전 `{꼴, 줄}` — `keys` = 줄마다 M01 키 후보(한 번)."""
|
||||
if stmate.is_stmate(sheets):
|
||||
rows = [
|
||||
{
|
||||
"갈래": CODE_KIND.get(code[0], "고정"),
|
||||
**one,
|
||||
"단가": None,
|
||||
"시트": "목록표",
|
||||
"행": None,
|
||||
"코드": code,
|
||||
} # fmt: skip
|
||||
for code, one in stmate._lists(sheets).items()
|
||||
]
|
||||
shape = "STmate"
|
||||
else:
|
||||
rows = []
|
||||
for sheet, kind in SHEET_KIND.items():
|
||||
if sheets.get(sheet):
|
||||
rows += _list_rows(sheets[sheet], sheet, kind)
|
||||
rows += _rates(sheets.get("기계경비적용기준") or [], "기계경비적용기준")
|
||||
shape = "EST"
|
||||
for row in rows:
|
||||
if row["갈래"] == "재료" and elem.oil_rows(row["이름"]):
|
||||
row["갈래"] = "유가"
|
||||
if keys:
|
||||
row["후보"] = m01_picks(row["갈래"], row)
|
||||
return {"꼴": shape, "줄": rows}
|
||||
|
||||
|
||||
def _index(lex: dict[str, Any] | None) -> dict[str, list[dict[str, Any]]]:
|
||||
out: dict[str, list[dict[str, Any]]] = {}
|
||||
for row in (lex or {}).get("줄") or []:
|
||||
if row.get("코드"):
|
||||
out.setdefault("코드|" + row["코드"], []).append(row)
|
||||
out.setdefault(f"{flat(row['이름'])}|{flat(row['규격'])}", []).append(row)
|
||||
out.setdefault(flat(row["이름"]), []).append(row)
|
||||
return out
|
||||
|
||||
|
||||
# ── M01 키 후보(표마다) ───────────────────────────────────────────────────
|
||||
def pick(kind: str, key: Any = None, name: Any = "", spec: Any = "", unit: Any = "", **more: Any):
|
||||
"""고른 것 한 꼴(설계 2-1)."""
|
||||
head = {"갈래": kind, "키": key, "이름": str(name or ""), "규격": str(spec or "")}
|
||||
return {**head, "단위": str(unit or ""), "단가": more.pop("단가", None), "입력값": {},
|
||||
"출처": more.pop("출처", {}), "몫": None, **more} # fmt: skip
|
||||
|
||||
|
||||
def _rows(table: str) -> dict[str, dict[str, Any]]:
|
||||
return m01.loaded()[1].numbers.get(table, {})
|
||||
|
||||
|
||||
def m01_picks(kind: str, line: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""갈래 하나 → M01 고른 것 후보(LB · MT · MO · EQ · FX) — 유일 짝이면 하나 · 같은 글 한 번."""
|
||||
text = tuple(str(line.get(k) or "") for k in ("이름", "규격", "단위"))
|
||||
return copy.deepcopy(_m01_picks(kind, *text, id(m01.loaded()[1])))
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _m01_picks(kind: str, name: str, spec: str, unit: str, _whole: int) -> list[dict[str, Any]]:
|
||||
if kind == "인력":
|
||||
trade = est.trade(name)
|
||||
hit = [r for r in _rows("LB").values() if trade and r.get("이름") == trade]
|
||||
if hit:
|
||||
return [
|
||||
pick("인력", hit[0]["키"], hit[0]["이름"], "", "인", 단가=_plain(hit[0].get("값")))
|
||||
]
|
||||
got = elem.labor(str(name or ""))
|
||||
return [pick("인력", x["키"], x["이름"], "", "인", 단가=x["단가"]) for x in got]
|
||||
if kind == "기계":
|
||||
text = f"{name} {spec}"
|
||||
one = elem.machine(text)
|
||||
rows = [one] if one else []
|
||||
got = [{"키": r["키"], "이름": r["이름"], "규격": r.get("규격")} for r in rows]
|
||||
got = got or elem.machines(text)
|
||||
return [pick("기계", x["키"], x["이름"], x.get("규격"), unit or "hr") for x in got]
|
||||
if kind == "유가":
|
||||
return [
|
||||
pick("유가", x["키"], x["이름"], x["규격"], x["단위"], 단가=x["단가"])
|
||||
for x in elem.oils(str(name or ""))
|
||||
]
|
||||
if kind == "환율":
|
||||
code = next((c for w, c in CURRENCY if w in str(name)), flat(name).upper())
|
||||
rows = [r for r in _rows("FX").values() if str(r.get("이름")).startswith(code)]
|
||||
return [
|
||||
pick("환율", r["키"], r["이름"], "", r.get("단위"), 단가=_plain(r["값"])) for r in rows
|
||||
]
|
||||
if kind == "재료":
|
||||
clean, apart = hmat.clean({"이름": name, "규격": spec, "단위": unit})
|
||||
sure, _ = hmat.sure(clean)
|
||||
got = [r["one"] for r in sure[:1] if not r["one"].get("전국")]
|
||||
got = got or hmat.candidates(clean, TOP)
|
||||
more = {"별도계상": True} if apart else {}
|
||||
return [
|
||||
pick("재료", x.get("자재"), x["이름"], x.get("규격"), x.get("단위"), **more)
|
||||
for x in got
|
||||
if x.get("자재")
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
# ── M02 층 SK · SC(같은 이름 · 참고) ────────────────────────────────────────
|
||||
def layer_docs(folder: str | Path | None) -> dict[str, list[dict[str, Any]]]:
|
||||
"""`{정규 이름: [고른 것]}` — 같은 층 → 위 층 · 단가산출 SK · 일위대가 SC."""
|
||||
out: dict[str, list[dict[str, Any]]] = {}
|
||||
if folder is None:
|
||||
return out
|
||||
for one in ledger.chain(folder):
|
||||
layer, owner = refs.where(one)
|
||||
for kind, sub in (("단가산출", "sangeun"), ("일위대가", "precombo")):
|
||||
for path in sorted((Path(one) / sub).glob("S[CK]-*.json")):
|
||||
doc = json.loads(path.read_bytes()) if path.is_file() else {}
|
||||
name = doc.get("이름") or path.stem
|
||||
got = pick(kind, path.stem, name, "", doc.get("단위"), 층=layer, 주인=owner)
|
||||
out.setdefault(mm.plain(name), []).append(got)
|
||||
return out
|
||||
|
||||
|
||||
# ── 줄 판정 ─────────────────────────────────────────────────────────────
|
||||
def first(line: dict[str, Any]) -> tuple[str | None, str, int | None]:
|
||||
"""1차 ①~③ — (갈래, 근거, 번) · 못 가름 = (None, "", None)."""
|
||||
ref = line.get("참조") or {}
|
||||
if ref and line.get("이음") != "이름":
|
||||
kind = "단가산출" if ref.get("종류") == "단산" else "일위대가"
|
||||
return kind, f"비고 {line.get('비고') or line.get('코드') or ''}".strip(), ref.get("번")
|
||||
text = flat(f"{line.get('이름')}{line.get('규격')}")
|
||||
if any(w in text for w in APART) or str(line.get("비고") or "").startswith("별산"):
|
||||
return "재료", "별도계상", None
|
||||
unit = flat(line.get("단위"))
|
||||
if unit == "%" or _RATE_BASE.search(str(line.get("규격") or "")):
|
||||
return "율", "단위 %", None
|
||||
if unit in MACHINE_UNITS:
|
||||
return "기계", "단위 hr", None
|
||||
if unit == "인":
|
||||
return "인력", "단위 인", None
|
||||
return None, "", None
|
||||
|
||||
|
||||
def _shape(line: dict[str, Any]) -> tuple[str, ...]:
|
||||
price = line.get("단가") or {}
|
||||
return tuple(k for k in COSTS if price.get(k))
|
||||
|
||||
|
||||
def second(line: dict[str, Any], index: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]:
|
||||
"""④ 사전 — 코드 › 품명+규격 › 품명 · 갈래 여럿이면 금액 칸 꼴로 가름 → 사전 줄 목록."""
|
||||
code = str(line.get("코드") or "")
|
||||
hits = index.get("코드|" + code) if code else None
|
||||
hits = hits or index.get(f"{flat(line.get('이름'))}|{flat(line.get('규격'))}")
|
||||
hits = hits or index.get(flat(line.get("이름"))) or []
|
||||
if len({h["갈래"] for h in hits}) > 1 and _shape(line) == ("재료비",):
|
||||
hits = [h for h in hits if h["갈래"] in ("재료", "유가")] or hits
|
||||
if not hits and code[:1] in CODE_KIND:
|
||||
hits = [{"갈래": CODE_KIND[code[0]], "이름": line.get("이름"), "규격": line.get("규격")}]
|
||||
return hits
|
||||
|
||||
|
||||
def _rate_pick(line: dict[str, Any]) -> dict[str, Any]:
|
||||
"""율 줄 — 기준 = 규격 「…비의」 글(EST 노 · 품 · 인 → 노무비 · 재 · 자 → 재료비 · 경 → 경비 ·
|
||||
합계 → 비목 셋 중 줄 금액 칸) · 비목 = 줄 단가가 든 칸(없으면 기준)."""
|
||||
hit = _RATE_BASE.search(f"{line.get('규격')}{line.get('이름')}")
|
||||
word = hit.group(1) if hit else ""
|
||||
base = _BASE_COST.get(word[:1], "경비" if word.startswith("경") else None)
|
||||
cost = next(iter(_shape(line)), None) or base or "재료비"
|
||||
one = pick("율", None, line.get("이름"), line.get("규격"), "%", 비목=cost, 기준=base or cost)
|
||||
return one
|
||||
|
||||
|
||||
def _fixed(line: dict[str, Any], apart: bool = False) -> dict[str, Any]:
|
||||
price = {k: _plain((line.get("단가") or {}).get(k)) or 0 for k in COSTS}
|
||||
more = {"별도계상": True} if apart else {}
|
||||
return pick(
|
||||
"고정", None, line.get("이름"), line.get("규격"), line.get("단위"), 단가=price, **more
|
||||
)
|
||||
|
||||
|
||||
def _from_ledger(row: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""장부 짝 → 고른 것 — 새 꼴(`갈래`) 그대로 · 옛 꼴 로직 = 기초 · 자재 = 재료 · 유가."""
|
||||
out = []
|
||||
for p in row.get("짝") or []:
|
||||
if p.get("갈래"):
|
||||
got = pick(p["갈래"], p.get("키"), p.get("이름"), "", p.get("단위"))
|
||||
got.update({k: p[k] for k in ("입력값", "몫", "비목", "기준", "별도계상") if k in p})
|
||||
elif p.get("로직"):
|
||||
got = {**pick("기초", p["로직"]), "입력값": p.get("입력값") or {}, "몫": p.get("몫")}
|
||||
else:
|
||||
code = str(p.get("자재") or "")
|
||||
got = pick("유가" if code.startswith("MO") else "재료", code)
|
||||
out.append({**got, "출처": {"장부": row.get("층")}, "배": p.get("배")})
|
||||
return out
|
||||
|
||||
|
||||
def judge(
|
||||
line: dict[str, Any],
|
||||
index: dict[str, list[dict[str, Any]]],
|
||||
docs: dict[str, list[dict[str, Any]]] | None = None,
|
||||
book: list | None = None,
|
||||
doc: str | None = None,
|
||||
folder: str | Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""줄 하나 → 줄 판 `{차례, 행, 갈래 [..], 근거, 번?, 후보, 판정, 수량, 장부?}`."""
|
||||
kind, why, no = first(line)
|
||||
hits = [] if kind else second(line, index)
|
||||
kinds = [kind] if kind else list(dict.fromkeys(h["갈래"] for h in hits)) or ["고정"]
|
||||
if not kind and hits:
|
||||
h = hits[0]
|
||||
why = f"사전 {h.get('시트')} {h.get('행') or h.get('코드') or ''}".strip()
|
||||
out = {"차례": line.get("차례"), "행": line.get("행"), "갈래": kinds, "근거": why}
|
||||
if no is not None:
|
||||
out["번"] = no
|
||||
qty = {"엑셀": line.get("수량"), "배": 1, "갈래": "같음"}
|
||||
picks: list[dict[str, Any]] = []
|
||||
found = None
|
||||
if folder is not None:
|
||||
found = ledger.recall(line, folder, book, doc=doc)
|
||||
if found and found.get("갈래") == "짝":
|
||||
picks = _from_ledger(found)
|
||||
apart = why == "별도계상"
|
||||
for k in kinds if not picks else ():
|
||||
if k == "일위대가" and no is not None:
|
||||
picks.append(pick("일위대가", f"제{no}호표", line.get("이름"), line.get("규격"),
|
||||
line.get("단위"), 출처={"엑셀": f"제{no}호표"})) # fmt: skip
|
||||
elif k in ("단가산출", "일위대가"):
|
||||
pass
|
||||
elif k == "율":
|
||||
picks.append(_rate_pick(line))
|
||||
elif any(h["갈래"] == k and "후보" in h for h in hits): # 사전이 붙인 M01 후보
|
||||
picks += [p for h in hits if h["갈래"] == k for p in h["후보"]]
|
||||
else:
|
||||
picks += m01_picks(k, line)
|
||||
if not found:
|
||||
picks += [p for p in (docs or {}).get(mm.plain(line.get("이름")), []) if p["갈래"] in kinds]
|
||||
if apart:
|
||||
picks = [{**p, "별도계상": True} for p in picks] or [_fixed(line, True)]
|
||||
picks = list({(p["갈래"], p.get("키"), p.get("층")): p for p in picks}.values())
|
||||
if found and found.get("갈래") in ("짝", "우리에 없음", "안 씀"):
|
||||
out["장부"] = {k: found.get(k) for k in ("갈래", "층", "제안", "규격다름")}
|
||||
if "단가산출" in kinds and not (found and found.get("갈래") == "짝"):
|
||||
verdict = "2차 필요"
|
||||
elif (
|
||||
found and found.get("갈래") in ("짝", "안 씀", "우리에 없음") and not found.get("규격다름")
|
||||
):
|
||||
verdict = "제안 있음"
|
||||
elif apart or kinds[0] in ("일위대가", "율") or len(picks) == 1:
|
||||
verdict = "제안 있음"
|
||||
elif picks:
|
||||
verdict = "후보 여럿"
|
||||
else:
|
||||
verdict, picks = "후보 없음", [_fixed(line)]
|
||||
return {**out, "후보": picks, "판정": verdict, "수량": qty}
|
||||
|
||||
|
||||
def rows(
|
||||
book: dict[str, Any], folder: str | Path | None = None, doc: str | None = None
|
||||
) -> dict[int, list[dict[str, Any]]]:
|
||||
"""Book(+ `사전`) → `{호표 번: [줄 판]}` — 장부 · 층 문서는 한 번 읽어 넘김."""
|
||||
index = _index(book.get("사전"))
|
||||
got = ledger.books(folder) if folder is not None else None
|
||||
docs = layer_docs(folder)
|
||||
return {
|
||||
h["번"]: [judge(x, index, docs, got, doc, folder) for x in h.get("줄") or []]
|
||||
for h in book.get("호표") or []
|
||||
}
|
||||
|
||||
|
||||
# ── 환산(`M02_Hopyo_Match.factor` 옮김) ─────────────────────────────────────
|
||||
def factor(have: Any, want: Any, spec: Any = "") -> dict[str, Any]:
|
||||
"""`{배, 갈래}` — 우리 수량 = 엑셀 수량 × 배 · 배 None = 사용자 입력."""
|
||||
units = [u.strip() for u in str(want or "").split("·") if u.strip()]
|
||||
plain = mm.plain_unit(have)
|
||||
if len(units) > 1:
|
||||
ok = plain in {mm.plain_unit(u) for u in units}
|
||||
return {"배": 1 if ok else None, "갈래": "두 단위" if ok else "입력"}
|
||||
if plain.replace("/", "") == mm.plain_unit(want).replace("/", ""): # 공/㎥ ≡ 공㎥
|
||||
return {"배": 1, "갈래": "같음"}
|
||||
if str(want or "").strip() == "자재": # 결과단위 「원/자재 단위」 = 줄 단위 그대로
|
||||
return {"배": 1, "갈래": "자재 단위"}
|
||||
a, b = mp.kind_of(have), mp.kind_of(want)
|
||||
if a is not None and a == b:
|
||||
scale = mp.UNITS[a]
|
||||
ratio = Decimal(scale[plain]) / Decimal(scale[mm.plain_unit(want)])
|
||||
return {"배": hm._json(ratio), "갈래": "단위표"}
|
||||
found = hm.numbers(spec)
|
||||
width, thick = hm._meters(found.get("B")), hm._meters(found.get("T"))
|
||||
by = {("넓이", "부피"): thick, ("길이", "넓이"): width}
|
||||
if (a, b) == ("길이", "부피") and width is not None and thick is not None:
|
||||
by[(a, b)] = width * thick
|
||||
got = by.get((a, b))
|
||||
if got:
|
||||
return {"배": hm._json(got), "갈래": "추정"}
|
||||
return {"배": None, "갈래": "입력"}
|
||||
|
||||
|
||||
def quantity(line: dict[str, Any], want: Any, share: Any = None) -> dict[str, Any]:
|
||||
"""단가산출 줄 수량 `{엑셀, 몫, 배, 갈래, 우리}` — 우리 = 엑셀 × 몫 × 배(배 None = 입력)."""
|
||||
got = factor(line.get("단위"), want, line.get("규격"))
|
||||
qty = line.get("수량")
|
||||
ours = None
|
||||
if qty is not None and got["배"] is not None:
|
||||
ours = _plain(Decimal(str(qty)) * Decimal(str(share or 1)) * Decimal(str(got["배"])))
|
||||
return {"엑셀": qty, "몫": share, **got, "우리": ours}
|
||||
@@ -170,13 +170,13 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
where = f"자재 {at}째"
|
||||
code = str(one.get("코드") or "") if isinstance(one, dict) else ""
|
||||
try:
|
||||
row = whole.get(code) if code[:2] == mm.MARKET else own.find(code)
|
||||
row = whole.get(code) if code[:2] in (mm.MARKET, "MO") else own.find(code) # MO 785-3
|
||||
except mf.FormulaError:
|
||||
row = None
|
||||
if row is None and own.is_own(code):
|
||||
raise _bad(f"{where} · 개인 자재 목록에 없는 코드(개인 층에서만) 「{code}」")
|
||||
if row is None:
|
||||
raise _bad(f"{where} · M01 자재품목에 없는 코드 「{code}」")
|
||||
raise _bad(f"{where} · M01 자재품목 · 유가전력에 없는 코드 「{code}」")
|
||||
if code in codes:
|
||||
raise _bad(f"{where} · 같은 자재를 두 번 담음 「{code}」")
|
||||
codes.add(code)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""M02 일위대가 고정 줄(PLAN 705-1 · 요구서 1장 · 4장 1 · 2 · 3) — 품 · 수량 · 단가 숫자를 그대로 적어 둔 줄.
|
||||
|
||||
줄 = `{고정: {이름, 규격, 단위, 단가: {노무비, 재료비, 경비}, 이음: {노임, 자재, 기계}, 출처?},
|
||||
줄 = `{고정: {이름, 규격, 단위, 단가: {노무비, 재료비, 경비}, 이음: {노임, 자재, 기계, 유가}, 출처?},
|
||||
별칭, 메모, 수량}` — 담은로직 안 로직 · 참조 줄과 섞임(줄 단위 꼬리표 · 기본 = 품셈).
|
||||
단가 = 그 줄 단위 1 개 값 · 빈 비목 = 0 · 합계만 있는 줄 = 고른 비목 하나(기본 재료비 · 붙이는 쪽 몫).
|
||||
이음 = 이 공사 노임(LB) · 자재(MT) · 기계(EQ) 키 · 못 이음 = null(「손 단가」) — 이은 비목만 단가 책 값
|
||||
이음 = 이 공사 노임(LB) · 자재(MT) · 기계(EQ) · 유가전력(MO · 785-3 · 재료비 = MO 값 · 묶음 재료) 키 ·
|
||||
못 이음 = null(「손 단가」) — 이은 비목만 단가 책 값
|
||||
(노임 새 판 때 따라감) · 품(수량)은 그대로.
|
||||
M02 셈(층 · 사본) = 노임 「값」 · 자재 기준단가로 이음 · 기계 = 줄 단가(기계 시간 단가 = B09 단가 책 몫).
|
||||
출처 = `{파일, 호표, 줄, 판}`(글 · 수만) · 줄 가름 = 「고정|이름|규격|단위」(짝 `Labor_Calc.ts` `lineId`).
|
||||
@@ -28,10 +29,11 @@ import master_cut as mcut # noqa: E402 — 끝수(751-3) · scripts 길은 `m01
|
||||
mm = mf.mp.mm
|
||||
COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비
|
||||
FIXED = "고정"
|
||||
LINKS = {"노임": "LB", "자재": "MT", "기계": "EQ"}
|
||||
LINKS = {"노임": "LB", "자재": "MT", "기계": "EQ", "유가": "MO"}
|
||||
SOURCE_KEYS = ("파일", "호표", "줄", "판")
|
||||
GROUPS = {"노임": "인력", "자재": "재료", "기계": "장비"} # 율 기준 묶음(778-4) ← 고정 줄 이음
|
||||
BASES = (*COSTS, *GROUPS.values())
|
||||
# 율 기준 묶음(778-4) ← 고정 줄 이음
|
||||
GROUPS = {"노임": "인력", "자재": "재료", "기계": "장비", "유가": "재료"}
|
||||
BASES = tuple(dict.fromkeys((*COSTS, *GROUPS.values())))
|
||||
|
||||
|
||||
def _bad(text: str) -> Exception:
|
||||
@@ -84,7 +86,8 @@ def clean(fx: Any, where: str, whole: Any) -> dict[str, Any]:
|
||||
code = str(given.get(k) or "") if isinstance(given, dict) else ""
|
||||
if code and (code[:2] != head or not _has(whole, code)):
|
||||
raise _bad(f"{where} · 고정 줄 {k} 이음 「{code}」 M01 에 없음")
|
||||
link[k] = code or None
|
||||
if code or k != "유가": # 유가 칸은 이은 줄만(옛 문서 꼴 그대로)
|
||||
link[k] = code or None
|
||||
rate = fx.get("비율")
|
||||
if rate is not None and not (
|
||||
isinstance(rate, dict) and _basis(rate.get("기준")) and rate.get("비목") in COSTS
|
||||
@@ -178,9 +181,9 @@ def line(whole: Any, fx: dict) -> dict[str, Any]:
|
||||
got = None
|
||||
if got is not None:
|
||||
base["노무비"], used = got, [*used, "노무비"]
|
||||
if code := link.get("자재"):
|
||||
for code in (link.get("자재"), link.get("유가")): # 유가 MO = M01 값(785-3)
|
||||
try:
|
||||
got = _num(mm.element(whole, code, {}, fx.get("단위") or None)[0])
|
||||
got = _num(mm.element(whole, code, {}, fx.get("단위") or None)[0]) if code else None
|
||||
except mf.FormulaError:
|
||||
got = None
|
||||
if got is not None:
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
* · (736-2) `machine` = M01 기계(EQ · 기계 줄 이음) 고름 · 개인 층에서 재료 고름이면 머리 [신규 자재](개인 층에 만들어 바로 고름) ·
|
||||
* 기계 줄은 「M01 전체」 탭 묶음 안(인력 · 재료와 같은 탭)
|
||||
* · (728-2) 돈 아닌 기초(결과단위가 「원」 아님 · ㎥/시간 등 21 개) = 목록에서 뺌
|
||||
* · (785-3) `oil` = M01 유가전력(MO · `/logic-lib/elements?group=재료` 의 MO 줄) 고름 · 고정 줄 꼴 = 이음 유가 MO ·
|
||||
* 재료비 = 지금 값(셈은 M01 값) · 「M01 전체」 탭 재료 아래
|
||||
* ========================================================================== */
|
||||
|
||||
import { createDataTable } from "@ui/ui_template_data_table";
|
||||
@@ -43,7 +45,7 @@ type Tab = "m01" | "sk" | "pre";
|
||||
|
||||
/** M01 인력 · 재료 한 줄(`/logic-lib/pick` · 코드 = LB- · MT- 키) */
|
||||
export interface M01Element {
|
||||
종류: "인력" | "재료" | "기계";
|
||||
종류: "인력" | "재료" | "기계" | "유가";
|
||||
코드: string;
|
||||
이름: string;
|
||||
규격: string;
|
||||
@@ -54,7 +56,7 @@ export interface M01Element {
|
||||
}
|
||||
|
||||
/** 고를 수 있는 종류 — logic = M01 단가산출 · 기초 로직 · labor = 인력 · material = 재료 · machine = 기계 · sk = M02 단가산출 · pre = 일위대가 */
|
||||
export type FindKind = "logic" | "labor" | "material" | "machine" | "sk" | "pre";
|
||||
export type FindKind = "logic" | "labor" | "material" | "machine" | "oil" | "sk" | "pre";
|
||||
|
||||
export interface FindPickOptions {
|
||||
layer: Layer;
|
||||
@@ -74,6 +76,8 @@ export interface FindPickOptions {
|
||||
element?: (e: M01Element) => void;
|
||||
/** 736-2 M01 기계(EQ) 고름 — 있을 때만 기계 줄이 보임 */
|
||||
machine?: (e: M01Element) => void;
|
||||
/** 785-3 M01 유가전력(MO) 고름 — 있을 때만 유가 줄이 보임 */
|
||||
oil?: (e: M01Element) => void;
|
||||
}
|
||||
|
||||
/** 727-1 기초마다 한 줄(옛 창 `Labor_Pick` 도 씀) — 규격 칸 = 「옵션 N」 */
|
||||
@@ -92,6 +96,16 @@ export function fixedOf(e: M01Element): LaborFixed {
|
||||
이음: { 노임: null, 자재: null, 기계: e.코드 },
|
||||
};
|
||||
}
|
||||
if (e.종류 === "유가") {
|
||||
// 785-3 유가전력 = 이음 유가 MO · 재료비 = 지금 값
|
||||
return {
|
||||
이름: e.이름,
|
||||
규격: e.규격,
|
||||
단위: e.단위,
|
||||
단가: { 노무비: 0, 재료비: e.값 ?? 0, 경비: 0 },
|
||||
이음: { 노임: null, 자재: null, 기계: null, 유가: e.코드 },
|
||||
};
|
||||
}
|
||||
const labor = e.종류 === "인력";
|
||||
return {
|
||||
이름: e.이름,
|
||||
@@ -162,6 +176,7 @@ export function openFindPicker(opt: FindPickOptions): void {
|
||||
let people: PickItem[] = [];
|
||||
let goods: PickItem[] = [];
|
||||
let machines: PickItem[] = [];
|
||||
let oils: PickItem[] = []; // 785-3 유가전력 MO
|
||||
const lists: Record<"sk" | "pre", SangeunRow[] | null> = { sk: null, pre: null };
|
||||
let shown: Line[] = [];
|
||||
let seq = 0;
|
||||
@@ -240,9 +255,11 @@ export function openFindPicker(opt: FindPickOptions): void {
|
||||
const put =
|
||||
kind === "기계"
|
||||
? opt.machine
|
||||
: kind === "재료" && opt.material
|
||||
? opt.material
|
||||
: opt.element;
|
||||
: kind === "유가"
|
||||
? opt.oil
|
||||
: kind === "재료" && opt.material
|
||||
? opt.material
|
||||
: opt.element;
|
||||
return {
|
||||
품명: m.이름,
|
||||
규격: e.규격,
|
||||
@@ -276,6 +293,7 @@ export function openFindPicker(opt: FindPickOptions): void {
|
||||
...(ok("logic") ? logicLines(q) : []),
|
||||
...(ok("labor") ? elementLines("인력", people) : []),
|
||||
...(ok("material") ? elementLines("재료", goods) : []),
|
||||
...(opt.oil && ok("oil") ? elementLines("유가", oils) : []),
|
||||
...(opt.machine && ok("machine") ? elementLines("기계", machines) : []),
|
||||
];
|
||||
} else {
|
||||
@@ -303,14 +321,20 @@ export function openFindPicker(opt: FindPickOptions): void {
|
||||
call<{ items: PickItem[] }>(
|
||||
`/logic-lib/elements?group=${encodeURIComponent("기계")}&q=${encodeURIComponent(q)}&limit=200`,
|
||||
).then((d) => d.items.map((x) => ({ ...x, 단위: "시간", 값: null }))); // 기계 단위 · 값 = 칸별 표(시간당 경비는 서버 셈)
|
||||
// ponytail: 재료 그룹 찾기에서 MO 줄만 — 유가전력 파일이 자재품목보다 앞(상한 200 안) · 넘치면 서버 kind
|
||||
const getOil = (): Promise<PickItem[]> =>
|
||||
call<{ items: PickItem[] }>(
|
||||
`/logic-lib/elements?group=${encodeURIComponent("재료")}&q=${encodeURIComponent(q)}&limit=200`,
|
||||
).then((d) => d.items.filter((x) => x.ref.startsWith("MO")));
|
||||
try {
|
||||
const [p, g, m] = await Promise.all([
|
||||
const [p, g, m, o] = await Promise.all([
|
||||
ok("labor") ? get("job") : Promise.resolve([]),
|
||||
q && ok("material") ? get("price") : Promise.resolve([]),
|
||||
opt.machine && ok("machine") ? getMachine() : Promise.resolve([]),
|
||||
opt.oil && ok("oil") ? getOil() : Promise.resolve([]),
|
||||
]);
|
||||
if (mine !== seq) return;
|
||||
[people, goods, machines] = [p, g, m];
|
||||
[people, goods, machines, oils] = [p, g, m, o];
|
||||
} catch (error) {
|
||||
if (mine !== seq) return;
|
||||
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
|
||||
|
||||
@@ -30,13 +30,13 @@ export interface LaborRef {
|
||||
키: string;
|
||||
}
|
||||
|
||||
/** 고정 줄(705-1) — 숫자 그대로 · 이음 = 이 공사 노임 · 자재 · 기계 키(못 이음 = null) */
|
||||
/** 고정 줄(705-1) — 숫자 그대로 · 이음 = 이 공사 노임 · 자재 · 기계 · 유가(MO · 785-3) 키(못 이음 = null) */
|
||||
export interface LaborFixed {
|
||||
이름: string;
|
||||
규격?: string;
|
||||
단위?: string;
|
||||
단가: Record<"노무비" | "재료비" | "경비", number>;
|
||||
이음?: Record<"노임" | "자재" | "기계", string | null>;
|
||||
이음?: Record<"노임" | "자재" | "기계", string | null> & { 유가?: string | null };
|
||||
출처?: Record<string, string | number>;
|
||||
/** 비율 줄(705-3 · 엑셀 % 줄) — 수량 = 율 · 반영[비목] = 밑(`rateBase`) × 율 ÷ 100 */
|
||||
비율?: { 기준: RateBasis; 비목: CostKey };
|
||||
@@ -52,10 +52,11 @@ export interface RateRow {
|
||||
비목: Partial<Record<CostKey, number | null>> | null;
|
||||
율?: boolean;
|
||||
}
|
||||
const GROUPS: Record<"노임" | "자재" | "기계", RateGroup> = {
|
||||
const GROUPS: Record<"노임" | "자재" | "기계" | "유가", RateGroup> = {
|
||||
노임: "인력",
|
||||
자재: "재료",
|
||||
기계: "장비",
|
||||
유가: "재료",
|
||||
};
|
||||
|
||||
export interface CalcLine {
|
||||
@@ -260,7 +261,7 @@ export function settle(
|
||||
return { 줄: lines, 자재, 기준합: base, 반영합: cutSums(done) };
|
||||
}
|
||||
|
||||
/** 고정 줄 묶음 — 첫 이음(노임 · 자재 · 기계 · 짝 `M02_Labor_Fixed.group_of`) */
|
||||
/** 고정 줄 묶음 — 첫 이음(노임 · 자재 · 기계 · 유가 · 짝 `M02_Labor_Fixed.group_of`) */
|
||||
export function groupOf(fx: LaborFixed | undefined): RateGroup | null {
|
||||
const link = fx?.이음;
|
||||
const hit = (Object.keys(GROUPS) as (keyof typeof GROUPS)[]).find((k) => link?.[k]);
|
||||
|
||||
@@ -110,6 +110,7 @@ export function openLogicPicker(
|
||||
ref: lib.pick,
|
||||
element: fixed && ((e) => fixed(fixedOf(e), e.이름)),
|
||||
machine: fixed && ((e) => fixed(fixedOf(e), e.이름)), // 736-3 기계 = 이음 기계 고정 줄
|
||||
oil: fixed && ((e) => fixed(fixedOf(e), e.이름)), // 785-3 유가전력 = 이음 유가 MO 고정 줄
|
||||
material: lib.material,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -86,8 +86,10 @@ export function openSangeunLinePicker(
|
||||
logic: (x) => done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 }),
|
||||
ref: (ref) => done({ 참조: ref, 수량: 1 }),
|
||||
element: (e) =>
|
||||
e.종류 !== "기계" &&
|
||||
(e.종류 === "인력" || e.종류 === "재료") &&
|
||||
done({ 종류: e.종류, 요소: e.코드, 이름: e.이름, 단위: e.단위, 수량: 1 }),
|
||||
// 785-3 유가전력 = 더한줄 재료 요소 MO
|
||||
oil: (e) => done({ 종류: "재료", 요소: e.코드, 이름: e.이름, 단위: e.단위, 수량: 1 }),
|
||||
// 736-3 기계 = 고정 줄(이음 기계 EQ · 단가 = 한 시간 노 · 재 · 경) · 수량 1
|
||||
machine: (e) => done({ 고정: fixedOf(e), 이름: e.이름, 단위: e.단위, 수량: 1 }),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"""642-3 1차 줄 판정 · 엑셀 사전 · 유가전력 MO 지킴 시험(PLAN 785-3 · 설계 642-A 5-3 셋째 줄).
|
||||
|
||||
- 북부청 임도 415 줄 1차 갈래 수 = 단산 268 · 일위 1 · 별도계상 64 · 재료(사전) 78 · 기계 3 · 율 1
|
||||
(분석 642-C 3-1) · 판정 2차 필요 268 · 같은 EST 꼴 사방 201 · 거창 68 = 모든 줄 갈래 하나(100 %)
|
||||
- 엑셀 사전 = 단가표 시트 갈래 수 · 노임 19 M01 LB 하나씩 · 경유 → MO
|
||||
- 장부 짝 고른 것 꼴(새) · 옛 꼴 그대로 · 틀린 키 400 · 환산 옮김(Match.factor = Row.factor)
|
||||
- 유가전력 MO — 고정 줄 이음 셈(재료비 = MO 값 · 묶음 재료 → 율 밑) · 자재 목록 MO · TS 짝 거울
|
||||
엑셀은 읽기만 · 층 · storage 안 씀.
|
||||
"""
|
||||
|
||||
import collections
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Hopyo_Elem as elem
|
||||
from M02_MasterTemplete import M02_Hopyo_Excel as ex
|
||||
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hm
|
||||
from M02_MasterTemplete import M02_Hopyo_Row as row
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Fixed as fixed
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
DOCS = HERE.parent / "knowledge" / "original" / "실무문서"
|
||||
BUKBU = DOCS / "2026_실무일위대가 모음" / "2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls"
|
||||
SABANG = DOCS / "2026_실무일위대가 모음" / "2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls"
|
||||
GEOCHANG = (
|
||||
DOCS
|
||||
/ "01.산불거창(거창북상병곡)"
|
||||
/ "01.내역서"
|
||||
/ "00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx"
|
||||
)
|
||||
OIL = "MO000002" # 자동차용경유 전국평균
|
||||
|
||||
|
||||
def _read(path: Path, keys: bool) -> dict:
|
||||
data = path.read_bytes()
|
||||
book = ex.read_book(path.name, data)
|
||||
sheets, _ = ex._open(path.name, data, row.SHEETS)
|
||||
return {**book, "사전": row.lexicon(sheets, keys)}
|
||||
|
||||
|
||||
def _kinds(book: dict) -> collections.Counter:
|
||||
"""줄마다 (갈래, 근거 첫 낱말) — 1차 ①~③ · ④ 사전."""
|
||||
index = row._index(book["사전"])
|
||||
out = collections.Counter()
|
||||
for h in book["호표"]:
|
||||
for line in h["줄"]:
|
||||
kind, why, _ = row.first(line)
|
||||
if kind is None:
|
||||
hits = row.second(line, index)
|
||||
kinds = sorted({x["갈래"] for x in hits}) or ["없음"]
|
||||
kind, why = "/".join(kinds), "사전"
|
||||
out[(kind, why.split(" ")[0])] += 1
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def bukbu() -> dict:
|
||||
return _read(BUKBU, True)
|
||||
|
||||
|
||||
def test_북부청_415_줄_1차_갈래(bukbu):
|
||||
got = _kinds(bukbu)
|
||||
assert sum(got.values()) == 415
|
||||
assert got == {
|
||||
("단가산출", "비고"): 268,
|
||||
("일위대가", "비고"): 1,
|
||||
("재료", "별도계상"): 64,
|
||||
("재료", "사전"): 78,
|
||||
("기계", "단위"): 3,
|
||||
("율", "단위"): 1,
|
||||
}
|
||||
|
||||
|
||||
def test_북부청_줄_판_판정과_후보(bukbu):
|
||||
out = row.rows(bukbu)
|
||||
lines = [x for v in out.values() for x in v]
|
||||
assert len(out) == 68 and len(lines) == 415
|
||||
verdict = collections.Counter(x["판정"] for x in lines)
|
||||
assert verdict["2차 필요"] == 268 and set(verdict) <= set(row.VERDICTS)
|
||||
assert all(set(x["갈래"]) <= set(row.KINDS) and x["후보"] is not None for x in lines)
|
||||
first = {x["행"]: x for x in out[1]} # 제1호표(설계 2-10 기대표)
|
||||
assert first[6]["갈래"] == ["재료"] and first[6]["근거"] == "별도계상"
|
||||
assert first[6]["판정"] == "제안 있음" and all(p["별도계상"] for p in first[6]["후보"])
|
||||
assert first[7]["번"] == 46 and first[7]["판정"] == "2차 필요"
|
||||
assert first[10]["갈래"] == ["재료"] and first[10]["근거"].startswith("사전 자재단가")
|
||||
rate = next(x for x in lines if x["갈래"] == ["율"])
|
||||
assert rate["판정"] == "제안 있음"
|
||||
assert (
|
||||
rate["후보"][0]["비목"] == "재료비" and rate["후보"][0]["기준"] == "재료비"
|
||||
) # 재료비의 3 %
|
||||
sub = next(x for x in lines if x["갈래"] == ["일위대가"])
|
||||
assert sub["번"] == 46 and sub["후보"][0]["키"] == "제46호표" and sub["판정"] == "제안 있음"
|
||||
for x in lines: # 고른 것 꼴(설계 2-1)
|
||||
for p in x["후보"]:
|
||||
assert {"갈래", "키", "이름", "단위", "입력값", "출처", "몫"} <= set(p)
|
||||
|
||||
|
||||
def test_엑셀_사전_단가표_시트(bukbu):
|
||||
lex = bukbu["사전"]
|
||||
assert lex["꼴"] == "EST"
|
||||
count = collections.Counter(x["갈래"] for x in lex["줄"])
|
||||
assert count == {
|
||||
"단가산출": 95,
|
||||
"재료": 91,
|
||||
"일위대가": 68,
|
||||
"기계": 32,
|
||||
"인력": 19,
|
||||
"유가": 4, # 자재단가 경유 · 휘발유 + 기본재료비 둘
|
||||
"환율": 3,
|
||||
}
|
||||
people = [x for x in lex["줄"] if x["갈래"] == "인력"]
|
||||
assert all(len(x["후보"]) == 1 and x["후보"][0]["키"].startswith("LB") for x in people)
|
||||
oil = next(x for x in lex["줄"] if x["갈래"] == "유가" and x["시트"] == "자재단가")
|
||||
assert oil["후보"][0]["키"] == OIL
|
||||
json.dumps(lex, ensure_ascii=False) # HP 에 둘 수 있는 꼴
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path, total", [(SABANG, 201), (GEOCHANG, 68)])
|
||||
def test_같은_EST_꼴_모든_줄_갈래_하나(path, total):
|
||||
got = _kinds(_read(path, False))
|
||||
assert sum(got.values()) == total
|
||||
assert not [k for k in got if "/" in k[0] or k[0] == "없음"]
|
||||
|
||||
|
||||
# ── 장부 짝 고른 것 꼴 ─────────────────────────────────────────────────────
|
||||
def test_장부_짝_고른_것_꼴과_옛_꼴():
|
||||
known = ledger._known()
|
||||
mat = sorted(known[1])[0]
|
||||
new = ledger.check_pair(
|
||||
{"갈래": "유가", "키": OIL, "배": 1, "이름": "경유", "단위": "L"}, "시험", known
|
||||
)
|
||||
assert new == {"갈래": "유가", "키": OIL, "입력값": {}, "배": 1, "몫": None, "이름": "경유",
|
||||
"단위": "L"} # fmt: skip
|
||||
rate = {"갈래": "율", "비목": "재료비", "기준": "재료", "배": 1}
|
||||
assert ledger.check_pair(rate, "시험", known)["기준"] == "재료"
|
||||
assert ledger.check_pair({"자재": mat, "배": 2}, "시험", known) == {
|
||||
"자재": mat, "입력값": {}, "배": 2, "몫": None} # fmt: skip
|
||||
for bad in (
|
||||
{"갈래": "유가", "키": "MT000008"},
|
||||
{"갈래": "고정", "키": "MO000002"},
|
||||
{"갈래": "인력", "키": "LB999999"},
|
||||
{"갈래": "재료", "키": mat, "입력값": {"x": 1}},
|
||||
{"갈래": "율", "비목": "재료비"},
|
||||
{"갈래": "없는갈래"},
|
||||
):
|
||||
with pytest.raises(ledger.store.StoreError) as got:
|
||||
ledger.check_pair(bad, "시험", known)
|
||||
assert got.value.status == 400
|
||||
line = {"이름": "경유", "규격": "", "단위": "ℓ", "수량": 3, "단가": {"재료비": 1376}}
|
||||
book = [("personal", "1/2", {ledger.key(line): {"갈래": "짝", "짝": [new]}})]
|
||||
out = row.judge(line, {}, {}, book, folder="x")
|
||||
assert out["판정"] == "제안 있음" and out["후보"][0]["키"] == OIL
|
||||
assert out["후보"][0]["출처"] == {"장부": "personal"}
|
||||
|
||||
|
||||
def test_환산_옮김():
|
||||
for have, want, spec in (
|
||||
("㎡", "㎡", ""),
|
||||
("cm", "m", ""),
|
||||
("㎡", "㎥", "T=20cm"),
|
||||
("개", "㎥", ""),
|
||||
):
|
||||
assert hm.factor(have, want, spec) == row.factor(have, want, spec)
|
||||
assert row.factor("cm", "m")["배"] == 0.01
|
||||
q = row.quantity({"수량": 2.5, "단위": "㎡", "규격": "T=20cm"}, "㎥", 0.5)
|
||||
assert q["배"] == 0.2 and q["우리"] == 0.25
|
||||
|
||||
|
||||
# ── 유가전력 MO ─────────────────────────────────────────────────────────
|
||||
def _oil_price() -> float:
|
||||
return float(m01.loaded()[1].get(OIL)["값"])
|
||||
|
||||
|
||||
def test_유가_후보():
|
||||
keys = [x["키"] for x in elem.oils("경유")]
|
||||
assert keys[0] == OIL and all(k.startswith("MO") for k in keys)
|
||||
assert elem.lost({"갈래": "유가", "이름": "경유"})[0]["키"] == OIL
|
||||
|
||||
|
||||
OIL_DOC = {
|
||||
"열": "rv",
|
||||
"담은로직": [
|
||||
{"로직": "GC000421", "수량": {"손": 2}},
|
||||
{
|
||||
"로직": "",
|
||||
"고정": {
|
||||
"이름": "경유",
|
||||
"단위": "L",
|
||||
"단가": {"재료비": 1376},
|
||||
"이음": {"유가": OIL},
|
||||
}, # fmt: skip
|
||||
"수량": {"손": 3},
|
||||
},
|
||||
{
|
||||
"로직": "",
|
||||
"고정": {
|
||||
"이름": "잡재료",
|
||||
"단위": "%",
|
||||
"단가": {},
|
||||
"비율": {"기준": "재료", "비목": "재료비"},
|
||||
}, # fmt: skip
|
||||
"수량": {"손": 10},
|
||||
},
|
||||
],
|
||||
"자재": [{"코드": OIL, "수량": {"손": 2}, "별도계상": "직접계산"}], # 기본 = 별도계상(합 밖)
|
||||
}
|
||||
|
||||
|
||||
def test_고정_줄_유가_이음_셈():
|
||||
doc = labor.clean(OIL_DOC)
|
||||
fx = doc["담은로직"][1]["고정"]
|
||||
assert fx["이음"] == {"노임": None, "자재": None, "기계": None, "유가": OIL}
|
||||
assert fixed.group_of(fx) == "재료"
|
||||
assert doc["자재"][0]["코드"] == OIL # 자재 목록 MO
|
||||
out = labor.calc(doc, {})
|
||||
line = out["줄"][1]
|
||||
assert line["기준"]["재료비"] == pytest.approx(_oil_price()) and line["이은"] == ["재료비"]
|
||||
assert out["자재"][0]["기준단가"] == pytest.approx(_oil_price())
|
||||
with pytest.raises(labor.store.StoreError): # 머리 틀림 = 400
|
||||
labor.clean({**OIL_DOC, "담은로직": [{**OIL_DOC["담은로직"][1], "고정": {
|
||||
**OIL_DOC["담은로직"][1]["고정"], "이음": {"유가": "MT000008"}}}]}) # fmt: skip
|
||||
old = labor.clean({"담은로직": [{"로직": "", "고정": {"이름": "a", "단가": {}}}]})
|
||||
assert old["담은로직"][0]["고정"]["이음"] == {"노임": None, "자재": None, "기계": None}
|
||||
|
||||
|
||||
def _ts(answer: dict, doc: dict, values: dict) -> dict:
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_m02_labor_calc.cjs")],
|
||||
input=json.dumps({"답": answer, "문서": doc, "값": values}, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _same(a, b) -> None:
|
||||
if isinstance(a, dict):
|
||||
assert set(a) == set(b)
|
||||
for k in a:
|
||||
_same(a[k], b[k])
|
||||
elif isinstance(a, list):
|
||||
assert len(a) == len(b)
|
||||
for x, y in zip(a, b):
|
||||
_same(x, y)
|
||||
elif isinstance(a, (int, float)) and not isinstance(a, bool):
|
||||
assert b == pytest.approx(a, rel=1e-9)
|
||||
else:
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_유가_율_고정_줄_TS_거울():
|
||||
doc = labor.clean(OIL_DOC)
|
||||
answer = labor.calc(doc, {})
|
||||
py = labor.settle(answer, doc, {})
|
||||
_same(py, _ts(answer, doc, {}))
|
||||
rate = py["줄"][2]
|
||||
oil = py["줄"][1]["반영"]["재료비"]
|
||||
mats = py["자재"][0]["반영"] or 0
|
||||
assert rate["기준"]["재료비"] == pytest.approx(oil + mats) # 묶음 재료 = 유가 줄 + 자재
|
||||
@@ -125,7 +125,7 @@ def test_736_1_항목_추가_찾기_창_하나():
|
||||
assert "material: lib.material," in pick
|
||||
find = _text(M02 / "M02_MasterTemplete_Find_Pick.ts")
|
||||
assert 'kind === "재료" && opt.material' in find and "? opt.material" in find
|
||||
assert '"logic" | "labor" | "material" | "machine" | "sk" | "pre"' in find
|
||||
assert '"logic" | "labor" | "material" | "machine" | "oil" | "sk" | "pre"' in find # 785-3 oil
|
||||
# 불러오기 엑셀 연결 = 기초(자재 아닌 줄) · 재료(자재 · 재료비 줄) · 별칭 장부 = 기초 · 재료 둘
|
||||
choose = _text(M02 / "M02_MasterTemplete_Hopyo_Choose.ts")
|
||||
assert "다른 로직 검색" not in choose and "다른 자재 검색" not in choose
|
||||
|
||||
Reference in New Issue
Block a user