Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_8
This commit is contained in:
@@ -329,7 +329,12 @@ class Builder:
|
||||
|
||||
|
||||
def fixed_line(fx: dict, qty: Any) -> dict[str, Any]:
|
||||
"""고정 줄(705-1) → 블록 줄 `fixed` — 수량 그대로(배 1) · 이음 없는 비목 = 줄 단가."""
|
||||
"""고정 줄(705-1) → 블록 줄 `fixed` — 수량 그대로(배 1) · 이음 없는 비목 = 줄 단가 ·
|
||||
비율 줄(705-3) = `pct`(밑 = 블록 그 비목 합 · 수량 = 율)."""
|
||||
if rate := fx.get("비율"):
|
||||
name = {"name": fx.get("이름") or "", "spec": fx.get("규격") or "", "unit": "%"}
|
||||
return {**name, "kind": "pct", "part": PART[rate["비목"]],
|
||||
"base": f"part:{PART[rate['기준']]}", "qty": _f(qty)} # fmt: skip
|
||||
link = fx.get("이음") or {}
|
||||
return {
|
||||
"kind": "fixed",
|
||||
|
||||
@@ -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()}}
|
||||
|
||||
@@ -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)}}
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)})
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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("단위"),
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""705-3 실무자료 불러오기 빠짐 0 · 시트 통째 한 번 · [품셈으로 바꾸기](요구서 1장 · 4장 2 · 3 · 7).
|
||||
|
||||
- 확인 안 한 줄 · 노임 · % · 「우리에 없음」 = 고정 줄(엑셀 숫자 그대로) · 체크한 줄만 품셈 · 하위 호표 = 참조
|
||||
- % 줄 = 비율 고정 줄 — M02 settle 거울(파이썬 ↔ TS) · 사전조합 셈 · B09 `pct` 줄
|
||||
- [모두 고정으로 들여오기] — 북부청 · 실무 xlsx 넷: 빠짐 0 · 엑셀 합계 = 우리 합계(호표마다) · 줄 꼬리표 수 ·
|
||||
확인된 호표 그대로 · 이음 = 단가까지 같은 줄만 · 같은 이름 줄 차례 가름 · 몸에 숫자 칸 없음(422)
|
||||
- [품셈으로 바꾸기] 전후 — 체크 한 줄 = 품셈 · 나머지 고정 · 되돌리기(빈 몸) = 모두 고정
|
||||
층은 임시 폴더(공용 storage 안 씀).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor
|
||||
from B09_Estimation.B09_Estimation_Labor_Source import fixed_line
|
||||
from M02_MasterTemplete import M02_Hopyo_Fixed as hfix
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
# 올리기 길 시험의 `client` 마당을 같이 씀
|
||||
# ruff: noqa: F811
|
||||
from resources.tester.test_m02_hopyo_excel import NORTH, _post, client, master_hash # noqa: F401
|
||||
|
||||
|
||||
def _load(name):
|
||||
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(f"{name}.py"))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
m02 = _load("test_m02_labor_calc_mirror")
|
||||
ALL = "/api/m02/layers/{}/hopyo-build-all"
|
||||
BUILD = "/api/m02/layers/{}/hopyo-build"
|
||||
DOCS = Path(__file__).resolve().parents[2] / "resources" / "knowledge" / "original" / "실무문서"
|
||||
FILES = { # 실무 xlsx 넷 — EST 꼴 거창 · STmate 꼴 영월 · 울진 대흥 · 울진 소광
|
||||
"거창": "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx",
|
||||
"영월": "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx",
|
||||
"대흥": "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/"
|
||||
"2024년 간선임도사업(기번3).xlsx",
|
||||
"소광": "25년 산불진화임도(기번8)(울진소광)/"
|
||||
"2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx",
|
||||
}
|
||||
# (호표, 줄, 고정, 하위 참조, 이음, 엑셀 = 우리) — 영월 제22호표 = STmate 하위 호표 비목 반올림 1 원 끝수
|
||||
EXPECT = {
|
||||
"거창": (14, 68, 56, 12, 11, 14),
|
||||
"영월": (23, 138, 135, 3, 0, 22),
|
||||
"대흥": (34, 171, 143, 28, 0, 34),
|
||||
"소광": (25, 117, 98, 19, 0, 25),
|
||||
}
|
||||
|
||||
|
||||
def _fx(name, price, rate=None, unit="식"):
|
||||
out = {"이름": name, "규격": "", "단위": unit, "단가": price, "이음": {}}
|
||||
return {**out, "비율": rate} if rate else out
|
||||
|
||||
|
||||
def _all(client, key="HP-000001"):
|
||||
res = client.post(ALL.format("personal"), json={"층": "personal", "키": key})
|
||||
assert res.status_code == 200, res.text
|
||||
return res.json()
|
||||
|
||||
|
||||
def _docs():
|
||||
folder = layers.personal_dir(7, 42) / "precombo"
|
||||
return [json.loads(p.read_bytes()) for p in sorted(folder.glob("SC-*.json"))]
|
||||
|
||||
|
||||
# ── 비율 고정 줄 셈 ──────────────────────────────────────────────────────────
|
||||
def _ratio_doc():
|
||||
return labor.clean(
|
||||
{
|
||||
"열": "rv",
|
||||
"담은로직": [
|
||||
{"고정": _fx("고화매트", {"재료비": 37000}, unit="㎡"), "수량": {"손": 1}},
|
||||
{"고정": _fx("고정핀", {"재료비": 200}, unit="EA"), "수량": {"손": 4.95}},
|
||||
{"고정": _fx("인부", {"노무비": 1000}, unit="인"), "수량": None},
|
||||
{"로직": m02.PLAIN, "수량": None},
|
||||
{
|
||||
"고정": _fx("잡재료비", {}, {"기준": "재료비", "비목": "재료비"}),
|
||||
"수량": {"손": 3},
|
||||
},
|
||||
{
|
||||
"고정": _fx("공구손료", {}, {"기준": "노무비", "비목": "경비"}),
|
||||
"수량": {"손": 2},
|
||||
},
|
||||
],
|
||||
"자재": [{"코드": m02.MAT, "수량": None}],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("values", [{}, {f"자재|{m02.MAT}": "3"}, {"사석 수상고르기|㎡": 2}])
|
||||
def test_비율_고정_줄_파이썬과_TS_가_같은_답(values):
|
||||
doc = _ratio_doc()
|
||||
answer = labor.calc(doc, {})
|
||||
py = labor.settle(answer, doc, values)
|
||||
m02._same(py, m02._ts(answer, doc, values))
|
||||
assert py == labor.calc(doc, values)
|
||||
misc, tool = [ln for ln in py["줄"] if (ln.get("고정") or {}).get("비율")]
|
||||
others = [ln for ln in py["줄"] if ln not in (misc, tool)]
|
||||
under = sum((ln["반영"] or {}).get("재료비", 0) for ln in others) # 자재 = 별도계상(합 밖)
|
||||
assert misc["반영"]["재료비"] == pytest.approx(under * 3 / 100) and misc["배"] == 100
|
||||
assert misc["반영"]["노무비"] == misc["반영"]["경비"] == 0
|
||||
assert tool["반영"]["경비"] == 0 # 인부 수량 없음 = 노무비 밑 0
|
||||
assert py["반영합"]["계"] == pytest.approx(
|
||||
sum((ln["반영"] or {}).get("계", 0) for ln in py["줄"])
|
||||
)
|
||||
|
||||
|
||||
def test_비율_정리_막힘():
|
||||
bad = _fx("잡", {}, {"기준": "합계", "비목": "재료비"})
|
||||
with pytest.raises(Exception, match="비율"):
|
||||
labor.clean({"담은로직": [{"고정": bad, "수량": {"손": 1}}]})
|
||||
|
||||
|
||||
def test_B09_비율_고정_줄_pct():
|
||||
fx = _fx("잡재료비", {}, {"기준": "재료비", "비목": "재료비"})
|
||||
one = fixed_line(fx, 3)
|
||||
assert {k: one[k] for k in ("kind", "part", "base", "qty", "unit")} == {
|
||||
"kind": "pct", "part": "material", "base": "part:material", "qty": 3.0, "unit": "%"
|
||||
} # fmt: skip
|
||||
lines = [
|
||||
{"kind": "fixed", "name": "고화매트", "qty": 1, "own": {"material": 37000.0},
|
||||
"link": {"labor": None, "material": None, "machine": None}},
|
||||
{"kind": "fixed", "name": "고정핀", "qty": 4.95, "own": {"material": 200.0},
|
||||
"link": {"labor": None, "material": None, "machine": None}},
|
||||
one,
|
||||
] # fmt: skip
|
||||
for n, ln in enumerate(lines):
|
||||
ln["cid"], ln["n"] = f"{ln['kind']}:{ln['name']}#0", n
|
||||
block = {"id": "DGF", "cover": "S:f", "kind": "조합", "name": "고화매트재료비", "spec": "",
|
||||
"unit": "㎡", "cite": "", "origin": {}, "lines": lines} # fmt: skip
|
||||
out = compute_labor({"blocks": [block], "owners": {}}, {}, {})
|
||||
got = out["daega_blocks"][0]
|
||||
pct = next(ln for ln in got["lines"] if ln["kind"] == "pct")
|
||||
assert pct["amounts"]["material"] == 1139.7 # (37000 + 990) × 3 %
|
||||
assert got["blank"] == 0 and out["gaps"] == []
|
||||
|
||||
|
||||
# ── 줄 하나 → 고정 줄 ────────────────────────────────────────────────────────
|
||||
def test_같은_이름_차례_가름_합계만_재료비_금액만():
|
||||
linker, taken = hfix.Linker(), set()
|
||||
line = {"차례": 0, "이름": "잡석", "규격": "", "단위": "㎥", "수량": 2, "갈래": "자재",
|
||||
"단가": {"계": 5000, "노무비": None, "재료비": None, "경비": None}} # fmt: skip
|
||||
a = hfix.line_of(line, [], {"파일": "x", "호표": 1}, taken, linker)
|
||||
b = hfix.line_of({**line, "차례": 1}, [], {"파일": "x", "호표": 1}, taken, linker)
|
||||
assert (a["고정"]["이름"], b["고정"]["이름"]) == ("잡석", "잡석 (2)")
|
||||
assert a["고정"]["단가"] == {"노무비": 0, "재료비": 5000, "경비": 0} # 합계만 = 재료비
|
||||
assert b["고정"]["출처"] == {"파일": "x", "호표": 1, "줄": 1} and a["수량"] == {"손": 2}
|
||||
doc = labor.clean({"담은로직": [a, b]}) # 같은 줄 가름 400 안 남
|
||||
assert len(doc["담은로직"]) == 2
|
||||
only = {**line, "차례": 2, "이름": "운반", "수량": None,
|
||||
"금액": {"계": 700, "노무비": 700, "재료비": None, "경비": None}} # fmt: skip
|
||||
c = hfix.line_of(only, [], {}, taken, linker)
|
||||
assert c["수량"] == {"손": 1} and c["고정"]["단가"]["노무비"] == 700 # 금액만
|
||||
|
||||
|
||||
def test_퍼센트_밑수_다르면_금액_그대로():
|
||||
pct = {"차례": 2, "이름": "소모자재", "규격": "주재료비의", "단위": "%", "수량": 11, "갈래": "%",
|
||||
"단가": {"계": 7937.2}, "금액": {"계": 873, "노무비": 0, "재료비": 873, "경비": 0}} # fmt: skip
|
||||
got = hfix.line_of(pct, [], {}, set(), hfix.Linker())
|
||||
assert "비율" not in got["고정"] and got["고정"]["단위"] == "식"
|
||||
assert got["수량"] == {"손": 1} and got["고정"]["단가"]["재료비"] == 873
|
||||
|
||||
|
||||
# ── 시트 통째 ────────────────────────────────────────────────────────────────
|
||||
def test_북부청_모두_고정_빠짐_0_합계_같음_그대로(client):
|
||||
before = master_hash()
|
||||
assert _post(client).status_code == 200
|
||||
got = _all(client)
|
||||
assert {k: got[k] for k in ("호표", "줄", "품셈", "고정", "호표참조", "빠짐", "같음")} == {
|
||||
"호표": 68, "줄": 415, "품셈": 0, "고정": 414, "호표참조": 1, "빠짐": 0, "같음": 68
|
||||
} # fmt: skip
|
||||
assert got["실패"] == [] and got["다름"] == [] and got["그대로"] == 0
|
||||
assert got["이음"] == 13 and got["이음후보"] == 31
|
||||
docs = [d for d in _docs() if d["본뜬"]["키"].count(" ") == 1] # 호표 문서(감싼 줄 빼고)
|
||||
lines = [x for d in docs for x in d["담은로직"]]
|
||||
assert len(docs) == 68 and all(d["본뜬"]["품셈"] == [] for d in docs)
|
||||
assert sum("고정" in x for x in lines) == 414 and sum("참조" in x for x in lines) == 1
|
||||
assert sum(bool(x.get("고정", {}).get("비율")) for x in lines) == 1 # 잡재료비 3 %
|
||||
again = _all(client) # 다시 = 확인된 호표 덮지 않음
|
||||
assert again["그대로"] == 68 and again["고정"] == 0 and again["같음"] == 68
|
||||
assert master_hash() == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", list(FILES))
|
||||
def test_실무_xlsx_넷_모두_고정(client, tag):
|
||||
path = DOCS / FILES[tag]
|
||||
res = _post(client, data=path.read_bytes(), name=path.name)
|
||||
assert res.status_code == 200, res.text
|
||||
got = _all(client)
|
||||
no, rows, fixed_n, kids, links, same = EXPECT[tag]
|
||||
assert (got["호표"], got["줄"], got["고정"], got["호표참조"]) == (no, rows, fixed_n, kids)
|
||||
assert got["빠짐"] == 0 and got["품셈"] == 0 and got["실패"] == []
|
||||
assert got["고정"] + got["호표참조"] == got["줄"] # 줄 하나도 안 빠짐
|
||||
assert got["이음"] == links and got["같음"] == same
|
||||
assert all(abs(x["차이"]) <= 1 for x in got["다름"])
|
||||
|
||||
|
||||
def test_몸에_숫자_칸_없음(client):
|
||||
assert _post(client).status_code == 200
|
||||
bad = {"층": "personal", "키": "HP-000001", "금액": 1}
|
||||
assert client.post(ALL.format("personal"), json=bad).status_code == 422
|
||||
|
||||
|
||||
# ── [품셈으로 바꾸기] 전후 ────────────────────────────────────────────────────
|
||||
def test_품셈으로_바꾸기_되돌리기(client):
|
||||
assert _post(client).status_code == 200
|
||||
_all(client)
|
||||
body = {"층": "personal", "키": "HP-000001", "번": 3}
|
||||
one = {"차례": 1, "대상": [{"로직": "GF000101", "입력값": {"지역": "서울"}, "배": 1}]}
|
||||
res = client.post(BUILD.format("personal"), json={**body, "줄": [one]})
|
||||
assert res.status_code == 200, res.text
|
||||
after = res.json()
|
||||
assert after["다시씀"] is True and after["줄수"]["품셈"] == 1 and after["줄수"]["고정"] == 3
|
||||
doc = next(d for d in _docs() if d["본뜬"]["키"] == "HP-000001 제3호표")
|
||||
assert doc["본뜬"]["품셈"] == [1] and doc["담은로직"][1]["로직"] == "GF000101"
|
||||
assert after["비교"]["같음"] is False # 품셈 줄 = 우리 값(엑셀과 다름)
|
||||
view = client.get("/api/m02/layers/personal/hopyo/HP-000001/3").json()
|
||||
assert view["호표"]["만든"]["품셈"] == [1] and view["호표"]["만든"]["고정"] == 3
|
||||
back = client.post(BUILD.format("personal"), json={**body, "줄": []}).json() # 되돌리기
|
||||
assert back["줄수"]["품셈"] == 0 and back["비교"]["같음"] is True
|
||||
@@ -59,7 +59,12 @@ def test_확인한_줄만_별칭_수량_빠진_줄_장부(client):
|
||||
["personal", "7/42"],
|
||||
)
|
||||
assert origin["확인"]["누가"] == {"id": 42, "이름": "갑"} and origin["확인"]["때"]
|
||||
assert doc["담은로직"] == [
|
||||
stone, *made, soil = doc["담은로직"] # 705-3 — 확인 안 한 줄 · 「우리에 없음」 = 고정 줄
|
||||
assert stone["고정"]["단가"] == {"노무비": 0, "재료비": 1400000, "경비": 0}
|
||||
assert stone["고정"]["출처"]["줄"] == 0 and stone["수량"] == {"손": 1}
|
||||
assert soil["고정"]["이름"] == "잔토처리(인력100%)" and soil["수량"] == {"손": 0.09}
|
||||
assert origin["품셈"] == [1, 2] and got["줄수"]["고정"] == 2 and got["빠진줄"] == []
|
||||
assert made == [
|
||||
{
|
||||
"로직": "GF000101",
|
||||
"입력값": {"지역": "서울"},
|
||||
@@ -75,11 +80,7 @@ def test_확인한_줄만_별칭_수량_빠진_줄_장부(client):
|
||||
"메모": "실무 0.05㎥ × 2",
|
||||
},
|
||||
]
|
||||
assert doc["자재"] == [] # 표지석 = 몸에 없음 → 안 들어감
|
||||
assert [(x["이름"], x["금액"], x["까닭"]) for x in got["빠진줄"]] == [
|
||||
("임도표지석(받침,글새김포함)", 1400000, "확인 안 함"),
|
||||
("잔토처리(인력100%)", 3097.1, "뺌 — 우리에 없음"),
|
||||
]
|
||||
assert doc["자재"] == [] # 표지석 = 몸에 없음 → 고정 줄
|
||||
assert got["비교"]["엑셀"] == 1415169 and got["비교"]["우리"] > 0
|
||||
assert got["다시씀"] is False and got["장부"] == {"씀": 3}
|
||||
book = _book(mine)
|
||||
@@ -104,7 +105,8 @@ def test_로직_여럿_같은_별칭_감쌈_자재_몫_덮음(client):
|
||||
got = _build(client, 3, lines)
|
||||
mine = layers.personal_dir(7, 42)
|
||||
doc = _doc(mine, got["참조"]["키"])
|
||||
first, second, wrapped = doc["담은로직"]
|
||||
first, second, fx, wrapped = doc["담은로직"]
|
||||
assert fx["고정"]["출처"]["줄"] == 2 # 확인 안 한 줄 = 고정(705-3)
|
||||
assert first["별칭"] == second["별칭"] == "구조물터파기(인력100%)" # 한 줄 로직 둘 = 같은 별칭
|
||||
assert second["수량"] == {"손": 0.014} and second["메모"] == "실무 0.14㎥ × 0.1(몫 0.1) · 토사"
|
||||
assert wrapped["별칭"] == "잔토처리(인력100%)" and wrapped["수량"] == {"손": 0.09}
|
||||
@@ -119,7 +121,7 @@ def test_로직_여럿_같은_별칭_감쌈_자재_몫_덮음(client):
|
||||
files = sorted(p.name for p in (mine / "precombo").glob("*.json"))
|
||||
again = _build(client, 3, lines[:1]) # 다시 확인 = 같은 본뜬 덮음
|
||||
assert again["다시씀"] is True and again["참조"] == got["참조"]
|
||||
assert _doc(mine, got["참조"]["키"])["담은로직"] == []
|
||||
assert [x["고정"]["출처"]["줄"] for x in _doc(mine, got["참조"]["키"])["담은로직"]] == [1, 2, 3]
|
||||
assert sorted(p.name for p in (mine / "precombo").glob("*.json")) == files
|
||||
|
||||
|
||||
@@ -177,8 +179,7 @@ def test_전국_자재_키_없이_본뜬에(client):
|
||||
def test_몸_없음_재검사_400(client):
|
||||
assert _post(client).status_code == 200
|
||||
one = {"로직": "GF000101", "배": 1}
|
||||
_build(client, 3, want=400) # 옛 몸(줄 없음) = 확인한 줄이 없음
|
||||
_build(client, 3, [], want=400)
|
||||
_build(client, 3, 통째={"대상": []}, want=400) # 빈 몸 = 모두 고정(705-3 시험) · 빈 통째 = 400
|
||||
bad = [
|
||||
[{"차례": 9, "대상": [one]}], # 호표 밖 줄
|
||||
[{"차례": 1, "대상": [one]}, {"차례": 1, "대상": [one]}], # 겹친 줄
|
||||
@@ -226,7 +227,9 @@ def test_하위_호표_먼저_확인_퍼센트_빠짐_통째(client):
|
||||
ref = [{"차례": 0, "대상": [{"호표": 46, "배": 1}]}]
|
||||
assert "제46호표 먼저 확인" in _build(client, 45, ref, want=409)["detail"]
|
||||
child = _build(client, 46, [{"차례": 0, "뺌": "안 씀"}])
|
||||
assert any(x["까닭"].startswith("% 줄") for x in child["빠진줄"])
|
||||
assert [x["까닭"] for x in child["빠진줄"]] == ["뺌 — 안 씀"] # % 줄 = 비율 고정 줄(705-3)
|
||||
rate = _doc(layers.personal_dir(7, 42), child["참조"]["키"])["담은로직"][-1]["고정"]
|
||||
assert rate["비율"] == {"기준": "재료비", "비목": "재료비"}
|
||||
got = _build(client, 45, ref)
|
||||
mine = layers.personal_dir(7, 42)
|
||||
line = _doc(mine, got["참조"]["키"])["담은로직"][0]
|
||||
|
||||
@@ -113,7 +113,8 @@ def test_끝까지_확인한_줄만_별칭(client):
|
||||
k: "별칭" for k in again["철근가공조립(현장)"]["대상"][0]["입력값"]
|
||||
}
|
||||
first = _one(client, "HP-000001", no)
|
||||
assert first["만든"] == {"키": made["참조"]["키"], "확인": True}
|
||||
assert {k: first["만든"][k] for k in ("키", "확인")} == {"키": made["참조"]["키"], "확인": True}
|
||||
assert first["만든"]["품셈"] == doc["본뜬"]["품셈"] # 705-3 — 품셈 줄 차례(첫 체크)
|
||||
assert master_hash() == before
|
||||
|
||||
|
||||
@@ -137,7 +138,7 @@ def test_막는_길(client):
|
||||
"/api/m02/layers/personal/hopyo-build",
|
||||
json={"층": "personal", "키": "HP-000001", "번": 1, "줄": []},
|
||||
)
|
||||
assert empty.status_code == 400 and "확인한 줄이 없음" in empty.text
|
||||
assert empty.status_code == 200 and empty.json()["줄수"]["품셈"] == 0 # 빈 몸 = 모두 고정(705-3)
|
||||
money = client.post(
|
||||
"/api/m02/layers/personal/hopyo-build",
|
||||
json={"층": "personal", "키": "HP-000001", "번": 1, "줄": [{"차례": 1, "금액": 1}]},
|
||||
|
||||
Reference in New Issue
Block a user