Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_8

This commit is contained in:
2026-10-31 15:16:26 +09:00
15 changed files with 825 additions and 51 deletions
+141 -17
View File
@@ -2,7 +2,7 @@
[확인하고 사전조합 만들기] 몸 = 사용자가 체크한 줄만 —
`줄 [{차례, 대상 [{로직|자재|호표, 입력값, 배, 몫}] | 뺌}]` · 직접 줄 호표 = `통째 {대상}`.
몸이 비면 400(확인 없이 들어가는 길 없음).
몸이 비면 = 모두 고정(705-3 · [모두 고정으로 들여오기]).
서버 재검사(브라우저 값을 받아 적지 않음 · 금액 · 품 칸 없음) — 차례 = 그 호표 줄 · 대상 하나 =
돈 로직 · M01 자재품목 · 확인된 호표 중 하나 · 입력 이름 = 그 로직 입력 · 배 0 넘음 · 몫 (0, 1] ·
호표 = 만들 층에 확인된 사전조합(없으면 409 「제N호표 먼저 확인」).
@@ -20,19 +20,24 @@
지역은 프로젝트에서) · 장부엔 안 남김.
같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 확인한 줄은 별칭 장부에 남음
(`M02_Hopyo_Ledger` · 호표 참조 줄 뺌).
빠진 줄(확인 안 함 · 뺌 · %) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가(참고).
빠짐 0(705-3) — 확인 안 한 줄 · 노임 · % · 「우리에 없음」 줄 = 고정 줄(`M02_Hopyo_Fixed` · 엑셀 숫자
그대로) · 확인 안 한 호표 줄 = 확인된 하위 사전조합 참조(단위 같음 · 아니면 고정) · 빠진 줄 = 「안 씀」 ·
통째 대신 · 로직 자재 겹침 · 전국만. `본뜬.품셈` = 품셈으로 넣은 엑셀 줄 차례([품셈으로 바꾸기] 첫 체크) ·
`줄수 {품셈, 고정, 호표, 빠짐, 이음, 이음후보}` · 만든 값 ↔ 엑셀 호표 단가(`비교` · 같음 = 비목마다 1 원 안).
엔진 · 일위대가 계산은 그대로(`M02_Labor_Ref.Expand`).
"""
from __future__ import annotations
import json
from decimal import Decimal
from decimal import ROUND_FLOOR, ROUND_HALF_UP, Decimal
from pathlib import Path
from typing import Any
from common_util.common_util_time import utc_now_text
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Finger as finger
from M02_MasterTemplete import M02_Hopyo_Fixed as hfix
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
from M02_MasterTemplete import M02_Hopyo_Material as hmat
@@ -177,8 +182,8 @@ class Builder:
"""몸 재검사 → ({차례: 확인 줄}, 통째 대상) — 틀리면 400 · 하위 호표 409."""
if whole is not None and lines:
raise _bad("통째 · 줄 중 하나만")
if not lines and not (whole or {}).get("대상"):
raise _bad("확인한 줄이 없음")
if whole is not None and not whole.get("대상"):
raise _bad("통째 대상이 없음")
if len(lines) > LINES_MAX:
raise _bad(f"줄은 {LINES_MAX} 개 안")
have = self.hopyo.get("줄") or []
@@ -221,6 +226,9 @@ class Builder:
hopyo, file = self.hopyo, self.doc.get("파일") or ""
lines, mats, missing, used, mat_lines = [], {}, [], set(), {}
nation: dict[tuple, dict[str, Any]] = {}
taken, linker, poomsem, kids, kid_n = set(), hfix.Linker(), [], set(), 0
where = {"파일": file, "호표": self.no}
rows = hopyo.get("줄") or []
def put(line: dict[str, Any], one: dict[str, Any], alias: str | None) -> None:
qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(one["배"]))
@@ -252,6 +260,7 @@ class Builder:
mat_lines[one["자재"]] = line
return
if "호표" in one:
kids.add(one["호표"])
ref = {
"층": refs.here()[0],
"주인": refs.here()[1],
@@ -282,18 +291,23 @@ class Builder:
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 []):
for at, line in enumerate(rows):
got = checked.get(at)
if line.get("갈래") == "%":
missing.append(_missing(line, PERCENT))
if whole:
missing.append(_missing(line, WHOLE))
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['뺌']}"))
if got and got.get("뺌"):
book[ledger.key(line)] = self._row(line, got["뺌"], [])
if got and got.get("뺌") == "안 씀":
missing.append(_missing(line, f"뺌 — {got['뺌']}"))
continue
if not got or got.get("뺌"): # 빠짐 0(705-3) — 하위 호표 참조 · 아니면 고정 줄
kid = self._kid(line, kids)
kid_n += bool(kid)
others = [x for x in rows if x is not line]
lines.append(kid or hfix.line_of(line, others, where, taken, linker))
continue
poomsem.append(at)
alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None
for one in got["대상"]:
put(line, one, alias)
@@ -314,9 +328,32 @@ class Builder:
}
if nation:
doc["본뜬"]["전국자재"] = list(nation.values())
doc["본뜬"]["품셈"] = poomsem
ref, again = _save(doc)
wrote = ledger.write(self.folder, book, self.who, file or None)
return {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}}
fixed_n = sum(1 for x in lines if "고정" in x)
links = sum(1 for x in lines if any(((x.get("고정") or {}).get("이음") or {}).values()))
count = {"품셈": len(poomsem), "고정": fixed_n, "호표": kid_n}
count |= {"빠짐": sum(1 for x in missing if x["까닭"] != TWICE), "이음": links}
out = {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}}
return {**out, "줄수": {**count, "이음후보": linker.near}}
def _kid(self, line: dict[str, Any], kids: set[int]) -> dict[str, Any] | None:
"""확인 안 한 호표 줄 → 확인된 하위 사전조합 참조(단위 같음 · 같은 하위 한 번) · 아니면 None."""
ref = line.get("참조") or {}
if finger.kind_of(line) != "호표" and ref.get("종류") != "일위":
return None
no = int(ref.get("번") or 0)
made = self.done.get(mark(self.key, no))
unit = next((h.get("단위") for h in self.doc["호표"] if int(h["번"]) == no), None)
times = hopyo_match.factor(line.get("단위"), unit, line.get("규격"))["배"]
if not made or no in kids or no == self.no or times != 1:
return None
kids.add(no)
qty = Decimal(str(line.get("수량") or 0))
head = {"층": refs.here()[0], "주인": refs.here()[1], "키": made}
return {"참조": head, "별칭": str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None,
"메모": f"실무 {qty}{line.get('단위') or ''} · 제{no}호표", "수량": {"손": _num(qty)}} # fmt: skip
def _inside(self, lines: list[dict[str, Any]], mats: dict[str, dict[str, Any]]) -> list[str]:
"""로직 자재와 같은 품목(Labor_Doc `no_twice` 400 막음) — 사용자 자재에서 빼고
@@ -346,14 +383,43 @@ class Builder:
}
# 엑셀 끝수 꼴(705-3) — (줄 금액 0.1 · 합 원 자름, 계 = 줄 계 합에서) · EST = 비목 합 내림 ·
# STmate = 줄 반올림 · 계 = 줄 계 합 내림(비목 머리 값과 따로)
CUTS = ((ROUND_FLOOR, False), (ROUND_HALF_UP, True), (ROUND_HALF_UP, False))
def _excel_round(got: dict[str, Any], how: str, by_line: bool) -> Decimal | None:
"""엑셀 끝수(705-3) — 줄 금액 비목마다 0.1(`how`) · 하위 호표 단가 = 원 · 비율 줄 = 기준 × 율 ÷ 100 ·
합 = 비목마다 원(`how`) 더함 · `by_line` = 줄 계 합 원 내림 · 못 셈 줄 있으면 None."""
parts = dict.fromkeys(labor.COSTS, Decimal(0))
for one in got.get("안") or []:
if one.get("기준") is None:
return None
cut = 100 if (one.get("고정") or {}).get("비율") else 1
for k in labor.COSTS:
unit = Decimal(one["기준"][k])
unit = unit.to_integral_value(how) if "참조" in one else unit
parts[k] += (unit * one["수량"] / cut).quantize(Decimal("0.1"), how)
if by_line:
return sum(parts.values()).to_integral_value(ROUND_FLOOR)
return sum(v.to_integral_value(how) for v in parts.values())
def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]:
"""만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가(참고 · 판정 아님)."""
"""만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가(참고 · 판정 아님) — 우리 = 엑셀 끝수 규칙(내림 ·
반올림 중 엑셀과 같은 쪽 · `_excel_round`)."""
got = refs.Expand(m01.loaded()[1])({k: ref[k] for k in ("층", "주인", "키")})
base = got.get("기준") or {}
ours = float(base["계"]) if "계" in base else None
excel = (hopyo.get("금액") or {}).get("계")
cuts = [_excel_round(got, *one) for one in CUTS] if base else []
cuts = [c for c in cuts if c is not None]
cut = next(
(c for c in cuts if abs(c - Decimal(str(excel or 0))) < 1), cuts[0] if cuts else None
)
ours = float(cut if cut is not None else base["계"]) if "계" in base else None
gap = round(ours - excel, 1) if ours is not None and excel is not None else None
return {"엑셀": excel, "우리": ours, "차이": gap, "까닭": got.get("까닭")}
same = ours is not None and abs(ours - (excel or 0)) < 1 # 705-3 — 엑셀 금액 없음 = 0
return {"엑셀": excel, "우리": ours, "차이": gap, "같음": same, "까닭": got.get("까닭")}
def build(
@@ -371,3 +437,61 @@ def build(
checked, pairs = maker.check(list(lines or []), whole)
made = maker.build(checked, pairs)
return {**made, "이름": maker.hopyo.get("이름"), "비교": compare(made["참조"], maker.hopyo)}
def _order(units: dict[int, dict[str, Any]]) -> list[int]:
"""하위 호표 먼저(호표 줄 참조) · 고리는 끊음."""
out: list[int] = []
seen: set[int] = set()
def visit(no: int) -> None:
if no in seen or no not in units:
return
seen.add(no)
for line in units[no].get("줄") or []:
ref = line.get("참조") or {}
if finger.kind_of(line) == "호표" or ref.get("종류") == "일위":
visit(int(ref.get("번") or 0))
out.append(no)
for no in sorted(units):
visit(no)
return out
def build_all(folder: Path, source: Path, key: str, who: Any = None) -> dict[str, Any]:
"""[모두 고정으로 들여오기](705-3) — 시트 호표 모두 · 하위 먼저 · 줄 = 고정(하위 = 참조) ·
이미 확인된 호표는 덮지 않음(`그대로`) → `{호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보,
같음, 그대로, 다름 [{번, 이름, 엑셀, 우리, 차이}], 실패 [{번, 까닭}]}`."""
units = {int(h["번"]): h for h in hopyo_store.read(source, key).get("호표") or []}
sums = dict.fromkeys(("품셈", "고정", "호표", "빠짐", "이음", "이음후보"), 0)
out: dict[str, Any] = {
"호표": len(units),
"줄": 0,
"같음": 0,
"그대로": 0,
"다름": [],
"실패": [],
}
for no in _order(units):
out["줄"] += len(units[no].get("줄") or [])
try:
maker = Builder(folder, source, key, no, who)
have = maker.done.get(mark(key, no))
if have:
out["그대로"] += 1
ref = {"층": refs.here()[0], "주인": refs.here()[1], "키": have}
else:
made = maker.build({}, [])
ref = made["참조"]
for k in sums:
sums[k] += made["줄수"][k]
got = compare(ref, maker.hopyo)
except store.StoreError as e:
out["실패"].append({"번": no, "까닭": str(e.detail)})
continue
if got["같음"]:
out["같음"] += 1
else:
out["다름"].append({"번": no, "이름": units[no].get("이름"), **got})
return {**out, **{("호표참조" if k == "호표" else k): v for k, v in sums.items()}}
+143
View File
@@ -0,0 +1,143 @@
"""M02 실무자료 줄 → 고정 줄(PLAN 705-3 · 요구서 1장 · 4장 2 · 3 · 7) — 엑셀 숫자 그대로 · 빠짐 0.
맞춤 확인 안 한 줄 · 노임 · % · 「우리에 없음」 줄 = 빠진 줄 대신 고정 줄(705-1 그릇 `M02_Labor_Fixed`).
숫자는 서버가 실무자료 파일에서 읽음(브라우저 값 안 받음).
단가 = 엑셀 단가 노무비 · 재료비 · 경비(빈 칸 0) · 합계만 있는 줄 = 재료비(4장 3) ·
수량 없음 = 수량 1 · 단가 = 엑셀 금액(「금액만」).
이음 = 이름 · 규격 · 단위 · 단가가 다 같은 노임(LB) · 자재(MT)만 저절로 → 엑셀 합계 = 우리 합계 ·
단가가 다른 짝 = `후보` 수만(바꾸기는 705-2 「이 공사 단가로 다시 잇기」).
% 줄 = 비율 줄 `비율 {기준, 비목}` — 엑셀 밑수(단가 계) = 다른 줄 금액[기준] 합일 때만 ·
아니면(주재료비 등) 수량 1 · 「식」 · 단가 = 엑셀 금액.
같은 이름 · 규격 · 단위 둘째부터 = 이름 끝 「 (2)」(줄 가름 · 705-1 400 피함).
출처 = `{파일, 호표, 줄}`(줄 = 엑셀 줄 차례 0부터 · [품셈으로 바꾸기] 짝).
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Finger as finger
from M02_MasterTemplete import M02_Hopyo_Match as hm
from M02_MasterTemplete import M02_Hopyo_Material as hmat
from M02_MasterTemplete import M02_Hopyo_Shares as shares
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Fixed as fixed
COSTS = ("노무비", "재료비", "경비")
_BASE = re.compile(r"(?<!주)(노무비|재료비|경비|인건비)\s*의")
GAP = Decimal(1) # 밑수 · 단가 같음 판정 끝수(원)
_d, _plain = fixed._num, fixed._plain
def _parts(money: Any) -> dict[str, Decimal]:
"""엑셀 단가 · 금액 → 세 비목(빈 칸 0) · 합계만 있으면 재료비(4장 3)."""
money = money if isinstance(money, dict) else {}
out = {k: _d(money.get(k)) or Decimal(0) for k in COSTS}
total = _d(money.get("계"))
if total and not any(_d(money.get(k)) is not None for k in COSTS):
out["재료비"] = total
return out
def _ratio(line: dict[str, Any], others: list[dict[str, Any]]) -> dict[str, str] | None:
"""% 줄 → `{기준, 비목}` — 밑수 = 다른 줄 엑셀 금액[기준] 합(끝수 1 원 안) · 금액 비목 하나."""
hit = _BASE.search(f"{line.get('규격') or ''} {line.get('이름') or ''}")
under = _d((line.get("단가") or {}).get("계"))
paid = [k for k, v in _parts(line.get("금액")).items() if v]
if not hit or under is None or len(paid) != 1:
return None
base = "노무비" if hit.group(1) == "인건비" else hit.group(1)
total = sum((_parts(x.get("금액"))[base] for x in others), Decimal(0))
return {"기준": base, "비목": paid[0]} if abs(total - under) <= GAP else None
class Linker:
"""이음 — 노임(LB 이름 같음) · 자재(이름 · 규격 같은 한 품목 · 단위 같음)가 단가까지 같을 때만."""
def __init__(self) -> None:
self.whole = m01.loaded()[1]
self.got = hm.targets()
self.labor: dict[str, list[tuple[str, Decimal | None]]] = {}
for row in self.whole.numbers.get("LB", {}).values():
pair = (str(row.get("키")), _d(row.get("값")))
self.labor.setdefault(str(row.get("이름") or ""), []).append(pair)
self.near = 0 # 단가 다른 이음 후보 수
def link(self, line: dict[str, Any], price: dict[str, Decimal]) -> dict[str, str | None]:
out: dict[str, str | None] = {"노임": None, "자재": None, "기계": None}
kind = finger.kind_of(line)
if kind == "노임":
pairs = self.labor.get(shares.trade(line.get("이름")) or "", [])
out["노임"] = self._same(pairs, price["노무비"])
elif kind in hm.MATERIAL_KINDS and price["재료비"]:
out["자재"] = self._same(self._mats(line), price["재료비"])
# ponytail: 기계 이음 없음 — 기계 시간 단가는 B09 중기목록표 셈 · 같은 값 판정 못 함
return out
def _same(self, pairs: list[tuple[str, Decimal | None]], want: Decimal) -> str | None:
same = [code for code, value in pairs if value is not None and abs(value - want) < GAP]
if len(same) == 1:
return same[0]
self.near += bool(pairs)
return None
def _mats(self, line: dict[str, Any]) -> list[tuple[str, Decimal | None]]:
me, _ = hmat.clean(line)
found = hm._exact(me, "자재", self.got)
if len(found) != 1:
found = [r["one"] for r in hmat.sure(me)[0]]
out = []
for one in found:
code = one.get("자재")
if not code or hm.factor(line.get("단위"), one.get("단위"))["배"] != 1:
continue
try:
price = fixed.mm.element(self.whole, code, {}, line.get("단위") or None)[0]
except fixed.mf.FormulaError:
price = None
out.append((code, _d(price)))
return out
def line_of(
line: dict[str, Any],
others: list[dict[str, Any]],
where: dict[str, Any],
taken: set[str],
linker: Linker,
) -> dict[str, Any]:
"""엑셀 줄 하나 → 일위대가 고정 줄 `{고정, 별칭, 메모, 수량}` — `taken` = 이 호표 줄 가름(채워 감)."""
qty = _d(line.get("수량"))
spec, unit = str(line.get("규격") or "").strip(), str(line.get("단위") or "").strip()
rate = _ratio(line, others) if finger.kind_of(line) == "%" else None
memo = f"실무 {qty if qty is not None else ''}{unit} 그대로"
if rate:
price = dict.fromkeys(COSTS, Decimal(0))
elif qty is None or finger.kind_of(line) == "%": # 금액만 · 밑수 다른 % 줄
price, qty, memo = _parts(line.get("금액")), Decimal(1), f"{memo} · 금액만"
unit = "식" if finger.kind_of(line) == "%" else unit
else:
price = _parts(line.get("단가"))
name = str(line.get("이름") or "").strip() or "이름 없음"
head, n = name, 1
while fixed.fixed_id({"이름": name, "규격": spec, "단위": unit}) in taken:
n += 1
name = f"{head} ({n})"
fx = {
"이름": name,
"규격": spec,
"단위": unit,
"단가": {k: _plain(v) for k, v in price.items()},
}
fx["이음"] = dict.fromkeys(fixed.LINKS) if rate else linker.link(line, price)
fx["출처"] = {**where, "줄": int(line.get("차례", 0))}
if rate:
fx["비율"] = rate
taken.add(fixed.fixed_id(fx))
alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None
return {"고정": fx, "별칭": alias, "메모": memo, "수량": {"손": _plain(qty)}}
+12 -1
View File
@@ -21,6 +21,7 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
@@ -382,10 +383,20 @@ def hopyo_view(hopyo: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str,
if full:
made = ctx.done.get(hopyo_build.mark(ctx.key, int(hopyo["번"])))
out.update(통째=whole(hopyo, ctx), 셈=ctx.calc)
out["만든"] = {"키": made, "확인": True} if made else None
out["만든"] = {"키": made, "확인": True, **_made(ctx.to, made)} if made else None
return out
def _made(folder: Path, key: str) -> dict[str, Any]:
"""만든 사전조합의 줄 갈래(705-3) — `품셈` = 품셈으로 넣은 엑셀 줄 차례(옛 것 = None) · `고정` 줄 수."""
try:
doc = json.loads((Path(folder) / "precombo" / f"{key}.json").read_bytes())
except (OSError, ValueError):
return {"품셈": None, "고정": 0}
fixed = sum(1 for x in doc.get("담은로직") or [] if isinstance(x, dict) and "고정" in x)
return {"품셈": (doc.get("본뜬") or {}).get("품셈"), "고정": fixed}
def guess(
folder: Path, key: str, no: int, at: int, target: Any, to: Path | None = None
) -> dict[str, Any]:
+1
View File
@@ -456,6 +456,7 @@ def settle(result: dict, doc: dict, given: dict[str, Any], col: Any = None) -> d
continue
bases.append({**zero, "재료비": price, "계": price} if price is not None else None)
reflects.append({**zero, "재료비": cost, "계": cost} if cost is not None else None)
fixed.ratios(lines, reflects, bases) # 비율 고정 줄(705-3) — 다른 줄 반영합 × 율
return _json({"줄": lines, "자재": mats, "기준합": _sum(bases), "반영합": _sum(reflects)})
+36 -1
View File
@@ -7,6 +7,8 @@
(노임 새 판 때 따라감) · 품(수량)은 그대로.
M02 셈(층 · 사본) = 노임 「값」 · 자재 기준단가로 이음 · 기계 = 줄 단가(기계 시간 단가 = B09 단가 책 몫).
출처 = `{파일, 호표, 줄, 판}`(글 · 수만) · 줄 가름 = 「고정|이름|규격|단위」(짝 `Labor_Calc.ts` `lineId`).
비율 줄(705-3 · 엑셀 % 줄) = `비율 {기준, 비목}` · 수량 = 율(%) — 반영[비목] = 다른 줄 반영합[기준] × 율 ÷ 100
(`ratios` · 짝 `Labor_Calc.ts` `ratioLines` · B09 = `pct` 줄).
B09 대가 갈래 `fixed` = `B09_Estimation_Labor_Source.fixed_line` · 셈 `B09_Estimation_Calc_Labor`.
"""
@@ -76,10 +78,16 @@ def clean(fx: Any, where: str, whole: Any) -> dict[str, Any]:
if code and (code[:2] != head or not _has(whole, code)):
raise _bad(f"{where} · 고정 줄 {k} 이음 「{code}」 M01 에 없음")
link[k] = code or None
rate = fx.get("비율")
if rate is not None and not (
isinstance(rate, dict) and rate.get("기준") in COSTS and rate.get("비목") in COSTS
):
raise _bad(f"{where} · 고정 줄 비율은 {{기준, 비목}} — 노무비 · 재료비 · 경비")
src = fx.get("출처") if isinstance(fx.get("출처"), dict) else {}
kept = {k: v for k in SOURCE_KEYS if isinstance(v := src.get(k), (str, int, float))}
out = {"이름": name, "규격": str(fx.get("규격") or ""), "단위": str(fx.get("단위") or "")}
return {**out, "단가": price, "이음": link, **({"출처": kept} if kept else {})}
out = {**out, "단가": price, "이음": link, **({"출처": kept} if kept else {})}
return {**out, "비율": {k: rate[k] for k in ("기준", "비목")}} if rate else out
def _has(whole: Any, code: str) -> bool:
@@ -102,6 +110,9 @@ def line(whole: Any, fx: dict) -> dict[str, Any]:
"""고정 줄 계산 답 — 기준 = 줄 단가 · 이은 노임 · 자재는 `whole` 값(없으면 줄 단가 그대로) ·
분모 = 줄 단위 · 배 1 · `이은` = 단가 책 값을 쓴 비목."""
base = {k: _num((fx.get("단가") or {}).get(k)) or Decimal(0) for k in COSTS}
if fx.get("비율"): # 기준 · 반영 = `ratios`(settle 끝)
return {FIXED: fx, "이름": fx.get("이름"), "알약": None, "분모": "%", "배": 100,
"기준": {**base, "계": Decimal(0)}, "이은": [], "까닭": None} # fmt: skip
link, used = fx.get("이음") or {}, []
if code := link.get("노임"):
try:
@@ -121,3 +132,27 @@ def line(whole: Any, fx: dict) -> dict[str, Any]:
money = {**base, "계": sum(base.values())}
head = {FIXED: fx, "이름": fx.get("이름"), "알약": None, "분모": fx.get("단위"), "배": 1}
return {**head, "기준": money, "이은": used, "까닭": None}
def ratio_row(rate: dict, qty: Decimal | None, under: dict) -> tuple[dict, dict | None]:
"""비율 줄 (기준, 반영) — 기준[비목] = 밑[기준] · 반영 = 기준 × 율 ÷ 100(수량 없음 = None)."""
base = dict.fromkeys((*COSTS, "계"), Decimal(0))
base[rate["비목"]] = base["계"] = under[rate["기준"]]
return base, None if qty is None else {k: v * qty / 100 for k, v in base.items()}
def ratios(lines: list[dict], reflects: list[dict | None], bases: list[dict | None]) -> None:
"""비율 줄(705-3) — `settle` 끝에 · 밑 = 비율 줄 뺀 반영합(줄 · 자재) · 배 100 · 기준합엔 안 셈 ·
`lines` · `reflects` · `bases` 앞 줄 = 같은 차례(짝 `Labor_Calc.ts` `ratioLines`)."""
at = {i for i, x in enumerate(lines) if (x.get(FIXED) or {}).get("비율")}
under = {
k: sum((r[k] for i, r in enumerate(reflects) if r and i not in at), Decimal(0))
for k in COSTS
}
for i in sorted(at):
base, done = ratio_row(lines[i][FIXED]["비율"], lines[i].get("수량"), under)
lines[i], reflects[i], bases[i] = (
{**lines[i], "기준": base, "배": 100, "반영": done},
done,
None,
)
+9 -1
View File
@@ -26,6 +26,7 @@ from decimal import Decimal
from pathlib import Path
from typing import Any
from M02_MasterTemplete import M02_Labor_Fixed as fixed
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
@@ -206,7 +207,7 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]:
lines = []
for one in labor.clean({"담은로직": doc.get("담은로직") or []})["담은로직"]:
head = {"참조": one["참조"]} if "참조" in one else {"로직": one["로직"]}
head = {k: one[k] for k in ("참조", "고정") if k in one} or {"로직": one["로직"]}
if "로직" in one:
head["입력값"] = one["입력값"]
head.update({k: one[k] for k in ("켜쌓기율", "고름", "자재표시") if k in one})
@@ -461,6 +462,8 @@ class Expand:
head, sub = {"참조": ref}, self(ref, trail)
elif one.get("로직"): # 로직 줄(51-11) = 1 단위 값(기준 ÷ 배 · 승격과 같음)
head, sub = {"로직": one["로직"]}, self._logic(one, by_logic.get(one["로직"]))
elif fixed.is_fixed(one): # 고정 줄(705-3) = 줄 단가 · 비율 줄 = 끝에(`ratio_row`)
head, sub = {"고정": one["고정"]}, fixed.line(self.whole, one["고정"])
else:
continue
qty = labor._number((one.get("수량") or {}).get("손"))
@@ -494,6 +497,11 @@ class Expand:
why = why or f"「{name}」 {got['까닭']}"
continue
rows.append({k: v * qty for k, v in base.items()})
under = labor._sum(rows)
for one in inner: # 비율 고정 줄(705-3) — 밑 = 다른 줄 합
if (one.get("고정") or {}).get("비율"):
one["기준"], row = fixed.ratio_row(one["고정"]["비율"], one["수량"], under)
rows.append(row)
return {
"이름": doc.get("이름"),
"단위": doc.get("단위"),
+23
View File
@@ -382,6 +382,29 @@ def hopyo_make(
return _call(folder, hopyo_build.build, *args)
class BuildAllBody(_Strict):
"""시트 통째(705-3) — 파일 층 `층` · `주인` · `키` 만(숫자 · 줄 칸 없음 · 서버가 파일에서 읽음)."""
층: Layer
주인: str | None = None
키: str
@router.post("/layers/{layer}/hopyo-build-all")
def hopyo_make_all(
layer: Layer,
body: BuildAllBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""[모두 고정으로 들여오기] — 시트 호표 모두 고정 줄로 · 하위 먼저 · 확인된 호표 그대로 →
`{호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보, 같음, 그대로, 다름, 실패}`."""
folder = _root(session, layer, owner, write=True)
source = _root(session, body.층, body.주인)
args = (folder, source, body.키, _who(session))
return _call(folder, hopyo_build.build_all, *args)
@router.get("/layers/{layer}/hopyo-ledger")
def hopyo_ledger_read(
layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)
@@ -181,7 +181,13 @@ export interface HopyoOne extends HopyoFile {
통째?: { 대상: HopyoPart[]; 글?: string; 체크: boolean } | null;
셈?: HopyoCalc;
/** 만들 층에 이 호표로 만든 사전조합 */
만든?: { 키: string; 확인: boolean } | null;
만든?: {
키: string;
확인: boolean;
/** 705-3 — 품셈으로 넣은 엑셀 줄 차례(옛 것 = null) · 고정 줄 수 */
품셈?: number[] | null;
고정?: number;
} | null;
};
}
@@ -226,8 +232,49 @@ export interface HopyoBuilt {
이름: string;
다시씀: boolean;
빠진줄: HopyoMissing[];
비교: { 엑셀: number | null; 우리: number | null; 차이: number | null; 까닭: string | null };
비교: {
엑셀: number | null;
우리: number | null;
차이: number | null;
/** 705-3 — 엑셀 끝수 규칙으로 같음 */
같음?: boolean;
까닭: string | null;
};
장부?: { 씀: number };
/** 705-3 — 엑셀 줄 갈래 수 */
줄수?: HopyoLineCount;
}
/** 705-3 줄 갈래 수 — 품셈 · 고정 · 호표(하위 참조) · 빠짐 · 이음(저절로) · 이음후보(단가 다름) */
export interface HopyoLineCount {
품셈: number;
고정: number;
호표: number;
빠짐: number;
이음: number;
이음후보: number;
}
/** [모두 고정으로 들여오기] 결과(705-3) */
export interface HopyoBuiltAll {
호표: number;
줄: number;
품셈: number;
고정: number;
호표참조: number;
빠짐: number;
이음: number;
이음후보: number;
같음: number;
그대로: number;
다름: {
번: number;
이름: string;
엑셀: number | null;
우리: number | null;
차이: number | null;
}[];
실패: { 번: number; 까닭: string }[];
}
/** [로직 추가] 「실무 호표」 줄 하나 — 확인 = 문서 층에 확인된 사전조합 */
@@ -340,6 +387,13 @@ export const buildHopyo = (
body: JSON.stringify(통째 ? { ...from, 통째 } : { ...from, 줄 }),
});
/** [모두 고정으로 들여오기](705-3) — 시트 호표 모두 · 숫자는 서버가 파일에서 */
export const buildHopyoAll = (
target: HopyoScope,
from: { 층: Layer; 주인: string | null; 키: string },
): Promise<HopyoBuiltAll> =>
call(at(target, "/hopyo-build-all"), { method: "POST", body: JSON.stringify(from) });
export const listHopyoPicks = async (scope: HopyoScope): Promise<HopyoPickRow[]> =>
(await call<{ 줄: HopyoPickRow[] }>(at(scope, "/hopyo-picks"))).줄;
@@ -9,6 +9,9 @@
* · 대상 · 옵션을 바꾸면 서버 `guess`(저장 안 함 · 줄마다 최신 응답만)가 우리 품을 다시 셈
* · 캐시 = sessionStorage `m02-hopyo-check:{층}:{주인}:{키}:{번}`(조작 즉시 · 만들기 성공하면 지움) · 자동 저장 없음
* · 화면 잠금은 [사전조합 생성] 때만 · 쓰기 단추 = 만들 층 쓰기 권한만.
* · 705-3 — 체크 안 한 줄 = 고정 줄(엑셀 숫자 그대로) · 만든 사전조합이 있으면 첫 체크 = 그 품셈 줄(`만든.품셈`) ·
* [품셈으로 바꾸기 (n)] = 맞음 · 별칭 줄 체크 + 생성 · [모두 고정으로] = 체크 모두 끔 + 생성(되돌리기) ·
* 생성 물음에 전후 금액(품셈 = 우리 미리 셈 · 고정 = 엑셀) · 품 다른 줄 수.
* 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build` · `/labor/{열}/calc`) — 화면은 보이기 · 체크 · 고름만.
* ========================================================================== */
@@ -240,6 +243,8 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
const one = await readHopyoOne(opt.scope, opt.key, opt.no, opt.target);
if (my !== seq) return;
got = one;
const made = one.호표.만든?.품셈; // 705-3 — 만든 사전조합의 품셈 줄 = 첫 체크
if (made && !hasDraft(k)) for (const l of one.호표.줄) d.체크[l.차례] = made.includes(l.차례);
draw();
} catch (error) {
if (my === seq && !quiet) root.replaceChildren(loadingError(why(error), () => void load()));
@@ -391,21 +396,38 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
단산행: 행,
});
/** 1 단위 금액(705-3) — 품셈 줄 = 우리 미리 셈 · 고정 줄 = 엑셀 · 셈 중 = null */
function sumOf(lines: HopyoLine[], poom: (l: HopyoLine) => boolean): number | null {
let total = 0;
for (const l of lines) {
const p = poom(l) ? price(l) : null;
if (p && !p.calc && !p.fail) return null;
total += p?.calc?.반영합?.계 ?? l.금액.계 ?? 0;
}
return total;
}
async function make(h: HopyoOne["호표"]): Promise<void> {
const whole = !!h.통째 && (d.통째 ?? h.통째.체크);
const lines = whole ? [] : bodyOf(h.줄, d);
const out = leftOut(h.줄, d);
const put = lines.filter((x) => x.대상).length;
const outs = lines.length - put;
const poom = (l: HopyoLine): boolean => isChecked(l, d) && checkable(l, d) && !d.뺌[l.차례];
const now = (l: HopyoLine): boolean => (h.만든?.품셈 ?? []).includes(l.차례);
const outs = h.줄.filter((l) => isChecked(l, d) && d.뺌[l.차례] === "안 씀").length;
const put = h.줄.filter(poom);
const diff = put.filter((l) => {
const q = d.품[l.차례] ?? l.맞춤.품;
return !!q && Object.values(q.같음).some((v) => !v);
}).length;
const after = sumOf(h.줄, poom);
const known = !!h.만든?.품셈; // 옛 사전조합(품셈 줄 기록 없음) = 지금 금액 모름
const before = known ? sumOf(h.줄, now) : null;
const money =
`1 단위 ${known ? `지금 ${before === null ? "셈 중" : won(Math.round(before))} → ` : ""}` +
`바뀐 뒤 ${after === null ? "셈 중" : won(Math.round(after))} · 엑셀 ${won(h.금액.계)}`;
const ask = whole
? `호표 통째로 만듦 — ${h.통째!.대상.map(targetText).join(" · ")}`
: `넣을 줄 ${put} · 뺌 ${outs} · 빠질 줄 ${Object.values(out).reduce((a, b) => a + b, 0)}` +
(Object.keys(out).length
? `(${Object.entries(out)
.map(([a, b]) => `${a} ${b}`)
.join(" · ")})`
: "") +
" — 사전조합 생성";
: `품셈 줄 ${put.length}${diff ? `(품 다름 ${diff})` : ""} · 고정 줄 ${h.줄.length - put.length - outs}` +
` · 뺌 ${outs} — ${money}(미리 셈 · 끝수 차 있음) — 사전조합 생성`;
if (!(await showConfirmDialog(ask, "생성"))) return;
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: opt.no };
showLoadingOverlay();
@@ -498,10 +520,32 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
});
const whole = !!h.통째 && (d.통째 ?? h.통째.체크);
const go = createButton({ label: "사전조합 생성", variant: "filled", size: "sm" });
go.disabled = !whole && checked < 1;
go.setAttribute("data-hopyo-make", "");
go.addEventListener("click", () => void make(h));
tools.push(all, go);
tools.push(all);
if (h.만든 && !whole) {
// 705-3 — 고정 ↔ 품셈 바꾸기(줄 하나 = 체크 하나 + [사전조합 생성])
const poom = createButton({ label: `품셈으로 바꾸기 (${sure.length})`, size: "sm" });
poom.setAttribute("data-hopyo-to-poom", "");
poom.disabled = !sure.length;
poom.addEventListener("click", () => {
for (const l of sure) d.체크[l.차례] = true;
keep();
draw();
void make(h);
});
const back = createButton({ label: "모두 고정으로", size: "sm" });
back.setAttribute("data-hopyo-to-fixed", "");
back.disabled = !h.만든.품셈?.length;
back.addEventListener("click", () => {
for (const l of h.줄) d.체크[l.차례] = false;
keep();
draw();
void make(h);
});
tools.push(poom, back);
}
tools.push(go);
}
const head = el("div", {
className: "ui-toolbar m02-hopyo__bar",
@@ -515,7 +559,16 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
...(calc && !calc.끝
? [note(`숫자 판정 셈 중 ${calc.한}/${calc.모두}${calc.까닭 ? ` · ${calc.까닭}` : ""}`)]
: []),
...(h.만든 ? [note(`만든 사전조합 ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}`)] : []),
...(h.만든
? [
note(
`만든 사전조합 ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}` +
(h.만든.품셈
? ` · 품셈 줄 ${h.만든.품셈.length} · 고정 줄 ${h.만든.고정 ?? 0}`
: ""),
),
]
: []),
createButton({
label: "엑셀 보기",
size: "sm",
@@ -9,6 +9,8 @@
* · [저장] = 체크한 호표마다 체크 줄(확인 창 캐시 · 없으면 서버 제안)로 하위 호표부터 차례로 `/hopyo-build` —
* 물음 한 번(확인 창 안 연 호표 수) · 체크 줄 없는 호표 건너뜀 · 하위 확인 전(409)은 안내 ·
* 결과 표(사전조합 키 · 빠진 줄 · 값 차이) · `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄)
* · [모두 고정으로 들여오기](705-3) = 물음 한 번 → 서버 `/hopyo-build-all`(호표 모두 · 하위 먼저 · 엑셀 숫자 그대로 ·
* 이미 만든 호표 그대로) → 알림 「호표 n · 줄 m · 품셈 줄 a · 고정 줄 b · 빠짐 0 · 엑셀 합계 = 우리 합계 n/n」 + 다음 걸음
* · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 저장은 됨
* ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게).
* ========================================================================== */
@@ -27,12 +29,14 @@ import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
import { StaleError } from "./M02_MasterTemplete_Api_Fetch";
import {
buildHopyo,
buildHopyoAll,
deleteHopyo,
readHopyo,
readHopyoGrid,
readHopyoOne,
type HopyoBrief,
type HopyoBuilt,
type HopyoBuiltAll,
type HopyoGrid,
type HopyoOne,
type HopyoScope,
@@ -167,6 +171,9 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
const go = createButton({ label: "저장", variant: "filled", size: "sm" });
go.disabled = true;
go.setAttribute("data-hopyo-save", "");
const fixAll = createButton({ label: "모두 고정으로 들여오기", size: "sm" });
fixAll.setAttribute("data-hopyo-all-fixed", "");
fixAll.disabled = true; // 엑셀을 다 읽은 뒤 켬
const result = el("div", { className: "m02-hopyo-sheet__result" });
const right = el("div", {
className: "m02-hopyo-sheet__side",
@@ -174,7 +181,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
search,
el("div", {
className: "ui-toolbar m02-hopyo__bar",
children: [count, ...(opt.canWrite ? [go] : [])],
children: [count, ...(opt.canWrite ? [fixAll, go] : [])],
}),
list,
result,
@@ -372,6 +379,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
]);
if (closed) return;
briefs = file.호표;
fixAll.disabled = !briefs.length;
spans = grid instanceof Error ? [] : grid.호표;
paintHead(file);
paintList();
@@ -508,5 +516,60 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
}
go.addEventListener("click", () => void save());
/** 들여오기 알림 글(705-3) */
const allText = (got: HopyoBuiltAll): string =>
`호표 ${got.호표} · 줄 ${got.줄} · 품셈 줄 ${got.품셈} · 고정 줄 ${got.고정} · ` +
`하위 호표 참조 ${got.호표참조} · 빠짐 ${got.빠짐} · 엑셀 합계 = 우리 합계 ${got.같음}/${got.호표}` +
(got.그대로 ? ` · 이미 만든 호표 ${got.그대로} 그대로` : "");
/** [모두 고정으로 들여오기](705-3) — 물음 한 번 · 서버가 호표 모두를 하위부터(숫자 = 서버가 파일에서) */
async function importAll(): Promise<void> {
const ask =
`호표 ${briefs.length}개를 엑셀 숫자 그대로(고정 줄) 사전조합으로 들여옴 — ` +
"이미 만든 호표는 그대로 · 품셈 맞춤은 뒤에 호표 [수정]에서 [품셈으로 바꾸기]";
if (!(await showConfirmDialog(ask, "들여오기"))) return;
fixAll.disabled = true;
showLoadingOverlay();
try {
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key };
const got = await buildHopyoAll(target, from);
if (got.호표 > got.그대로) document.dispatchEvent(new Event(HOPYO_BUILT));
const next =
"다음 — 호표 [수정]에서 [품셈으로 바꾸기]" +
(got.이음후보
? ` · 이음 후보 ${got.이음후보} 줄(이름은 같고 단가가 다름)은 엑셀 단가 그대로 — 「이 공사 단가로 다시 잇기」에서 바꿈`
: "");
const rows = [
...got.다름.map((x) =>
el("tr", {
children: [
el("td", { text: `제${x.번}호표 ${x.이름}` }),
el("td", {
text: `엑셀 ${won(x.엑셀)} · 우리 ${won(x.우리)}`,
className: "m02-hopyo__num",
}),
el("td", { text: won(x.차이), className: "m02-hopyo__num" }),
],
}),
),
...got.실패.map((x) =>
el("tr", { children: [el("td", { text: `제${x.번}호표` }), el("td", { text: x.까닭 })] }),
),
];
result.replaceChildren(
note(allText(got)),
note(next),
...(rows.length ? [table(["호표", "합계 다름 · 실패", "차이"], rows)] : []),
);
showToast(allText(got), got.빠짐 || got.실패.length ? "warning" : "success");
} catch (error) {
showToast(`실패 — ${why(error)}`, "error");
} finally {
hideLoadingOverlay();
fixAll.disabled = false;
}
}
fixAll.addEventListener("click", () => void importAll());
void load();
}
@@ -33,7 +33,10 @@ export interface LaborFixed {
단가: Record<"노무비" | "재료비" | "경비", number>;
이음?: Record<"노임" | "자재" | "기계", string | null>;
출처?: Record<string, string | number>;
/** 비율 줄(705-3 · 엑셀 % 줄) — 수량 = 율 · 반영[비목] = 다른 줄 반영합[기준] × 율 ÷ 100 */
비율?: { 기준: CostKey; 비목: CostKey };
}
type CostKey = "노무비" | "재료비" | "경비";
export interface CalcLine {
/** 참조 줄은 서버 답에 없음(빈 글로 다룸) */
@@ -220,5 +223,26 @@ export function settle(
if (cost !== null && !aside.has(one.코드)) add(done, { ...zero(), 재료비: cost, 계: cost });
return { ...one, 수량: qty, 수량출처: source, 반영: cost };
});
return { 줄, 자재, 기준합: base, 반영합: done };
return { 줄: ratioLines(줄, done, base), 자재, 기준합: base, 반영합: done };
}
/** 비율 고정 줄(705-3 · 짝 `M02_Labor_Fixed.ratios`) — 밑 = 비율 줄 뺀 반영합 · 기준[비목] = 밑[기준] ·
* 배 100 · 반영 = 기준 × 율 ÷ 100 · `done` 에 더함 · 기준합(`base`)엔 안 셈 */
function ratioLines(lines: CalcLine[], done: Money, base: Money): CalcLine[] {
for (const l of lines.filter((x) => x.고정?.비율)) {
for (const k of MONEY_KEYS) {
done[k] -= l.반영?.[k] ?? 0;
base[k] -= l.기준?.[k] ?? 0;
}
}
const under = { ...done };
return lines.map((line) => {
const rate = line.고정?.비율;
if (!rate) return line;
const own = zero();
own[rate.비목] = own.계 = under[rate.기준];
const got = reflect(own, line.수량 ?? null, 100);
if (got) for (const k of MONEY_KEYS) done[k] += got[k];
return { ...line, 기준: own, 배: 100, 반영: got };
});
}