Merge remote-tracking branch 'origin/dev' into sub_laptop_1
This commit is contained in:
@@ -5,8 +5,11 @@
|
||||
다음 머리 앞까지 한 블록 ·
|
||||
출처 = 「…품셈」(오타 「품줌」) 뒤 20 글자 안 절 번호 — 앞 글에 산림 · 임도 = 산림품셈 ·
|
||||
그 밖 = 건설품셈 ·
|
||||
직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로).
|
||||
견줌(`candidates`) = 같은 책 조항 같음 1.0 · 앞 마디 같음 0.5(조항이 안 맞으면 후보 아님) ·
|
||||
직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로) ·
|
||||
글(338) = 머리 뺀 몸 줄 `GLINES` 줄 · 줄마다 `GCHARS` 자 안(맞춤 지문 재료) · 산출근거 열이 빈 줄은
|
||||
그 오른쪽 ~ 돈 머리(맨 오른쪽 묶음) 앞 칸을 빈칸으로 이어 붙임(거창 꼴 · 돈 머리 없으면 `WIDE` 열).
|
||||
견줌(`candidates`) = 같은 책 조항 같음 1.0 · 윗마디 0.9(엑셀 6-2 ⊃ 우리 6-2-2) ·
|
||||
앞 마디 같음 0.5(조항이 안 맞으면 후보 아님 · `clause`) · 조항 오타 「13-114」 = 「13-11-4」 ·
|
||||
건설 공통 · 토목 덤 0.1 · 직종 겹침(보통인부 뺌) 0.5 · 단위 0.15 · 직종도 뜻 낱말도 안 겹치면 버림 ·
|
||||
같은 점수는 글자 두 개 묶음(괄호 글 포함)이 가름 · 이름 · 낱말(괄호 글 포함 · 인력 · 기계 뺌)이
|
||||
하나도 안 겹치는 근거 후보는(근거 맞음 = 조항 · 직종 · 단위 다 맞으면 빼고) 겹치는 근거 후보 뒤로 ·
|
||||
@@ -31,6 +34,8 @@ BONUS_PARTS = ("공통", "토목")
|
||||
_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
|
||||
GLINES, GCHARS, WIDE = 200, 300, 140
|
||||
_TYPOS = (("깍기", "깎기"),)
|
||||
_PLAIN_WORDS = frozenset({"인력", "기계", "장비"}) # 방법 낱말 — 「터널굴착 1발파당 인력」
|
||||
_MONEY = {"합계": "계", "노무비": "노무비", "재료비": "재료비", "경비": "경비"}
|
||||
@@ -41,6 +46,12 @@ def _flat(value: Any) -> str:
|
||||
|
||||
|
||||
# ── 엑셀 블록 읽기 ─────────────────────────────────────────────────────
|
||||
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)."""
|
||||
@@ -66,35 +77,46 @@ def sources(text: str) -> list[list[str]]:
|
||||
continue
|
||||
before = text[max(0, hit.start() - 12) : hit.start()]
|
||||
book = "산림품셈" if "산림" in before or "임도" in before else "건설품셈"
|
||||
if [book, num.group()] not in out:
|
||||
out.append([book, num.group()])
|
||||
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 _columns(rows: list[list[Any]]) -> tuple[int, int, dict[str, int]]:
|
||||
"""(자료 첫 줄, 산출근거 열, {계 · 노무비 · 재료비 · 경비: 열}) — 머리 없으면 (0, 1, {})."""
|
||||
"""(자료 첫 줄, 산출근거 열, {계 · 노무비 · 재료비 · 경비: 열}) — 머리 없으면 (0, 1, {}) ·
|
||||
돈 머리가 둘 묶음이면 맨 오른쪽(거창 꼴 · 값이 든 쪽)."""
|
||||
for at, row in enumerate(rows[:10]):
|
||||
flat = [_flat(v) for v in row]
|
||||
if "산출근거" in flat:
|
||||
money = {key: flat.index(head) for head, key in _MONEY.items() if head 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: {번, 이름, 단위, 행: [시작, 끝], 출처, 직종, 금액}}`
|
||||
"""단가산출 시트 줄 → `{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
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
continue
|
||||
head = None if text[:1].isspace() else _HEAD.match(text)
|
||||
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)),
|
||||
@@ -105,6 +127,7 @@ def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]:
|
||||
"행": [at, at],
|
||||
"출처": [],
|
||||
"직종": [],
|
||||
"글": [],
|
||||
"금액": {
|
||||
key: v
|
||||
for key, c in money.items()
|
||||
@@ -117,6 +140,8 @@ def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]:
|
||||
if block is None:
|
||||
continue
|
||||
block["행"][1] = at
|
||||
if len(block["글"]) < GLINES:
|
||||
block["글"].append(text.rstrip()[:GCHARS])
|
||||
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
|
||||
@@ -163,12 +188,16 @@ def _head(number: str) -> str:
|
||||
return number.rsplit("-", 1)[0]
|
||||
|
||||
|
||||
def _clause(theirs: list[list[str]], our: dict[str, Any]) -> tuple[float, list[str], str]:
|
||||
"""(점수, 맞은 우리 조항 글, 꼬리표) — 같은 책만."""
|
||||
def clause(theirs: list[list[str]], our: dict[str, Any]) -> tuple[float, list[str], str]:
|
||||
"""엑셀 인용 조항 `[[책, 번호]]` ↔ 우리 로직(`ours()` 한 줄) → (점수, 맞은 우리 조항 글,
|
||||
꼬리표) — 같은 책만 · 같음 1 · 윗마디 0.9(엑셀 6-2 ⊃ 우리 6-2-2) · 앞 마디 0.5."""
|
||||
nums = [n for book, n in theirs if book == our["책"]]
|
||||
same = sorted(n for n in nums if n in our["조항"])
|
||||
if same:
|
||||
return 1.0, [f"{our['구분']} {n}" for n in same], "조항 같음"
|
||||
kids = sorted({c for c in our["조항"] for n in nums if c.startswith(n + "-")})
|
||||
if kids:
|
||||
return 0.9, [f"{our['구분']} {n}" for n in kids], "윗마디 같음"
|
||||
heads = {_head(n) for n in nums}
|
||||
near = sorted(n for n in our["조항"] if _head(n) in heads)
|
||||
if near:
|
||||
@@ -206,7 +235,7 @@ def candidates(line: dict[str, Any], got: dict[str, Any]) -> list[dict[str, Any]
|
||||
our = table.get(one["로직"])
|
||||
if not our:
|
||||
continue
|
||||
score, clauses, tag = _clause(theirs, our)
|
||||
score, clauses, tag = clause(theirs, our)
|
||||
if not score:
|
||||
continue
|
||||
shared = sorted(trades & our["직종"])
|
||||
@@ -227,7 +256,7 @@ def candidates(line: dict[str, Any], got: dict[str, Any]) -> list[dict[str, Any]
|
||||
why = {"조항": clauses, "직종": shared, "단위": unit, "점수": round(score, 3)}
|
||||
theirs_loose = _loose_words(one["이름"])
|
||||
overlap = any(a in b or b in a for a in loose for b in theirs_loose)
|
||||
full = tag == "조항 같음" and unit and (bool(shared) or not trades) # 근거 맞음
|
||||
full = tag != "앞 마디 같음" and unit and (bool(shared) or not trades) # 근거 맞음
|
||||
why["까닭"] = " · ".join(tags)
|
||||
extra = {"_겹침": dice, "_낱말": overlap or full}
|
||||
scored.append((score, dice, {**hopyo_match._public(one), "근거": why, **extra}))
|
||||
|
||||
@@ -1,39 +1,44 @@
|
||||
"""M02 일위대가 실무자료 → 우리 일위대가 사전조합(PLAN 324-3).
|
||||
"""M02 일위대가 실무자료 → 우리 일위대가 사전조합(PLAN 324-3 · 338-3 · 설계 3-6 · 3-7).
|
||||
|
||||
[확인하고 사전조합 만들기] 몸 = 사용자가 체크한 줄만 —
|
||||
`줄 [{차례, 대상 [{로직|자재|호표, 입력값, 배, 몫}] | 뺌}]` · 직접 줄 호표 = `통째 {대상}`.
|
||||
몸이 비면 400(확인 없이 들어가는 길 없음).
|
||||
서버 재검사(브라우저 값을 받아 적지 않음 · 금액 · 품 칸 없음) — 차례 = 그 호표 줄 · 대상 하나 =
|
||||
돈 로직 · M01 자재품목 · 확인된 호표 중 하나 · 입력 이름 = 그 로직 입력 · 배 0 넘음 · 몫 (0, 1] ·
|
||||
호표 = 만들 층에 확인된 사전조합(없으면 409 「제N호표 먼저 확인」).
|
||||
호표 하나 = 사전조합 하나 — 이름 = 품명 + 규격 · 단위 = 엑셀 단위 · 구분 「실무자료」 ·
|
||||
상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일} ·
|
||||
만드는 층 = 부르는 층(일위대가 문서 층). 들어가는 줄(`M02_Hopyo_Match` 의 `들어감`)만:
|
||||
로직 = {로직, 별칭 = 엑셀 품명 40 자, 메모 = 「실무 0.14㎥ × 1 · 규격」, 입력값,
|
||||
수량 {손 = 엑셀 수량 × 환산}} · 같은 로직 둘째부터 = 그 줄 하나짜리 사전조합으로 감싸 참조
|
||||
(같은 로직 두 번 금지) · 자재 = {코드, 수량 {손}, 별도계상 = 엑셀 값 없음 「별도계상」 ·
|
||||
아니면 「직접계산」}(같은 자재 = 수량 더함) · 일위N = 그 호표 사전조합을 먼저 만들어 참조
|
||||
(순환 = 빠진 줄).
|
||||
같은 층 · 같은 파일 · 같은 호표 = 있던 것을 다시 씀 · `새로` = 그 키에 다시 만들어 덮음(감싼 것도).
|
||||
빠진 줄(% · 못 맞춤 · 빼기 · 환산 입력 · 추정) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가.
|
||||
상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일, 원본 [층, 주인],
|
||||
확인 {누가, 때}} · 만드는 층 = 부르는 층(일위대가 문서 층).
|
||||
로직 = {로직, 별칭 = 엑셀 품명 40 자, 메모 「실무 0.14㎥ × 1 · 규격」, 입력값,
|
||||
수량 {손 = 엑셀 수량 × 배}} · 한 줄 로직 여럿 = 같은 별칭 · 같은 로직 둘째부터 = 한 줄
|
||||
사전조합으로 감싸 참조(같은 로직 두 번 금지) ·
|
||||
자재 = {코드, 수량 {손}, 별도계상}(같은 자재 = 수량 더함) · 호표 = 확인된 사전조합 참조.
|
||||
같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 확인한 줄은 별칭 장부에 남음
|
||||
(`M02_Hopyo_Ledger` · 호표 참조 줄 뺌).
|
||||
빠진 줄(확인 안 함 · 뺌 · %) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가(참고).
|
||||
엔진 · 일위대가 계산은 그대로(`M02_Labor_Ref.Expand`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as match
|
||||
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
ORIGIN = "실무자료"
|
||||
WHY = {
|
||||
"%": "% 줄 — 우리 로직 덧줄이 셈",
|
||||
"빼기": "뺌(확정)",
|
||||
"후보": "못 맞춤 — 후보 확정 필요",
|
||||
"없음": "못 맞춤",
|
||||
}
|
||||
LINES_MAX = 500
|
||||
UNCHECKED = "확인 안 함"
|
||||
PERCENT = "% 줄 — 우리 로직 덧줄이 셈"
|
||||
WHOLE = "호표 통째로 대신"
|
||||
|
||||
|
||||
def mark(key: str, no: int, at: int | None = None) -> str:
|
||||
@@ -42,25 +47,46 @@ def mark(key: str, no: int, at: int | None = None) -> str:
|
||||
return head if at is None else f"{head} {at + 1}째 줄"
|
||||
|
||||
|
||||
def _found(origin: str) -> tuple[str, str] | None:
|
||||
"""지금 층 사전조합 가운데 본뜬 = 이 실무 표시 → (키, 판)."""
|
||||
for path in sorted((store.root() / "precombo").glob("SC-*.json")):
|
||||
def _origins(folder: Path):
|
||||
"""층 사전조합 가운데 실무자료 본뜬 → (키, 본뜬, 판)."""
|
||||
for path in sorted((Path(folder) / "precombo").glob("SC-*.json")):
|
||||
try:
|
||||
doc = json.loads(path.read_bytes())
|
||||
raw = path.read_bytes()
|
||||
doc = json.loads(raw)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
got = doc.get("본뜬") if isinstance(doc, dict) else None
|
||||
if isinstance(got, dict) and got.get("층") == ORIGIN and got.get("키") == origin:
|
||||
return path.stem, store.version_of(path.read_bytes())
|
||||
return None
|
||||
if isinstance(got, dict) and got.get("층") == ORIGIN:
|
||||
yield path.stem, got, raw
|
||||
|
||||
|
||||
def _save(doc: dict[str, Any], fresh: bool) -> tuple[dict[str, Any], bool]:
|
||||
"""(참조, 다시씀) — 있으면 그대로(`fresh` 면 그 키에 덮음) · 없으면 새 키."""
|
||||
def _same_source(got: dict[str, Any], source: refs.Here | None) -> bool:
|
||||
"""본뜬 원본 층이 같음 — 원본 없는 옛 것 · `source` None 은 가리지 않음."""
|
||||
have = got.get("원본")
|
||||
return source is None or not have or tuple(have) == tuple(source)
|
||||
|
||||
|
||||
def confirmed(folder: str | Path, source: refs.Here | None = None) -> dict[str, str]:
|
||||
"""층에 확인된 실무 사전조합 `{본뜬 키: SC 키}`(`source` = 파일 층 (층, 주인)으로 거름)."""
|
||||
return {
|
||||
got["키"]: key
|
||||
for key, got, _ in _origins(Path(folder))
|
||||
if got.get("확인") and _same_source(got, source)
|
||||
}
|
||||
|
||||
|
||||
def _save(doc: dict[str, Any]) -> tuple[dict[str, Any], bool]:
|
||||
"""(참조, 다시씀) — 같은 본뜬(키 · 원본)이 있으면 그 키에 덮음 · 없으면 새 키."""
|
||||
at = refs.here()
|
||||
have = _found(doc["본뜬"]["키"])
|
||||
if have and not fresh:
|
||||
return {"층": at[0], "주인": at[1], "키": have[0]}, True
|
||||
want = doc["본뜬"]
|
||||
have = next(
|
||||
(
|
||||
(key, store.version_of(raw))
|
||||
for key, got, raw in _origins(store.root())
|
||||
if got.get("키") == want["키"] and _same_source(got, tuple(want["원본"]))
|
||||
),
|
||||
None,
|
||||
)
|
||||
if have:
|
||||
store.write("precombo", have[0], have[1], doc)
|
||||
key = have[0]
|
||||
@@ -74,10 +100,11 @@ def _num(value: Decimal) -> int | float:
|
||||
return int(value) if value == value.to_integral_value() else float(value)
|
||||
|
||||
|
||||
def _memo(line: dict[str, Any], times: Any) -> str:
|
||||
def _memo(line: dict[str, Any], one: dict[str, Any]) -> str:
|
||||
qty = line.get("수량")
|
||||
spec = str(line.get("규격") or "").strip()
|
||||
head = f"실무 {qty if qty is not None else ''}{line.get('단위') or ''} × {times}"
|
||||
head = f"실무 {qty if qty is not None else ''}{line.get('단위') or ''} × {one['배']}"
|
||||
head += f"(몫 {one['몫']})" if one.get("몫") is not None else ""
|
||||
return f"{head} · {spec}" if spec else head
|
||||
|
||||
|
||||
@@ -90,96 +117,178 @@ def _missing(line: dict[str, Any], why: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _bad(text: str, status: int = 400) -> store.StoreError:
|
||||
return store.StoreError(status, text)
|
||||
|
||||
|
||||
class Builder:
|
||||
"""한 번 만들기 — 파일 · 층 기억 · 우리 쪽 목록을 한 번 읽고 호표마다 만든 참조를 기억."""
|
||||
"""한 번 만들기 — 파일 · 우리 쪽 목록 · 확인된 호표를 한 번 읽고 몸을 검사한 뒤 씀."""
|
||||
|
||||
def __init__(self, source: Path, folder: Path, key: str, fresh: bool) -> None:
|
||||
def __init__(self, folder: Path, source: Path, key: str, no: int, who: Any) -> None:
|
||||
self.folder = Path(folder)
|
||||
self.doc = hopyo_store.read(source, key)
|
||||
self.key, self.fresh = key, fresh
|
||||
self.memo, self.units = match.memories(folder), match.units_of(self.doc)
|
||||
self.got = match.targets()
|
||||
self.made: dict[int, dict[str, Any]] = {}
|
||||
self.missing: dict[int, list[dict[str, Any]]] = {}
|
||||
self.key, self.no, self.who = key, int(no), who
|
||||
self.source = refs.where(source)
|
||||
self.done = confirmed(folder, self.source)
|
||||
self.known = ledger._known()
|
||||
self.hopyo = next((h for h in self.doc.get("호표") or [] if int(h["번"]) == self.no), None)
|
||||
if self.hopyo is None:
|
||||
raise _bad(f"없는 호표 「제{no}호표」", 404)
|
||||
|
||||
def hopyo(self, no: int) -> dict[str, Any]:
|
||||
found = next((h for h in self.doc.get("호표") or [] if int(h["번"]) == int(no)), None)
|
||||
if found is None:
|
||||
raise store.StoreError(404, f"없는 호표 「제{no}호표」")
|
||||
return found
|
||||
# ── 재검사 ───────────────────────────────────────────────────────
|
||||
def _target(self, raw: dict[str, Any], where: str) -> dict[str, Any]:
|
||||
if raw.get("호표") is None:
|
||||
return ledger.check_pair(
|
||||
{k: v for k, v in raw.items() if k != "호표"}, where, self.known
|
||||
)
|
||||
if raw.get("로직") or raw.get("자재") or raw.get("입력값"):
|
||||
raise _bad(f"호표 대상은 호표 · 배만 「{where}」")
|
||||
child = int(raw["호표"])
|
||||
if child == self.no:
|
||||
raise _bad(f"호표가 자기를 담음 「{where}」")
|
||||
if mark(self.key, child) not in self.done:
|
||||
raise _bad(f"제{child}호표 먼저 확인", 409)
|
||||
times, part = ledger.times_of(raw, where)
|
||||
return {"호표": child, "배": times, "몫": part}
|
||||
|
||||
def build(self, no: int, trail: tuple[int, ...] = ()) -> dict[str, Any]:
|
||||
"""호표 → 사전조합 참조 — 일위N 은 먼저(순환은 빠진 줄)."""
|
||||
if no in self.made:
|
||||
return self.made[no]
|
||||
hopyo = match.match_hopyo(self.hopyo(no), self.memo, self.units, True, self.got)
|
||||
lines, mats, missing = [], {}, []
|
||||
used: set[str] = set()
|
||||
for line in hopyo["줄"]:
|
||||
got = line["맞춤"]
|
||||
if not got["들어감"]:
|
||||
conv = got.get("환산") or {}
|
||||
why = WHY.get(got["상태"]) or (
|
||||
"추정 환산 — 확정 필요" if conv.get("갈래") == "추정" else "환산 입력 필요"
|
||||
)
|
||||
missing.append(_missing(line, why))
|
||||
continue
|
||||
times = got["환산"]["배"]
|
||||
qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(times))
|
||||
target = got["대상"]
|
||||
alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None
|
||||
memo = _memo(line, times)
|
||||
if "자재" in target:
|
||||
code = target["자재"]
|
||||
def check(self, lines: list[dict[str, Any]], whole: dict[str, Any] | None) -> tuple[dict, list]:
|
||||
"""몸 재검사 → ({차례: 확인 줄}, 통째 대상) — 틀리면 400 · 하위 호표 409."""
|
||||
if whole is not None and lines:
|
||||
raise _bad("통째 · 줄 중 하나만")
|
||||
if not lines and not (whole or {}).get("대상"):
|
||||
raise _bad("확인한 줄이 없음")
|
||||
if len(lines) > LINES_MAX:
|
||||
raise _bad(f"줄은 {LINES_MAX} 개 안")
|
||||
have = self.hopyo.get("줄") or []
|
||||
out: dict[int, dict[str, Any]] = {}
|
||||
for raw in lines:
|
||||
at = int(raw.get("차례", -1))
|
||||
if not 0 <= at < len(have) or at in out:
|
||||
raise _bad(f"없는 줄 · 겹친 줄 「제{self.no}호표 {at}」")
|
||||
where = f"제{self.no}호표 {at + 1}째 줄"
|
||||
targets = raw.get("대상") or []
|
||||
if bool(raw.get("뺌")) == bool(targets):
|
||||
raise _bad(f"대상 · 뺌 중 하나만 「{where}」")
|
||||
if raw.get("뺌") and raw["뺌"] not in ledger.KINDS[1:]:
|
||||
raise _bad(f"뺌 까닭이 틀림 「{where}」")
|
||||
if len(targets) > 20:
|
||||
raise _bad(f"대상은 스물 이하 「{where}」")
|
||||
out[at] = {"뺌": raw.get("뺌")} if raw.get("뺌") else {
|
||||
"대상": [self._target(t, where) for t in targets]
|
||||
} # fmt: skip
|
||||
pairs = [self._target(t, f"제{self.no}호표 통째") for t in (whole or {}).get("대상") or []]
|
||||
if any("호표" in p for p in pairs) or len(pairs) > 20:
|
||||
raise _bad("통째 대상은 로직 · 자재만")
|
||||
return out, pairs
|
||||
|
||||
# ── 쓰기 ─────────────────────────────────────────────────────────
|
||||
def _origin(self, origin: str, stamp: str) -> dict[str, Any]:
|
||||
sure = {"누가": self.who, "때": stamp}
|
||||
file = self.doc.get("파일")
|
||||
return {
|
||||
"층": ORIGIN,
|
||||
"주인": None,
|
||||
"키": origin,
|
||||
"파일": file,
|
||||
"원본": list(self.source),
|
||||
"확인": sure,
|
||||
}
|
||||
|
||||
def build(self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]]) -> dict:
|
||||
stamp = datetime.now().isoformat(timespec="seconds")
|
||||
hopyo, file = self.hopyo, self.doc.get("파일") or ""
|
||||
lines, mats, missing, used = [], {}, [], set()
|
||||
|
||||
def put(line: dict[str, Any], one: dict[str, Any], alias: str | None) -> None:
|
||||
qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(one["배"]))
|
||||
memo = _memo(line, one)
|
||||
if "자재" in one:
|
||||
apart = "별도계상" if line.get("갈래") == "별도계상" else "직접계산"
|
||||
have = mats.get(code)
|
||||
have = mats.get(one["자재"])
|
||||
hand = qty + Decimal(str(have["수량"]["손"])) if have else qty
|
||||
mats[code] = {"코드": code, "수량": {"손": _num(hand)}, "별도계상": apart}
|
||||
continue
|
||||
if "호표" in target:
|
||||
child = int(target["호표"])
|
||||
if child in (*trail, no):
|
||||
missing.append(_missing(line, "순환 — 호표가 서로 담음"))
|
||||
continue
|
||||
ref = self.build(child, (*trail, no))
|
||||
mats[one["자재"]] = {
|
||||
"코드": one["자재"],
|
||||
"수량": {"손": _num(hand)},
|
||||
"별도계상": apart,
|
||||
}
|
||||
return
|
||||
if "호표" in one:
|
||||
ref = {
|
||||
"층": refs.here()[0],
|
||||
"주인": refs.here()[1],
|
||||
"키": self.done[mark(self.key, one["호표"])],
|
||||
}
|
||||
lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}})
|
||||
continue
|
||||
one = {"로직": target["로직"], "입력값": got.get("입력값") or {}, "메모": memo}
|
||||
if target["로직"] not in used:
|
||||
used.add(target["로직"])
|
||||
lines.append({**one, "별칭": alias, "수량": {"손": _num(qty)}})
|
||||
continue
|
||||
return
|
||||
row = {"로직": one["로직"], "입력값": one["입력값"], "메모": memo}
|
||||
if one["로직"] not in used:
|
||||
used.add(one["로직"])
|
||||
lines.append({**row, "별칭": alias, "수량": {"손": _num(qty)}})
|
||||
return
|
||||
at = int(line.get("차례", 0))
|
||||
wrap = { # 같은 로직 둘째 — 한 줄 사전조합으로 감쌈
|
||||
"이름": f"{hopyo['이름']} — {line.get('이름')}",
|
||||
"단위": target.get("단위"),
|
||||
"단위": line.get("단위") or None,
|
||||
"구분": ORIGIN,
|
||||
"상세구분": self.doc.get("파일") or "",
|
||||
"담은로직": [{**one, "별칭": alias, "수량": {"손": 1}}],
|
||||
"상세구분": file,
|
||||
"담은로직": [{**row, "별칭": alias, "수량": {"손": 1}}],
|
||||
"자재": [],
|
||||
"본뜬": self._origin(mark(self.key, no, line["차례"])),
|
||||
"본뜬": self._origin(f"{mark(self.key, self.no, at)} {one['로직']}", stamp),
|
||||
}
|
||||
ref, _ = _save(wrap, self.fresh)
|
||||
ref, _ = _save(wrap)
|
||||
lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}})
|
||||
|
||||
book: dict[str, dict[str, Any]] = {}
|
||||
if whole:
|
||||
for one in whole:
|
||||
put({"수량": 1, "단위": hopyo.get("단위"), "이름": hopyo.get("이름")}, one, None)
|
||||
book[ledger.hopyo_key(hopyo)] = self._row(hopyo, "짝", whole)
|
||||
for at, line in enumerate(hopyo.get("줄") or []):
|
||||
got = checked.get(at)
|
||||
if line.get("갈래") == "%":
|
||||
missing.append(_missing(line, PERCENT))
|
||||
continue
|
||||
if whole or got is None:
|
||||
missing.append(_missing(line, WHOLE if whole else UNCHECKED))
|
||||
continue
|
||||
if got.get("뺌"):
|
||||
missing.append(_missing(line, f"뺌 — {got['뺌']}"))
|
||||
book[ledger.key(line)] = self._row(line, got["뺌"], [])
|
||||
continue
|
||||
alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None
|
||||
for one in got["대상"]:
|
||||
put(line, one, alias)
|
||||
pairs = [p for p in got["대상"] if "호표" not in p]
|
||||
if pairs:
|
||||
book[ledger.key(line)] = self._row(line, "짝", pairs)
|
||||
name = " ".join(x for x in (hopyo["이름"], hopyo.get("규격")) if x)
|
||||
doc = {
|
||||
"이름": name,
|
||||
"단위": hopyo.get("단위") or None,
|
||||
"구분": ORIGIN,
|
||||
"상세구분": self.doc.get("파일") or "",
|
||||
"상세구분": file,
|
||||
"담은로직": lines,
|
||||
"자재": list(mats.values()),
|
||||
"본뜬": self._origin(mark(self.key, no)),
|
||||
"본뜬": self._origin(mark(self.key, self.no), stamp),
|
||||
}
|
||||
ref, again = _save(doc, self.fresh)
|
||||
self.made[no] = {**ref, "다시씀": again}
|
||||
self.missing[no] = missing
|
||||
return self.made[no]
|
||||
ref, again = _save(doc)
|
||||
wrote = ledger.write(self.folder, book, self.who, file or None)
|
||||
return {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}}
|
||||
|
||||
def _origin(self, origin: str) -> dict[str, Any]:
|
||||
return {"층": ORIGIN, "주인": None, "키": origin, "파일": self.doc.get("파일")}
|
||||
def _row(self, line: dict[str, Any], kind: str, pairs: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""장부 한 줄 — 엑셀조항 = 줄 근거 출처(있으면)."""
|
||||
origin = {"품명": line.get("이름"), "규격": line.get("규격"), "단위": line.get("단위")}
|
||||
clauses = (line.get("근거") or {}).get("출처") or []
|
||||
return {
|
||||
"원래": {k: str(v or "") for k, v in origin.items()},
|
||||
"갈래": kind,
|
||||
"짝": pairs,
|
||||
"엑셀조항": clauses if isinstance(clauses, list) else [],
|
||||
}
|
||||
|
||||
|
||||
def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]:
|
||||
"""만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가."""
|
||||
"""만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가(참고 · 판정 아님)."""
|
||||
got = refs.Expand(m01.loaded()[1])({k: ref[k] for k in ("층", "주인", "키")})
|
||||
base = got.get("기준") or {}
|
||||
ours = float(base["계"]) if "계" in base else None
|
||||
@@ -188,17 +297,18 @@ def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"엑셀": excel, "우리": ours, "차이": gap, "까닭": got.get("까닭")}
|
||||
|
||||
|
||||
def build(folder: Path, source: Path, key: str, no: int, fresh: bool = False) -> dict[str, Any]:
|
||||
"""실무 호표(`source` 층 파일) → 지금 층(`folder`) 사전조합 —
|
||||
`{참조, 이름, 다시씀, 빠진줄, 비교}`(부르는 쪽이 `store.at(folder)`)."""
|
||||
maker = Builder(source, folder, key, fresh)
|
||||
made = maker.build(int(no))
|
||||
hopyo = maker.hopyo(int(no))
|
||||
ref = {k: made[k] for k in ("층", "주인", "키")}
|
||||
return {
|
||||
"참조": ref,
|
||||
"이름": hopyo.get("이름"),
|
||||
"다시씀": made["다시씀"],
|
||||
"빠진줄": maker.missing.get(int(no), []),
|
||||
"비교": compare(ref, hopyo),
|
||||
}
|
||||
def build(
|
||||
folder: Path,
|
||||
source: Path,
|
||||
key: str,
|
||||
no: int,
|
||||
lines: list[dict[str, Any]] | None = None,
|
||||
whole: dict[str, Any] | None = None,
|
||||
who: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""실무 호표(`source` 층 파일) + 확인한 줄 → 지금 층(`folder`) 사전조합 ·
|
||||
별칭 장부 — `{참조, 이름, 다시씀, 빠진줄, 비교, 장부}`(부르는 쪽이 `store.at(folder)`)."""
|
||||
maker = Builder(folder, source, key, no, who)
|
||||
checked, pairs = maker.check(list(lines or []), whole)
|
||||
made = maker.build(checked, pairs)
|
||||
return {**made, "이름": maker.hopyo.get("이름"), "비교": compare(made["참조"], maker.hopyo)}
|
||||
|
||||
@@ -7,13 +7,19 @@
|
||||
호표 줄 = 「제N호표」 · 호표 1 단위 값 = 금액 열 · 구성 줄 = 호표 칸 빔 · 품명 있음 ·
|
||||
끝 = 다음 호표.
|
||||
갈래 = % · 호표(비고 일위N참조) · 단가산출(단산N참조) · 기계(단위 hr) · 별도계상(값 없음) ·
|
||||
자재(재료비만) · 기타 — 규격은 원문 그대로 둠.
|
||||
자재(재료비만) · 노임(단위 인 + 우리 인력 이름) · 기타 — 규격은 원문 그대로 둠.
|
||||
이음(338) = 비고 「단산 · 산근 · 단가산출 N(참조 · 호표)」 → 단산 N · 「일위 · 대가 · 일위대가 N」 →
|
||||
호표 N(빈칸 무시) · 비고 빈 줄(노무비 · 경비 단가 있음)은 품명 + 단위가 같은 블록이 하나뿐이면
|
||||
이음(줄 `이음` = 「이름」) · 하이퍼링크 안 읽음.
|
||||
STmate 꼴(「일위대가표」 · 「단가산출근거」 시트) = `M02_Hopyo_Stmate.read` ·
|
||||
문서 보안(DRM) 파일 = 400 글.
|
||||
검산 = 줄 금액 floor(단가 × 수량 × 10) / 10 · 합계 단가 = 셋 합 ·
|
||||
% 줄 = floor(바탕 × 율 / 100 × 10) / 10 · 호표 비목 = floor(줄 금액 합) · 총괄표 합계 —
|
||||
어긋남은 글 목록(막지 않음).
|
||||
행(329-1) = 호표 [제N호표 줄, 마지막 구성 줄] · 구성 줄 한 줄(엑셀 1부터) ·
|
||||
근거(330-1) = 단산N참조 줄에 「단가산출」 블록 `{출처, 직종, 행}`(`M02_Hopyo_Basis.read`) ·
|
||||
`단산` = 블록마다 `{번, 이름, 단위, 행}`.
|
||||
`단산` = 블록마다 `{번, 이름, 단위, 행, 출처, 직종, 글}`(338 · 글 = 머리 뺀 몸 줄) ·
|
||||
`꼴` = 「엑셀꼴」.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,6 +30,7 @@ from decimal import ROUND_FLOOR, Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
|
||||
from M02_MasterTemplete import M02_Hopyo_Stmate as hopyo_stmate
|
||||
|
||||
SHEET = "일위대가"
|
||||
SUMMARY = "일위대가총괄표"
|
||||
@@ -31,7 +38,9 @@ COSTS = ("노무비", "재료비", "경비")
|
||||
HEADS = ("호표", "품명", "규격", "수량", "단위", "비고")
|
||||
NO_SHEET = "일위대가 시트 없음"
|
||||
_HOPYO = re.compile(r"제\s*(\d+)\s*호표")
|
||||
_REF = re.compile(r"^(단산|일위)\s*(\d+)\s*참조$")
|
||||
_REF = re.compile(r"^(?:(단산|산근|단가산출)|(일위대가|일위|대가))(\d+)(?:참조|호표)?$")
|
||||
DRM = "문서 보안(DRM)으로 잠긴 파일 — 보안을 푼 사본으로 저장해 올림"
|
||||
_OLE = b"\xd0\xcf\x11\xe0"
|
||||
_MACHINE_UNITS = ("hr", "시간", "h")
|
||||
|
||||
|
||||
@@ -76,11 +85,15 @@ def _sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]:
|
||||
wanted = (SHEET, SUMMARY, hopyo_basis.SHEET)
|
||||
if not data:
|
||||
raise ExcelError("빈 파일")
|
||||
if b"DRMONE" in data[:256] and data[:4] not in (b"PK\x03\x04", _OLE):
|
||||
raise ExcelError(DRM)
|
||||
try:
|
||||
if data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx"):
|
||||
import openpyxl
|
||||
|
||||
book = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
|
||||
if hopyo_stmate.is_stmate([t.strip() for t in book.sheetnames]):
|
||||
wanted = hopyo_stmate.SHEETS
|
||||
out = {
|
||||
ws.title.strip(): [list(row) for row in ws.iter_rows(values_only=True)]
|
||||
for ws in book.worksheets
|
||||
@@ -142,6 +155,8 @@ def kind_of(line: dict[str, Any]) -> str:
|
||||
return "단가산출"
|
||||
if unit in _MACHINE_UNITS:
|
||||
return "기계"
|
||||
if unit == "인" and _flat(line.get("이름")) in hopyo_basis.trade_names():
|
||||
return "노임"
|
||||
if not any(price.get(k) for k in ("계", *COSTS)):
|
||||
return "별도계상"
|
||||
if price.get("재료비") and not price.get("노무비") and not price.get("경비"):
|
||||
@@ -207,10 +222,28 @@ def _summary(rows: list[list[Any]] | None) -> dict[int, Decimal]:
|
||||
return out
|
||||
|
||||
|
||||
def _by_name(raw: list[list[dict[str, Any]]], blocks: dict[int, dict[str, Any]]) -> None:
|
||||
"""비고 빈 줄(노무비 · 경비 단가 있음) → 품명 + 단위가 같은 블록이 하나뿐이면 단산 이음."""
|
||||
index: dict[tuple[str, str], list[int]] = {}
|
||||
for no, block in blocks.items():
|
||||
index.setdefault((_flat(block["이름"]), _flat(block["단위"]).lower()), []).append(no)
|
||||
for line in (one for lines in raw for one in lines):
|
||||
if line["참조"] or line["비고"] or line["갈래"] == "%":
|
||||
continue
|
||||
if not (line["단가"].get("노무비") or line["단가"].get("경비")):
|
||||
continue
|
||||
hit = index.get((_flat(line["이름"]), _flat(line["단위"]).lower())) or []
|
||||
if len(hit) == 1:
|
||||
line["참조"], line["이음"] = {"종류": "단산", "번": hit[0]}, "이름"
|
||||
line["갈래"] = kind_of(line)
|
||||
|
||||
|
||||
# ── 읽기 ──────────────────────────────────────────────────────────────
|
||||
def read_book(name: str, data: bytes) -> dict[str, Any]:
|
||||
"""엑셀 → `{시트, 호표: [...], 검산: {어긋남: [글 …], 총괄표: 맞춘 수}}` — 값은 JSON 수."""
|
||||
sheets = _sheets(name, data)
|
||||
if hopyo_stmate.is_stmate(sheets):
|
||||
return hopyo_stmate.read(sheets)
|
||||
rows = sheets.get(SHEET)
|
||||
if rows is None:
|
||||
raise ExcelError(NO_SHEET)
|
||||
@@ -237,7 +270,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
|
||||
if head or not name_ or not hopyos:
|
||||
continue
|
||||
note = _text(_cell(row, cols.get("비고")))
|
||||
ref = _REF.match(note.replace(" ", ""))
|
||||
ref = _REF.match(_flat(note))
|
||||
line = {
|
||||
"차례": len(raw[-1]),
|
||||
"이름": name_,
|
||||
@@ -247,7 +280,9 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
|
||||
"단가": _money(row, cols, 0),
|
||||
"금액": _money(row, cols, 1),
|
||||
"비고": note,
|
||||
"참조": {"종류": ref.group(1), "번": int(ref.group(2))} if ref else None,
|
||||
"참조": {"종류": "단산" if ref.group(1) else "일위", "번": int(ref.group(3))}
|
||||
if ref
|
||||
else None,
|
||||
}
|
||||
line["갈래"] = kind_of(line)
|
||||
line["행"] = hopyos[-1]["행"][1] = at
|
||||
@@ -255,6 +290,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
|
||||
if not hopyos:
|
||||
raise ExcelError(f"{SHEET} 시트에 호표(제N호표)가 없음")
|
||||
blocks = hopyo_basis.read(sheets.get(hopyo_basis.SHEET))
|
||||
_by_name(raw, blocks)
|
||||
for line in (one for lines in raw for one in lines):
|
||||
ref = line["참조"] or {}
|
||||
if ref.get("종류") == "단산" and (block := blocks.get(ref["번"])):
|
||||
@@ -270,8 +306,10 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
|
||||
{**_json(hopyo), "줄": [_json(line) for line in lines]}
|
||||
for hopyo, lines in zip(hopyos, raw, strict=True)
|
||||
]
|
||||
basis = [{k: b[k] for k in ("번", "이름", "단위", "행")} for b in blocks.values()]
|
||||
keep = ("번", "이름", "단위", "행", "출처", "직종", "글")
|
||||
basis = [{k: b[k] for k in keep} for b in blocks.values()]
|
||||
return {
|
||||
"꼴": "엑셀꼴",
|
||||
"시트": SHEET,
|
||||
"호표": out,
|
||||
"단산": basis,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""M02 일위대가 실무자료 엑셀꼴 격자(PLAN 329-1) — 올린 엑셀 「일위대가」 · 「단가산출」 시트 →
|
||||
"""M02 일위대가 실무자료 엑셀꼴 격자(PLAN 329-1) — 올린 엑셀 「일위대가」 · 「단가산출」 시트
|
||||
(STmate = 「일위대가표」 · 「단가산출근거」 · 338) →
|
||||
A00 산출근거 스프레드시트 Workbook(`A00_Common/spreadsheet/spreadsheet_types.ts` 꼴 ·
|
||||
읽기 전용 보기).
|
||||
|
||||
xls = xlrd `formatting_info` — 값 · 병합 · 열 폭(÷256 글자) ·
|
||||
행 높이(÷20 pt · 시트 기본과 다른 것만) · 서식 표(글꼴 · 굵게 · 맞춤 · 줄바꿈 · 테두리 · 채움 ·
|
||||
숫자 형식) · 틀고정(파일 값 · 없으면 머리 4행).
|
||||
xlsx = 값만 · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지.
|
||||
xlsx = 값만 · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지 · STmate 숨은 칸 글(「_x0005_`BDCOD…」) 뺌.
|
||||
결과 `{책, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` — 행 = 엑셀 1부터 [시작, 끝].
|
||||
"""
|
||||
|
||||
@@ -18,8 +19,9 @@ from openpyxl.utils import get_column_letter
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
|
||||
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel
|
||||
from M02_MasterTemplete import M02_Hopyo_Stmate as hopyo_stmate
|
||||
|
||||
SHEETS = (hopyo_excel.SHEET, hopyo_basis.SHEET)
|
||||
SHEETS = (hopyo_excel.SHEET, hopyo_basis.SHEET, hopyo_stmate.SHEET, hopyo_stmate.CALC)
|
||||
HEAD_ROWS = 4
|
||||
_HALIGN = {1: "left", 2: "center", 3: "right", 6: "centerContinuous", 7: "distributed"}
|
||||
_VALIGN = {0: "top", 1: "center"}
|
||||
@@ -192,7 +194,7 @@ def _xlsx(name: str, data: bytes) -> dict[str, Any]:
|
||||
}
|
||||
for r, row in enumerate(rows)
|
||||
for c, v in enumerate(row[:width])
|
||||
if v not in (None, "")
|
||||
if v not in (None, "") and not str(v).startswith("_x000") # STmate 숨은 칸 글
|
||||
}
|
||||
sheets.append({"id": f"s{at}", "이름": title, "칸": cells, "틀고정": {"행": HEAD_ROWS}})
|
||||
return {"서식": [{}], "시트": sheets}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"""M02 일위대가 실무자료 별칭 장부(PLAN 338-3 · 설계 3-5) — 층 `실무자료/맞춤.json` 을 키움.
|
||||
|
||||
줄 = {열쇠(품명|규격|단위 정규화): {원래 {품명, 규격, 단위},
|
||||
갈래 「짝」 · 「우리에 없음」 · 「안 씀」, 짝 [{로직|자재, 입력값, 배, 몫}],
|
||||
누가, 때, 처음파일, 엑셀조항, 판}} ·
|
||||
직접 줄 호표 통째 = 열쇠 「호표|품명|규격|단위」.
|
||||
옛 줄 {로직|자재|빼기, 입력값, 환산, 확정} = 읽을 때 새 꼴로 바꿔 읽음(빼기 = 「안 씀」 ·
|
||||
파일은 다음 쓰기 때 새 꼴).
|
||||
읽는 차례 = 같은 층 → 위 층(개인 → 회사 → 마스터) · 아래 층이 이김 · 정확 열쇠가 먼저 · 없으면
|
||||
품명|단위 느슨 열쇠(`규격다름`).
|
||||
쓰는 때 = [확인하고 사전조합 만들기] · 장부 화면 [저장]만 · 쓰기 = 그 층만(권한은 길 몫) ·
|
||||
짝은 서버가 다시 검사(돈 로직 · 그 로직 입력 이름 · M01 자재품목 · 배 0 넘음 · 몫 (0, 1]).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
mm = m01.cm.mf.mp.mm
|
||||
KINDS = ("짝", "우리에 없음", "안 씀")
|
||||
HOPYO = "호표|"
|
||||
|
||||
|
||||
# ── 열쇠 ─────────────────────────────────────────────────────────────
|
||||
def key(line: dict[str, Any]) -> str:
|
||||
"""줄 열쇠 = 품명 | 규격 | 단위(정규화 · `M02_Hopyo_Match.line_key` 와 같음)."""
|
||||
return "|".join(
|
||||
(
|
||||
mm.plain(line.get("이름")),
|
||||
mm.plain_spec(line.get("규격")),
|
||||
mm.plain_unit(line.get("단위")),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def hopyo_key(hopyo: dict[str, Any]) -> str:
|
||||
"""직접 줄 호표 통째 열쇠 — 「호표|품명|규격|단위」."""
|
||||
return HOPYO + key(hopyo)
|
||||
|
||||
|
||||
def _loose(full: str) -> str:
|
||||
"""느슨 열쇠 = 품명 | 단위(규격 뺌)."""
|
||||
head, _, rest = full.rpartition("|")
|
||||
return f"{head.rsplit('|', 1)[0]}|{rest}"
|
||||
|
||||
|
||||
# ── 옛 꼴 → 새 꼴 ─────────────────────────────────────────────────────
|
||||
def _plain_of(full: str) -> dict[str, str]:
|
||||
name, spec, unit = full.removeprefix(HOPYO).split("|")
|
||||
return {"품명": name, "규격": spec, "단위": unit}
|
||||
|
||||
|
||||
def upgrade(full: str, raw: Any) -> dict[str, Any] | None:
|
||||
"""장부 한 줄을 새 꼴로 — 옛 기억 줄 · 새 꼴 · 모르는 꼴(None)."""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
if raw.get("갈래") in KINDS:
|
||||
return {"원래": _plain_of(full), "짝": [], **raw}
|
||||
head = {"원래": _plain_of(full), "때": raw.get("확정"), "누가": None, "처음파일": None}
|
||||
if raw.get("빼기"):
|
||||
return {**head, "갈래": "안 씀", "짝": []}
|
||||
pair = {"로직": raw["로직"]} if raw.get("로직") else {"자재": raw.get("자재")}
|
||||
if not any(pair.values()):
|
||||
return None
|
||||
pair.update(입력값=raw.get("입력값") or {}, 배=raw.get("환산"), 몫=None)
|
||||
return {**head, "갈래": "짝", "짝": [pair]}
|
||||
|
||||
|
||||
def read(folder: str | Path) -> dict[str, dict[str, Any]]:
|
||||
"""한 층 장부 `{열쇠: 새 꼴 줄}`(옛 꼴은 바꿔 읽음 · 모르는 줄 뺌)."""
|
||||
out = {}
|
||||
for full, raw in hopyo_store.memory(folder).items():
|
||||
if str(full).removeprefix(HOPYO).count("|") == 2 and (got := upgrade(str(full), raw)):
|
||||
out[str(full)] = got
|
||||
return out
|
||||
|
||||
|
||||
# ── 층 ───────────────────────────────────────────────────────────────
|
||||
def chain(folder: str | Path) -> list[Path]:
|
||||
"""장부를 볼 층 폴더 — 같은 층 → 위 층(개인 → 회사 → 마스터)."""
|
||||
layer, owner = refs.where(folder)
|
||||
out = [Path(folder)]
|
||||
if layer == "personal":
|
||||
out.append(refs.folder_of("company", str(owner).split("/")[0]))
|
||||
if layer in ("personal", "company"):
|
||||
out.append(Path(store.FOLDER))
|
||||
return out
|
||||
|
||||
|
||||
def books(folder: str | Path) -> list[tuple[str, str | None, dict[str, dict[str, Any]]]]:
|
||||
"""층마다 `(층, 주인, 장부)` — 같은 층부터(줄 여럿을 찾을 때 한 번 읽어 넘김)."""
|
||||
return [(*refs.where(one), read(one)) for one in chain(folder)]
|
||||
|
||||
|
||||
def recall(
|
||||
line: dict[str, Any], folder: str | Path, got: list | None = None, whole: bool = False
|
||||
) -> dict[str, Any] | None:
|
||||
"""줄(`whole` = 호표 통째)의 장부 짝 — `{…줄, 층, 주인, 규격다름}` · 없으면 None.
|
||||
정확 열쇠(아래 층 먼저) → 품명|단위 느슨(규격다름 True · 체크 안 함)."""
|
||||
full = hopyo_key(line) if whole else key(line)
|
||||
got = books(folder) if got is None else got
|
||||
for layer, owner, book in got:
|
||||
if full in book:
|
||||
return {**book[full], "층": layer, "주인": owner, "규격다름": False}
|
||||
loose = _loose(full)
|
||||
for layer, owner, book in got:
|
||||
for one, row in book.items():
|
||||
if one.startswith(HOPYO) == whole and _loose(one) == loose:
|
||||
return {**row, "층": layer, "주인": owner, "규격다름": True}
|
||||
return None
|
||||
|
||||
|
||||
def rows(folder: str | Path, writable: bool) -> list[dict[str, Any]]:
|
||||
"""장부 화면 — 이 층 + 위 층 줄 `{열쇠, …줄, 층, 주인, 고침}`(고침 = 이 층 · 쓰기 권한)."""
|
||||
out = []
|
||||
for at, (layer, owner, book) in enumerate(books(folder)):
|
||||
for full, row in sorted(book.items()):
|
||||
out.append(
|
||||
{"열쇠": full, **row, "층": layer, "주인": owner, "고침": writable and not at}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# ── 검사 · 쓰기 ───────────────────────────────────────────────────────
|
||||
def _bad(text: str) -> store.StoreError:
|
||||
return store.StoreError(400, text)
|
||||
|
||||
|
||||
def _number(raw: Any) -> Decimal | None:
|
||||
if isinstance(raw, bool):
|
||||
return None
|
||||
try:
|
||||
got = Decimal(str(raw).replace(",", "").strip())
|
||||
except ArithmeticError:
|
||||
return None
|
||||
return got if got.is_finite() else None
|
||||
|
||||
|
||||
def _json(value: Decimal) -> int | float:
|
||||
value = value.normalize()
|
||||
return int(value) if value == value.to_integral_value() else float(value)
|
||||
|
||||
|
||||
def _known() -> tuple[set[str], set[str]]:
|
||||
"""(돈 로직 키, M01 자재품목 키) — 맞춤 목록 한 벌(`M02_Hopyo_Match.targets`)."""
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as match # 맞춤이 장부를 부름 — 늦게 읽음
|
||||
|
||||
got = match.targets()
|
||||
return {x["로직"] for x in got["로직"]}, {x["자재"] for x in got["자재"]}
|
||||
|
||||
|
||||
def input_names(code: str) -> set[str]:
|
||||
got = labor_inputs.logic_inputs([code])["로직"]
|
||||
return {str(x.get("이름")) for x in (got[0]["입력"] if got else [])}
|
||||
|
||||
|
||||
def check_pair(raw: Any, where: str, known: tuple[set[str], set[str]]) -> dict[str, Any]:
|
||||
"""짝 하나 재검사 → `{로직|자재, 입력값, 배, 몫}` — 틀리면 400."""
|
||||
if not isinstance(raw, dict):
|
||||
raise _bad(f"짝 꼴이 틀림 「{where}」")
|
||||
extra = set(raw) - {"로직", "자재", "입력값", "배", "몫", "이름", "단위"}
|
||||
if extra:
|
||||
raise _bad(f"모르는 칸 {sorted(extra)} 「{where}」")
|
||||
code, mat = str(raw.get("로직") or ""), str(raw.get("자재") or "")
|
||||
if bool(code) == bool(mat):
|
||||
raise _bad(f"돈 로직 · 우리 자재 하나만 「{where}」")
|
||||
if (code and code not in known[0]) or (mat and mat not in known[1]):
|
||||
raise _bad(f"없는 {'로직' if code else '자재'} 「{code or mat}」 「{where}」")
|
||||
values = raw.get("입력값") or {}
|
||||
if not isinstance(values, dict) or (mat and values):
|
||||
raise _bad(f"입력값이 틀림 「{where}」")
|
||||
unknown = set(values) - input_names(code) if code and values else set()
|
||||
if unknown:
|
||||
raise _bad(f"모르는 입력 {sorted(unknown)} 「{where}」")
|
||||
times, part = times_of(raw, where)
|
||||
head = {"로직": code} if code else {"자재": mat}
|
||||
return {
|
||||
**head,
|
||||
"입력값": values,
|
||||
"배": times,
|
||||
"몫": part,
|
||||
}
|
||||
|
||||
|
||||
def times_of(raw: dict[str, Any], where: str) -> tuple[int | float, int | float | None]:
|
||||
"""(배, 몫) 재검사 — 배 0 넘는 수 · 몫 없음 또는 (0, 1]."""
|
||||
times = _number(raw.get("배", 1))
|
||||
if times is None or times <= 0:
|
||||
raise _bad(f"배는 0 넘는 수 「{where}」")
|
||||
share = raw.get("몫")
|
||||
part = None if share is None else _number(share)
|
||||
if share is not None and (part is None or not 0 < part <= 1):
|
||||
raise _bad(f"몫은 0 넘고 1 이하 「{where}」")
|
||||
return _json(times), None if part is None else _json(part)
|
||||
|
||||
|
||||
def check(full: str, raw: Any, known: tuple[set[str], set[str]] | None = None) -> dict[str, Any]:
|
||||
"""장부 한 줄 재검사(장부 화면 [저장]) → 쓸 꼴(누가 · 때 빼고)."""
|
||||
if str(full).removeprefix(HOPYO).count("|") != 2 or not isinstance(raw, dict):
|
||||
raise _bad(f"장부 꼴이 틀림 「{full}」")
|
||||
kind = raw.get("갈래")
|
||||
if kind not in KINDS:
|
||||
raise _bad(f"갈래는 {' · '.join(KINDS)} 「{full}」")
|
||||
pairs = raw.get("짝") or []
|
||||
if kind != "짝" and pairs:
|
||||
raise _bad(f"「{kind}」 줄은 짝 없음 「{full}」")
|
||||
if kind == "짝" and (not isinstance(pairs, list) or not pairs or len(pairs) > 20):
|
||||
raise _bad(f"짝은 하나 넘고 스물 이하 「{full}」")
|
||||
known = known or _known()
|
||||
origin = raw.get("원래") if isinstance(raw.get("원래"), dict) else _plain_of(full)
|
||||
return {
|
||||
"원래": {k: str(origin.get(k) or "") for k in ("품명", "규격", "단위")},
|
||||
"갈래": kind,
|
||||
"짝": [check_pair(p, full, known) for p in pairs],
|
||||
"엑셀조항": raw.get("엑셀조항") if isinstance(raw.get("엑셀조항"), list) else [],
|
||||
}
|
||||
|
||||
|
||||
def _version() -> str | None:
|
||||
"""마스터 판 열쇠(가 창 `M02_Hopyo_Ours.version` — 아직 없으면 None)."""
|
||||
try:
|
||||
from M02_MasterTemplete import M02_Hopyo_Ours as ours
|
||||
except ImportError:
|
||||
return None
|
||||
return ours.version() if hasattr(ours, "version") else None
|
||||
|
||||
|
||||
def write(
|
||||
folder: str | Path,
|
||||
entries: dict[str, dict[str, Any] | None],
|
||||
who: Any,
|
||||
file: str | None = None,
|
||||
) -> int:
|
||||
"""검사 끝난 줄을 층 장부에 씀(None = 지움) — 같은 열쇠는 덮음 · 처음파일은 있던 값 둠 ·
|
||||
옛 꼴 줄은 이때 새 꼴로 → 쓴 줄 수."""
|
||||
if not entries:
|
||||
return 0
|
||||
have = read(folder)
|
||||
stamp = datetime.now().isoformat(timespec="seconds")
|
||||
version = _version()
|
||||
out: dict[str, dict[str, Any] | None] = dict(have) # 옛 꼴도 새 꼴로 다시 씀
|
||||
for full, row in entries.items():
|
||||
if row is None:
|
||||
out[full] = None
|
||||
continue
|
||||
first = (have.get(full) or {}).get("처음파일") or file
|
||||
out[full] = {**row, "누가": who, "때": stamp, "처음파일": first, "판": version}
|
||||
hopyo_store.remember(folder, out)
|
||||
return len(entries)
|
||||
|
||||
|
||||
def put(folder: str | Path, entries: Any, who: Any) -> dict[str, Any]:
|
||||
"""장부 화면 [저장] — `{열쇠: 줄 | None}` 재검사 뒤 이 층에 씀 → `{씀, 줄}`."""
|
||||
if not isinstance(entries, dict) or not entries or len(entries) > 2000:
|
||||
raise _bad("저장할 줄이 없음")
|
||||
known = _known()
|
||||
clean = {str(k): None if v is None else check(str(k), v, known) for k, v in entries.items()}
|
||||
return {"씀": write(folder, clean, who), "줄": rows(folder, True)}
|
||||
@@ -0,0 +1,264 @@
|
||||
"""M02 일위대가 실무자료 STmate 꼴 읽기(PLAN 338-2b) — 「일위대가표」 · 「단가산출근거」 시트 +
|
||||
목록표(노무비 · 중기 · 재료비 · 경비 · 단가산출근거) → 엑셀꼴과 같은 HP 꼴
|
||||
(`M02_Hopyo_Excel.read_book`).
|
||||
|
||||
일위대가표 = A 열 「제N호표」 줄(B~Z 열 「BDCOD|B…」 = 호표 코드) → 다음 줄 A 이름 · B 규격 ·
|
||||
D 단위 → 구성 줄(A 이름 · B 규격 · C 수량 · D 단위 · E 계 · G 노무비 · I 재료비 · K 경비 단가 ·
|
||||
다음 열 금액 · M 비고) → 「합계」 줄에서 끝.
|
||||
줄 코드 = M~Z 열에서 `_CODE` 꼴 첫 것(판마다 열 수가 달라도 읽힘 · 하이퍼링크 안 읽음) ·
|
||||
코드 없는 줄 = 호표 `근거글`.
|
||||
갈래 = D 단가산출(참조 단산 · 번 = 코드 숫자) · B 호표(참조 일위 · 번 = 호표 코드 표) · L 노임 ·
|
||||
X 기계 · M 자재(비고 「별산」 · 규격 「별도계상」 · 값 없음 = 별도계상) · S · W 별도계상 ·
|
||||
PR · 단위 % = % · 그 밖 기타.
|
||||
단가산출근거 = 「BDCOD|D…」 머리 → 다음 머리 · 「총계」 앞까지 한 블록 · 글 = B 열(머리 이름 줄 ·
|
||||
소계 · 계 뺌) · 자원 = M 열 「X00000_n」 줄(품 = H 열 · 이름 · 규격 = X 열 「이름 / 규격」 ·
|
||||
비면 목록표 · 기계 셋 줄(노무비 · 재료비 · 경비)은 하나로 · 절 = 그 앞 번호 절 제목) ·
|
||||
머리 이름 · 규격 · 단위 = 단가산출근거목록표 · 출처 = 블록 글 `Basis.sources` ·
|
||||
직종 = 자원 L 이름(우리 인력 · 옛이름 = 지금 이름).
|
||||
검산 안 함 — `검산 {어긋남: [], 총괄표: 0}`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
|
||||
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel # 서로 부름 — 부를 때만 씀
|
||||
|
||||
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+)?(?: →)?$")
|
||||
_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})")
|
||||
_RES = re.compile(r"^([LMXS])(\d{5})_\d+$")
|
||||
_RES_A = re.compile(r"^([LMXS]\d{5})[LMS]$") # A 열 「L00002L」
|
||||
_RES_G = re.compile(r"&([LMXS]\d{5})[LMS]&") # G 열 식 「&L00002L&」
|
||||
_SECTION = re.compile(r"^\s*(?:\d+\s*[.)]|[①-⑩])\s*(.*\S)")
|
||||
_COLS = {"합계": 4, "노무비": 6, "재료비": 8, "경비": 10}
|
||||
NOTE, CODE_COLS, RES_COL, QTY_COL, NAME_COL = 12, range(12, 26), 12, 7, 23
|
||||
_KIND = {"D": "단가산출", "B": "호표", "L": "노임", "X": "기계", "S": "별도계상", "W": "별도계상"}
|
||||
|
||||
|
||||
def is_stmate(names: Any) -> bool:
|
||||
return SHEET in names and CALC in names
|
||||
|
||||
|
||||
def _cell(row: list[Any], col: int) -> Any:
|
||||
return row[col] if col < len(row) else None
|
||||
|
||||
|
||||
def _s(value: Any) -> str:
|
||||
return hopyo_excel._text(value)
|
||||
|
||||
|
||||
def _flat(value: Any) -> str:
|
||||
return re.sub(r"\s+", "", str(value or ""))
|
||||
|
||||
|
||||
def _code(row: list[Any], cols: Any = CODE_COLS) -> str | None:
|
||||
for col in cols:
|
||||
hit = _CODE.match(_s(_cell(row, col)))
|
||||
if hit:
|
||||
return hit.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _lists(sheets: dict[str, list[list[Any]]]) -> dict[str, dict[str, str]]:
|
||||
"""목록표 → {코드: {이름, 규격, 단위}} — B 이름 · C 규격 · D 단위 · 코드 = 숨은 칸
|
||||
「COD|…」 · 없으면 코드 꼴 첫 칸(판마다 A 열 · 뒤 열)."""
|
||||
out: dict[str, dict[str, str]] = {}
|
||||
for title in LISTS:
|
||||
for row in sheets.get(title) or []:
|
||||
meta = _META.search(" ".join(_s(v) for v in row))
|
||||
code = meta.group(1) if meta else _code(row, range(len(row)))
|
||||
if code and _s(_cell(row, 1)):
|
||||
out[code] = {
|
||||
"이름": _s(row[1]),
|
||||
"규격": _s(_cell(row, 2)),
|
||||
"단위": _s(_cell(row, 3)),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
# ── 단가산출근거 블록 ──────────────────────────────────────────────────
|
||||
def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str, dict[str, Any]]:
|
||||
"""{D 코드: {번, 코드, 이름, 규격, 단위, 행, 출처, 직종, 글, 자원}}."""
|
||||
names = hopyo_basis.trade_names()
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
block: dict[str, Any] | None = None
|
||||
title = ""
|
||||
for at, row in enumerate(rows, 1):
|
||||
head = next(
|
||||
(hit for v in row[:8] if (hit := _HEAD_D.search(_s(v)))), None
|
||||
) # 머리 = G 열(판마다 B 열 글이 다름)
|
||||
if head:
|
||||
code = head.group(1)
|
||||
info = table.get(code) or {}
|
||||
block = out.setdefault(
|
||||
code,
|
||||
{
|
||||
"번": int(code[1:]),
|
||||
"코드": code,
|
||||
"이름": info.get("이름", ""),
|
||||
"규격": info.get("규격", ""),
|
||||
"단위": info.get("단위", ""),
|
||||
"행": [at, at],
|
||||
"글": [],
|
||||
"자원": [],
|
||||
},
|
||||
)
|
||||
title = ""
|
||||
continue
|
||||
if block is None:
|
||||
continue
|
||||
first = _flat(_cell(row, 0))
|
||||
if first.startswith("총계"):
|
||||
block["행"][1] = at
|
||||
block = None
|
||||
continue
|
||||
text = _s(_cell(row, 1))
|
||||
res = _RES.match(_s(_cell(row, RES_COL)))
|
||||
code = res.group(1) + res.group(2) if res else None
|
||||
if not code:
|
||||
alt = _RES_A.match(first) or _RES_G.search(_s(_cell(row, 6)))
|
||||
code = alt.group(1) if alt else None
|
||||
if not text and not code:
|
||||
continue
|
||||
block["행"][1] = at
|
||||
if _s(_cell(row, RES_COL)) == block["코드"]: # 머리 이름 줄 「이름 규격 / 단위」
|
||||
if not block["이름"]:
|
||||
name, _, unit = text.rpartition("/")
|
||||
block["이름"], block["단위"] = name.strip() or text, unit.strip()
|
||||
continue
|
||||
if text and _flat(text) not in ("소계", "계"):
|
||||
if len(block["글"]) < hopyo_basis.GLINES:
|
||||
block["글"].append(text[: hopyo_basis.GCHARS])
|
||||
if (sec := _SECTION.match(text)) and not re.match(r"^\s*\d+\.\d", text):
|
||||
title = sec.group(1).strip()
|
||||
if not code:
|
||||
continue
|
||||
last = block["자원"][-1] if block["자원"] else None
|
||||
if code[0] == "X" and last and last["코드"] == code and last["절"] == title:
|
||||
continue # 기계 노무비 · 재료비 · 경비 셋 줄 = 하나
|
||||
name, _, spec = _s(_cell(row, NAME_COL)).partition("/")
|
||||
info = table.get(code) or {}
|
||||
qty = hopyo_excel._num(_cell(row, QTY_COL))
|
||||
block["자원"].append(
|
||||
{
|
||||
"갈래": code[0],
|
||||
"코드": code,
|
||||
"이름": name.strip() or info.get("이름", ""),
|
||||
"규격": spec.strip() if name.strip() else info.get("규격", ""),
|
||||
"품": float(qty) if qty is not None else None,
|
||||
"절": title,
|
||||
}
|
||||
)
|
||||
for block in out.values():
|
||||
block["출처"] = hopyo_basis.sources(" ".join(block["글"]))
|
||||
trades = [names.get(_flat(r["이름"])) for r in block["자원"] if r["갈래"] == "L"]
|
||||
block["직종"] = list(dict.fromkeys(t for t in trades if t))
|
||||
return out
|
||||
|
||||
|
||||
# ── 일위대가표 ────────────────────────────────────────────────────────
|
||||
def _kind(code: str, line: dict[str, Any]) -> str:
|
||||
if code.startswith("PR") or _flat(line["단위"]) == "%":
|
||||
return "%"
|
||||
if code[0] == "M":
|
||||
apart = line["비고"].startswith("별산") or line["규격"] == "별도계상"
|
||||
return "별도계상" if apart or not any(line["단가"].values()) else "자재"
|
||||
return _KIND.get(code[0], "기타")
|
||||
|
||||
|
||||
def _hopyos(rows: list[list[Any]]) -> tuple[list[dict[str, Any]], list[list[dict[str, Any]]]]:
|
||||
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["갈래"] = _kind(code, line)
|
||||
line["참조"] = (
|
||||
{"종류": "단산", "번": int(code[1:]), "코드": code}
|
||||
if code[0] == "D"
|
||||
else {"종류": "일위", "번": None, "코드": code}
|
||||
if code[0] == "B"
|
||||
else None
|
||||
)
|
||||
raw[-1].append(line)
|
||||
return hopyos, raw
|
||||
|
||||
|
||||
def read(sheets: dict[str, list[list[Any]]]) -> dict[str, Any]:
|
||||
"""STmate 시트 → HP 꼴 `{꼴, 시트, 호표, 단산, 검산}`(값은 JSON 수)."""
|
||||
hopyos, raw = _hopyos(sheets.get(SHEET) or [])
|
||||
if not hopyos:
|
||||
raise hopyo_excel.ExcelError(f"{SHEET} 시트에 호표(제N호표)가 없음")
|
||||
table = _lists(sheets)
|
||||
blocks = _blocks(sheets.get(CALC) or [], table)
|
||||
by_code = {h["코드"]: h["번"] for h in hopyos if h["코드"]}
|
||||
for line in (one for lines in raw for one in lines):
|
||||
ref = line["참조"] or {}
|
||||
if ref.get("종류") == "일위":
|
||||
ref["번"] = by_code.get(ref["코드"])
|
||||
elif ref.get("종류") == "단산" and (block := blocks.get(ref["코드"])):
|
||||
line["근거"] = {k: block[k] for k in ("출처", "직종", "행")}
|
||||
out = []
|
||||
for hopyo, lines in zip(hopyos, raw, strict=True):
|
||||
for key in ("이름", "규격", "단위"):
|
||||
hopyo.setdefault(key, "")
|
||||
if not hopyo["근거글"]:
|
||||
del hopyo["근거글"]
|
||||
out.append({**hopyo_excel._json(hopyo), "줄": [hopyo_excel._json(x) for x in lines]})
|
||||
keep = ("번", "코드", "이름", "규격", "단위", "행", "출처", "직종", "글", "자원")
|
||||
return {
|
||||
"꼴": "STmate",
|
||||
"시트": SHEET,
|
||||
"호표": out,
|
||||
"단산": [{k: b[k] for k in keep} for b in blocks.values()],
|
||||
"검산": {"어긋남": [], "총괄표": 0},
|
||||
}
|
||||
@@ -4,23 +4,28 @@
|
||||
여기는 저장하지 않는 셈만 + 서고(단가승격 · 사전조합 · 50-3) 새로 · 복사(키는 서버가 줌 · 고침 ·
|
||||
지우기는 양식 길 그대로) + 개인 자재 목록 · 추가(148-2 · 개인 층만 · 품번은 서버) +
|
||||
사전조합 나무 자유 폴더(158-1 · `M02_Precombo_Folders` · 읽기 = 층 읽기 · 고침 = 층 쓰기) +
|
||||
일위대가 실무자료(324 · `M02_Hopyo_*` · 올리기 · 지우기 = 층 쓰기 · 목록 · 읽기 = 층 읽기).
|
||||
일위대가 실무자료(324 · `M02_Hopyo_*` · 올리기 · 지우기 = 층 쓰기 · 목록 · 읽기 = 층 읽기) ·
|
||||
338 = 확인한 줄만 만들기(몸 없음 400 · 옛 즉시 확정 길 없앰) · 별칭 장부 보기 · 고치기 ·
|
||||
올린 뒤 맞춤 뒤 셈(`M02_Hopyo_Fit.ensure`) · 시동 때 우리 지문 데움(`M02_Hopyo_Ours.warm`).
|
||||
층 권한 = `Router_Items._root`(`M02_Layer_Access` 한 벌).
|
||||
⚠ 등록은 `main.py`(로그인만).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build
|
||||
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel
|
||||
from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid
|
||||
from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
@@ -168,10 +173,27 @@ def new_material(
|
||||
return _call(folder, own.add, folder, body)
|
||||
|
||||
|
||||
# ── 일위대가 실무자료(324) ─────────────────────────────────────────────
|
||||
# ── 일위대가 실무자료(324 · 338) ───────────────────────────────────────
|
||||
HOPYO_MAX_BYTES = 20 * 1024 * 1024
|
||||
|
||||
|
||||
def _later(module: str, name: str):
|
||||
"""맞춤 엔진(가 창) 함수 — 아직 없으면 None(올리기 · 시동은 그대로)."""
|
||||
try:
|
||||
got = importlib.import_module(f"M02_MasterTemplete.{module}")
|
||||
except ImportError:
|
||||
return None
|
||||
return getattr(got, name, None)
|
||||
|
||||
|
||||
if (_warm := _later("M02_Hopyo_Ours", "warm")) is not None:
|
||||
_warm() # 시동 — 우리 지문 캐시(디스크) 읽어 둠
|
||||
|
||||
|
||||
def _who(session: Session) -> dict[str, Any]:
|
||||
return {"id": session.get("user_id"), "이름": session.get("name")}
|
||||
|
||||
|
||||
def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
parsed = hopyo_excel.read_book(name, data)
|
||||
@@ -181,7 +203,10 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
|
||||
grid = hopyo_grid.read(name, data, parsed)
|
||||
except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404)
|
||||
grid = None
|
||||
return hopyo_store.save(store.root(), name, parsed, who, grid)
|
||||
row = hopyo_store.save(store.root(), name, parsed, who, grid)
|
||||
if (ensure := _later("M02_Hopyo_Fit", "ensure")) is not None:
|
||||
ensure(store.root(), row["키"]) # 맞춤 뒤 셈(일감 줄 · 화면 안 기다림)
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/hopyo")
|
||||
@@ -198,9 +223,20 @@ def hopyo_picks(
|
||||
layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)
|
||||
) -> dict:
|
||||
"""[로직 추가] 「실무 호표」 줄 — 이 층 · 위 층 파일의 호표마다
|
||||
`{층, 주인, 키, 파일, 번, 이름, 규격, 단위, 맞춤}`(맞춤 n/m = 이 층 기억)."""
|
||||
`{층, 주인, 키, 파일, 번, 이름, 규격, 단위, 맞춤, 확인}`(확인 = 이 층에 확인된 사전조합)."""
|
||||
folder = _root(session, layer, owner)
|
||||
return {"줄": _call(folder, hopyo_match.picks, folder)}
|
||||
return {"줄": _call(folder, _picks, folder)}
|
||||
|
||||
|
||||
def _picks(folder: Path) -> list[dict[str, Any]]:
|
||||
rows, here, done = hopyo_match.picks(folder), refs.where(folder), {}
|
||||
for row in rows:
|
||||
src = (row["층"], row.get("주인"))
|
||||
if src not in done:
|
||||
done[src] = hopyo_build.confirmed(folder, src)
|
||||
key = done[src].get(hopyo_build.mark(row["키"], row["번"]))
|
||||
row["확인"] = {"층": here[0], "주인": here[1], "키": key} if key else None
|
||||
return rows
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/hopyo")
|
||||
@@ -217,8 +253,7 @@ def hopyo_upload(
|
||||
if len(data) > HOPYO_MAX_BYTES:
|
||||
raise HTTPException(status_code=400, detail="파일이 너무 큼(20 MB 넘음)")
|
||||
name = Path(file.filename or "실무자료").name[:120]
|
||||
who = {"id": session.get("user_id"), "이름": session.get("name")}
|
||||
return _call(folder, _upload, name, data, who)
|
||||
return _call(folder, _upload, name, data, _who(session))
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/hopyo/{key}")
|
||||
@@ -246,11 +281,17 @@ def hopyo_one(
|
||||
key: str,
|
||||
no: int,
|
||||
owner: str | None = None,
|
||||
to: Layer | None = None,
|
||||
to_owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""호표 하나 — 줄마다 맞춤(상태 · 대상 · 입력값 · 환산 · 들어감 · 후보 5)."""
|
||||
"""호표 하나 — 줄마다 맞춤(상태 · 체크 제안 · 대상 · 후보 5) · `to` = 만들 층(없으면 파일 층 ·
|
||||
호표 참조 줄 체크 · 만든 · 맞춤 엔진이 `to` 를 받을 때만)."""
|
||||
folder = _root(session, layer, owner)
|
||||
return _call(folder, hopyo_match.view, folder, key, no)
|
||||
if to is None or "to" not in inspect.signature(hopyo_match.view).parameters:
|
||||
return _call(folder, hopyo_match.view, folder, key, no)
|
||||
target = _root(session, to, to_owner)
|
||||
return _call(folder, lambda: hopyo_match.view(folder, key, no, to=target))
|
||||
|
||||
|
||||
class GuessBody(BaseModel):
|
||||
@@ -274,33 +315,44 @@ def hopyo_guess(
|
||||
return _call(folder, hopyo_match.guess, folder, key, no, body.차례, body.대상)
|
||||
|
||||
|
||||
class MemoryBody(BaseModel):
|
||||
"""`줄` = {줄 열쇠: {로직|자재, 입력값, 환산} | {빼기: true} | null(지움)}."""
|
||||
|
||||
줄: dict[str, Any]
|
||||
class _Strict(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid") # 금액 · 품 칸 = 400(서버가 셈)
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/hopyo-memory")
|
||||
def hopyo_memory(
|
||||
layer: Layer,
|
||||
body: MemoryBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""맞춤 확정 · 빼기 → 층 기억(층 쓰기) — 다음부터 같은 줄은 자동."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
return _call(folder, hopyo_match.confirm, folder, body.줄)
|
||||
class BuildTarget(_Strict):
|
||||
"""대상 하나 — 로직 · 자재 · 호표 중 하나 · 배 = 우리 수량 ÷ 엑셀 수량 · 몫 = 섞인 줄 몫."""
|
||||
|
||||
로직: str | None = None
|
||||
자재: str | None = None
|
||||
호표: int | None = None
|
||||
입력값: dict[str, Any] = Field(default_factory=dict)
|
||||
배: float = 1
|
||||
몫: float | None = None
|
||||
|
||||
|
||||
class BuildBody(BaseModel):
|
||||
"""실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` ·
|
||||
`새로` = 있던 것을 덮음."""
|
||||
class BuildLine(_Strict):
|
||||
"""확인한 줄 — `차례` = 호표 안 줄(0부터) · `대상` 목록 또는 `뺌` 까닭."""
|
||||
|
||||
차례: int
|
||||
대상: list[BuildTarget] = Field(default_factory=list)
|
||||
뺌: Literal["우리에 없음", "안 씀"] | None = None
|
||||
|
||||
|
||||
class BuildWhole(_Strict):
|
||||
대상: list[BuildTarget]
|
||||
|
||||
|
||||
class BuildBody(_Strict):
|
||||
"""실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` + 확인한 `줄` ·
|
||||
직접 줄 호표 = `통째` · 같은 본뜬은 늘 덮음(`새로` = 옛 화면 호환 · 안 씀)."""
|
||||
|
||||
층: Layer
|
||||
주인: str | None = None
|
||||
키: str
|
||||
번: int
|
||||
새로: bool = False
|
||||
줄: list[BuildLine] = Field(default_factory=list, max_length=500)
|
||||
통째: BuildWhole | None = None
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/hopyo-build")
|
||||
@@ -310,11 +362,43 @@ def hopyo_make(
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""실무 호표 → 우리 일위대가 사전조합(이 층 쓰기 · 파일 층 읽기) —
|
||||
`{참조, 이름, 다시씀, 빠진줄, 비교}`."""
|
||||
"""[확인하고 사전조합 만들기] — 확인한 줄만(이 층 쓰기 · 파일 층 읽기) · 별칭 장부 씀 →
|
||||
`{참조, 이름, 다시씀, 빠진줄, 비교, 장부}` · 몸 없음 400 · 하위 호표 확인 전 409."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
source = _root(session, body.층, body.주인)
|
||||
return _call(folder, hopyo_build.build, folder, source, body.키, body.번, body.새로)
|
||||
lines = [one.model_dump(exclude_none=True) for one in body.줄]
|
||||
whole = body.통째.model_dump(exclude_none=True) if body.통째 else None
|
||||
args = (folder, source, body.키, body.번, lines, whole, _who(session))
|
||||
return _call(folder, hopyo_build.build, *args)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/hopyo-ledger")
|
||||
def hopyo_ledger_read(
|
||||
layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)
|
||||
) -> dict:
|
||||
"""별칭 장부 — 이 층 + 위 층 줄 `{열쇠, 원래, 갈래, 짝, 누가, 때, 처음파일, 층, 주인, 고침}`
|
||||
(고침 = 이 층 · 쓰기 권한)."""
|
||||
folder = _root(session, layer, owner)
|
||||
writable = access.grant(session, layer, owner).write
|
||||
return {"줄": _call(folder, hopyo_ledger.rows, folder, writable)}
|
||||
|
||||
|
||||
class LedgerBody(_Strict):
|
||||
"""`줄` = {열쇠: 장부 줄 | null(지움)} — 이 층만 · 서버가 짝을 다시 검사."""
|
||||
|
||||
줄: dict[str, Any]
|
||||
|
||||
|
||||
@router.put("/layers/{layer}/hopyo-ledger")
|
||||
def hopyo_ledger_write(
|
||||
layer: Layer,
|
||||
body: LedgerBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""별칭 장부 [저장] — 고친 줄 · 지운 줄(층 쓰기) → `{씀, 줄}`."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
return _call(folder, hopyo_ledger.put, folder, body.줄, _who(session))
|
||||
|
||||
|
||||
@router.delete("/layers/{layer}/hopyo/{key}")
|
||||
|
||||
Reference in New Issue
Block a user