feat(b09): 설계내역서 자유 편집 셈 · 저장 — user.bill v2 덮개 · 차례 · 부모 · gone · 새 1단 공종 · 블록 키 · 옛 꼴 읽음(184-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
"""B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음.
|
"""B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음.
|
||||||
|
|
||||||
⚠ 짝 = `B09_Estimation_Calc_Bill.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
⚠ 짝 = `B09_Estimation_Calc_Bill.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
||||||
거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨) · `test_184_2_b09_bill_free.py`(자유 편집).
|
거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨) ·
|
||||||
|
`test_184_2_b09_bill_free.py`(자유 편집).
|
||||||
|
|
||||||
실무 엑셀 설계내역서 꼴 · 공종 나무 = 엑셀 꼴 고정(`SECTIONS` · 공구 안 나눔):
|
실무 엑셀 설계내역서 꼴 · 기본 공종 나무(초기값) = 엑셀 꼴(`SECTIONS` · 공구 안 나눔):
|
||||||
1 토공사(1.1~1.6) · 2 구조물공(2.1~2.3) · 3 운반공 · 4 자재대 · 관급자재대(제외금액)
|
1 토공사(1.1~1.6) · 2 구조물공(2.1~2.3) · 3 운반공 · 4 자재대 · 관급자재대(제외금액)
|
||||||
항목(토공 · 구조물 · 운반) = 서버가 B08 수량으로 짠 줄(`B09_Estimation_Bill_Items`)
|
항목(토공 · 구조물 · 운반) = 서버가 B08 수량으로 짠 줄(`B09_Estimation_Bill_Items`)
|
||||||
4 자재대 = 자재단가대비표에서 「별도」 표시한 B08 자재 · 관급자재대 = 「관급」 표시 + 조달수수료
|
4 자재대 = 자재단가대비표에서 「별도」 표시한 B08 자재 · 관급자재대 = 「관급」 표시 + 조달수수료
|
||||||
@@ -15,31 +16,38 @@
|
|||||||
단가 = `ref_price(ref)` 하나 — dg = 일위대가목록표 · sg = 단가산출근거목록표 ·
|
단가 = `ref_price(ref)` 하나 — dg = 일위대가목록표 · sg = 단가산출근거목록표 ·
|
||||||
logic = M01 로직 새 산근(서버가 `sg_hand{줄 키: 산근 키}` 로 줌 · 그전엔 빈칸 「[저장] 뒤」) ·
|
logic = M01 로직 새 산근(서버가 `sg_hand{줄 키: 산근 키}` 로 줌 · 그전엔 빈칸 「[저장] 뒤」) ·
|
||||||
그 밖 = `inputs.hopyo` · 비고 = 「대가n」 · 「산근 n호표」(155-3 꼴).
|
그 밖 = `inputs.hopyo` · 비고 = 「대가n」 · 「산근 n호표」(155-3 꼴).
|
||||||
자유 편집(184) = 기본 로직 표 = 초기값(저장 안 함) · 사용자 값 `user.bill` v2 = 덮개(계약 — 184-3 화면):
|
자유 편집(184) = 기본 로직 표 = 초기값(저장 안 함) ·
|
||||||
|
사용자 값 `user.bill` v2 = 덮개(계약 — 184-3 화면):
|
||||||
{"v": 2,
|
{"v": 2,
|
||||||
"rows": [표 차례 전체(order · 부모 먼저 · 자손 이어서) {
|
"rows": [표 차례 전체(order · 부모 먼저 · 자손 이어서) {
|
||||||
"key": 줄 키, "parent": 묶음 줄 키(`sec:*` · `hand:*`) | 없음 = 맨 위,
|
"key": 줄 키, "parent": 묶음 줄 키(`sec:*` · `hand:*`) | 없음 = 맨 위,
|
||||||
자동 줄(`sec:*` · `earth:` · `st:` · `haul:` · `mat:`) = 고친 칸만 — 칸 없음 = 자동(B08 따라감) ·
|
자동 줄(`sec:*` · `earth:` · `st:` · `haul:` · `mat:`) = 고친 칸만 —
|
||||||
값 = 지킴 · null = 일부러 빈칸 · 칸 = name · spec · unit · qty · ref
|
칸 없음 = 자동(B08 따라감) · 값 = 지킴 · null = 일부러 빈칸 ·
|
||||||
(제목 `sec:*` = name 만 · `mat:` = ref 없음 · `fee` = 덮개 없음 · 차례에 안 넣음 — `sec:G` 끝 고정)
|
칸 = name · spec · unit · qty · ref
|
||||||
|
(제목 `sec:*` = name 만 · `mat:` = ref 없음 ·
|
||||||
|
`fee` = 덮개 없음 · 차례에 안 넣음 — `sec:G` 끝 고정)
|
||||||
새 줄(`hand:*`) = 모든 칸 + "kind": "group"(묶음 · 수량 · 연결 없음) | 없음 = 항목 ·
|
새 줄(`hand:*`) = 모든 칸 + "kind": "group"(묶음 · 수량 · 연결 없음) | 없음 = 항목 ·
|
||||||
"cost": "direct"(맨 위 새 묶음 · 없음도 direct)}],
|
"cost": "direct"(맨 위 새 묶음 · 없음도 direct)}],
|
||||||
"gone": [지운 자동 줄 키 — `sec:G` · `fee` · `mat:` 는 안 받음(자재 줄 주인 = 대비표 표시)]}
|
"gone": [지운 자동 줄 키 — `sec:G` · `fee` · `mat:` 는 안 받음(자재 줄 주인 = 대비표 표시)]}
|
||||||
덮개 없음 = 기본 표 그대로 · [초기화] = `bill` 지움 ·
|
덮개 없음 = 기본 표 그대로 · [초기화] = `bill` 지움 ·
|
||||||
`rows` 에 없고 `gone` 에도 없는 자동 줄(B08 새 열 · 대비표 새 표시) = 기본 부모 끝 + 비고 「새 자동 줄」
|
`rows` 에 없고 `gone` 에도 없는 자동 줄(B08 새 열 · 대비표 새 표시) =
|
||||||
(기본 부모가 없으면 표 끝 맨 위) · 원천이 안 낸 키 = 안 보임 ·
|
기본 부모 끝 + 비고 「새 자동 줄」(기본 부모가 없으면 표 끝 맨 위) ·
|
||||||
|
원천이 안 낸 키 = 안 보임 ·
|
||||||
부모 못 찾음 · 묶음 아님 · 돎 = 자동 줄 기본 부모 / 새 줄 맨 위 · `sec:G` = 맨 위 고정 ·
|
부모 못 찾음 · 묶음 아님 · 돎 = 자동 줄 기본 부모 / 새 줄 맨 위 · `sec:G` = 맨 위 고정 ·
|
||||||
관급 자재 줄 = `sec:G` 밖 못 감(밖이면 `sec:G` 아래) · 깊이 5 넘음 = 레벨 4 조상 아래.
|
관급 자재 줄 = `sec:G` 밖 못 감(밖이면 `sec:G` 아래) · 깊이 5 넘음 = 레벨 4 조상 아래.
|
||||||
옛 꼴(v 없음 · 166-2 · 182) = `qty{줄 키: 수}` 수량 덮개 + `rows[]` 손 줄(`section` · `after` 앞 줄
|
옛 꼴(v 없음 · 166-2 · 182) = `qty{줄 키: 수}` 수량 덮개 + `rows[]` 손 줄(`section` ·
|
||||||
뒤에 끼움 · `parent` = 같은 공종 손 묶음) — 그대로 읽음(화면 첫 손질 때 v2 로 굳힘).
|
`after` 앞 줄 뒤에 끼움 · `parent` = 같은 공종 손 묶음) — 그대로 읽음(화면 첫 손질 때 v2).
|
||||||
셈 출력 줄 칸(계약) = kind("title" 자동 묶음 · "group" 새 묶음 · "item") · key · name(번호 뺀 이름) ·
|
셈 출력 줄 칸(계약) = kind("title" 자동 묶음 · "group" 새 묶음 · "item") · key ·
|
||||||
parent(실제 부모 키 · None = 맨 위) · level(1~5 = 깊이) · no(레벨 1 · 2 묶음 차례 번호 「1」 · 「1.1」 ·
|
name(번호 뺀 이름) · parent(실제 부모 키 · None = 맨 위) · level(1~5 = 깊이) ·
|
||||||
관급 · 항목 = "") · cost("direct" | "supply" = 맨 위 조상 갈래 · `sec:G` 만 supply) ·
|
no(레벨 1 · 2 묶음 차례 번호 「1」 · 「1.1」 · 관급 · 항목 = "") ·
|
||||||
|
cost("direct" | "supply" = 맨 위 조상 갈래 · `sec:G` 만 supply) ·
|
||||||
auto(True = 자동 줄) | hand(True = 새 줄) · auto_{name|spec|unit|qty|ref}(고친 칸만 = 자동값) ·
|
auto(True = 자동 줄) | hand(True = 새 줄) · auto_{name|spec|unit|qty|ref}(고친 칸만 = 자동값) ·
|
||||||
section(가장 가까운 제목 공종 · 없으면 None).
|
section(가장 가까운 제목 공종 · 없으면 None).
|
||||||
묶음 금액 = 자손 항목 합 + 「빈 줄 n」 · 구조물 블록 줄 키 꼬리 = 대가 블록 키(대가 번호 밀림에 안 흔들림).
|
묶음 금액 = 자손 항목 합 + 「빈 줄 n」 ·
|
||||||
원가계산서로 넘길 합 = `sums`(cost direct 항목의 직접재료비 · 직접노무비 · 산출경비 · supply 항목의 관급
|
구조물 블록 줄 키 꼬리 = 대가 블록 키(대가 번호 밀림에 안 흔들림).
|
||||||
순자재대 — 145-4 이음 꼴 · 빈 줄이 있으면 None) · 화면 표시용 = `totals`(아는 금액 합 · 빈 줄 수 · 조달수수료).
|
원가계산서로 넘길 합 = `sums`(cost direct 항목의 직접재료비 · 직접노무비 · 산출경비 ·
|
||||||
|
supply 항목의 관급 순자재대 — 145-4 이음 꼴 · 빈 줄이 있으면 None) ·
|
||||||
|
화면 표시용 = `totals`(아는 금액 합 · 빈 줄 수 · 조달수수료).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -66,9 +74,11 @@ SECTIONS: list[tuple[str, str, str | None]] = [
|
|||||||
("4", "자재대", None),
|
("4", "자재대", None),
|
||||||
("G", "관급자재대", None),
|
("G", "관급자재대", None),
|
||||||
]
|
]
|
||||||
#: 순공사비에 드는 맨 위 공종(관급자재대 = 제외금액)
|
|
||||||
DIRECT = ("1", "2", "3", "4")
|
|
||||||
PARTS = ("labor", "material", "expense")
|
PARTS = ("labor", "material", "expense")
|
||||||
|
#: 184 덮개로 고칠 칸 · 묶음 깊이 한도
|
||||||
|
FIELDS = ("name", "spec", "unit", "qty", "ref")
|
||||||
|
DEPTH = 5
|
||||||
|
NEW_AUTO = "새 자동 줄"
|
||||||
|
|
||||||
|
|
||||||
def _amounts(qty: float | None, prices: dict[str, float | None] | None) -> dict[str, Any]:
|
def _amounts(qty: float | None, prices: dict[str, float | None] | None) -> dict[str, Any]:
|
||||||
@@ -86,32 +96,29 @@ def _amounts(qty: float | None, prices: dict[str, float | None] | None) -> dict[
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _item_row(
|
def _item_row(item: dict[str, Any], prices: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
item: dict[str, Any], prices: dict[str, Any] | None, gaps: list, qty_of: dict | None = None
|
"""항목 줄 — 「연결 없음」은 `_gaps` 에 담아 둠(보이는 줄만 표 차례로 모음)."""
|
||||||
) -> dict[str, Any]:
|
|
||||||
qty = num(item.get("qty"))
|
qty = num(item.get("qty"))
|
||||||
over = {}
|
|
||||||
if qty_of and item["key"] in qty_of and not item.get("hand"): # 166-2 수량 덮어쓰기
|
|
||||||
over = {"qty_auto": qty}
|
|
||||||
qty = num(qty_of[item["key"]])
|
|
||||||
row = {
|
row = {
|
||||||
"kind": "item",
|
"kind": "item",
|
||||||
"key": item["key"],
|
"key": item["key"],
|
||||||
"section": item["section"],
|
"section": item.get("section"),
|
||||||
"name": item.get("name") or "",
|
"name": item.get("name") or "",
|
||||||
"spec": item.get("spec") or "",
|
"spec": item.get("spec") or "",
|
||||||
"unit": item.get("unit") or "",
|
"unit": item.get("unit") or "",
|
||||||
"qty": qty,
|
"qty": qty,
|
||||||
"note": item.get("note") or "",
|
"note": item.get("note") or "",
|
||||||
"ref": item.get("ref"),
|
"ref": item.get("ref"),
|
||||||
**over,
|
**{k: v for k, v in item.items() if k.startswith("auto_")},
|
||||||
**({"hand": True} if item.get("hand") else {}),
|
**({"hand": True} if item.get("hand") else {"auto": True}),
|
||||||
**_amounts(qty, prices),
|
**_amounts(qty, prices),
|
||||||
}
|
}
|
||||||
|
row["_gaps"] = []
|
||||||
if qty is None:
|
if qty is None:
|
||||||
gaps.append(gap("bill", item["key"], row["name"], "수량 연결 없음"))
|
row["_gaps"].append(gap("bill", item["key"], row["name"], "수량 연결 없음"))
|
||||||
if prices is None:
|
if prices is None:
|
||||||
gaps.append(gap("bill", item["key"], row["name"], item.get("why") or "단가 연결 없음"))
|
why = item.get("why") or "단가 연결 없음"
|
||||||
|
row["_gaps"].append(gap("bill", item["key"], row["name"], why))
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
@@ -162,9 +169,11 @@ def ref_price(
|
|||||||
|
|
||||||
|
|
||||||
def hand_items(user: dict[str, Any], books: dict[str, Any], hopyo: dict) -> list[tuple]:
|
def hand_items(user: dict[str, Any], books: dict[str, Any], hopyo: dict) -> list[tuple]:
|
||||||
"""손 줄 → (항목, 단가) — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠)."""
|
"""새 줄(`hand:`) → (항목, 단가) — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠)."""
|
||||||
out = []
|
out = []
|
||||||
for hand in (user.get("bill") or {}).get("rows") or []:
|
for hand in (user.get("bill") or {}).get("rows") or []:
|
||||||
|
if not str(hand.get("key") or "").startswith("hand:"):
|
||||||
|
continue
|
||||||
price = ref_price(hand.get("ref"), books, hopyo, hand["key"])
|
price = ref_price(hand.get("ref"), books, hopyo, hand["key"])
|
||||||
ref = {"tab": price["tab"], "key": price["key"]} if price and price.get("tab") else None
|
ref = {"tab": price["tab"], "key": price["key"]} if price and price.get("tab") else None
|
||||||
why = WHY_LOGIC if (hand.get("ref") or {}).get("tab") == "logic" else WHY_HAND
|
why = WHY_LOGIC if (hand.get("ref") or {}).get("tab") == "logic" else WHY_HAND
|
||||||
@@ -173,12 +182,51 @@ def hand_items(user: dict[str, Any], books: dict[str, Any], hopyo: dict) -> list
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _covered(
|
||||||
|
item: dict[str, Any], prices: Any, cover: dict | None, books: dict, hopyo: dict
|
||||||
|
) -> tuple[dict[str, Any], Any]:
|
||||||
|
"""자동 줄 덮개(184) — 고친 칸 = 그 값(null = 빈칸) · `auto_{칸}` = 자동값 ·
|
||||||
|
연결 덮개 = 그 단가(자재 줄 = 단가 주인 대비표라 연결 안 받음)."""
|
||||||
|
if not cover:
|
||||||
|
return item, prices
|
||||||
|
out = dict(item)
|
||||||
|
for field in FIELDS:
|
||||||
|
if field not in cover or (field == "ref" and item["key"].startswith("mat:")):
|
||||||
|
continue
|
||||||
|
if field == "qty":
|
||||||
|
out["auto_qty"] = num(item.get("qty"))
|
||||||
|
elif field == "ref":
|
||||||
|
out["auto_ref"] = item.get("ref")
|
||||||
|
else:
|
||||||
|
out[f"auto_{field}"] = item.get(field) or ""
|
||||||
|
out[field] = cover[field]
|
||||||
|
if "auto_ref" in out:
|
||||||
|
prices = ref_price(cover["ref"], books, hopyo, item["key"])
|
||||||
|
tab = (prices or {}).get("tab")
|
||||||
|
out["ref"] = {"tab": tab, "key": prices["key"]} if tab else None
|
||||||
|
out["note"] = (prices or {}).get("no") or ""
|
||||||
|
if (cover["ref"] or {}).get("tab") == "logic":
|
||||||
|
out["why"] = WHY_LOGIC
|
||||||
|
return out, prices
|
||||||
|
|
||||||
|
|
||||||
|
def _title_row(code: str, name: str, parent: str | None, cover: dict | None) -> dict[str, Any]:
|
||||||
|
"""기본 공종 제목(자동 묶음) — 이름 덮개만 · 번호는 `no`(차례 자동)."""
|
||||||
|
row = {"kind": "title", "key": f"sec:{code}", "section": code, "name": name, "auto": True}
|
||||||
|
if cover and "name" in cover:
|
||||||
|
row["auto_name"] = name
|
||||||
|
row["name"] = cover["name"] or ""
|
||||||
|
row["parent"] = f"sec:{parent}" if parent else None
|
||||||
|
row["_home"] = row["parent"]
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
def _group_row(item: dict[str, Any]) -> dict[str, Any]:
|
def _group_row(item: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""묶음 손 줄(182) — 이름만 · 금액 = 자손 항목 합(`_nest`)."""
|
"""새 묶음 줄(182 · 184) — 이름만 · 금액 = 자손 항목 합."""
|
||||||
return {
|
return {
|
||||||
"kind": "group",
|
"kind": "group",
|
||||||
"key": item["key"],
|
"key": item["key"],
|
||||||
"section": item["section"],
|
"section": item.get("section"),
|
||||||
"name": item.get("name") or "",
|
"name": item.get("name") or "",
|
||||||
"spec": item.get("spec") or "",
|
"spec": item.get("spec") or "",
|
||||||
"unit": item.get("unit") or "",
|
"unit": item.get("unit") or "",
|
||||||
@@ -186,51 +234,17 @@ def _group_row(item: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"note": "",
|
"note": "",
|
||||||
"ref": None,
|
"ref": None,
|
||||||
"hand": True,
|
"hand": True,
|
||||||
"sum": 0.0,
|
|
||||||
"labor": 0.0,
|
|
||||||
"material": 0.0,
|
|
||||||
"expense": 0.0,
|
|
||||||
"blank": 0,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _nest(rows: list[dict[str, Any]]) -> None:
|
def _hand_row(item: dict[str, Any], prices: Any, why: str) -> dict[str, Any]:
|
||||||
"""182 레벨 — 부모 = 같은 공종 묶음 손 줄(못 찾거나 돌면 공종 바로 아래) · level · 묶음 금액."""
|
if item.get("kind") == "group":
|
||||||
groups = {row["key"]: row for row in rows if row["kind"] == "group"}
|
return _group_row(item)
|
||||||
for row in rows:
|
return _item_row({**item, "why": why}, prices)
|
||||||
if not row.get("hand"):
|
|
||||||
continue
|
|
||||||
up = groups.get(row.get("parent") or "")
|
|
||||||
if up is None or up["section"] != row["section"]:
|
|
||||||
row["parent"] = None
|
|
||||||
continue
|
|
||||||
at, n = up, 0
|
|
||||||
while at is not None and n <= len(groups):
|
|
||||||
if at["key"] == row["key"]:
|
|
||||||
row["parent"] = None
|
|
||||||
break
|
|
||||||
at, n = groups.get(at.get("parent") or ""), n + 1
|
|
||||||
for row in rows:
|
|
||||||
chain = []
|
|
||||||
at = groups.get(row.get("parent") or "")
|
|
||||||
while at is not None:
|
|
||||||
chain.append(at)
|
|
||||||
at = groups.get(at.get("parent") or "")
|
|
||||||
depth = 2 if next(p for c, _n, p in SECTIONS if c == row["section"]) else 1
|
|
||||||
row["level"] = depth if row["kind"] == "title" else depth + 1 + len(chain)
|
|
||||||
if row["kind"] != "item":
|
|
||||||
continue
|
|
||||||
for g in chain:
|
|
||||||
for p in ("sum", *PARTS):
|
|
||||||
g[p] += row[p] or 0
|
|
||||||
if row["sum"] is None:
|
|
||||||
g["blank"] += 1
|
|
||||||
for g in groups.values():
|
|
||||||
g["note"] = f"빈 줄 {g['blank']}" if g["blank"] else ""
|
|
||||||
|
|
||||||
|
|
||||||
def _place(items: list[dict[str, Any]], hand: dict[str, Any], after: str) -> None:
|
def _place(items: list[dict[str, Any]], hand: dict[str, Any], after: str) -> None:
|
||||||
"""손 줄 끼우기 — `after` 줄 바로 뒤 · 제목(`sec:`) = 그 공종 첫 자리 · 못 찾으면 끝."""
|
"""옛 꼴 손 줄 끼우기 — `after` 줄 바로 뒤 · 제목(`sec:`) = 그 공종 첫 자리 · 못 찾으면 끝."""
|
||||||
at = next((n + 1 for n, row in enumerate(items) if row["key"] == after), None)
|
at = next((n + 1 for n, row in enumerate(items) if row["key"] == after), None)
|
||||||
if at is None and after == f"sec:{hand['section']}":
|
if at is None and after == f"sec:{hand['section']}":
|
||||||
at = next((n for n, row in enumerate(items) if row["section"] == hand["section"]), None)
|
at = next((n for n, row in enumerate(items) if row["section"] == hand["section"]), None)
|
||||||
@@ -239,21 +253,23 @@ def _place(items: list[dict[str, Any]], hand: dict[str, Any], after: str) -> Non
|
|||||||
|
|
||||||
def split_by_block(item: dict[str, Any], price_of: PriceOf) -> list[tuple[dict, dict | None]]:
|
def split_by_block(item: dict[str, Any], price_of: PriceOf) -> list[tuple[dict, dict | None]]:
|
||||||
"""구조물 열 → 대가 블록마다 한 줄 — 수량 = 그 블록 사본 주인 값 합(ROUND 3) ·
|
"""구조물 열 → 대가 블록마다 한 줄 — 수량 = 그 블록 사본 주인 값 합(ROUND 3) ·
|
||||||
대가 없는 주인은 한 줄로 모음(단가 빈칸 「연결 없음」) · 차례 = 주인이 처음 나온 차례."""
|
대가 없는 주인은 한 줄로 모음(단가 빈칸 「연결 없음」) · 차례 = 주인이 처음 나온 차례 ·
|
||||||
|
줄 키 꼬리 = 대가 블록 키(184 — 대가 번호가 밀려도 덮개가 같은 줄)."""
|
||||||
groups: dict[str, dict[str, Any]] = {}
|
groups: dict[str, dict[str, Any]] = {}
|
||||||
for owner, value in (item.get("owners") or {}).items():
|
for owner, value in (item.get("owners") or {}).items():
|
||||||
price = price_of(owner)
|
price = price_of(owner)
|
||||||
tag = str(price.get("no") or "") if price else ""
|
tag = str(price.get("key") or price.get("no") or "") if price else ""
|
||||||
group = groups.setdefault(tag, {"qty": 0.0, "price": price, "owners": []})
|
group = groups.setdefault(tag, {"qty": 0.0, "price": price, "owners": []})
|
||||||
group["qty"] += num(value) or 0
|
group["qty"] += num(value) or 0
|
||||||
group["owners"].append(owner)
|
group["owners"].append(owner)
|
||||||
out = []
|
out = []
|
||||||
for tag, group in groups.items():
|
for tag, group in groups.items():
|
||||||
|
no = str((group["price"] or {}).get("no") or "")
|
||||||
part = {
|
part = {
|
||||||
**item,
|
**item,
|
||||||
"key": f"{item['key']}#{tag}" if tag else item["key"],
|
"key": f"{item['key']}#{tag}" if tag else item["key"],
|
||||||
"qty": round_half(group["qty"], 3),
|
"qty": round_half(group["qty"], 3),
|
||||||
"note": " · ".join(t for t in (tag, item.get("note") or "") if t),
|
"note": " · ".join(t for t in (no, item.get("note") or "") if t),
|
||||||
"owners": {o: item["owners"][o] for o in group["owners"]},
|
"owners": {o: item["owners"][o] for o in group["owners"]},
|
||||||
}
|
}
|
||||||
if group["price"] and group["price"].get("key"): # 대가 블록(145-2) — 이동 · 집계표 역전개
|
if group["price"] and group["price"].get("key"): # 대가 블록(145-2) — 이동 · 집계표 역전개
|
||||||
@@ -262,6 +278,114 @@ def split_by_block(item: dict[str, Any], price_of: PriceOf) -> list[tuple[dict,
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _order_old(bill: dict, titles: list[dict], items: list[dict], hands: list[tuple]) -> list:
|
||||||
|
"""옛 꼴(166-2 · 182) — 손 줄 = `after` 뒤 · 부모 = 같은 공종 손 묶음(아니면 공종 제목)."""
|
||||||
|
groups = {i["key"]: i for i, _p, _w in hands if i.get("kind") == "group"}
|
||||||
|
for item, prices, why in hands:
|
||||||
|
row = _hand_row(item, prices, why)
|
||||||
|
up = groups.get(item.get("parent") or "")
|
||||||
|
row["parent"] = up["key"] if up and up.get("section") == item.get("section") else None
|
||||||
|
_place(items, row, item.get("after") or "")
|
||||||
|
out = []
|
||||||
|
for title in titles:
|
||||||
|
out.append(title)
|
||||||
|
for row in items:
|
||||||
|
if row["section"] == title["section"]:
|
||||||
|
row["_home"] = title["key"]
|
||||||
|
row["parent"] = row.get("parent") or title["key"]
|
||||||
|
out.append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _order_v2(bill: dict, titles: list[dict], items: list[dict], hands: list[tuple]) -> list:
|
||||||
|
"""184 v2 — 저장 차례 · 부모 · `gone` 뺌 · 저장에 없는 자동 줄 = 기본 부모 끝 「새 자동 줄」."""
|
||||||
|
auto = {row["key"]: row for row in titles}
|
||||||
|
for row in items:
|
||||||
|
row["_home"] = f"sec:{row['section']}"
|
||||||
|
auto[row["key"]] = row
|
||||||
|
gone = {k for k in bill.get("gone") or [] if k != "sec:G" and not k.startswith("mat:")}
|
||||||
|
new = {item["key"]: (item, prices, why) for item, prices, why in hands}
|
||||||
|
out, seen = [], set()
|
||||||
|
for saved in bill.get("rows") or []:
|
||||||
|
key = saved["key"]
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
if key in new:
|
||||||
|
row = _hand_row(*new[key])
|
||||||
|
row["_home"] = None
|
||||||
|
elif key in auto and key not in gone:
|
||||||
|
row = auto[key]
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
row["parent"] = saved.get("parent")
|
||||||
|
out.append(row)
|
||||||
|
for row in [*titles, *items]:
|
||||||
|
if row["key"] in seen or row["key"] in gone:
|
||||||
|
continue
|
||||||
|
row["parent"] = row["_home"]
|
||||||
|
if row["kind"] == "item":
|
||||||
|
row["note"] = " · ".join(t for t in (row["note"], NEW_AUTO) if t)
|
||||||
|
out.append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _tree(order: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""부모 바로잡기(없음 · 항목 · 돎 = 기본 부모 / 맨 위 · `sec:G` 맨 위 ·
|
||||||
|
관급 자재 줄 = `sec:G` 안) → 표 차례(부모 먼저 · 형제 = 저장 차례) · level · cost · section
|
||||||
|
(깊이 5 넘음 = 레벨 4 조상 아래)."""
|
||||||
|
by_key = {row["key"]: row for row in order}
|
||||||
|
|
||||||
|
def loops(row: dict[str, Any]) -> bool:
|
||||||
|
at, n = by_key.get(row["parent"] or ""), 0
|
||||||
|
while at is not None and n <= len(order):
|
||||||
|
if at is row:
|
||||||
|
return True
|
||||||
|
at, n = by_key.get(at["parent"] or ""), n + 1
|
||||||
|
return False
|
||||||
|
|
||||||
|
def root(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
while row["parent"]:
|
||||||
|
row = by_key[row["parent"]]
|
||||||
|
return row
|
||||||
|
|
||||||
|
for row in order:
|
||||||
|
up = by_key.get(row.get("parent") or "")
|
||||||
|
if row["key"] == "sec:G":
|
||||||
|
row["parent"] = None
|
||||||
|
elif up is None or up["kind"] == "item" or up is row:
|
||||||
|
row["parent"] = row["_home"] if row["_home"] in by_key else None
|
||||||
|
for row in order:
|
||||||
|
if loops(row):
|
||||||
|
row["parent"] = row["_home"] if row["_home"] in by_key else None
|
||||||
|
if loops(row):
|
||||||
|
row["parent"] = None
|
||||||
|
for row in order:
|
||||||
|
if (
|
||||||
|
row["key"].startswith("mat:")
|
||||||
|
and row["_home"] == "sec:G"
|
||||||
|
and root(row)["key"] != "sec:G"
|
||||||
|
):
|
||||||
|
row["parent"] = "sec:G"
|
||||||
|
kids: dict[Any, list[dict[str, Any]]] = {}
|
||||||
|
for row in order:
|
||||||
|
kids.setdefault(row["parent"], []).append(row)
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
stack: list[tuple[dict, list]] = [(row, []) for row in reversed(kids.get(None, []))]
|
||||||
|
while stack:
|
||||||
|
row, chain = stack.pop()
|
||||||
|
if len(chain) >= DEPTH:
|
||||||
|
chain = chain[: DEPTH - 1]
|
||||||
|
row["parent"] = chain[-1]["key"]
|
||||||
|
row["level"] = len(chain) + 1
|
||||||
|
row["cost"] = "supply" if (chain[0] if chain else row)["key"] == "sec:G" else "direct"
|
||||||
|
if row["kind"] != "title":
|
||||||
|
row["section"] = chain[-1]["section"] if chain else None
|
||||||
|
rows.append(row)
|
||||||
|
stack.extend((kid, [*chain, row]) for kid in reversed(kids.get(row["key"], [])))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def compute_bill(
|
def compute_bill(
|
||||||
inputs: dict[str, Any],
|
inputs: dict[str, Any],
|
||||||
user: dict[str, Any],
|
user: dict[str, Any],
|
||||||
@@ -272,30 +396,30 @@ def compute_bill(
|
|||||||
"""`price_of(사본 주인 id)` = 대가 1 단위 단가(145-2 `daega_price`) —
|
"""`price_of(사본 주인 id)` = 대가 1 단위 단가(145-2 `daega_price`) —
|
||||||
주면 구조물 열을 블록마다 나눔 ·
|
주면 구조물 열을 블록마다 나눔 ·
|
||||||
`books` = 호표 목록(`daega` · `sg_list` · `sg_hand` · 166-2)."""
|
`books` = 호표 목록(`daega` · `sg_list` · `sg_hand` · 166-2)."""
|
||||||
gaps: list[dict[str, str]] = []
|
|
||||||
hopyo = inputs.get("hopyo") or {}
|
hopyo = inputs.get("hopyo") or {}
|
||||||
books = books or {}
|
books = books or {}
|
||||||
qty_of = (user.get("bill") or {}).get("qty") or {}
|
bill = user.get("bill") or {}
|
||||||
|
v2 = bill.get("v") == 2
|
||||||
|
if v2:
|
||||||
|
cover = {r["key"]: r for r in bill.get("rows") or [] if not r["key"].startswith("hand:")}
|
||||||
|
else:
|
||||||
|
cover = {k: {"qty": v} for k, v in (bill.get("qty") or {}).items()}
|
||||||
items: list[dict[str, Any]] = []
|
items: list[dict[str, Any]] = []
|
||||||
for item in (inputs.get("bill") or {}).get("items") or []:
|
for item in (inputs.get("bill") or {}).get("items") or []:
|
||||||
if price_of is not None and item.get("owners"):
|
if price_of is not None and item.get("owners"):
|
||||||
for part, prices in split_by_block(item, price_of):
|
parts = split_by_block(item, price_of)
|
||||||
items.append(_item_row(part, prices, gaps, qty_of))
|
else:
|
||||||
continue
|
parts = [(item, ref_price(item.get("ref"), books, hopyo))]
|
||||||
prices = ref_price(item.get("ref"), books, hopyo)
|
for part, prices in parts:
|
||||||
items.append(_item_row(item, prices, gaps, qty_of))
|
items.append(_item_row(*_covered(part, prices, cover.get(part["key"]), books, hopyo)))
|
||||||
for item in _material_items(inputs, base["compare"]):
|
for item in _material_items(inputs, base["compare"]):
|
||||||
price = item["_price"]
|
price = item["_price"]
|
||||||
prices = None if price is None else {"labor": 0, "material": price, "expense": 0}
|
prices = None if price is None else {"labor": 0, "material": price, "expense": 0}
|
||||||
items.append(_item_row(item, prices, gaps, qty_of))
|
items.append(_item_row(*_covered(item, prices, cover.get(item["key"]), books, hopyo)))
|
||||||
for item, prices, why in hand_items(user, books, hopyo):
|
titles = [_title_row(c, n, p, cover.get(f"sec:{c}")) for c, n, p in SECTIONS]
|
||||||
if item.get("kind") == "group":
|
hands = hand_items(user, books, hopyo)
|
||||||
row = _group_row(item)
|
rows = _tree((_order_v2 if v2 else _order_old)(bill, titles, items, hands))
|
||||||
else:
|
owner = [row for row in rows if row["kind"] == "item" and row["cost"] == "supply"]
|
||||||
row = _item_row({**item, "why": why}, prices, gaps)
|
|
||||||
row["parent"] = item.get("parent")
|
|
||||||
_place(items, row, item.get("after") or "")
|
|
||||||
owner = [row for row in items if row["section"] == "G" and row["kind"] == "item"]
|
|
||||||
net = 0.0
|
net = 0.0
|
||||||
for row in owner:
|
for row in owner:
|
||||||
net += row["material"] or 0
|
net += row["material"] or 0
|
||||||
@@ -311,6 +435,7 @@ def compute_bill(
|
|||||||
"qty": pct,
|
"qty": pct,
|
||||||
"note": "관급 순자재대 밑수",
|
"note": "관급 순자재대 밑수",
|
||||||
"ref": None,
|
"ref": None,
|
||||||
|
"auto": True,
|
||||||
"price_labor": 0,
|
"price_labor": 0,
|
||||||
"price_material": net,
|
"price_material": net,
|
||||||
"price_expense": 0,
|
"price_expense": 0,
|
||||||
@@ -319,53 +444,54 @@ def compute_bill(
|
|||||||
"material": fee,
|
"material": fee,
|
||||||
"expense": 0 if pct is not None else None,
|
"expense": 0 if pct is not None else None,
|
||||||
"sum": fee,
|
"sum": fee,
|
||||||
|
"parent": "sec:G",
|
||||||
|
"level": 2,
|
||||||
|
"cost": "supply",
|
||||||
|
"_gaps": [gap("bill", "fee", "조달수수료", "조달수수료율 입력 없음")]
|
||||||
|
if pct is None
|
||||||
|
else [],
|
||||||
}
|
}
|
||||||
if owner:
|
if owner: # 조달수수료 = `sec:G` 끝 고정
|
||||||
items.append(fee_row)
|
at = next(n for n, row in enumerate(rows) if row["key"] == "sec:G") + 1
|
||||||
if pct is None:
|
while at < len(rows) and rows[at]["level"] > 1:
|
||||||
gaps.append(gap("bill", "fee", "조달수수료", "조달수수료율 입력 없음"))
|
at += 1
|
||||||
rows: list[dict[str, Any]] = []
|
rows.insert(at, fee_row)
|
||||||
sums: dict[str, dict[str, Any]] = {}
|
by_key = {row["key"]: row for row in rows}
|
||||||
for code, name, parent in SECTIONS:
|
for row in rows:
|
||||||
sums[code] = {"sum": 0.0, "labor": 0.0, "material": 0.0, "expense": 0.0, "blank": 0}
|
|
||||||
for row in items:
|
|
||||||
if row["kind"] != "item":
|
if row["kind"] != "item":
|
||||||
continue
|
row.update({"sum": 0.0, "labor": 0.0, "material": 0.0, "expense": 0.0, "blank": 0})
|
||||||
code = row["section"]
|
|
||||||
chain = [code]
|
|
||||||
parent = next(p for c, _n, p in SECTIONS if c == code)
|
|
||||||
if parent:
|
|
||||||
chain.append(parent)
|
|
||||||
for c in chain:
|
|
||||||
for p in ("sum", *PARTS):
|
|
||||||
sums[c][p] += row[p] or 0
|
|
||||||
if row["sum"] is None:
|
|
||||||
sums[c]["blank"] += 1
|
|
||||||
for code, name, parent in SECTIONS:
|
|
||||||
s = sums[code]
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
"kind": "title",
|
|
||||||
"key": f"sec:{code}",
|
|
||||||
"section": code,
|
|
||||||
"name": f"{code} {name}" if code != "G" else name,
|
|
||||||
"sum": s["sum"],
|
|
||||||
"labor": s["labor"],
|
|
||||||
"material": s["material"],
|
|
||||||
"expense": s["expense"],
|
|
||||||
"blank": s["blank"],
|
|
||||||
"note": ("(제외금액)" if code == "G" else "")
|
|
||||||
+ (f" 빈 줄 {s['blank']}" if s["blank"] else ""),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
rows.extend(row for row in items if row["section"] == code)
|
|
||||||
_nest(rows)
|
|
||||||
direct = {p: 0.0 for p in ("sum", *PARTS)}
|
direct = {p: 0.0 for p in ("sum", *PARTS)}
|
||||||
blank = 0
|
blank = 0
|
||||||
for code in DIRECT:
|
for row in rows:
|
||||||
for p in direct:
|
if row["kind"] != "item":
|
||||||
direct[p] += sums[code][p]
|
continue
|
||||||
blank += sums[code]["blank"]
|
at = by_key.get(row["parent"] or "")
|
||||||
|
while at is not None:
|
||||||
|
for p in ("sum", *PARTS):
|
||||||
|
at[p] += row[p] or 0
|
||||||
|
if row["sum"] is None:
|
||||||
|
at["blank"] += 1
|
||||||
|
at = by_key.get(at["parent"] or "")
|
||||||
|
if row["cost"] == "direct":
|
||||||
|
for p in direct:
|
||||||
|
direct[p] += row[p] or 0
|
||||||
|
if row["sum"] is None:
|
||||||
|
blank += 1
|
||||||
|
gaps: list[dict[str, str]] = []
|
||||||
|
seq: dict[Any, int] = {}
|
||||||
|
for row in rows:
|
||||||
|
gaps.extend(row.pop("_gaps", []))
|
||||||
|
row.pop("_home", None)
|
||||||
|
row["no"] = ""
|
||||||
|
if row["kind"] == "title":
|
||||||
|
empty = f" 빈 줄 {row['blank']}" if row["blank"] else ""
|
||||||
|
row["note"] = ("(제외금액)" if row["key"] == "sec:G" else "") + empty
|
||||||
|
elif row["kind"] == "group":
|
||||||
|
row["note"] = f"빈 줄 {row['blank']}" if row["blank"] else ""
|
||||||
|
if row["kind"] != "item" and row["cost"] == "direct" and row["level"] <= 2:
|
||||||
|
up = row["parent"] # 번호 = 레벨 1 · 2 묶음 차례(1 · 1.1 · 관급 없음)
|
||||||
|
seq[up] = seq.get(up, 0) + 1
|
||||||
|
row["no"] = f"{by_key[up]['no']}.{seq[up]}" if up else str(seq[up])
|
||||||
totals = {
|
totals = {
|
||||||
"direct": {**direct, "blank": blank},
|
"direct": {**direct, "blank": blank},
|
||||||
"owner": {"net_material": net, "fee": fee, "total": net + (fee or 0)},
|
"owner": {"net_material": net, "fee": fee, "total": net + (fee or 0)},
|
||||||
|
|||||||
@@ -43,8 +43,11 @@ export const SECTIONS: Array<[string, string, string | null]> = [
|
|||||||
["4", "자재대", null],
|
["4", "자재대", null],
|
||||||
["G", "관급자재대", null],
|
["G", "관급자재대", null],
|
||||||
];
|
];
|
||||||
const DIRECT = ["1", "2", "3", "4"];
|
|
||||||
const PARTS = ["labor", "material", "expense"];
|
const PARTS = ["labor", "material", "expense"];
|
||||||
|
/** 184 덮개로 고칠 칸 · 묶음 깊이 한도 */
|
||||||
|
const FIELDS = ["name", "spec", "unit", "qty", "ref"];
|
||||||
|
const DEPTH = 5;
|
||||||
|
const NEW_AUTO = "새 자동 줄";
|
||||||
|
|
||||||
function amounts(qty: Num, prices: Row | null): Row {
|
function amounts(qty: Num, prices: Row | null): Row {
|
||||||
const unit: Record<string, Num> = {};
|
const unit: Record<string, Num> = {};
|
||||||
@@ -60,30 +63,27 @@ function amounts(qty: Num, prices: Row | null): Row {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function itemRow(item: Row, prices: Row | null, gaps: Gap[], qtyOf: Row | null = null): Row {
|
/** 항목 줄 — 「연결 없음」은 _gaps 에 담아 둠(보이는 줄만 표 차례로 모음) */
|
||||||
let qty = num(item.qty);
|
function itemRow(item: Row, prices: Row | null): Row {
|
||||||
const over: Row = {};
|
const qty = num(item.qty);
|
||||||
// 166-2 수량 덮어쓰기
|
|
||||||
if (qtyOf && item.key in qtyOf && !item.hand) {
|
|
||||||
over.qty_auto = qty;
|
|
||||||
qty = num(qtyOf[item.key]);
|
|
||||||
}
|
|
||||||
const row: Row = {
|
const row: Row = {
|
||||||
kind: "item",
|
kind: "item",
|
||||||
key: item.key,
|
key: item.key,
|
||||||
section: item.section,
|
section: item.section ?? null,
|
||||||
name: item.name || "",
|
name: item.name || "",
|
||||||
spec: item.spec || "",
|
spec: item.spec || "",
|
||||||
unit: item.unit || "",
|
unit: item.unit || "",
|
||||||
qty,
|
qty,
|
||||||
note: item.note || "",
|
note: item.note || "",
|
||||||
ref: item.ref ?? null,
|
ref: item.ref ?? null,
|
||||||
...over,
|
...Object.fromEntries(Object.entries(item).filter(([k]) => k.startsWith("auto_"))),
|
||||||
...(item.hand ? { hand: true } : {}),
|
...(item.hand ? { hand: true } : { auto: true }),
|
||||||
...amounts(qty, prices),
|
...amounts(qty, prices),
|
||||||
};
|
};
|
||||||
if (qty === null) gaps.push(gap("bill", item.key, row.name, "수량 연결 없음"));
|
row._gaps = [];
|
||||||
if (prices === null) gaps.push(gap("bill", item.key, row.name, item.why || "단가 연결 없음"));
|
if (qty === null) row._gaps.push(gap("bill", item.key, row.name, "수량 연결 없음"));
|
||||||
|
if (prices === null)
|
||||||
|
row._gaps.push(gap("bill", item.key, row.name, item.why || "단가 연결 없음"));
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,10 +133,11 @@ export function refPrice(ref: Row | null, books: Row, hopyo: Row, rowKey = ""):
|
|||||||
return key ? (hopyo[key] ?? null) : null;
|
return key ? (hopyo[key] ?? null) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 손 줄 → [항목, 단가, 까닭] — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠) */
|
/** 새 줄(hand:) → [항목, 단가, 까닭] — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠) */
|
||||||
export function handItems(user: Row, books: Row, hopyo: Row): Array<[Row, Row | null, string]> {
|
export function handItems(user: Row, books: Row, hopyo: Row): Array<[Row, Row | null, string]> {
|
||||||
const out: Array<[Row, Row | null, string]> = [];
|
const out: Array<[Row, Row | null, string]> = [];
|
||||||
for (const hand of (user.bill || {}).rows || []) {
|
for (const hand of (user.bill || {}).rows || []) {
|
||||||
|
if (!String(hand.key || "").startsWith("hand:")) continue;
|
||||||
const price = refPrice(hand.ref ?? null, books, hopyo, hand.key);
|
const price = refPrice(hand.ref ?? null, books, hopyo, hand.key);
|
||||||
const ref = price?.tab ? { tab: price.tab, key: price.key } : null;
|
const ref = price?.tab ? { tab: price.tab, key: price.key } : null;
|
||||||
const why = hand.ref?.tab === "logic" ? WHY_LOGIC : WHY_HAND;
|
const why = hand.ref?.tab === "logic" ? WHY_LOGIC : WHY_HAND;
|
||||||
@@ -145,12 +146,51 @@ export function handItems(user: Row, books: Row, hopyo: Row): Array<[Row, Row |
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 묶음 손 줄(182) — 이름만 · 금액 = 자손 항목 합(`nest`) */
|
/** 자동 줄 덮개(184) — 고친 칸 = 그 값(null = 빈칸) · auto_{칸} = 자동값 · 연결 덮개 = 그 단가(자재 줄 = 연결 안 받음) */
|
||||||
|
function covered(
|
||||||
|
item: Row,
|
||||||
|
prices: Row | null,
|
||||||
|
cover: Row | undefined,
|
||||||
|
books: Row,
|
||||||
|
hopyo: Row,
|
||||||
|
): [Row, Row | null] {
|
||||||
|
if (!cover) return [item, prices];
|
||||||
|
const out: Row = { ...item };
|
||||||
|
for (const field of FIELDS) {
|
||||||
|
if (!(field in cover) || (field === "ref" && item.key.startsWith("mat:"))) continue;
|
||||||
|
if (field === "qty") out.auto_qty = num(item.qty);
|
||||||
|
else if (field === "ref") out.auto_ref = item.ref ?? null;
|
||||||
|
else out[`auto_${field}`] = item[field] || "";
|
||||||
|
out[field] = cover[field];
|
||||||
|
}
|
||||||
|
if ("auto_ref" in out) {
|
||||||
|
prices = refPrice(cover.ref, books, hopyo, item.key);
|
||||||
|
const tab = prices?.tab;
|
||||||
|
out.ref = tab ? { tab, key: prices!.key } : null;
|
||||||
|
out.note = prices?.no || "";
|
||||||
|
if (cover.ref?.tab === "logic") out.why = WHY_LOGIC;
|
||||||
|
}
|
||||||
|
return [out, prices];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기본 공종 제목(자동 묶음) — 이름 덮개만 · 번호는 no(차례 자동) */
|
||||||
|
function titleRow(code: string, name: string, parent: string | null, cover: Row | undefined): Row {
|
||||||
|
const row: Row = { kind: "title", key: `sec:${code}`, section: code, name, auto: true };
|
||||||
|
if (cover && "name" in cover) {
|
||||||
|
row.auto_name = name;
|
||||||
|
row.name = cover.name || "";
|
||||||
|
}
|
||||||
|
row.parent = parent ? `sec:${parent}` : null;
|
||||||
|
row._home = row.parent;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 새 묶음 줄(182 · 184) — 이름만 · 금액 = 자손 항목 합 */
|
||||||
function groupRow(item: Row): Row {
|
function groupRow(item: Row): Row {
|
||||||
return {
|
return {
|
||||||
kind: "group",
|
kind: "group",
|
||||||
key: item.key,
|
key: item.key,
|
||||||
section: item.section,
|
section: item.section ?? null,
|
||||||
name: item.name || "",
|
name: item.name || "",
|
||||||
spec: item.spec || "",
|
spec: item.spec || "",
|
||||||
unit: item.unit || "",
|
unit: item.unit || "",
|
||||||
@@ -158,50 +198,14 @@ function groupRow(item: Row): Row {
|
|||||||
note: "",
|
note: "",
|
||||||
ref: null,
|
ref: null,
|
||||||
hand: true,
|
hand: true,
|
||||||
sum: 0,
|
|
||||||
labor: 0,
|
|
||||||
material: 0,
|
|
||||||
expense: 0,
|
|
||||||
blank: 0,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 182 레벨 — 부모 = 같은 공종 묶음 손 줄(못 찾거나 돌면 공종 바로 아래) · level · 묶음 금액 */
|
function handRow(item: Row, prices: Row | null, why: string): Row {
|
||||||
function nest(rows: Row[]): void {
|
return item.kind === "group" ? groupRow(item) : itemRow({ ...item, why }, prices);
|
||||||
const groups = new Map<string, Row>();
|
|
||||||
for (const row of rows) if (row.kind === "group") groups.set(row.key, row);
|
|
||||||
for (const row of rows) {
|
|
||||||
if (!row.hand) continue;
|
|
||||||
const up = groups.get(row.parent ?? "");
|
|
||||||
if (!up || up.section !== row.section) {
|
|
||||||
row.parent = null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let at: Row | undefined = up;
|
|
||||||
for (let n = 0; at && n <= groups.size; n += 1) {
|
|
||||||
if (at.key === row.key) {
|
|
||||||
row.parent = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
at = groups.get(at.parent ?? "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const row of rows) {
|
|
||||||
const chain: Row[] = [];
|
|
||||||
for (let at = groups.get(row.parent ?? ""); at; at = groups.get(at.parent ?? ""))
|
|
||||||
chain.push(at);
|
|
||||||
const depth = SECTIONS.find(([c]) => c === row.section)![2] ? 2 : 1;
|
|
||||||
row.level = row.kind === "title" ? depth : depth + 1 + chain.length;
|
|
||||||
if (row.kind !== "item") continue;
|
|
||||||
for (const g of chain) {
|
|
||||||
for (const p of ["sum", ...PARTS]) g[p] += row[p] || 0;
|
|
||||||
if (row.sum === null) g.blank += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const g of groups.values()) g.note = g.blank ? `빈 줄 ${g.blank}` : "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 손 줄 끼우기 — after 줄 바로 뒤 · 제목(sec:) = 그 공종 첫 자리 · 못 찾으면 끝 */
|
/** 옛 꼴 손 줄 끼우기 — after 줄 바로 뒤 · 제목(sec:) = 그 공종 첫 자리 · 못 찾으면 끝 */
|
||||||
function place(items: Row[], hand: Row, after: string): void {
|
function place(items: Row[], hand: Row, after: string): void {
|
||||||
let at = items.findIndex((row) => row.key === after);
|
let at = items.findIndex((row) => row.key === after);
|
||||||
if (at >= 0) at += 1;
|
if (at >= 0) at += 1;
|
||||||
@@ -209,12 +213,13 @@ function place(items: Row[], hand: Row, after: string): void {
|
|||||||
items.splice(at < 0 ? items.length : at, 0, hand);
|
items.splice(at < 0 ? items.length : at, 0, hand);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 구조물 열 → 대가 블록마다 한 줄(수량 = 그 블록 주인 값 합 ROUND 3 · 대가 없는 주인 = 한 줄) */
|
/** 구조물 열 → 대가 블록마다 한 줄(수량 = 그 블록 주인 값 합 ROUND 3 · 대가 없는 주인 = 한 줄) ·
|
||||||
|
* 줄 키 꼬리 = 대가 블록 키(184 — 대가 번호가 밀려도 덮개가 같은 줄) */
|
||||||
export function splitByBlock(item: Row, priceOf: PriceOf): Array<[Row, Row | null]> {
|
export function splitByBlock(item: Row, priceOf: PriceOf): Array<[Row, Row | null]> {
|
||||||
const groups = new Map<string, Row>();
|
const groups = new Map<string, Row>();
|
||||||
for (const [owner, value] of Object.entries(item.owners || {})) {
|
for (const [owner, value] of Object.entries(item.owners || {})) {
|
||||||
const price = priceOf(owner);
|
const price = priceOf(owner);
|
||||||
const tag = price ? String(price.no || "") : "";
|
const tag = price ? String(price.key || price.no || "") : "";
|
||||||
if (!groups.has(tag)) groups.set(tag, { qty: 0, price, owners: [] });
|
if (!groups.has(tag)) groups.set(tag, { qty: 0, price, owners: [] });
|
||||||
const group = groups.get(tag)!;
|
const group = groups.get(tag)!;
|
||||||
group.qty += num(value) || 0;
|
group.qty += num(value) || 0;
|
||||||
@@ -222,11 +227,12 @@ export function splitByBlock(item: Row, priceOf: PriceOf): Array<[Row, Row | nul
|
|||||||
}
|
}
|
||||||
const out: Array<[Row, Row | null]> = [];
|
const out: Array<[Row, Row | null]> = [];
|
||||||
for (const [tag, group] of groups) {
|
for (const [tag, group] of groups) {
|
||||||
|
const no = String(group.price?.no || "");
|
||||||
const part: Row = {
|
const part: Row = {
|
||||||
...item,
|
...item,
|
||||||
key: tag ? `${item.key}#${tag}` : item.key,
|
key: tag ? `${item.key}#${tag}` : item.key,
|
||||||
qty: roundHalf(group.qty, 3),
|
qty: roundHalf(group.qty, 3),
|
||||||
note: [tag, item.note || ""].filter((t) => t).join(" · "),
|
note: [no, item.note || ""].filter((t) => t).join(" · "),
|
||||||
owners: Object.fromEntries(group.owners.map((o: string) => [o, item.owners[o]])),
|
owners: Object.fromEntries(group.owners.map((o: string) => [o, item.owners[o]])),
|
||||||
};
|
};
|
||||||
// 대가 블록(145-2) — 이동 · 집계표 역전개
|
// 대가 블록(145-2) — 이동 · 집계표 역전개
|
||||||
@@ -236,6 +242,121 @@ export function splitByBlock(item: Row, priceOf: PriceOf): Array<[Row, Row | nul
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Hands = Array<[Row, Row | null, string]>;
|
||||||
|
|
||||||
|
/** 옛 꼴(166-2 · 182) — 손 줄 = after 뒤 · 부모 = 같은 공종 손 묶음(아니면 공종 제목) */
|
||||||
|
function orderOld(_bill: Row, titles: Row[], items: Row[], hands: Hands): Row[] {
|
||||||
|
const groups = new Map<string, Row>();
|
||||||
|
for (const [item] of hands) if (item.kind === "group") groups.set(item.key, item);
|
||||||
|
for (const [item, prices, why] of hands) {
|
||||||
|
const row = handRow(item, prices, why);
|
||||||
|
const up = groups.get(item.parent ?? "");
|
||||||
|
row.parent = up && (up.section ?? null) === (item.section ?? null) ? up.key : null;
|
||||||
|
place(items, row, item.after || "");
|
||||||
|
}
|
||||||
|
const out: Row[] = [];
|
||||||
|
for (const title of titles) {
|
||||||
|
out.push(title);
|
||||||
|
for (const row of items) {
|
||||||
|
if (row.section !== title.section) continue;
|
||||||
|
row._home = title.key;
|
||||||
|
row.parent = row.parent || title.key;
|
||||||
|
out.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 184 v2 — 저장 차례 · 부모 · gone 뺌 · 저장에 없는 자동 줄 = 기본 부모 끝 「새 자동 줄」 */
|
||||||
|
function orderV2(bill: Row, titles: Row[], items: Row[], hands: Hands): Row[] {
|
||||||
|
const auto = new Map<string, Row>();
|
||||||
|
for (const row of titles) auto.set(row.key, row);
|
||||||
|
for (const row of items) {
|
||||||
|
row._home = `sec:${row.section}`;
|
||||||
|
auto.set(row.key, row);
|
||||||
|
}
|
||||||
|
const gone = new Set<string>(
|
||||||
|
(bill.gone || []).filter((k: string) => k !== "sec:G" && !k.startsWith("mat:")),
|
||||||
|
);
|
||||||
|
const fresh = new Map<string, [Row, Row | null, string]>();
|
||||||
|
for (const hand of hands) fresh.set(hand[0].key, hand);
|
||||||
|
const out: Row[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const saved of bill.rows || []) {
|
||||||
|
const key = saved.key;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
let row: Row;
|
||||||
|
if (fresh.has(key)) {
|
||||||
|
row = handRow(...fresh.get(key)!);
|
||||||
|
row._home = null;
|
||||||
|
} else if (auto.has(key) && !gone.has(key)) row = auto.get(key)!;
|
||||||
|
else continue;
|
||||||
|
seen.add(key);
|
||||||
|
row.parent = saved.parent ?? null;
|
||||||
|
out.push(row);
|
||||||
|
}
|
||||||
|
for (const row of [...titles, ...items]) {
|
||||||
|
if (seen.has(row.key) || gone.has(row.key)) continue;
|
||||||
|
row.parent = row._home;
|
||||||
|
if (row.kind === "item") row.note = [row.note, NEW_AUTO].filter((t) => t).join(" · ");
|
||||||
|
out.push(row);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 부모 바로잡기(없음 · 항목 · 돎 = 기본 부모 / 맨 위 · sec:G 맨 위 · 관급 자재 줄 = sec:G 안) →
|
||||||
|
* 표 차례(부모 먼저 · 형제 = 저장 차례) · level · cost · section(깊이 5 넘음 = 레벨 4 조상 아래) */
|
||||||
|
function tree(order: Row[]): Row[] {
|
||||||
|
const byKey = new Map<string, Row>();
|
||||||
|
for (const row of order) byKey.set(row.key, row);
|
||||||
|
const home = (row: Row) => (row._home != null && byKey.has(row._home) ? row._home : null);
|
||||||
|
const loops = (row: Row): boolean => {
|
||||||
|
let at = byKey.get(row.parent ?? "");
|
||||||
|
for (let n = 0; at && n <= order.length; n += 1) {
|
||||||
|
if (at === row) return true;
|
||||||
|
at = byKey.get(at.parent ?? "");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const root = (row: Row): Row => {
|
||||||
|
while (row.parent) row = byKey.get(row.parent)!;
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
for (const row of order) {
|
||||||
|
const up = byKey.get(row.parent ?? "");
|
||||||
|
if (row.key === "sec:G") row.parent = null;
|
||||||
|
else if (!up || up.kind === "item" || up === row) row.parent = home(row);
|
||||||
|
}
|
||||||
|
for (const row of order) {
|
||||||
|
if (!loops(row)) continue;
|
||||||
|
row.parent = home(row);
|
||||||
|
if (loops(row)) row.parent = null;
|
||||||
|
}
|
||||||
|
for (const row of order)
|
||||||
|
if (row.key.startsWith("mat:") && row._home === "sec:G" && root(row).key !== "sec:G")
|
||||||
|
row.parent = "sec:G";
|
||||||
|
const kids = new Map<string | null, Row[]>();
|
||||||
|
for (const row of order) {
|
||||||
|
if (!kids.has(row.parent)) kids.set(row.parent, []);
|
||||||
|
kids.get(row.parent)!.push(row);
|
||||||
|
}
|
||||||
|
const rows: Row[] = [];
|
||||||
|
const stack: Array<[Row, Row[]]> = [...(kids.get(null) || [])].reverse().map((r) => [r, []]);
|
||||||
|
while (stack.length) {
|
||||||
|
let [row, chain] = stack.pop()!;
|
||||||
|
if (chain.length >= DEPTH) {
|
||||||
|
chain = chain.slice(0, DEPTH - 1);
|
||||||
|
row.parent = chain[chain.length - 1].key;
|
||||||
|
}
|
||||||
|
row.level = chain.length + 1;
|
||||||
|
row.cost = (chain.length ? chain[0] : row).key === "sec:G" ? "supply" : "direct";
|
||||||
|
if (row.kind !== "title") row.section = chain.length ? chain[chain.length - 1].section : null;
|
||||||
|
rows.push(row);
|
||||||
|
for (const kid of [...(kids.get(row.key) || [])].reverse()) stack.push([kid, [...chain, row]]);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
export function computeBill(
|
export function computeBill(
|
||||||
inputs: Row,
|
inputs: Row,
|
||||||
user: Row,
|
user: Row,
|
||||||
@@ -243,31 +364,32 @@ export function computeBill(
|
|||||||
priceOf: PriceOf | null = null,
|
priceOf: PriceOf | null = null,
|
||||||
books: Row | null = null,
|
books: Row | null = null,
|
||||||
): Row {
|
): Row {
|
||||||
const gaps: Gap[] = [];
|
|
||||||
const hopyo: Row = inputs.hopyo || {};
|
const hopyo: Row = inputs.hopyo || {};
|
||||||
const book: Row = books || {};
|
const book: Row = books || {};
|
||||||
const qtyOf: Row = (user.bill || {}).qty || {};
|
const bill: Row = user.bill || {};
|
||||||
|
const v2 = bill.v === 2;
|
||||||
|
const cover: Record<string, Row> = {};
|
||||||
|
if (v2) {
|
||||||
|
for (const r of bill.rows || []) if (!r.key.startsWith("hand:")) cover[r.key] = r;
|
||||||
|
} else for (const [k, v] of Object.entries(bill.qty || {})) cover[k] = { qty: v };
|
||||||
const items: Row[] = [];
|
const items: Row[] = [];
|
||||||
for (const item of (inputs.bill || {}).items || []) {
|
for (const item of (inputs.bill || {}).items || []) {
|
||||||
if (priceOf !== null && item.owners && Object.keys(item.owners).length) {
|
const parts: Array<[Row, Row | null]> =
|
||||||
for (const [part, prices] of splitByBlock(item, priceOf))
|
priceOf !== null && item.owners && Object.keys(item.owners).length
|
||||||
items.push(itemRow(part, prices, gaps, qtyOf));
|
? splitByBlock(item, priceOf)
|
||||||
continue;
|
: [[item, refPrice(item.ref ?? null, book, hopyo)]];
|
||||||
}
|
for (const [part, prices] of parts)
|
||||||
const prices = refPrice(item.ref ?? null, book, hopyo);
|
items.push(itemRow(...covered(part, prices, cover[part.key], book, hopyo)));
|
||||||
items.push(itemRow(item, prices, gaps, qtyOf));
|
|
||||||
}
|
}
|
||||||
for (const item of materialItems(inputs, base.compare)) {
|
for (const item of materialItems(inputs, base.compare)) {
|
||||||
const price: Num = item._price;
|
const price: Num = item._price;
|
||||||
const prices = price === null ? null : { labor: 0, material: price, expense: 0 };
|
const prices = price === null ? null : { labor: 0, material: price, expense: 0 };
|
||||||
items.push(itemRow(item, prices, gaps, qtyOf));
|
items.push(itemRow(...covered(item, prices, cover[item.key], book, hopyo)));
|
||||||
}
|
}
|
||||||
for (const [item, prices, why] of handItems(user, book, hopyo)) {
|
const titles = SECTIONS.map(([c, n, p]) => titleRow(c, n, p, cover[`sec:${c}`]));
|
||||||
const row = item.kind === "group" ? groupRow(item) : itemRow({ ...item, why }, prices, gaps);
|
const hands = handItems(user, book, hopyo);
|
||||||
row.parent = item.parent ?? null;
|
const rows = tree((v2 ? orderV2 : orderOld)(bill, titles, items, hands));
|
||||||
place(items, row, item.after || "");
|
const owner = rows.filter((row) => row.kind === "item" && row.cost === "supply");
|
||||||
}
|
|
||||||
const owner = items.filter((row) => row.section === "G" && row.kind === "item");
|
|
||||||
let net = 0;
|
let net = 0;
|
||||||
for (const row of owner) net += row.material || 0;
|
for (const row of owner) net += row.material || 0;
|
||||||
const pct = num((user.estimate || {}).procurement_fee_pct);
|
const pct = num((user.estimate || {}).procurement_fee_pct);
|
||||||
@@ -282,6 +404,7 @@ export function computeBill(
|
|||||||
qty: pct,
|
qty: pct,
|
||||||
note: "관급 순자재대 밑수",
|
note: "관급 순자재대 밑수",
|
||||||
ref: null,
|
ref: null,
|
||||||
|
auto: true,
|
||||||
price_labor: 0,
|
price_labor: 0,
|
||||||
price_material: net,
|
price_material: net,
|
||||||
price_expense: 0,
|
price_expense: 0,
|
||||||
@@ -290,48 +413,52 @@ export function computeBill(
|
|||||||
material: fee,
|
material: fee,
|
||||||
expense: pct !== null ? 0 : null,
|
expense: pct !== null ? 0 : null,
|
||||||
sum: fee,
|
sum: fee,
|
||||||
|
parent: "sec:G",
|
||||||
|
level: 2,
|
||||||
|
cost: "supply",
|
||||||
|
_gaps: pct === null ? [gap("bill", "fee", "조달수수료", "조달수수료율 입력 없음")] : [],
|
||||||
};
|
};
|
||||||
if (owner.length) {
|
if (owner.length) {
|
||||||
items.push(feeRow);
|
// 조달수수료 = sec:G 끝 고정
|
||||||
if (pct === null) gaps.push(gap("bill", "fee", "조달수수료", "조달수수료율 입력 없음"));
|
let at = rows.findIndex((row) => row.key === "sec:G") + 1;
|
||||||
|
while (at < rows.length && rows[at].level > 1) at += 1;
|
||||||
|
rows.splice(at, 0, feeRow);
|
||||||
}
|
}
|
||||||
const sums: Record<string, Row> = {};
|
const byKey = new Map<string, Row>();
|
||||||
for (const [code] of SECTIONS)
|
for (const row of rows) byKey.set(row.key, row);
|
||||||
sums[code] = { sum: 0, labor: 0, material: 0, expense: 0, blank: 0 };
|
for (const row of rows)
|
||||||
for (const row of items) {
|
if (row.kind !== "item")
|
||||||
if (row.kind !== "item") continue;
|
Object.assign(row, { sum: 0, labor: 0, material: 0, expense: 0, blank: 0 });
|
||||||
const code = row.section;
|
|
||||||
const chain = [code];
|
|
||||||
const parent = SECTIONS.find(([c]) => c === code)![2];
|
|
||||||
if (parent) chain.push(parent);
|
|
||||||
for (const c of chain) {
|
|
||||||
for (const p of ["sum", ...PARTS]) sums[c][p] += row[p] || 0;
|
|
||||||
if (row.sum === null) sums[c].blank += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const rows: Row[] = [];
|
|
||||||
for (const [code, name] of SECTIONS) {
|
|
||||||
const s = sums[code];
|
|
||||||
rows.push({
|
|
||||||
kind: "title",
|
|
||||||
key: `sec:${code}`,
|
|
||||||
section: code,
|
|
||||||
name: code !== "G" ? `${code} ${name}` : name,
|
|
||||||
sum: s.sum,
|
|
||||||
labor: s.labor,
|
|
||||||
material: s.material,
|
|
||||||
expense: s.expense,
|
|
||||||
blank: s.blank,
|
|
||||||
note: (code === "G" ? "(제외금액)" : "") + (s.blank ? ` 빈 줄 ${s.blank}` : ""),
|
|
||||||
});
|
|
||||||
rows.push(...items.filter((row) => row.section === code));
|
|
||||||
}
|
|
||||||
nest(rows);
|
|
||||||
const direct: Row = { sum: 0, labor: 0, material: 0, expense: 0 };
|
const direct: Row = { sum: 0, labor: 0, material: 0, expense: 0 };
|
||||||
let blank = 0;
|
let blank = 0;
|
||||||
for (const code of DIRECT) {
|
for (const row of rows) {
|
||||||
for (const p of Object.keys(direct)) direct[p] += sums[code][p];
|
if (row.kind !== "item") continue;
|
||||||
blank += sums[code].blank;
|
for (let at = byKey.get(row.parent ?? ""); at; at = byKey.get(at.parent ?? "")) {
|
||||||
|
for (const p of ["sum", ...PARTS]) at[p] += row[p] || 0;
|
||||||
|
if (row.sum === null) at.blank += 1;
|
||||||
|
}
|
||||||
|
if (row.cost === "direct") {
|
||||||
|
for (const p of Object.keys(direct)) direct[p] += row[p] || 0;
|
||||||
|
if (row.sum === null) blank += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const gaps: Gap[] = [];
|
||||||
|
const seq = new Map<string | null, number>();
|
||||||
|
for (const row of rows) {
|
||||||
|
gaps.push(...(row._gaps || []));
|
||||||
|
delete row._gaps;
|
||||||
|
delete row._home;
|
||||||
|
row.no = "";
|
||||||
|
if (row.kind === "title")
|
||||||
|
row.note =
|
||||||
|
(row.key === "sec:G" ? "(제외금액)" : "") + (row.blank ? ` 빈 줄 ${row.blank}` : "");
|
||||||
|
else if (row.kind === "group") row.note = row.blank ? `빈 줄 ${row.blank}` : "";
|
||||||
|
if (row.kind !== "item" && row.cost === "direct" && row.level <= 2) {
|
||||||
|
// 번호 = 레벨 1 · 2 묶음 차례(1 · 1.1 · 관급 없음)
|
||||||
|
const up = row.parent;
|
||||||
|
seq.set(up, (seq.get(up) || 0) + 1);
|
||||||
|
row.no = up ? `${byKey.get(up)!.no}.${seq.get(up)}` : String(seq.get(up));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const totals = {
|
const totals = {
|
||||||
direct: { ...direct, blank },
|
direct: { ...direct, blank },
|
||||||
|
|||||||
@@ -483,7 +483,8 @@ def hand_sources(user: dict[str, Any] | None) -> list[tuple[str, str, dict[str,
|
|||||||
continue
|
continue
|
||||||
src = {"code": str(ref["code"]), "given": _clean_given(ref.get("given")),
|
src = {"code": str(ref["code"]), "given": _clean_given(ref.get("given")),
|
||||||
"rate": ref.get("rate"), "picks": ref.get("picks")} # fmt: skip
|
"rate": ref.get("rate"), "picks": ref.get("picks")} # fmt: skip
|
||||||
out.append((row["key"], f"수기 줄 {row.get('name') or row['key']}", src))
|
where = "수기 줄" if row["key"].startswith("hand:") else "내역 줄" # 184 자동 줄 연결 덮개
|
||||||
|
out.append((row["key"], f"{where} {row.get('name') or ref.get('name') or row['key']}", src))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -110,10 +110,50 @@ def _clean_ref(ref: Any) -> dict[str, Any] | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_v2(raw: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""184 덮개 v2 — 표 차례(겹친 키 · `fee` 버림) · 새 줄(`hand:`) = 모든 칸 ·
|
||||||
|
자동 줄 = 고친 칸만(값 · null = 일부러 빈칸 · 못 쓰는 값 = 칸 버림) · `gone` = 자동 줄 키."""
|
||||||
|
rows, seen = [], set()
|
||||||
|
for row in raw.get("rows") if isinstance(raw.get("rows"), list) else []:
|
||||||
|
key = row.get("key") if isinstance(row, dict) else None
|
||||||
|
if not isinstance(key, str) or not key or key in seen or key == "fee":
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
parent = row.get("parent")
|
||||||
|
out: dict[str, Any] = {"key": key}
|
||||||
|
if isinstance(parent, str) and parent:
|
||||||
|
out["parent"] = parent
|
||||||
|
if key.startswith("hand:"):
|
||||||
|
group = row.get("kind") == "group"
|
||||||
|
if group:
|
||||||
|
out["kind"] = "group"
|
||||||
|
if row.get("cost") == "direct":
|
||||||
|
out["cost"] = "direct"
|
||||||
|
out.update({k: _text(row.get(k)) for k in ("name", "spec", "unit")})
|
||||||
|
out["qty"] = None if group else _num(row.get("qty"))
|
||||||
|
out["ref"] = None if group else _clean_ref(row.get("ref"))
|
||||||
|
else:
|
||||||
|
for k in ("name", "spec", "unit"):
|
||||||
|
if k in row:
|
||||||
|
out[k] = None if row[k] is None else _text(row[k])
|
||||||
|
if "qty" in row and (row["qty"] is None or _num(row["qty"]) is not None):
|
||||||
|
out["qty"] = _num(row["qty"])
|
||||||
|
if "ref" in row and (row["ref"] is None or _clean_ref(row["ref"]) is not None):
|
||||||
|
out["ref"] = _clean_ref(row["ref"])
|
||||||
|
rows.append(out)
|
||||||
|
given = raw.get("gone") if isinstance(raw.get("gone"), list) else []
|
||||||
|
gone = list(
|
||||||
|
dict.fromkeys(k for k in given if isinstance(k, str) and k[:5] not in ("", "hand:"))
|
||||||
|
)
|
||||||
|
return {"v": 2, "rows": rows, "gone": gone} if rows or gone else {}
|
||||||
|
|
||||||
|
|
||||||
def clean_bill(raw: Any) -> dict[str, Any]:
|
def clean_bill(raw: Any) -> dict[str, Any]:
|
||||||
"""설계내역서 손질(166-2) — 수량 덮어쓰기(수만) · 손 줄(`hand:` 키 · 아는 공종만) ·
|
"""설계내역서 덮개 — 184 v2(`_clean_v2`) · 옛 꼴(166-2 수량 덮어쓰기(수만) · 손 줄(`hand:` 키 ·
|
||||||
182 레벨 = `kind`(group) · `parent`(`hand:` 키)."""
|
아는 공종만) · 182 레벨 = `kind`(group) · `parent`(`hand:` 키))."""
|
||||||
raw = raw if isinstance(raw, dict) else {}
|
raw = raw if isinstance(raw, dict) else {}
|
||||||
|
if raw.get("v") == 2:
|
||||||
|
return _clean_v2(raw)
|
||||||
qty = raw.get("qty") if isinstance(raw.get("qty"), dict) else {}
|
qty = raw.get("qty") if isinstance(raw.get("qty"), dict) else {}
|
||||||
codes = {code for code, _name, _parent in SECTIONS}
|
codes = {code for code, _name, _parent in SECTIONS}
|
||||||
rows = []
|
rows = []
|
||||||
|
|||||||
@@ -199,12 +199,15 @@ def test_거울_TS_가_같은_값():
|
|||||||
assert _run_ts(INPUTS, user) == compute_estimation(INPUTS, user)
|
assert _run_ts(INPUTS, user) == compute_estimation(INPUTS, user)
|
||||||
|
|
||||||
|
|
||||||
#: 사본 주인 → 대가 1 단위(145-2 `daega_price` 자리 견본) · 엑셀 일위대가목록표 대가17 · 24
|
#: 사본 주인 → 대가 1 단위(145-2 `daega_price` 자리 견본 · key = 대가 블록 키) ·
|
||||||
|
#: 엑셀 일위대가목록표 대가17 · 24
|
||||||
|
A17 = {"no": "대가17", "key": "blk:a", "labor": 96868, "material": 11309, "expense": 16417}
|
||||||
DAEGA = {
|
DAEGA = {
|
||||||
"pipe@100.0__gsoiladd1": {"no": "대가17", "labor": 96868, "material": 11309, "expense": 16417},
|
"pipe@100.0__gsoiladd1": A17,
|
||||||
"pipe@200.0__gsoiladd1": {"no": "대가17", "labor": 96868, "material": 11309, "expense": 16417},
|
"pipe@200.0__gsoiladd1": A17,
|
||||||
"pipe@300.0__gsoiladd1": {"no": "대가24", "labor": 129370, "material": 15059, "expense": 21833},
|
"pipe@300.0__gsoiladd1": {"no": "대가24", "key": "blk:b", "labor": 129370, "material": 15059,
|
||||||
}
|
"expense": 21833},
|
||||||
|
} # fmt: skip
|
||||||
REVET = {
|
REVET = {
|
||||||
"key": "st:pipe:revet|돌쌓기(찰)|2.0", "section": "2.1", "name": "흙막이",
|
"key": "st:pipe:revet|돌쌓기(찰)|2.0", "section": "2.1", "name": "흙막이",
|
||||||
"spec": "찰쌓기 H=2.0", "unit": "m", "qty": 42.5, "note": "",
|
"spec": "찰쌓기 H=2.0", "unit": "m", "qty": 42.5, "note": "",
|
||||||
@@ -219,9 +222,10 @@ def test_구조물_열_대가_블록마다_나눔():
|
|||||||
sheets = compute_estimation(SPLIT_INPUTS, USER, DAEGA.get)
|
sheets = compute_estimation(SPLIT_INPUTS, USER, DAEGA.get)
|
||||||
rows = [r for r in sheets["bill"]["rows"] if r["key"].startswith("st:")]
|
rows = [r for r in sheets["bill"]["rows"] if r["key"].startswith("st:")]
|
||||||
got = [(r["key"].split("#")[-1], r["qty"], r["sum"], r["note"]) for r in rows]
|
got = [(r["key"].split("#")[-1], r["qty"], r["sum"], r["note"]) for r in rows]
|
||||||
|
# 184 — 줄 키 꼬리 = 대가 블록 키(번호 밀림에 덮개 안 흔들림) · 비고 = 대가 번호
|
||||||
assert got == [
|
assert got == [
|
||||||
("대가17", 20, 2491880, "대가17"), # 124,594 × 20 — 비목마다 ROUNDDOWN
|
("blk:a", 20, 2491880, "대가17"), # 124,594 × 20 — 비목마다 ROUNDDOWN
|
||||||
("대가24", 12.5, 2078274, "대가24"), # 129,370 · 15,059 · 21,833 × 12.5
|
("blk:b", 12.5, 2078274, "대가24"), # 129,370 · 15,059 · 21,833 × 12.5
|
||||||
("st:pipe:revet|돌쌓기(찰)|2.0", 10, None, ""), # 대가 없는 주인 = 「연결 없음」
|
("st:pipe:revet|돌쌓기(찰)|2.0", 10, None, ""), # 대가 없는 주인 = 「연결 없음」
|
||||||
]
|
]
|
||||||
assert rows[0]["labor"] == 1937360 and rows[1]["material"] == 188237 # 15,059 × 12.5 버림
|
assert rows[0]["labor"] == 1937360 and rows[1]["material"] == 188237 # 15,059 × 12.5 버림
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""166-2 B09 설계내역서 손질 — 수량 덮어쓰기 · 손 줄 · 호표 연결 · 새 산근 · 거울 · 우클릭 메뉴.
|
"""166-2 B09 설계내역서 손질 — 수량 덮어쓰기 · 손 줄 · 호표 연결 · 새 산근 · 거울 · 우클릭 메뉴.
|
||||||
|
|
||||||
- 수량 = `user.bill.qty[줄 키]` 가 있으면 그 값(`qty_auto` = 자동 값) · 키 지우면 자동(B08)
|
- 수량 = 옛 꼴(184 전) `user.bill.qty[줄 키]` 가 있으면 그 값(`auto_qty` = 자동 값) · 키 지우면 자동(B08)
|
||||||
- 손 줄 = `user.bill.rows` — `after` 줄 뒤(제목 = 공종 첫 자리 · 못 찾으면 공종 끝) ·
|
- 손 줄 = `user.bill.rows` — `after` 줄 뒤(제목 = 공종 첫 자리 · 못 찾으면 공종 끝) ·
|
||||||
단가 = `ref_price` 하나(dg 대가 목록 · sg 산근 목록 · logic = `sg_hand` 산근) ·
|
단가 = `ref_price` 하나(dg 대가 목록 · sg 산근 목록 · logic = `sg_hand` 산근) ·
|
||||||
비고 = 「대가n」 · 「산근 n호표」
|
비고 = 「대가n」 · 「산근 n호표」
|
||||||
@@ -92,9 +92,9 @@ def _ts(names: list[str]) -> str:
|
|||||||
def test_수량_덮어쓰기_자동_값_남김_지우면_자동():
|
def test_수량_덮어쓰기_자동_값_남김_지우면_자동():
|
||||||
rows = _rows(compute_estimation(INPUTS, USER, None, BOOKS))
|
rows = _rows(compute_estimation(INPUTS, USER, None, BOOKS))
|
||||||
row = rows["earth:4"]
|
row = rows["earth:4"]
|
||||||
assert (row["qty"], row["qty_auto"], row["labor"]) == (100, 5675, 111600) # 1,116 × 100
|
assert (row["qty"], row["auto_qty"], row["labor"]) == (100, 5675, 111600) # 1,116 × 100
|
||||||
plain = _rows(compute_estimation(INPUTS, {}, None, BOOKS))["earth:4"]
|
plain = _rows(compute_estimation(INPUTS, {}, None, BOOKS))["earth:4"]
|
||||||
assert plain["qty"] == 5675 and "qty_auto" not in plain
|
assert plain["qty"] == 5675 and "auto_qty" not in plain # 184 auto_{칸} 꼴
|
||||||
# 단가 그대로 · 수량 없던 줄도 덮어쓰면 금액이 생김
|
# 단가 그대로 · 수량 없던 줄도 덮어쓰면 금액이 생김
|
||||||
rows = _rows(compute_estimation(INPUTS, {"bill": {"qty": {"earth:1": 1}}}, None, BOOKS))
|
rows = _rows(compute_estimation(INPUTS, {"bill": {"qty": {"earth:1": 1}}}, None, BOOKS))
|
||||||
assert rows["earth:1"]["sum"] == 25821
|
assert rows["earth:1"]["sum"] == 25821
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ SPOTS = {
|
|||||||
"B08_Quantity/B08_Quantity_UI_Material.ts": ["2공구 엑셀 수기 입력값과 다름"],
|
"B08_Quantity/B08_Quantity_UI_Material.ts": ["2공구 엑셀 수기 입력값과 다름"],
|
||||||
"B09_Estimation/B09_Estimation_Calc_Bill.ts": ['"수기 줄 — 연결 없음(우클릭 [연결])"'],
|
"B09_Estimation/B09_Estimation_Calc_Bill.ts": ['"수기 줄 — 연결 없음(우클릭 [연결])"'],
|
||||||
"B09_Estimation/B09_Estimation_Calc_Bill.py": ['"수기 줄 — 연결 없음(우클릭 [연결])"'],
|
"B09_Estimation/B09_Estimation_Calc_Bill.py": ['"수기 줄 — 연결 없음(우클릭 [연결])"'],
|
||||||
"B09_Estimation/B09_Estimation_Sangeun.py": ['f"수기 줄 {'],
|
"B09_Estimation/B09_Estimation_Sangeun.py": ['"수기 줄" if'],
|
||||||
"B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가 = 수기 입력"],
|
"B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가 = 수기 입력"],
|
||||||
"B09_Estimation/B09_Estimation_UI_Tabs_Total.ts": ['"수기 입력"', '"수기 입력 = 안전관리비'],
|
"B09_Estimation/B09_Estimation_UI_Tabs_Total.ts": ['"수기 입력"', '"수기 입력 = 안전관리비'],
|
||||||
"ui_template/ui_template_locale_b2.ts": [
|
"ui_template/ui_template_locale_b2.ts": [
|
||||||
@@ -49,4 +49,4 @@ def test_저장_키는_그대로():
|
|||||||
assert 'export type QtySource = "손" | "칸" | "없음";' in calc
|
assert 'export type QtySource = "손" | "칸" | "없음";' in calc
|
||||||
assert "export type Qty = { 손: number } | null;" in calc
|
assert "export type Qty = { 손: number } | null;" in calc
|
||||||
assert "손?: boolean;" in _text("ui_template/sheet/ui_template_sheet_types.ts")
|
assert "손?: boolean;" in _text("ui_template/sheet/ui_template_sheet_types.ts")
|
||||||
assert "rows[] 손 줄" in _text("B09_Estimation/B09_Estimation_Calc_Bill.ts") # 주석 그대로
|
assert "손 줄 after" in _text("B09_Estimation/B09_Estimation_Calc_Bill.ts") # 주석 그대로
|
||||||
|
|||||||
@@ -100,14 +100,15 @@ def test_레벨_차례_부모():
|
|||||||
] # fmt: skip
|
] # fmt: skip
|
||||||
rows = _rows(sheets)
|
rows = _rows(sheets)
|
||||||
assert rows["sec:1"]["level"] == 1 and rows["sec:3"]["level"] == 1
|
assert rows["sec:1"]["level"] == 1 and rows["sec:3"]["level"] == 1
|
||||||
|
# 184 — parent = 실제 부모 키(공종 바로 아래 = 그 제목 키)
|
||||||
assert (rows["haul:1"]["level"], rows["hand:y"]["level"], rows["hand:y"]["parent"]) == (
|
assert (rows["haul:1"]["level"], rows["hand:y"]["level"], rows["hand:y"]["parent"]) == (
|
||||||
2,
|
2,
|
||||||
2,
|
2,
|
||||||
None,
|
"sec:3",
|
||||||
)
|
)
|
||||||
assert rows["hand:x"]["parent"] is None and rows["hand:b"]["parent"] == "hand:g2"
|
assert rows["hand:x"]["parent"] == "sec:1.2" and rows["hand:b"]["parent"] == "hand:g2"
|
||||||
# 돌기 — 먼저 나온 줄이 끊김(p = 공종 아래 · q = p 아래)
|
# 돌기 — 먼저 나온 줄이 끊김(p = 공종 아래 · q = p 아래)
|
||||||
assert (rows["hand:p"]["parent"], rows["hand:p"]["level"]) == (None, 3)
|
assert (rows["hand:p"]["parent"], rows["hand:p"]["level"]) == ("sec:1.1", 3)
|
||||||
assert (rows["hand:q"]["parent"], rows["hand:q"]["level"]) == ("hand:p", 4)
|
assert (rows["hand:q"]["parent"], rows["hand:q"]["level"]) == ("hand:p", 4)
|
||||||
# 묶음 줄 = 이름만 · 「연결 없음」 목록에 안 듦
|
# 묶음 줄 = 이름만 · 「연결 없음」 목록에 안 듦
|
||||||
g1 = rows["hand:g1"]
|
g1 = rows["hand:g1"]
|
||||||
@@ -211,7 +212,8 @@ def test_우클릭_레벨_메뉴_TS():
|
|||||||
assert pick("auto") == [] and "줄 지우기" not in out["auto"]
|
assert pick("auto") == [] and "줄 지우기" not in out["auto"]
|
||||||
assert {k: out["child"][k] for k in ("section", "after", "parent")} == {
|
assert {k: out["child"][k] for k in ("section", "after", "parent")} == {
|
||||||
"section": "1.2", "after": "hand:c", "parent": "hand:g2"} # fmt: skip
|
"section": "1.2", "after": "hand:c", "parent": "hand:g2"} # fmt: skip
|
||||||
assert (out["below"]["after"], "parent" in out["below"]) == ("hand:c", False) # g1 자손 끝
|
# g1 자손 끝 · 부모 = 셈 parent 그대로(184 공종 제목 키 — 옛 꼴 다듬기가 hand: 아닌 부모 버림)
|
||||||
|
assert (out["below"]["after"], out["below"]["parent"]) == ("hand:c", "sec:1.2")
|
||||||
assert (out["belowB"]["after"], out["belowB"]["parent"]) == ("hand:b", "hand:g2")
|
assert (out["belowB"]["after"], out["belowB"]["parent"]) == ("hand:b", "hand:g2")
|
||||||
assert out["indent"]["parent"] == "hand:g1"
|
assert out["indent"]["parent"] == "hand:g1"
|
||||||
assert "parent" not in out["outdent"] # g1 이 맨 위 → 공종 아래
|
assert "parent" not in out["outdent"] # g1 이 맨 위 → 공종 아래
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
"""184-2 B09 설계내역서 자유 편집 — 셈 · 저장(`user.bill` v2 덮개 · 계약 = `Calc_Bill.py` 머리).
|
||||||
|
|
||||||
|
- 덮개 없음 · 굳힘(보이는 줄 키 · 부모 · 차례만) = 기본 표 그대로 ·
|
||||||
|
번호 자동(1 · 1.1 · 관급 없음)
|
||||||
|
- 자동 줄 칸 덮기(이름 · 규격 · 단위 · 수량 · 연결) = 그 값 + auto_{칸} · null = 빈칸 ·
|
||||||
|
안 고친 칸 = B08 따라감
|
||||||
|
- gone = 안 살아남(「연결 없음」에도 없음) · 저장에 없는 자동 줄 = 기본 부모 끝 「새 자동 줄」 ·
|
||||||
|
원천이 안 낸 키 = 안 보임
|
||||||
|
- 새 1단 공종 = 직접비(sums) · 관급 자재 줄 = 관급자재대 밖 못 감 · 조달수수료 = 관급자재대 끝 ·
|
||||||
|
깊이 5 · 돎 · 옛 꼴 = 같은 표의 v2 · 구조물 블록 키 · 자동 줄 로직 연결 → sg_hand · 거울 · 다듬기
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from B09_Estimation import B09_Estimation_Sangeun as sg # noqa: E402
|
||||||
|
from B09_Estimation import B09_Estimation_Store as store # noqa: E402
|
||||||
|
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402
|
||||||
|
|
||||||
|
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
||||||
|
|
||||||
|
ITEMS = [
|
||||||
|
{"key": "earth:1", "section": "1.1", "name": "규준틀", "unit": "개소", "qty": 14,
|
||||||
|
"ref": {"tab": "dg_list", "key": "B00194"}},
|
||||||
|
{"key": "earth:4", "section": "1.2", "name": "절토(토사)", "spec": "기계", "unit": "㎥",
|
||||||
|
"qty": 10, "ref": {"tab": "sg_list", "key": "D01341"}},
|
||||||
|
{"key": "earth:5", "section": "1.2", "name": "절토(연암)", "unit": "㎥", "qty": 9, "ref": None,
|
||||||
|
"why": "조합 밖 토공 산근 — 연결 없음"},
|
||||||
|
{"key": "haul:1", "section": "3", "name": "운반", "unit": "㎥", "qty": 2,
|
||||||
|
"ref": {"tab": "sg_list", "key": "D01341"}},
|
||||||
|
] # fmt: skip
|
||||||
|
HOPYO = {
|
||||||
|
"B00194": {"labor": 21936, "material": 3885, "expense": 0},
|
||||||
|
"D01341": {"labor": 1116, "material": 360, "expense": 463},
|
||||||
|
}
|
||||||
|
MATERIALS = [
|
||||||
|
{"key": "b08:pipe_800", "name": "파형강관", "spec": "Φ800", "unit": "M", "origin": "B08",
|
||||||
|
"prices": {"관급": 157000}, "qty": 212},
|
||||||
|
{"key": "b08:sand", "name": "골재", "spec": "모래", "unit": "㎥", "origin": "B08",
|
||||||
|
"prices": {"물가정보": 30000}, "qty": 16},
|
||||||
|
] # fmt: skip
|
||||||
|
INPUTS = {"materials": MATERIALS, "bill": {"items": ITEMS}, "hopyo": HOPYO}
|
||||||
|
BASE = {
|
||||||
|
"materials": {"b08:pipe_800": {"supply": "관급"}, "b08:sand": {"supply": "별도"}},
|
||||||
|
"estimate": {"procurement_fee_pct": 0.54},
|
||||||
|
}
|
||||||
|
BOOKS = {
|
||||||
|
"daega": [{"no": 3, "key": "DG:a", "labor": 1000, "material": 200, "expense": 30,
|
||||||
|
"sum": 1230}],
|
||||||
|
"sg_list": [{"no": 2, "key": "SG:new", "labor": 10, "material": 1, "expense": 0, "sum": 11}],
|
||||||
|
"sg_hand": {"earth:5": "SG:new"},
|
||||||
|
} # fmt: skip
|
||||||
|
DG = {"tab": "dg", "key": "DG:a"}
|
||||||
|
MAT = "mat:b08:pipe_800"
|
||||||
|
|
||||||
|
|
||||||
|
def _calc(bill=None, inputs=INPUTS, price_of=None):
|
||||||
|
user = {**BASE, **({"bill": bill} if bill is not None else {})}
|
||||||
|
return compute_estimation(inputs, user, price_of, BOOKS)
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(sheets):
|
||||||
|
return {row["key"]: row for row in sheets["bill"]["rows"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _keys(sheets):
|
||||||
|
return [row["key"] for row in sheets["bill"]["rows"]]
|
||||||
|
|
||||||
|
|
||||||
|
def _freeze(sheets, **more):
|
||||||
|
"""화면 첫 손질 굳힘 — 보이는 줄의 키 · 부모 · 차례(새 줄은 모든 칸) · fee 뺌."""
|
||||||
|
rows = []
|
||||||
|
for r in sheets["bill"]["rows"]:
|
||||||
|
if r["key"] == "fee":
|
||||||
|
continue
|
||||||
|
row = {"key": r["key"], "parent": r["parent"]}
|
||||||
|
if r.get("hand"):
|
||||||
|
row.update({k: r[k] for k in ("name", "spec", "unit", "qty", "ref")})
|
||||||
|
row.update({"kind": "group"} if r["kind"] == "group" else {})
|
||||||
|
rows.append(row)
|
||||||
|
return {"v": 2, "rows": rows, "gone": [], **more}
|
||||||
|
|
||||||
|
|
||||||
|
def _put(bill, key, **cover):
|
||||||
|
"""굳힌 덮개의 한 줄 칸 고침 · 부모 바꿈."""
|
||||||
|
out = copy.deepcopy(bill)
|
||||||
|
next(r for r in out["rows"] if r["key"] == key).update(cover)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def test_덮개_없음_빈_굳힘_기본_표_번호():
|
||||||
|
plain = _calc()
|
||||||
|
assert _calc({})["bill"] == plain["bill"] # [초기화] = bill 지움(빈 v2 는 다듬기가 버림)
|
||||||
|
assert _calc(_freeze(plain))["bill"] == plain["bill"] # 굳혀도 표 · 합 · 연결 없음 그대로
|
||||||
|
rows = _rows(plain)
|
||||||
|
got = [(r["key"], r["no"], r["name"], r["level"], r["parent"]) for r in plain["bill"]["rows"]
|
||||||
|
if r["kind"] == "title"][:3] # fmt: skip
|
||||||
|
assert got == [("sec:1", "1", "토공사", 1, None), ("sec:1.1", "1.1", "준비공", 2, "sec:1"),
|
||||||
|
("sec:1.2", "1.2", "절토", 2, "sec:1")] # fmt: skip
|
||||||
|
assert (rows["sec:4"]["no"], rows["sec:G"]["no"], rows["fee"]["no"]) == ("4", "", "")
|
||||||
|
assert (rows["earth:4"]["auto"], rows["earth:4"]["parent"], rows["earth:4"]["cost"]) == (
|
||||||
|
True, "sec:1.2", "direct") # fmt: skip
|
||||||
|
assert (rows[MAT]["cost"], rows["fee"]["cost"], _keys(plain)[-1]) == ("supply", "supply", "fee")
|
||||||
|
assert not any(k.startswith("auto_") for r in rows.values() for k in r)
|
||||||
|
|
||||||
|
|
||||||
|
def test_자동_줄_칸_덮기_자동값_남김():
|
||||||
|
bill = _put(
|
||||||
|
_freeze(_calc()), "earth:4", name="절토 고침", spec=None, unit="m3", qty=100, ref=DG
|
||||||
|
)
|
||||||
|
row = _rows(_calc(bill))["earth:4"]
|
||||||
|
assert (row["name"], row["spec"], row["unit"], row["qty"]) == ("절토 고침", "", "m3", 100)
|
||||||
|
assert (row["auto_name"], row["auto_spec"], row["auto_unit"], row["auto_qty"]) == (
|
||||||
|
"절토(토사)", "기계", "㎥", 10) # fmt: skip
|
||||||
|
assert (row["ref"], row["auto_ref"], row["note"]) == (DG, {"tab": "sg_list", "key": "D01341"},
|
||||||
|
"대가3") # fmt: skip
|
||||||
|
assert (row["labor"], row["sum"]) == (100000, 123000) # 대가3 1,230 × 100
|
||||||
|
# 제목 이름 덮개 · 제목 = 이름만(수량 덮개 안 받음)
|
||||||
|
title = _rows(_calc(_put(bill, "sec:1.2", name="절토공", qty=5)))["sec:1.2"]
|
||||||
|
assert (title["name"], title["auto_name"], title["no"], "auto_qty" in title) == (
|
||||||
|
"절토공", "절토", "1.2", False) # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def test_B08_바뀜_안_고친_칸_따라감_고친_칸_지킴_null_빈칸():
|
||||||
|
bill = _put(_freeze(_calc()), "earth:4", qty=100)
|
||||||
|
bill = _put(bill, "earth:1", qty=None)
|
||||||
|
changed = copy.deepcopy(INPUTS)
|
||||||
|
for item in changed["bill"]["items"]:
|
||||||
|
item["qty"] = item["qty"] * 2
|
||||||
|
item["name"] = item["name"] + "*"
|
||||||
|
sheets = _calc(bill, changed)
|
||||||
|
rows = _rows(sheets)
|
||||||
|
assert (rows["earth:4"]["qty"], rows["earth:4"]["auto_qty"], rows["earth:4"]["name"]) == (
|
||||||
|
100, 20, "절토(토사)*") # fmt: skip
|
||||||
|
assert (rows["earth:5"]["qty"], rows["haul:1"]["qty"]) == (18, 4) # 안 고친 줄 = 따라감
|
||||||
|
assert rows["earth:1"]["qty"] is None and rows["earth:1"]["sum"] is None # 일부러 빈칸
|
||||||
|
assert ("earth:1", "수량 연결 없음") in {(g["key"], g["why"]) for g in sheets["gaps"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_gone_안_살아남_새_자동_줄_기본_부모_끝_원천_사라짐_안_보임():
|
||||||
|
bill = _freeze(_calc(), gone=["earth:5", "sec:G", MAT]) # sec:G · 자재 줄 = 못 지움
|
||||||
|
bill["rows"].append({"key": "earth:99", "parent": "sec:1.2", "qty": 3}) # 원천 없음
|
||||||
|
more = copy.deepcopy(INPUTS)
|
||||||
|
more["bill"]["items"] += [
|
||||||
|
{"key": "earth:5", "section": "1.2", "name": "되살아남?", "unit": "㎥", "qty": 1},
|
||||||
|
{"key": "earth:6", "section": "1.2", "name": "암발파", "unit": "㎥", "qty": 4, "ref": None},
|
||||||
|
]
|
||||||
|
sheets = _calc(bill, more)
|
||||||
|
keys = _keys(sheets)
|
||||||
|
assert "earth:5" not in keys and "earth:99" not in keys
|
||||||
|
assert {"sec:G", MAT} <= set(keys)
|
||||||
|
assert "earth:5" not in {g["key"] for g in sheets["gaps"]} # 지운 줄 = 연결 없음에도 없음
|
||||||
|
at = keys.index("earth:6")
|
||||||
|
assert keys[at - 1] == "earth:4" and keys[at + 1] == "sec:1.3" # 절토 끝
|
||||||
|
assert _rows(sheets)["earth:6"]["note"] == "새 자동 줄"
|
||||||
|
# 제목을 지웠으면 새 자동 줄 = 표 끝 맨 위
|
||||||
|
gone = _calc(_freeze(_calc(), gone=["sec:1.2", "earth:4", "earth:5"]), more)
|
||||||
|
assert (_keys(gone)[-1], _rows(gone)["earth:6"]["parent"]) == ("earth:6", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_새_1단_공종_직접비_번호_옮김_들여_내어():
|
||||||
|
plain = _calc()
|
||||||
|
bill = _freeze(plain)
|
||||||
|
keys = [r["key"] for r in bill["rows"]]
|
||||||
|
at = keys.index("sec:G") # 4 자재대 뒤 · 관급자재대 앞에 새 1단 공종
|
||||||
|
bill["rows"][at:at] = [
|
||||||
|
{"key": "hand:g", "kind": "group", "cost": "direct", "name": "부대공", "spec": "",
|
||||||
|
"unit": "", "qty": None, "ref": None},
|
||||||
|
{"key": "hand:a", "parent": "hand:g", "name": "a", "spec": "", "unit": "㎡", "qty": 10,
|
||||||
|
"ref": DG},
|
||||||
|
{"key": "hand:s", "parent": "hand:g", "kind": "group", "name": "소공종", "spec": "",
|
||||||
|
"unit": "", "qty": None, "ref": None},
|
||||||
|
] # fmt: skip
|
||||||
|
sheets = _calc(bill)
|
||||||
|
rows = _rows(sheets)
|
||||||
|
g = rows["hand:g"]
|
||||||
|
assert (g["no"], g["level"], g["cost"], g["sum"], g["section"]) == ("5", 1, "direct", 12300,
|
||||||
|
None) # fmt: skip
|
||||||
|
assert (rows["hand:s"]["no"], rows["hand:a"]["no"]) == ("5.1", "") # 레벨 2 묶음만 번호
|
||||||
|
totals = sheets["bill"]["totals"]["direct"]
|
||||||
|
assert totals["labor"] - plain["bill"]["totals"]["direct"]["labor"] == 10000
|
||||||
|
# 형제 차례 바꿈(위로 ↑) = 번호 따라감 · 들여(다른 묶음 아래) · 공종 제목도 옮김
|
||||||
|
up = copy.deepcopy(bill)
|
||||||
|
keys = [r["key"] for r in up["rows"]]
|
||||||
|
up["rows"].insert(keys.index("sec:1.1"), up["rows"].pop(keys.index("sec:1.2")))
|
||||||
|
rows = _rows(_calc(up))
|
||||||
|
assert (rows["sec:1.2"]["no"], rows["sec:1.1"]["no"]) == ("1.1", "1.2")
|
||||||
|
moved = _put(_put(bill, "earth:4", parent="sec:1.1"), "sec:3", parent="hand:g")
|
||||||
|
rows = _rows(_calc(moved))
|
||||||
|
assert (rows["earth:4"]["parent"], rows["earth:4"]["level"], rows["earth:4"]["section"]) == (
|
||||||
|
"sec:1.1", 3, "1.1") # fmt: skip
|
||||||
|
assert (rows["sec:3"]["level"], rows["sec:3"]["no"], rows["hand:s"]["no"]) == (2, "4.1", "4.2")
|
||||||
|
assert (rows["sec:4"]["no"], rows["hand:g"]["no"]) == ("3", "4")
|
||||||
|
assert rows["sec:1.1"]["sum"] == _rows(plain)["sec:1.1"]["sum"] + _rows(plain)["earth:4"]["sum"]
|
||||||
|
assert _calc(moved)["bill"]["sums"] == sheets["bill"]["sums"] # 옮겨도 직접비 합 같음
|
||||||
|
|
||||||
|
|
||||||
|
def test_관급_줄_관급자재대_밖_못_감_수수료_끝():
|
||||||
|
plain = _calc()
|
||||||
|
bill = _put(_freeze(plain), MAT, parent="sec:1")
|
||||||
|
bill["rows"].append({"key": "hand:k", "parent": "sec:G", "name": "관급 손", "spec": "",
|
||||||
|
"unit": "개", "qty": 10, "ref": DG}) # fmt: skip
|
||||||
|
sheets = _calc(bill)
|
||||||
|
rows = _rows(sheets)
|
||||||
|
assert (rows[MAT]["parent"], rows[MAT]["cost"]) == ("sec:G", "supply")
|
||||||
|
assert _keys(sheets)[-3:] == [MAT, "hand:k", "fee"] # 조달수수료 = 관급자재대 끝
|
||||||
|
net = 212 * 157000 + 2000 # + 손 줄 재료비 200 × 10
|
||||||
|
assert sheets["bill"]["sums"]["supply_net"] == net
|
||||||
|
assert sheets["bill"]["sums"]["labor"] == plain["bill"]["sums"]["labor"] # 관급 = 직접비 밖
|
||||||
|
assert rows["fee"]["material"] == int(net * 0.54 / 100)
|
||||||
|
|
||||||
|
|
||||||
|
def test_깊이_5_돎_부모_바로잡기():
|
||||||
|
bill = _freeze(_calc())
|
||||||
|
chain = [("hand:g1", "sec:3"), ("hand:g2", "hand:g1"), ("hand:g3", "hand:g2"),
|
||||||
|
("hand:g4", "hand:g3"), ("hand:g5", "hand:g4"), ("hand:g6", "hand:g5")] # fmt: skip
|
||||||
|
for key, parent in chain:
|
||||||
|
bill["rows"].append({"key": key, "parent": parent, "kind": "group", "name": key})
|
||||||
|
bill["rows"].append({"key": "hand:i", "parent": "hand:g6", "name": "i", "qty": 1, "ref": DG})
|
||||||
|
bill["rows"] += [
|
||||||
|
{"key": "hand:p", "parent": "hand:q", "kind": "group", "name": "p"},
|
||||||
|
{"key": "hand:q", "parent": "hand:p", "kind": "group", "name": "q"},
|
||||||
|
]
|
||||||
|
bill = _put(_put(bill, "sec:1", parent="earth:4"), "sec:G", parent="sec:1")
|
||||||
|
rows = _rows(_calc(bill))
|
||||||
|
got = [(k, rows[k]["level"], rows[k]["parent"]) for k in ("hand:g4", "hand:g5", "hand:g6",
|
||||||
|
"hand:i")] # fmt: skip
|
||||||
|
assert got == [("hand:g4", 5, "hand:g3"), ("hand:g5", 5, "hand:g3"),
|
||||||
|
("hand:g6", 5, "hand:g3"), ("hand:i", 5, "hand:g3")] # fmt: skip
|
||||||
|
assert rows["hand:g1"]["sum"] == 1230 and rows["sec:3"]["sum"] == rows["haul:1"]["sum"] + 1230
|
||||||
|
assert (rows["hand:p"]["parent"], rows["hand:q"]["parent"]) == (None, "hand:p") # 돎 = 맨 위
|
||||||
|
assert (rows["sec:1"]["parent"], rows["sec:G"]["parent"]) == (None, None) # 항목 부모 · G 고정
|
||||||
|
|
||||||
|
|
||||||
|
#: 옛 꼴(166-2 수량 · 182 손 줄 after · parent)
|
||||||
|
OLD = {
|
||||||
|
"qty": {"earth:4": 100},
|
||||||
|
"rows": [
|
||||||
|
{"key": "hand:g1", "section": "1.2", "after": "earth:4", "kind": "group", "name": "묶음"},
|
||||||
|
{"key": "hand:a", "section": "1.2", "after": "hand:g1", "parent": "hand:g1", "name": "a",
|
||||||
|
"unit": "㎡", "qty": 10, "ref": DG},
|
||||||
|
{"key": "hand:b", "section": "3", "after": "sec:3", "name": "b", "qty": 2, "ref": None},
|
||||||
|
],
|
||||||
|
} # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def test_옛_꼴_그대로_읽음_굳히면_같은_표():
|
||||||
|
old = _calc(OLD)
|
||||||
|
rows = _rows(old)
|
||||||
|
keys = _keys(old)
|
||||||
|
assert keys[keys.index("sec:1.2") :][:5] == ["sec:1.2", "earth:4", "hand:g1", "hand:a",
|
||||||
|
"earth:5"] # fmt: skip
|
||||||
|
assert (rows["earth:4"]["qty"], rows["earth:4"]["auto_qty"]) == (100, 10)
|
||||||
|
assert (rows["hand:a"]["parent"], rows["hand:b"]["parent"]) == ("hand:g1", "sec:3")
|
||||||
|
v2 = _put(_freeze(old), "earth:4", qty=100) # 화면 첫 손질 = v2 로 굳힘
|
||||||
|
assert _calc(v2)["bill"] == old["bill"]
|
||||||
|
|
||||||
|
|
||||||
|
REVET = {"key": "st:r", "section": "2.1", "name": "흙막이", "unit": "m", "qty": 30, "note": "",
|
||||||
|
"ref": {"tab": "dg_list", "key": "r"}, "owners": {"o1": 10, "o2": 20}} # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def test_구조물_블록_키_대가_번호_밀려도_덮개_같은_줄():
|
||||||
|
inputs = {**INPUTS, "bill": {"items": [REVET]}}
|
||||||
|
first = {"no": "대가17", "key": "blk:a", "labor": 100, "material": 0, "expense": 0}
|
||||||
|
shifted = {**first, "no": "대가18"} # 앞에 블록이 끼어 번호만 밀림
|
||||||
|
bill = _put(_freeze(_calc({}, inputs, lambda o: first)), "st:r#blk:a", qty=7)
|
||||||
|
row = _rows(_calc(bill, inputs, lambda o: shifted))["st:r#blk:a"]
|
||||||
|
assert (row["qty"], row["auto_qty"], row["note"], row["labor"]) == (7, 30, "대가18", 700)
|
||||||
|
assert row["ref"] == {"tab": "dg", "key": "blk:a"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_자동_줄_로직_연결_새_산근_쓰는_곳():
|
||||||
|
logic = {"tab": "logic", "code": "GF000219", "name": "돌쌓기", "given": {}}
|
||||||
|
bill = _put(_freeze(_calc()), "earth:5", ref=logic)
|
||||||
|
user = {**BASE, "bill": store.clean_bill(bill)}
|
||||||
|
assert [(k, u) for k, u, _s in sg.hand_sources(user)] == [("earth:5", "내역 줄 돌쌓기")]
|
||||||
|
row = _rows(compute_estimation(INPUTS, user, None, BOOKS))["earth:5"]
|
||||||
|
assert (row["note"], row["ref"], row["sum"]) == (
|
||||||
|
"산근 2호표",
|
||||||
|
{"tab": "sg", "key": "SG:new"},
|
||||||
|
99,
|
||||||
|
)
|
||||||
|
assert row["auto_ref"] is None # 원래 연결 없음
|
||||||
|
|
||||||
|
|
||||||
|
def test_저장_다듬기_v2_왕복(tmp_path):
|
||||||
|
raw = {
|
||||||
|
"v": 2,
|
||||||
|
"rows": [
|
||||||
|
{"key": "sec:1", "name": None, "qty": 3, "junk": 1},
|
||||||
|
{"key": "earth:4", "parent": "sec:1.1", "qty": "x", "unit": None, "ref": {"tab": "zz"}},
|
||||||
|
{"key": "earth:5", "qty": None, "ref": None, "name": 12},
|
||||||
|
{"key": "earth:4", "qty": 1}, # 겹친 키
|
||||||
|
{"key": "fee", "qty": 9}, # 조달수수료 = 덮개 없음
|
||||||
|
{"key": 3},
|
||||||
|
{"key": "hand:g", "kind": "group", "cost": "direct", "qty": 5, "ref": DG},
|
||||||
|
{"key": "hand:a", "parent": "hand:g", "kind": "zz", "cost": "direct", "qty": "1"},
|
||||||
|
],
|
||||||
|
"gone": ["earth:1", "earth:1", "hand:a", 3, ""],
|
||||||
|
}
|
||||||
|
clean = store.write_user(tmp_path, {"bill": raw})["bill"]
|
||||||
|
assert store.read_user(tmp_path)["bill"] == clean
|
||||||
|
assert clean["gone"] == ["earth:1"]
|
||||||
|
assert clean["rows"] == [
|
||||||
|
{"key": "sec:1", "name": None, "qty": 3},
|
||||||
|
{"key": "earth:4", "parent": "sec:1.1", "unit": None},
|
||||||
|
{"key": "earth:5", "name": "12", "qty": None, "ref": None},
|
||||||
|
{"key": "hand:g", "kind": "group", "cost": "direct", "name": "", "spec": "", "unit": "",
|
||||||
|
"qty": None, "ref": None},
|
||||||
|
{"key": "hand:a", "parent": "hand:g", "name": "", "spec": "", "unit": "", "qty": None,
|
||||||
|
"ref": None},
|
||||||
|
] # fmt: skip
|
||||||
|
assert "bill" not in store.clean_user({"bill": {"v": 2, "rows": [], "gone": []}})
|
||||||
|
back = store.read_user(tmp_path)
|
||||||
|
rows = _rows(compute_estimation(INPUTS, {**BASE, **back}, None, BOOKS))
|
||||||
|
assert "earth:1" not in rows and rows["earth:4"]["parent"] == "sec:1.1"
|
||||||
|
assert (rows["sec:1"]["name"], rows["earth:5"]["qty"]) == ("", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _node(source: str) -> str:
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "--experimental-strip-types", "--no-warnings",
|
||||||
|
"--input-type=module-typescript"],
|
||||||
|
input=source, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||||
|
) # fmt: skip
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_거울_TS_가_같은_값():
|
||||||
|
calc = "".join(
|
||||||
|
IMPORTS.sub("", (ROOT / "B09_Estimation" / f"B09_Estimation_{n}.ts").read_text("utf-8"))
|
||||||
|
for n in ("Calc_Base", "Calc_Bill")
|
||||||
|
)
|
||||||
|
frozen = _freeze(_calc())
|
||||||
|
bills = [
|
||||||
|
None,
|
||||||
|
OLD,
|
||||||
|
frozen,
|
||||||
|
_put(_put(frozen, "earth:4", name=None, qty=3, ref=DG), MAT, parent="sec:1"),
|
||||||
|
{**frozen, "gone": ["earth:5", "sec:1.1"], "rows": frozen["rows"][3:]},
|
||||||
|
{"v": 2, "rows": [{"key": "hand:g", "kind": "group", "name": "g"},
|
||||||
|
{"key": "hand:a", "parent": "hand:g", "qty": 1, "ref": DG},
|
||||||
|
{"key": "sec:1", "parent": "hand:a"}], "gone": []},
|
||||||
|
] # fmt: skip
|
||||||
|
users = [{**BASE, **({"bill": b} if b else {})} for b in bills]
|
||||||
|
body = (
|
||||||
|
f"\nconst i = {json.dumps(INPUTS, ensure_ascii=False)};"
|
||||||
|
f"\nconst us = {json.dumps(users, ensure_ascii=False)};"
|
||||||
|
f"\nconst b = {json.dumps(BOOKS, ensure_ascii=False)};"
|
||||||
|
"\nconsole.log(JSON.stringify(us.map((u) => computeEstimation(i, u, null, b))));"
|
||||||
|
)
|
||||||
|
got = json.loads(_node(calc + body))
|
||||||
|
assert got == [compute_estimation(INPUTS, u, None, BOOKS) for u in users]
|
||||||
Reference in New Issue
Block a user