Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1 — 166-2 설계내역서 손질(Store docstring · import 양쪽 살림)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
This commit is contained in:
@@ -12,6 +12,12 @@
|
||||
조달수수료 = ROUNDDOWN(관급 순자재대 × 율 ÷ 100, 0)(율 = 사용자 입력 · 빈칸 시작)
|
||||
제목 줄 = 아래 항목 금액 합(엑셀 SUMIF — 빈 줄은 0 이 아니라 「빈 줄 n」 으로 셈)
|
||||
빈칸 = 수량이나 단가를 낸 원천이 없으면 그 줄 금액 None + 「연결 없음」 목록.
|
||||
손질(166-2) = 사용자 값 `user.bill` — `qty{줄 키: 수}` 자동 줄 수량 덮어쓰기(키 지우면 자동) ·
|
||||
`rows[]` 손 줄(`hand:` · 공종 · `after` 앞 줄 키 뒤에 끼움 · 명칭 · 규격 · 단위 · 수량 ·
|
||||
연결 `ref`) ·
|
||||
단가 = `ref_price(ref)` 하나 — dg = 일위대가목록표 · sg = 단가산출근거목록표 ·
|
||||
logic = M01 로직 새 산근(서버가 `sg_hand{줄 키: 산근 키}` 로 줌 · 그전엔 빈칸 「[저장] 뒤」) ·
|
||||
그 밖 = `inputs.hopyo` · 비고 = 「대가n」 · 「산근 n호표」(155-3 꼴).
|
||||
원가계산서로 넘길 합 = `sums`(직접재료비 · 직접노무비 · 산출경비 · 관급 순자재대 — 145-4 이음 꼴 ·
|
||||
빈 줄이 있으면 None) · 화면 표시용 = `totals`(아는 금액 합 · 빈 줄 수 · 조달수수료).
|
||||
"""
|
||||
@@ -60,8 +66,14 @@ def _amounts(qty: float | None, prices: dict[str, float | None] | None) -> dict[
|
||||
return out
|
||||
|
||||
|
||||
def _item_row(item: dict[str, Any], prices: dict[str, Any] | None, gaps: list) -> dict[str, Any]:
|
||||
def _item_row(
|
||||
item: dict[str, Any], prices: dict[str, Any] | None, gaps: list, qty_of: dict | None = None
|
||||
) -> dict[str, Any]:
|
||||
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 = {
|
||||
"kind": "item",
|
||||
"key": item["key"],
|
||||
@@ -72,6 +84,8 @@ def _item_row(item: dict[str, Any], prices: dict[str, Any] | None, gaps: list) -
|
||||
"qty": qty,
|
||||
"note": item.get("note") or "",
|
||||
"ref": item.get("ref"),
|
||||
**over,
|
||||
**({"hand": True} if item.get("hand") else {}),
|
||||
**_amounts(qty, prices),
|
||||
}
|
||||
if qty is None:
|
||||
@@ -105,6 +119,46 @@ def _material_items(inputs: dict[str, Any], compare: list[dict[str, Any]]) -> li
|
||||
|
||||
|
||||
PriceOf = Callable[[str], dict[str, Any] | None]
|
||||
WHY_HAND = "손 줄 — 연결 없음(우클릭 [연결])"
|
||||
WHY_LOGIC = "새 산근 — [저장] 뒤 단가(서버 셈)"
|
||||
|
||||
|
||||
def ref_price(
|
||||
ref: dict[str, Any] | None, books: dict[str, Any], hopyo: dict[str, Any], row_key: str = ""
|
||||
) -> dict[str, Any] | None:
|
||||
"""연결 → 1 단위 단가 `{no, key, labor, material, expense, sum}` — 없으면 None."""
|
||||
ref = ref or {}
|
||||
tab, key = ref.get("tab"), ref.get("key")
|
||||
if tab == "logic":
|
||||
tab, key = "sg", (books.get("sg_hand") or {}).get(row_key)
|
||||
if tab in ("dg", "sg") and key:
|
||||
listing = books.get("daega" if tab == "dg" else "sg_list") or []
|
||||
row = next((r for r in listing if r["key"] == key), None)
|
||||
if row is None:
|
||||
return None
|
||||
no = f"대가{row['no']}" if tab == "dg" else f"산근 {row['no']}호표"
|
||||
return {"no": no, "tab": tab, "key": key, **{p: row.get(p) for p in (*PARTS, "sum")}}
|
||||
return hopyo.get(key) if key else None
|
||||
|
||||
|
||||
def hand_items(user: dict[str, Any], books: dict[str, Any], hopyo: dict) -> list[tuple]:
|
||||
"""손 줄 → (항목, 단가) — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠)."""
|
||||
out = []
|
||||
for hand in (user.get("bill") or {}).get("rows") or []:
|
||||
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
|
||||
why = WHY_LOGIC if (hand.get("ref") or {}).get("tab") == "logic" else WHY_HAND
|
||||
item = {**hand, "hand": True, "note": (price or {}).get("no") or "", "ref": ref}
|
||||
out.append((item, price, why))
|
||||
return out
|
||||
|
||||
|
||||
def _place(items: list[dict[str, Any]], hand: dict[str, Any], after: str) -> None:
|
||||
"""손 줄 끼우기 — `after` 줄 바로 뒤 · 제목(`sec:`) = 그 공종 첫 자리 · 못 찾으면 끝."""
|
||||
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']}":
|
||||
at = next((n for n, row in enumerate(items) if row["section"] == hand["section"]), None)
|
||||
items.insert(len(items) if at is None else at, hand)
|
||||
|
||||
|
||||
def split_by_block(item: dict[str, Any], price_of: PriceOf) -> list[tuple[dict, dict | None]]:
|
||||
@@ -137,24 +191,29 @@ def compute_bill(
|
||||
user: dict[str, Any],
|
||||
base: dict[str, Any],
|
||||
price_of: PriceOf | None = None,
|
||||
books: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""`price_of(사본 주인 id)` = 대가 1 단위 단가(145-2 `daega_price`) —
|
||||
주면 구조물 열을 블록마다 나눔."""
|
||||
주면 구조물 열을 블록마다 나눔 ·
|
||||
`books` = 호표 목록(`daega` · `sg_list` · `sg_hand` · 166-2)."""
|
||||
gaps: list[dict[str, str]] = []
|
||||
hopyo = inputs.get("hopyo") or {}
|
||||
books = books or {}
|
||||
qty_of = (user.get("bill") or {}).get("qty") or {}
|
||||
items: list[dict[str, Any]] = []
|
||||
for item in (inputs.get("bill") or {}).get("items") or []:
|
||||
if price_of is not None and item.get("owners"):
|
||||
for part, prices in split_by_block(item, price_of):
|
||||
items.append(_item_row(part, prices, gaps))
|
||||
items.append(_item_row(part, prices, gaps, qty_of))
|
||||
continue
|
||||
ref = item.get("ref") or {}
|
||||
prices = hopyo.get(ref.get("key")) if ref.get("key") else None
|
||||
items.append(_item_row(item, prices, gaps))
|
||||
prices = ref_price(item.get("ref"), books, hopyo)
|
||||
items.append(_item_row(item, prices, gaps, qty_of))
|
||||
for item in _material_items(inputs, base["compare"]):
|
||||
price = item["_price"]
|
||||
prices = None if price is None else {"labor": 0, "material": price, "expense": 0}
|
||||
items.append(_item_row(item, prices, gaps))
|
||||
items.append(_item_row(item, prices, gaps, qty_of))
|
||||
for item, prices, why in hand_items(user, books, hopyo):
|
||||
_place(items, _item_row({**item, "why": why}, prices, gaps), item.get("after") or "")
|
||||
owner = [row for row in items if row["section"] == "G"]
|
||||
net = 0.0
|
||||
for row in owner:
|
||||
@@ -240,9 +299,12 @@ def compute_bill(
|
||||
|
||||
|
||||
def compute_estimation(
|
||||
inputs: dict[str, Any], user: dict[str, Any], price_of: PriceOf | None = None
|
||||
inputs: dict[str, Any],
|
||||
user: dict[str, Any],
|
||||
price_of: PriceOf | None = None,
|
||||
books: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""144 바탕 + 145-3 설계내역서 — 「연결 없음」 목록은 둘을 이음."""
|
||||
base = compute_base(inputs, user)
|
||||
bill = compute_bill(inputs, user, base, price_of)
|
||||
bill = compute_bill(inputs, user, base, price_of, books)
|
||||
return {**base, "bill": bill, "gaps": base["gaps"] + bill["gaps"]}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료
|
||||
* 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 조달수수료 = ROUNDDOWN(순자재대 × 율 ÷ 100, 0)
|
||||
* 제목 줄 = 아래 항목 금액 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(빈 줄 있으면 null)
|
||||
* 손질(166-2) = `user.bill` — qty{줄 키: 수} 자동 줄 수량 덮어쓰기 · rows[] 손 줄(after 뒤에 끼움) ·
|
||||
* 단가 = `refPrice(ref)` 하나(dg 대가 목록 · sg 산근 목록 · logic = 서버 sg_hand 산근 · 그 밖 hopyo)
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -56,8 +58,14 @@ function amounts(qty: Num, prices: Row | null): Row {
|
||||
return out;
|
||||
}
|
||||
|
||||
function itemRow(item: Row, prices: Row | null, gaps: Gap[]): Row {
|
||||
const qty = num(item.qty);
|
||||
function itemRow(item: Row, prices: Row | null, gaps: Gap[], qtyOf: Row | null = null): Row {
|
||||
let qty = num(item.qty);
|
||||
const over: Row = {};
|
||||
// 166-2 수량 덮어쓰기
|
||||
if (qtyOf && item.key in qtyOf && !item.hand) {
|
||||
over.qty_auto = qty;
|
||||
qty = num(qtyOf[item.key]);
|
||||
}
|
||||
const row: Row = {
|
||||
kind: "item",
|
||||
key: item.key,
|
||||
@@ -68,6 +76,8 @@ function itemRow(item: Row, prices: Row | null, gaps: Gap[]): Row {
|
||||
qty,
|
||||
note: item.note || "",
|
||||
ref: item.ref ?? null,
|
||||
...over,
|
||||
...(item.hand ? { hand: true } : {}),
|
||||
...amounts(qty, prices),
|
||||
};
|
||||
if (qty === null) gaps.push(gap("bill", item.key, row.name, "수량 연결 없음"));
|
||||
@@ -98,6 +108,48 @@ function materialItems(inputs: Row, compare: Row[]): Row[] {
|
||||
|
||||
/** 사본 주인 id → 대가 1 단위 단가(145-2 `daegaPrice`) */
|
||||
export type PriceOf = (owner: string) => Row | null;
|
||||
const WHY_HAND = "손 줄 — 연결 없음(우클릭 [연결])";
|
||||
const WHY_LOGIC = "새 산근 — [저장] 뒤 단가(서버 셈)";
|
||||
|
||||
/** 연결 → 1 단위 단가 {no, key, labor, material, expense, sum} — 없으면 null */
|
||||
export function refPrice(ref: Row | null, books: Row, hopyo: Row, rowKey = ""): Row | null {
|
||||
let tab = ref?.tab;
|
||||
let key = ref?.key;
|
||||
if (tab === "logic") {
|
||||
tab = "sg";
|
||||
key = (books.sg_hand || {})[rowKey];
|
||||
}
|
||||
if ((tab === "dg" || tab === "sg") && key) {
|
||||
const listing: Row[] = books[tab === "dg" ? "daega" : "sg_list"] || [];
|
||||
const row = listing.find((r) => r.key === key);
|
||||
if (!row) return null;
|
||||
const no = tab === "dg" ? `대가${row.no}` : `산근 ${row.no}호표`;
|
||||
const out: Row = { no, tab, key };
|
||||
for (const p of [...PARTS, "sum"]) out[p] = row[p] ?? null;
|
||||
return out;
|
||||
}
|
||||
return key ? (hopyo[key] ?? null) : null;
|
||||
}
|
||||
|
||||
/** 손 줄 → [항목, 단가, 까닭] — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠) */
|
||||
export function handItems(user: Row, books: Row, hopyo: Row): Array<[Row, Row | null, string]> {
|
||||
const out: Array<[Row, Row | null, string]> = [];
|
||||
for (const hand of (user.bill || {}).rows || []) {
|
||||
const price = refPrice(hand.ref ?? null, books, hopyo, hand.key);
|
||||
const ref = price?.tab ? { tab: price.tab, key: price.key } : null;
|
||||
const why = hand.ref?.tab === "logic" ? WHY_LOGIC : WHY_HAND;
|
||||
out.push([{ ...hand, hand: true, note: price?.no || "", ref }, price, why]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 손 줄 끼우기 — after 줄 바로 뒤 · 제목(sec:) = 그 공종 첫 자리 · 못 찾으면 끝 */
|
||||
function place(items: Row[], hand: Row, after: string): void {
|
||||
let at = items.findIndex((row) => row.key === after);
|
||||
if (at >= 0) at += 1;
|
||||
else if (after === `sec:${hand.section}`) at = items.findIndex((r) => r.section === hand.section);
|
||||
items.splice(at < 0 ? items.length : at, 0, hand);
|
||||
}
|
||||
|
||||
/** 구조물 열 → 대가 블록마다 한 줄(수량 = 그 블록 주인 값 합 ROUND 3 · 대가 없는 주인 = 한 줄) */
|
||||
export function splitByBlock(item: Row, priceOf: PriceOf): Array<[Row, Row | null]> {
|
||||
@@ -131,25 +183,29 @@ export function computeBill(
|
||||
user: Row,
|
||||
base: Row,
|
||||
priceOf: PriceOf | null = null,
|
||||
books: Row | null = null,
|
||||
): Row {
|
||||
const gaps: Gap[] = [];
|
||||
const hopyo: Row = inputs.hopyo || {};
|
||||
const book: Row = books || {};
|
||||
const qtyOf: Row = (user.bill || {}).qty || {};
|
||||
const items: Row[] = [];
|
||||
for (const item of (inputs.bill || {}).items || []) {
|
||||
if (priceOf !== null && item.owners && Object.keys(item.owners).length) {
|
||||
for (const [part, prices] of splitByBlock(item, priceOf))
|
||||
items.push(itemRow(part, prices, gaps));
|
||||
items.push(itemRow(part, prices, gaps, qtyOf));
|
||||
continue;
|
||||
}
|
||||
const ref: Row = item.ref || {};
|
||||
const prices = ref.key ? (hopyo[ref.key] ?? null) : null;
|
||||
items.push(itemRow(item, prices, gaps));
|
||||
const prices = refPrice(item.ref ?? null, book, hopyo);
|
||||
items.push(itemRow(item, prices, gaps, qtyOf));
|
||||
}
|
||||
for (const item of materialItems(inputs, base.compare)) {
|
||||
const price: Num = item._price;
|
||||
const prices = price === null ? null : { labor: 0, material: price, expense: 0 };
|
||||
items.push(itemRow(item, prices, gaps));
|
||||
items.push(itemRow(item, prices, gaps, qtyOf));
|
||||
}
|
||||
for (const [item, prices, why] of handItems(user, book, hopyo))
|
||||
place(items, itemRow({ ...item, why }, prices, gaps), item.after || "");
|
||||
const owner = items.filter((row) => row.section === "G");
|
||||
let net = 0;
|
||||
for (const row of owner) net += row.material || 0;
|
||||
@@ -231,8 +287,13 @@ export function computeBill(
|
||||
}
|
||||
|
||||
/** 144 바탕 + 145-3 설계내역서 */
|
||||
export function computeEstimation(inputs: Row, user: Row, priceOf: PriceOf | null = null): Row {
|
||||
export function computeEstimation(
|
||||
inputs: Row,
|
||||
user: Row,
|
||||
priceOf: PriceOf | null = null,
|
||||
books: Row | null = null,
|
||||
): Row {
|
||||
const base = computeBase(inputs, user);
|
||||
const bill = computeBill(inputs, user, base, priceOf);
|
||||
const bill = computeBill(inputs, user, base, priceOf, books);
|
||||
return { ...base, bill, gaps: [...base.gaps, ...bill.gaps] };
|
||||
}
|
||||
|
||||
@@ -64,10 +64,11 @@ def compute_sheets(root: Path, inputs: dict[str, Any], user: dict[str, Any]) ->
|
||||
|
||||
화면 짝 = `B09_Estimation_UI_Shell.computeSheets`(산근만 서버 답 그대로)."""
|
||||
base = compute_base(inputs, user)
|
||||
sg = sangeun.build(root, base) # 145-1 — 굳힌 단가로 풀이
|
||||
sg = sangeun.build(root, base, user) # 145-1 — 굳힌 단가로 풀이(+ 166-2 손 줄 로직)
|
||||
by_key = {row["key"]: row for row in sg["sg_list"]}
|
||||
labor = compute_labor(inputs.get("daega"), {**base, "gaps": []}, by_key)
|
||||
sheets = compute_estimation(inputs, user, lambda owner: daega_price(labor, owner))
|
||||
books = {"daega": labor["daega"], "sg_list": sg["sg_list"], "sg_hand": sg["sg_hand"]}
|
||||
sheets = compute_estimation(inputs, user, lambda owner: daega_price(labor, owner), books)
|
||||
sheets.update(sg)
|
||||
sheets.update({k: v for k, v in labor.items() if k != "gaps"})
|
||||
sheets["gaps"] = [*sheets["gaps"], *sg["sg_gaps"], *labor["gaps"]]
|
||||
@@ -78,10 +79,18 @@ def compute_sheets(root: Path, inputs: dict[str, Any], user: dict[str, Any]) ->
|
||||
return sheets
|
||||
|
||||
|
||||
async def _demand(root: Path) -> list[str]:
|
||||
"""단가 수요 키 — 연결된 사본 + 설계내역서 손 줄 로직(166-2)."""
|
||||
keys = await asyncio.to_thread(linked_resource_keys, root)
|
||||
user = await asyncio.to_thread(store.read_user, root)
|
||||
hand = await asyncio.to_thread(sangeun.hand_resource_keys, user)
|
||||
return [*keys, *(k for k in hand if k not in keys)]
|
||||
|
||||
|
||||
async def payload(project_id: UUID, root: Path) -> dict[str, Any]:
|
||||
notes: list[str] = []
|
||||
b08 = await _b08(project_id, notes)
|
||||
keys = await asyncio.to_thread(linked_resource_keys, root)
|
||||
keys = await _demand(root)
|
||||
if not keys:
|
||||
notes.append(
|
||||
"연결된 구조물 조합 사본 없음 — 노무 · 경비 · 중기 단가 줄은 사본이 연결되면 생김"
|
||||
@@ -116,7 +125,7 @@ async def estimation(project_id: UUID) -> dict[str, Any]:
|
||||
async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
await asyncio.to_thread(store.write_user, root, body.get("user"))
|
||||
keys = await asyncio.to_thread(linked_resource_keys, root)
|
||||
keys = await _demand(root)
|
||||
book = await asyncio.to_thread(store.read_book, root)
|
||||
book = await asyncio.to_thread(prices.extend_book, book, keys)
|
||||
await asyncio.to_thread(store.write_book, root, book)
|
||||
@@ -126,7 +135,7 @@ async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) ->
|
||||
@router.post("/{project_id}/estimation/prices")
|
||||
async def refresh_prices(project_id: UUID) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
keys = await asyncio.to_thread(linked_resource_keys, root)
|
||||
keys = await _demand(root)
|
||||
book = await asyncio.to_thread(prices.refresh_book, keys)
|
||||
await asyncio.to_thread(store.write_book, root, book)
|
||||
return await payload(project_id, root)
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
+ 「연결 없음」.
|
||||
조합 밖 토공 · 운반 · 사토 산근 · 준비공 = 원천 없음(만들지 않음 · 목록 안내 한 줄).
|
||||
대가(145-2) · 내역서(145-3)가 읽는 길 = `sangeun_key` + `price_of`.
|
||||
손 줄 산근(166-2) = 설계내역서 손 줄이 고른 M01 로직(`ref` tab logic) — 마스터 로직을 같은 풀이로 ·
|
||||
번호 = 사본 산근 뒤 · 쓰는 곳 = 「손 줄 명칭」 ·
|
||||
`sg_hand{줄 키: 산근 키}` 로 내역서가 단가를 읽음.
|
||||
텍스트 수식(155-5) = `B09_Estimation_Sangeun_Text`(같은 B09 단가) — `head`(머리 · 입력 · 중간) ·
|
||||
줄 `text`(줄마다 · 중기 = 노 · 재 · 경 세 줄) · `foot`(계) · % 덧줄 = 규격 「노무비의 %」 ·
|
||||
수량 = 율 · 단위 %(149-4 `pct_extras`).
|
||||
@@ -471,13 +474,66 @@ class Solver:
|
||||
LISTED = ("no", "key", "logic", "name", "spec", "spec_raw", "unit", "sum", *PARTS, "uses")
|
||||
|
||||
|
||||
def collect(entries: list[tuple[str, Path]], sheets: dict[str, Any]) -> dict[str, Any]:
|
||||
"""`entries` = [(구조물 id, 사본 폴더)] → 블록 · 목록 · 「연결 없음」."""
|
||||
def hand_sources(user: dict[str, Any] | None) -> list[tuple[str, str, dict[str, Any]]]:
|
||||
"""설계내역서 손 줄 중 M01 로직 연결 → [(줄 키, 쓰는 곳 글, 산근 원천)]."""
|
||||
out = []
|
||||
for row in ((user or {}).get("bill") or {}).get("rows") or []:
|
||||
ref = row.get("ref") or {}
|
||||
if ref.get("tab") != "logic" or not ref.get("code"):
|
||||
continue
|
||||
src = {"code": str(ref["code"]), "given": _clean_given(ref.get("given")),
|
||||
"rate": ref.get("rate"), "picks": ref.get("picks")} # fmt: skip
|
||||
out.append((row["key"], f"손 줄 {row.get('name') or row['key']}", src))
|
||||
return out
|
||||
|
||||
|
||||
def hand_whole(hands: list[tuple[str, str, dict[str, Any]]]) -> Any:
|
||||
"""손 줄 로직의 닫힌 단가 묶음(사본 `단가.json` 꼴 · 마스터 캐시는 안 건드림)."""
|
||||
codes = list(dict.fromkeys(src["code"] for _k, _u, src in hands))
|
||||
return item_prices.load_prices(item_prices.to_json(item_prices.collect(codes)))
|
||||
|
||||
|
||||
def hand_resource_keys(user: dict[str, Any] | None) -> list[str]:
|
||||
"""손 줄 로직이 부르는 자원 키(단가 책 수요 · `Linked.used_keys` 꼴) — 부른 차례 · 겹침 없음."""
|
||||
hands = hand_sources(user)
|
||||
if not hands:
|
||||
return []
|
||||
from B09_Estimation.B09_Estimation_Linked import RESOURCE_TABLES
|
||||
|
||||
whole = hand_whole(hands)
|
||||
seen: list[str] = []
|
||||
base = whole.get
|
||||
|
||||
def get(ref: str) -> dict[str, Any]:
|
||||
row = base(ref)
|
||||
key = str(row.get("키") or "")
|
||||
if key[:2] in RESOURCE_TABLES and key not in seen:
|
||||
seen.append(key)
|
||||
return row
|
||||
|
||||
whole.get = get # 이 묶음 한 벌에만
|
||||
for _key, _use, src in hands:
|
||||
labor_doc.run_line(whole, src["code"], src["given"], src["rate"], src["picks"])
|
||||
return seen
|
||||
|
||||
|
||||
def collect(
|
||||
entries: list[tuple[str, Path]],
|
||||
sheets: dict[str, Any],
|
||||
hands: list[tuple[str, str, dict[str, Any]]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""`entries` = [(구조물 id, 사본 폴더)] → 블록 · 목록 · 「연결 없음」 ·
|
||||
`hands` = 손 줄 로직(166-2 · 사본 뒤 번호 · 마스터 로직)."""
|
||||
solver = Solver(sheets)
|
||||
for owner, folder in entries:
|
||||
whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {})
|
||||
for src in rows_of(folder, whole):
|
||||
solver.add(whole, src, owner)
|
||||
hand_keys: dict[str, str] = {}
|
||||
if hands:
|
||||
whole = hand_whole(hands)
|
||||
for row_key, use, src in hands:
|
||||
hand_keys[row_key] = solver.add(whole, src, use)["key"]
|
||||
blocks = sorted(solver.blocks.values(), key=lambda b: b["no"])
|
||||
listing = [{k: b[k] for k in LISTED} for b in blocks]
|
||||
gaps = [
|
||||
@@ -490,15 +546,18 @@ def collect(entries: list[tuple[str, Path]], sheets: dict[str, Any]) -> dict[str
|
||||
"sg_list": listing,
|
||||
"sg_gaps": gaps,
|
||||
"sg_notes": [NO_SOURCE] + ([] if blocks else [EMPTY]),
|
||||
"sg_hand": hand_keys,
|
||||
}
|
||||
|
||||
|
||||
def build(project_root: str | Path, sheets: dict[str, Any]) -> dict[str, Any]:
|
||||
"""연결된 사본 전부(구조물 id 차례)의 산근."""
|
||||
def build(
|
||||
project_root: str | Path, sheets: dict[str, Any], user: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""연결된 사본 전부(구조물 id 차례)의 산근 + 설계내역서 손 줄 로직(166-2 · 뒤 번호)."""
|
||||
base = structures_dir(project_root)
|
||||
entries = [
|
||||
(owner, base / linked["폴더"])
|
||||
for owner, linked in sorted(linked_snapshots(project_root).items())
|
||||
if "일위대가" in linked.get("부분", [])
|
||||
]
|
||||
return collect(entries, sheets)
|
||||
return collect(entries, sheets, hand_sources(user))
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
— [단가 갱신] 때만 M01 로 새로 ·
|
||||
[저장] 때 책에 없는 새 줄만 더함(있는 줄 안 바꿈)
|
||||
user_values.json 사용자 값 {"materials": {자재 키: {applied?, supply?, quote?, quote_page?,
|
||||
pick?}}, "estimate": {…}}
|
||||
pick?}}, "estimate": {…},
|
||||
"bill": {"qty": {줄 키: 수}, "rows": [손 줄]}}(166-2 설계내역서 손질)
|
||||
— 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본)
|
||||
|
||||
quotes/<자재 키>/ 견적서 파일(166-4 · 여러 개) — 폴더가 목록(키의 못 쓰는 글자는 %XX) ·
|
||||
@@ -25,6 +26,7 @@ from typing import Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import SOURCES, SUPPLY
|
||||
from B09_Estimation.B09_Estimation_Calc_Bill import SECTIONS
|
||||
|
||||
FOLDER = ("B09_Estimation", "v1")
|
||||
BOOK = "price_book.json"
|
||||
@@ -86,6 +88,56 @@ def _num(value: Any) -> float | None:
|
||||
return float(value) if math.isfinite(value) else None
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
return str(value)[:200] if isinstance(value, (str, int, float)) else ""
|
||||
|
||||
|
||||
def _clean_ref(ref: Any) -> dict[str, Any] | None:
|
||||
"""손 줄 연결 — dg · sg = 호표 키 · logic = M01 로직(입력값 · 켜쌓기율 · 고름)."""
|
||||
if not isinstance(ref, dict):
|
||||
return None
|
||||
tab = ref.get("tab")
|
||||
if tab in ("dg", "sg") and isinstance(ref.get("key"), str) and ref["key"]:
|
||||
return {"tab": tab, "key": ref["key"]}
|
||||
if tab == "logic" and isinstance(ref.get("code"), str) and ref["code"]:
|
||||
given = ref.get("given") if isinstance(ref.get("given"), dict) else {}
|
||||
picks = ref.get("picks") if isinstance(ref.get("picks"), dict) else None
|
||||
return {"tab": "logic", "code": ref["code"], "name": _text(ref.get("name")),
|
||||
"given": {str(k): v for k, v in given.items() if isinstance(v, (str, int, float))},
|
||||
"rate": _num(ref.get("rate")), "picks": picks} # fmt: skip
|
||||
return None
|
||||
|
||||
|
||||
def clean_bill(raw: Any) -> dict[str, Any]:
|
||||
"""설계내역서 손질(166-2) — 수량 덮어쓰기(수만) · 손 줄(`hand:` 키 · 아는 공종만)."""
|
||||
raw = raw if isinstance(raw, dict) else {}
|
||||
qty = raw.get("qty") if isinstance(raw.get("qty"), dict) else {}
|
||||
codes = {code for code, _name, _parent in SECTIONS}
|
||||
rows = []
|
||||
for row in raw.get("rows") if isinstance(raw.get("rows"), list) else []:
|
||||
if not isinstance(row, dict) or not str(row.get("key") or "").startswith("hand:"):
|
||||
continue
|
||||
if row.get("section") not in codes:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"key": str(row["key"]),
|
||||
"section": row["section"],
|
||||
"after": _text(row.get("after")),
|
||||
**{k: _text(row.get(k)) for k in ("name", "spec", "unit")},
|
||||
"qty": _num(row.get("qty")),
|
||||
"ref": _clean_ref(row.get("ref")),
|
||||
}
|
||||
)
|
||||
out: dict[str, Any] = {}
|
||||
kept = {str(k): _num(v) for k, v in qty.items() if _num(v) is not None}
|
||||
if kept:
|
||||
out["qty"] = kept
|
||||
if rows:
|
||||
out["rows"] = rows
|
||||
return out
|
||||
|
||||
|
||||
def clean_user(raw: Any) -> dict[str, Any]:
|
||||
"""사용자 값 다듬기 — 모르는 칸 버림 · 수 칸은 수 · null(빈칸)만."""
|
||||
raw = raw if isinstance(raw, dict) else {}
|
||||
@@ -116,7 +168,8 @@ def clean_user(raw: Any) -> dict[str, Any]:
|
||||
rates = given.get("rates")
|
||||
if isinstance(rates, dict):
|
||||
estimate["rates"] = {str(k): _num(v) for k, v in rates.items()}
|
||||
return {"materials": materials, "estimate": estimate}
|
||||
bill = clean_bill(raw.get("bill"))
|
||||
return {"materials": materials, "estimate": estimate, **({"bill": bill} if bill else {})}
|
||||
|
||||
|
||||
def read_user(project_root: str | Path) -> dict[str, Any]:
|
||||
|
||||
@@ -110,3 +110,8 @@
|
||||
.b09-sg-card__cols .b09-sg-card__num {
|
||||
font-variant-numeric: normal;
|
||||
}
|
||||
|
||||
/* 166-2 설계내역서 손 줄 — 사용자가 더한 줄 표시(왼쪽 띠) */
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-hand td:first-child {
|
||||
background-image: linear-gradient(to right, var(--color-primary) 3px, transparent 3px);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
buildMaterial,
|
||||
} from "./B09_Estimation_UI_Tabs_Base";
|
||||
import { buildBill } from "./B09_Estimation_UI_Tabs_Bill";
|
||||
import { attachRowMenu, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
||||
import {
|
||||
buildSumExpense,
|
||||
buildSumLabor,
|
||||
@@ -107,13 +108,16 @@ function note(textValue: string): HTMLElement {
|
||||
return p;
|
||||
}
|
||||
|
||||
/** 산근(145-1) = 서버 단독 — 받은 그대로 두는 칸 */
|
||||
/** 산근(145-1) = 서버 단독 — 받은 그대로 두는 칸(+ sg_hand = 손 줄 로직 → 산근 키 · 166-2) */
|
||||
const SERVER_ONLY = ["sg", "sg_list", "sg_gaps", "sg_notes"] as const;
|
||||
|
||||
/** 브라우저 셈 — 서버 짝과 같은 식(조작 중) · 산근은 서버 답 그대로(+ 「연결 없음」) */
|
||||
export function computeSheets(payload: Row, user: Row): Row {
|
||||
const server: Row = payload.sheets || {};
|
||||
const kept = Object.fromEntries(SERVER_ONLY.map((k) => [k, server[k] ?? []]));
|
||||
const kept: Row = {
|
||||
...Object.fromEntries(SERVER_ONLY.map((k) => [k, server[k] ?? []])),
|
||||
sg_hand: server.sg_hand ?? {},
|
||||
};
|
||||
// 대가(145-2) — 산근 단가는 서버 답 목록 그대로 · 내역(145-3)은 대가 단가로 블록마다
|
||||
const sg = Object.fromEntries((kept.sg_list as Row[]).map((r) => [r.key, r]));
|
||||
const labor = computeLabor(
|
||||
@@ -121,7 +125,13 @@ export function computeSheets(payload: Row, user: Row): Row {
|
||||
{ ...computeBase(payload.inputs, user), gaps: [] },
|
||||
sg,
|
||||
);
|
||||
const base = computeEstimation(payload.inputs, user, (owner: string) => daegaPrice(labor, owner));
|
||||
const books = { daega: labor.daega, sg_list: kept.sg_list, sg_hand: kept.sg_hand };
|
||||
const base = computeEstimation(
|
||||
payload.inputs,
|
||||
user,
|
||||
(owner: string) => daegaPrice(labor, owner),
|
||||
books,
|
||||
);
|
||||
const { gaps: daegaGaps, ...daega } = labor;
|
||||
return {
|
||||
...base,
|
||||
@@ -161,7 +171,11 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
user = next;
|
||||
if (projectId) writeState("b09-estimation", user, projectId);
|
||||
recompute();
|
||||
// 고친 뒤 다시 그려도 보던 자리 지킴(166-2 내역서 수량 · 손 줄)
|
||||
const top = panel.querySelector<HTMLElement>(".ui-data-table")?.scrollTop ?? 0;
|
||||
show(current);
|
||||
const box = panel.querySelector<HTMLElement>(".ui-data-table");
|
||||
if (box && top) box.scrollTop = top;
|
||||
};
|
||||
|
||||
/** 탭 하나 그림 · focus = 이동해 온 줄 키 */
|
||||
@@ -208,6 +222,9 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
},
|
||||
});
|
||||
made.root.dataset.b09Table = tab;
|
||||
// 166-2 줄 우클릭 메뉴(설계내역서) — 공용 표는 그대로 · 표 root 에 붙임
|
||||
const menu = (spec as BillTable).menu;
|
||||
if (menu) attachRowMenu(made.root, menu);
|
||||
// 157-3 칸 풍선 — 공용 표는 그대로 · 올린 칸에 title
|
||||
if (spec.tip)
|
||||
made.root.addEventListener("mouseover", (event) => {
|
||||
|
||||
@@ -3,23 +3,25 @@
|
||||
* B09 설계내역서 탭(PLAN 145-3) — 실무 엑셀 꼴(공종 · 명칭 · 규격 · 수량 · 단위 · 합계 · 노무 · 재료 · 경비 · 비고).
|
||||
*
|
||||
* 값 = `B09_Estimation_Calc_Bill`(조작 중 브라우저 · [저장] 때 서버 정본)
|
||||
* 고칠 칸 = 관급자재대 조달수수료 율(%) · 빈칸 = 「연결 없음」(수량 · 단가 원천 없음)
|
||||
* 고칠 칸 = 관급자재대 조달수수료 율(%) · 항목 수량(166-2 · 덮어쓰기 · 빈칸 = 자동) ·
|
||||
* 손 줄 명칭 · 규격 · 단위 · 수량 · 빈칸 = 「연결 없음」(수량 · 단가 원천 없음)
|
||||
* 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표)
|
||||
* 우클릭 = 줄 추가 · 지우기 · 연결(`…_UI_Tabs_Bill_Edit`)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import { money, parseNumber, qty, text, type TabBuild, type TabId } from "./B09_Estimation_UI_Tabs";
|
||||
import {
|
||||
money,
|
||||
parseNumber,
|
||||
qty,
|
||||
text,
|
||||
type TabBuild,
|
||||
type TabId,
|
||||
type TabTable,
|
||||
} from "./B09_Estimation_UI_Tabs";
|
||||
billMenu,
|
||||
editQty,
|
||||
editText,
|
||||
handEditable,
|
||||
type BillTable,
|
||||
} from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
||||
|
||||
export const buildBill: TabBuild = ({ sheets, user, setUser }): TabTable => {
|
||||
export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
const { sheets, user, setUser } = ctx;
|
||||
const bill: Row = sheets.bill || { rows: [], totals: null };
|
||||
const rows: DataRow[] = bill.rows.map((r: Row) => ({
|
||||
...r,
|
||||
@@ -47,14 +49,30 @@ export const buildBill: TabBuild = ({ sheets, user, setUser }): TabTable => {
|
||||
frozen: 1,
|
||||
hint:
|
||||
"공종 나무 = 엑셀 꼴 고정 · 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재 · 조달수수료 율(%)은 수량 칸에 넣음 · " +
|
||||
"수량 칸 = 덮어쓰기(빈칸 = 자동) · 우클릭 = 줄 추가 · 지우기 · 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · " +
|
||||
`빈 줄 ${direct.blank ?? 0} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`,
|
||||
editable: (i, key) => key === "qty" && rows[i]?.key === "fee",
|
||||
editable: (i, key) => {
|
||||
const r = rows[i];
|
||||
if (!r || r.kind !== "item") return false;
|
||||
return r.hand ? handEditable(key) : key === "qty";
|
||||
},
|
||||
onEdit: (i, key, value) => {
|
||||
if (key !== "qty" || rows[i]?.key !== "fee") return;
|
||||
const r = rows[i];
|
||||
if (!r) return;
|
||||
if (r.hand && key !== "qty") return editText(ctx, String(r.key), key, value);
|
||||
if (key !== "qty") return;
|
||||
const v = parseNumber(value);
|
||||
if (v === undefined) return;
|
||||
if (r.key !== "fee") return editQty(ctx, r, v);
|
||||
setUser({ ...user, estimate: { ...(user.estimate || {}), procurement_fee_pct: v } });
|
||||
},
|
||||
tip: (i, key) => {
|
||||
const r = rows[i];
|
||||
if (key !== "qty" || !r || !("qty_auto" in r)) return null;
|
||||
return `자동 값 ${r.qty_auto ?? "빈칸"} — 우클릭 [수량 자동으로 되돌리기]`;
|
||||
},
|
||||
rowClass: (i) => (rows[i]?.hand ? "is-hand" : null),
|
||||
menu: (i) => billMenu(ctx, rows, i),
|
||||
link: (i) => {
|
||||
const ref = rows[i]?.ref as Row | null | undefined;
|
||||
return ref?.key ? { tab: ref.tab as TabId, key: String(ref.key) } : null;
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Bill_Edit.ts
|
||||
* B09 설계내역서 손질(PLAN 166-2) — 우클릭 메뉴 · 손 줄 더하기 · 지우기 · 연결 고르기 · 수량 덮어쓰기.
|
||||
*
|
||||
* 저장 = 사용자 값 `user.bill`(캐시 → [저장] 때 `user_values.json`) — 셈은 짝 `Calc_Bill` 이 바로 다시
|
||||
* qty{줄 키: 수} = 자동 줄 수량 덮어쓰기(빈칸 · [수량 자동으로] = 키 지움 → B08 값)
|
||||
* rows[] = 손 줄 {key hand:… · section · after(앞 줄 키 · 제목 = sec:공종) · 명칭 · 규격 · 단위 · 수량 · ref}
|
||||
* 연결 = 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가)
|
||||
* 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리) · 로직 고르기 · 입력값 = M02 창 재사용
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import {
|
||||
createMapContextMenu,
|
||||
type MapContextMenu,
|
||||
type MapContextMenuItem,
|
||||
} from "@ui/ui_template_context_menu";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { createFindList } from "@ui/ui_template_find_list";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
|
||||
import { openLaborInput } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Input";
|
||||
import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import type { TabContext, TabTable } from "./B09_Estimation_UI_Tabs";
|
||||
|
||||
/** 설계내역서 표 = 탭 표 + 줄 우클릭 메뉴 */
|
||||
export type BillTable = TabTable & { menu?: (index: number) => MapContextMenuItem[] };
|
||||
|
||||
const NEW_NAME = "새 줄";
|
||||
const TEXT_KEYS = ["name", "spec", "unit"];
|
||||
|
||||
interface Bill {
|
||||
qty: Record<string, number>;
|
||||
rows: Row[];
|
||||
}
|
||||
|
||||
function billOf(user: Row): Bill {
|
||||
const bill: Row = user.bill || {};
|
||||
return { qty: { ...(bill.qty || {}) }, rows: (bill.rows || []).map((r: Row) => ({ ...r })) };
|
||||
}
|
||||
|
||||
function put(ctx: TabContext, bill: Bill): void {
|
||||
ctx.setUser({ ...ctx.user, bill });
|
||||
}
|
||||
|
||||
/** 손 줄 고칠 칸 — 명칭 · 규격 · 단위 · 수량 */
|
||||
export const handEditable = (key: string): boolean => key === "qty" || TEXT_KEYS.includes(key);
|
||||
|
||||
/** 수량 고침 — 손 줄 = 그 줄 수량 · 자동 줄 = 덮어쓰기(빈칸 = 자동으로) */
|
||||
export function editQty(ctx: TabContext, row: DataRow, value: number | null): void {
|
||||
const bill = billOf(ctx.user);
|
||||
const key = String(row.key);
|
||||
if (row.hand) {
|
||||
const own = bill.rows.find((h) => h.key === key);
|
||||
if (!own) return;
|
||||
own.qty = value;
|
||||
} else if (value === null) delete bill.qty[key];
|
||||
else bill.qty[key] = value;
|
||||
put(ctx, bill);
|
||||
}
|
||||
|
||||
/** 손 줄 글 칸(명칭 · 규격 · 단위) */
|
||||
export function editText(ctx: TabContext, key: string, field: string, value: string): void {
|
||||
const bill = billOf(ctx.user);
|
||||
const own = bill.rows.find((h) => h.key === key);
|
||||
if (!own || !TEXT_KEYS.includes(field)) return;
|
||||
own[field] = value.trim();
|
||||
put(ctx, bill);
|
||||
}
|
||||
|
||||
/** 새 줄 자리 — 공종 · 앞 줄 키(위 = 앞 줄 · 공종 첫 줄이면 제목 sec:공종) */
|
||||
function slot(rows: DataRow[], i: number, below: boolean): { section: string; after: string } {
|
||||
const row = rows[i];
|
||||
const section = String(row.section);
|
||||
if (row.kind === "title") return { section, after: `sec:${section}` };
|
||||
if (below) return { section, after: String(row.key) };
|
||||
const prev = rows[i - 1];
|
||||
const same = prev && prev.kind !== "title" && prev.section === section;
|
||||
return { section, after: same ? String(prev.key) : `sec:${section}` };
|
||||
}
|
||||
|
||||
function addRow(ctx: TabContext, rows: DataRow[], i: number, below: boolean): void {
|
||||
const bill = billOf(ctx.user);
|
||||
const key = `hand:${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
const at = slot(rows, i, below);
|
||||
bill.rows.push({ key, ...at, name: NEW_NAME, spec: "", unit: "", qty: null, ref: null });
|
||||
put(ctx, bill);
|
||||
}
|
||||
|
||||
/** 손 줄 지움 — 그 줄 뒤에 붙던 손 줄은 지운 줄 자리로 옮김 */
|
||||
function removeRow(ctx: TabContext, key: string): void {
|
||||
const bill = billOf(ctx.user);
|
||||
const gone = bill.rows.find((h) => h.key === key);
|
||||
if (!gone) return;
|
||||
bill.rows = bill.rows.filter((h) => h.key !== key);
|
||||
for (const h of bill.rows) if (h.after === key) h.after = gone.after;
|
||||
put(ctx, bill);
|
||||
}
|
||||
|
||||
/** 연결 — 명칭이 비었거나 「새 줄」이면 고른 호표 명칭 · 규격 · 단위를 채움 */
|
||||
function setRef(ctx: TabContext, key: string, ref: Row | null, from?: Row): void {
|
||||
const bill = billOf(ctx.user);
|
||||
const own = bill.rows.find((h) => h.key === key);
|
||||
if (!own) return;
|
||||
own.ref = ref;
|
||||
if (from && (!own.name || own.name === NEW_NAME)) {
|
||||
own.name = from.name || own.name;
|
||||
own.spec = own.spec || from.spec || "";
|
||||
own.unit = own.unit || from.unit || "";
|
||||
}
|
||||
put(ctx, bill);
|
||||
}
|
||||
|
||||
/** 있는 호표 고르기 창 — 일위대가목록표(dg) · 단가산출근거목록표(sg) */
|
||||
function pickHopyo(ctx: TabContext, key: string, tab: "dg" | "sg"): void {
|
||||
const listing: Row[] = (tab === "dg" ? ctx.sheets.daega : ctx.sheets.sg_list) || [];
|
||||
if (!listing.length) return showToast("고를 호표 없음 — 연결된 구조물 조합 사본이 없음", "error");
|
||||
const label = (r: Row): string => (tab === "dg" ? `대가${r.no}` : `산근 ${r.no}호표`);
|
||||
openModal({
|
||||
title: tab === "dg" ? "일위대가 호표 고르기" : "단가산출 호표 고르기",
|
||||
closeLabel: "닫기",
|
||||
mount: (body, close) => {
|
||||
const finder = createFindList({ placeholder: "호표 · 명칭 · 규격 찾기" });
|
||||
const find = (): void => {
|
||||
const q = finder.search.value.trim().toLowerCase();
|
||||
const found = listing.filter(
|
||||
(r) => !q || `${label(r)} ${r.name} ${r.spec}`.toLowerCase().includes(q),
|
||||
);
|
||||
finder.hint.textContent = `${found.length} / ${listing.length}`;
|
||||
finder.setRows(
|
||||
found.map((r) => ({
|
||||
key: String(r.key),
|
||||
tag: label(r),
|
||||
name: String(r.name || ""),
|
||||
sub: String(r.spec || ""),
|
||||
unit: String(r.unit || ""),
|
||||
onPick: () => {
|
||||
close();
|
||||
setRef(ctx, key, { tab, key: r.key }, r);
|
||||
},
|
||||
})),
|
||||
);
|
||||
};
|
||||
finder.search.addEventListener("input", find);
|
||||
find();
|
||||
body.append(finder.root);
|
||||
requestAnimationFrame(() => finder.search.focus());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** M01 로직 → 입력값 창 → 손 줄 연결(새 산근 · 단가는 [저장] 뒤 서버 셈) */
|
||||
function askInputs(ctx: TabContext, key: string, code: string, name: string, old?: Row): void {
|
||||
fetchLogicInputs([code]).then(
|
||||
(got) => {
|
||||
const logic = got.로직[0] ?? null;
|
||||
openLaborInput({
|
||||
title: `${code} ${name}`.trim(),
|
||||
logic,
|
||||
values: old?.given || {},
|
||||
memo: null,
|
||||
rate: old?.rate ?? null,
|
||||
readOnly: false,
|
||||
okLabel: "연결",
|
||||
notice: "새 산근 = 단가산출근거 뒤 번호 · 단가는 [저장] 뒤 서버가 셈",
|
||||
done: (next) => {
|
||||
const unit = String(logic?.결과단위 || "")
|
||||
.replace(/^원\//, "")
|
||||
.replace(/^[\d.]+/, "");
|
||||
const ref = { tab: "logic", code, name, given: next.입력값, rate: next.켜쌓기율 };
|
||||
setRef(ctx, key, { ...ref, picks: old?.picks ?? null }, { name, unit });
|
||||
showToast("연결함 — 새 산근 단가는 [저장] 뒤에 채워짐", "success");
|
||||
},
|
||||
});
|
||||
},
|
||||
(error: unknown) => showToast(`로직 입력을 못 읽음 — ${String(error)}`, "error"),
|
||||
);
|
||||
}
|
||||
|
||||
function pickLogic(ctx: TabContext, key: string): void {
|
||||
openLogicPicker((logic) => askInputs(ctx, key, logic.키, logic.이름));
|
||||
}
|
||||
|
||||
/** 줄 우클릭 메뉴 — 위 · 아래 줄 추가 · (손 줄) 연결 · 연결 풀기 · 지우기 · (자동 줄) 수량 자동으로 */
|
||||
export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContextMenuItem[] {
|
||||
const row = rows[i];
|
||||
if (!row) return [];
|
||||
const key = String(row.key);
|
||||
const bill = billOf(ctx.user);
|
||||
const items: MapContextMenuItem[] = [];
|
||||
if (row.kind !== "title") items.push(["위에 줄 추가", () => addRow(ctx, rows, i, false)]);
|
||||
items.push(["아래에 줄 추가", () => addRow(ctx, rows, i, true)]);
|
||||
if (row.hand) {
|
||||
const ref: Row | null = bill.rows.find((h) => h.key === key)?.ref ?? null;
|
||||
items.push("-", {
|
||||
label: "연결",
|
||||
children: [
|
||||
["일위대가 호표", () => pickHopyo(ctx, key, "dg")],
|
||||
["단가산출 호표", () => pickHopyo(ctx, key, "sg")],
|
||||
["단가산출 로직(M01 · 새 산근)", () => pickLogic(ctx, key)],
|
||||
],
|
||||
});
|
||||
if (ref?.tab === "logic")
|
||||
items.push(["로직 입력값 고치기", () => askInputs(ctx, key, ref.code, ref.name || "", ref)]);
|
||||
if (ref) items.push(["연결 풀기", () => setRef(ctx, key, null)]);
|
||||
items.push("-", ["줄 지우기", () => removeRow(ctx, key)]);
|
||||
} else if (row.kind === "item" && key in bill.qty) {
|
||||
items.push("-", [
|
||||
"수량 자동으로 되돌리기",
|
||||
() => {
|
||||
delete bill.qty[key];
|
||||
put(ctx, bill);
|
||||
},
|
||||
]);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
let shared: MapContextMenu | null = null;
|
||||
|
||||
/** 표에 줄 우클릭 메뉴를 붙임(공용 표는 안 고침) — 글 칸 위는 브라우저 메뉴(붙여넣기) 그대로 */
|
||||
export function attachRowMenu(
|
||||
root: HTMLElement,
|
||||
menu: (index: number) => MapContextMenuItem[],
|
||||
): void {
|
||||
shared ??= createMapContextMenu("b09-estimation");
|
||||
const ctxMenu = shared;
|
||||
if (!ctxMenu.element.isConnected) {
|
||||
ctxMenu.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y)
|
||||
document.body.append(ctxMenu.element);
|
||||
}
|
||||
root.addEventListener("contextmenu", (event) => {
|
||||
const target = event.target as Element;
|
||||
if (target.closest("input")) return;
|
||||
const row = target.closest<HTMLElement>("tr[data-i]");
|
||||
if (!row || !root.contains(row)) return;
|
||||
event.preventDefault();
|
||||
const items = menu(Number(row.dataset.i));
|
||||
if (items.length) ctxMenu.open(event.clientX, event.clientY, items);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
"""166-2 B09 설계내역서 손질 — 수량 덮어쓰기 · 손 줄 · 호표 연결 · 새 산근 · 거울 · 우클릭 메뉴.
|
||||
|
||||
- 수량 = `user.bill.qty[줄 키]` 가 있으면 그 값(`qty_auto` = 자동 값) · 키 지우면 자동(B08)
|
||||
- 손 줄 = `user.bill.rows` — `after` 줄 뒤(제목 = 공종 첫 자리 · 못 찾으면 공종 끝) ·
|
||||
단가 = `ref_price` 하나(dg 대가 목록 · sg 산근 목록 · logic = `sg_hand` 산근) ·
|
||||
비고 = 「대가n」 · 「산근 n호표」
|
||||
- 새 산근(M01 로직) = 사본 산근 뒤 번호 · 쓰는 곳 = 「손 줄 …」 · 같은 로직 · 입력 = 같은 산근
|
||||
- 호표 탭 연동 = 손 줄이 연결한 산근이 단가산출근거(목록)에 실리고 집계표 역전개에 듦
|
||||
"""
|
||||
|
||||
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_Prices as prices # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Sangeun as sg # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Calc_Bill import WHY_LOGIC, compute_estimation # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Linked import used_keys # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Store import clean_user # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines # noqa: E402
|
||||
from M02_MasterTemplete import M02_Item_Prices as item_prices # noqa: E402
|
||||
|
||||
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
||||
STONE = "GF000219" # 돌쌓기 메쌓기(인력) — 석공 0.195 · 보통인부 0.156 / ㎡
|
||||
NET = "GC000337"
|
||||
NET_IN = {"구분": "기계식", "방지망": "포켓식", "지역": "전국평균", "크레인": "2104-0010"}
|
||||
|
||||
ITEMS = [
|
||||
{"key": "earth:1", "section": "1.1", "name": "규준틀설치 종단규준틀", "unit": "개소", "qty": 14,
|
||||
"ref": {"tab": "dg_list", "key": "B00194"}},
|
||||
{"key": "earth:4", "section": "1.2", "name": "절토(토사)", "unit": "㎥", "qty": 5675,
|
||||
"ref": {"tab": "sg_list", "key": "D01341"}},
|
||||
{"key": "earth:5", "section": "1.2", "name": "절토(연암)", "unit": "㎥", "qty": 972,
|
||||
"ref": None, "why": "조합 밖 토공 산근 — 연결 없음"},
|
||||
] # fmt: skip
|
||||
HOPYO = {
|
||||
"B00194": {"labor": 21936, "material": 3885, "expense": 0},
|
||||
"D01341": {"labor": 1116, "material": 360, "expense": 463},
|
||||
}
|
||||
INPUTS = {"materials": [], "bill": {"items": ITEMS}, "hopyo": HOPYO}
|
||||
BOOKS = {
|
||||
"daega": [{"no": 3, "key": "DG:a", "labor": 1000, "material": 200, "expense": 30,
|
||||
"sum": 1230}],
|
||||
"sg_list": [{"no": 1, "key": "SG:x", "labor": 500, "material": 0, "expense": 7, "sum": 507},
|
||||
{"no": 2, "key": "SG:new", "labor": 10, "material": 1, "expense": 0, "sum": 11}],
|
||||
"sg_hand": {"hand:c": "SG:new"},
|
||||
} # fmt: skip
|
||||
HAND = [
|
||||
{"key": "hand:a", "section": "1.1", "after": "earth:1", "name": "제근", "spec": "",
|
||||
"unit": "㎡",
|
||||
"qty": 10, "ref": {"tab": "dg", "key": "DG:a"}},
|
||||
{"key": "hand:b", "section": "1.2", "after": "sec:1.2", "name": "벌개", "spec": "",
|
||||
"unit": "㎡",
|
||||
"qty": 4, "ref": {"tab": "sg", "key": "SG:x"}},
|
||||
{"key": "hand:c", "section": "1.2", "after": "없는줄", "name": "메쌓기", "spec": "",
|
||||
"unit": "㎡",
|
||||
"qty": 2.5, "ref": {"tab": "logic", "code": STONE, "given": {}, "rate": None, "picks": None}},
|
||||
{"key": "hand:d", "section": "1.2", "after": "hand:b", "name": "새 산근", "spec": "",
|
||||
"unit": "㎡", "qty": 3, "ref": {"tab": "logic", "code": NET, "given": NET_IN}},
|
||||
] # fmt: skip
|
||||
USER = {"bill": {"qty": {"earth:4": 100}, "rows": HAND}}
|
||||
|
||||
|
||||
def _rows(sheets):
|
||||
return {row["key"]: row for row in sheets["bill"]["rows"]}
|
||||
|
||||
|
||||
def _node(source: str) -> str:
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", source],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _ts(names: list[str]) -> str:
|
||||
return "".join(
|
||||
IMPORTS.sub("", (ROOT / "B09_Estimation" / f"B09_Estimation_{n}.ts").read_text("utf-8"))
|
||||
for n in names
|
||||
)
|
||||
|
||||
|
||||
def test_수량_덮어쓰기_자동_값_남김_지우면_자동():
|
||||
rows = _rows(compute_estimation(INPUTS, USER, None, BOOKS))
|
||||
row = rows["earth:4"]
|
||||
assert (row["qty"], row["qty_auto"], row["labor"]) == (100, 5675, 111600) # 1,116 × 100
|
||||
plain = _rows(compute_estimation(INPUTS, {}, None, BOOKS))["earth:4"]
|
||||
assert plain["qty"] == 5675 and "qty_auto" not in plain
|
||||
# 단가 그대로 · 수량 없던 줄도 덮어쓰면 금액이 생김
|
||||
rows = _rows(compute_estimation(INPUTS, {"bill": {"qty": {"earth:1": 1}}}, None, BOOKS))
|
||||
assert rows["earth:1"]["sum"] == 25821
|
||||
|
||||
|
||||
def test_손_줄_자리_단가_비고():
|
||||
sheets = compute_estimation(INPUTS, USER, None, BOOKS)
|
||||
keys = [r["key"] for r in sheets["bill"]["rows"] if r["section"] in ("1.1", "1.2")]
|
||||
assert keys == [
|
||||
"sec:1.1", "earth:1", "hand:a", # earth:1 뒤
|
||||
# 제목 = 첫 자리 · 못 찾음 = 끝
|
||||
"sec:1.2", "hand:b", "hand:d", "earth:4", "earth:5", "hand:c",
|
||||
] # fmt: skip
|
||||
rows = _rows(sheets)
|
||||
a = rows["hand:a"]
|
||||
assert (a["note"], a["ref"], a["hand"]) == ("대가3", {"tab": "dg", "key": "DG:a"}, True)
|
||||
assert (a["labor"], a["material"], a["expense"], a["sum"]) == (10000, 2000, 300, 12300)
|
||||
assert rows["hand:b"]["note"] == "산근 1호표" and rows["hand:b"]["sum"] == 2028
|
||||
c = rows["hand:c"] # 로직 = 서버가 준 sg_hand 산근
|
||||
assert (c["note"], c["ref"], c["sum"]) == ("산근 2호표", {"tab": "sg", "key": "SG:new"}, 27)
|
||||
d = rows["hand:d"] # 새 산근 [저장] 전 = 빈칸 + 까닭
|
||||
assert d["sum"] is None and d["ref"] is None
|
||||
assert ("bill", "hand:d", WHY_LOGIC) in {(g["tab"], g["key"], g["why"]) for g in sheets["gaps"]}
|
||||
assert rows["sec:1.1"]["sum"] == 361494 + 12300 # 제목 합에 손 줄
|
||||
assert rows["sec:1.2"]["blank"] == 2 # 절토(연암) · 새 산근
|
||||
|
||||
|
||||
def test_거울_TS_가_같은_값():
|
||||
calc = _ts(["Calc_Base", "Calc_Bill"])
|
||||
for user in (USER, {}, {"bill": {"qty": {"earth:5": None}}}):
|
||||
body = (
|
||||
f"\nconst i = {json.dumps(INPUTS, ensure_ascii=False)};"
|
||||
f"\nconst u = {json.dumps(user, ensure_ascii=False)};"
|
||||
f"\nconst b = {json.dumps(BOOKS, ensure_ascii=False)};"
|
||||
"\nconsole.log(JSON.stringify(computeEstimation(i, u, null, b)));"
|
||||
)
|
||||
assert json.loads(_node(calc + body)) == compute_estimation(INPUTS, user, None, BOOKS)
|
||||
|
||||
|
||||
def test_저장_다듬기_bill():
|
||||
raw = {
|
||||
"bill": {
|
||||
"qty": {"earth:4": 100, "earth:5": "x", "st:a#대가1": 2.5},
|
||||
"rows": [
|
||||
*HAND[:1],
|
||||
{"key": "x:1", "section": "1.1"}, # hand: 아님
|
||||
{"key": "hand:z", "section": "9"}, # 모르는 공종
|
||||
{**HAND[2], "ref": {**HAND[2]["ref"], "given": {"a": 1, "b": [1]}}, "junk": 1},
|
||||
{**HAND[1], "ref": {"tab": "zz", "key": "q"}},
|
||||
],
|
||||
}
|
||||
}
|
||||
bill = clean_user(raw)["bill"]
|
||||
assert bill["qty"] == {"earth:4": 100, "st:a#대가1": 2.5}
|
||||
assert [r["key"] for r in bill["rows"]] == ["hand:a", "hand:c", "hand:b"]
|
||||
assert bill["rows"][1]["ref"] == {"tab": "logic", "code": STONE, "name": "", "given": {"a": 1},
|
||||
"rate": None, "picks": None} # fmt: skip
|
||||
assert "junk" not in bill["rows"][1] and bill["rows"][2]["ref"] is None
|
||||
assert "bill" not in clean_user({"bill": {"qty": {}, "rows": []}})
|
||||
|
||||
|
||||
def _snapshot(folder: Path, rows, values=None) -> Path:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
files = {
|
||||
"단가.json": item_prices.to_json(item_prices.collect([STONE, NET])),
|
||||
"일위대가.json": {"줄": {"종류": "일위대가", "담은로직": rows}},
|
||||
"입력값.json": {k: {n: {"값": v} for n, v in d.items()} for k, d in (values or {}).items()},
|
||||
}
|
||||
for name, data in files.items():
|
||||
(folder / name).write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
return folder
|
||||
|
||||
|
||||
def test_새_산근_사본_뒤_번호_호표_탭_집계표(tmp_path):
|
||||
a = _snapshot(tmp_path / "a", [{"로직": NET}], {NET: NET_IN})
|
||||
user = {"bill": {"rows": [{**HAND[2], "qty": 10}, HAND[3]]}}
|
||||
hand_keys = sg.hand_resource_keys(user)
|
||||
assert {"LB000033", "LB000002"} <= set(hand_keys) # 석공 · 보통인부 → 단가 책 수요
|
||||
keys = list(dict.fromkeys([*used_keys(a), *hand_keys]))
|
||||
base = compute_base(prices.build_inputs(None, keys), {})
|
||||
got = sg.collect([("s1", a)], base, sg.hand_sources(user))
|
||||
listed = [(r["no"], r["logic"], r["uses"]) for r in got["sg_list"]]
|
||||
assert listed == [(1, NET, ["s1", "손 줄 새 산근"]), (2, STONE, ["손 줄 메쌓기"])]
|
||||
assert got["sg_hand"] == {
|
||||
"hand:c": got["sg_list"][1]["key"],
|
||||
"hand:d": got["sg_list"][0]["key"],
|
||||
}
|
||||
stone = got["sg"][1]
|
||||
assert stone["sum"] is not None and stone["why"] is None
|
||||
# 내역서 — 손 줄 단가 = 새 산근 · 비고 「산근 2호표」 · 집계표 역전개에 듦
|
||||
books = {"daega": [], "sg_list": got["sg_list"], "sg_hand": got["sg_hand"]}
|
||||
sheets = {**compute_estimation({**INPUTS, "bill": {"items": []}}, user, None, books), **got}
|
||||
row = _rows(sheets)["hand:c"]
|
||||
assert (row["note"], row["price_sum"]) == ("산근 2호표", stone["sum"])
|
||||
labor = compute_summary(sheets, hopyo_lines(sheets))["sum_labor"]
|
||||
qty = {r["key"]: r["qty"] for r in labor}
|
||||
assert qty["LB000033"] >= round(10 * 0.195, 2) # 석공 = 손 줄 수량 × 0.195(+ 낙석망)
|
||||
|
||||
|
||||
def test_우클릭_메뉴_줄_추가_지우기_연결_TS():
|
||||
src = _ts(["UI_Tabs", "UI_Tabs_Bill_Edit"])
|
||||
rows = [
|
||||
{"kind": "title", "key": "sec:1.2", "section": "1.2"},
|
||||
{"kind": "item", "key": "earth:4", "section": "1.2"},
|
||||
{"kind": "item", "key": "hand:b", "section": "1.2", "hand": True},
|
||||
]
|
||||
body = f"""
|
||||
const rows = {json.dumps(rows, ensure_ascii=False)};
|
||||
const first = {json.dumps(HAND[1], ensure_ascii=False)};
|
||||
let user = {{ bill: {{ qty: {{ "earth:4": 100 }}, rows: [first] }} }};
|
||||
const ctx = {{ sheets: {{}}, payload: {{}}, get user() {{ return user; }},
|
||||
setUser: (u) => (user = u) }};
|
||||
const name = (x) => (x === "-" ? "-" : Array.isArray(x) ? x[0] : x.label);
|
||||
const labels = (i) => billMenu(ctx, rows, i).map(name);
|
||||
const out = {{ title: labels(0), auto: labels(1), hand: labels(2) }};
|
||||
billMenu(ctx, rows, 1).find((x) => Array.isArray(x) && x[0] === "위에 줄 추가")[1]();
|
||||
out.above = user.bill.rows[1];
|
||||
billMenu(ctx, rows, 1).find((x) => Array.isArray(x) && x[0] === "수량 자동으로 되돌리기")[1]();
|
||||
out.qty = user.bill.qty;
|
||||
billMenu(ctx, rows, 2).find((x) => Array.isArray(x) && x[0] === "줄 지우기")[1]();
|
||||
out.left = user.bill.rows.map((r) => [r.key.slice(0, 5), r.after]);
|
||||
out.edit = ["name", "qty", "sum"].map(handEditable);
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
out = json.loads(_node(src + body))
|
||||
assert out["title"] == ["아래에 줄 추가"]
|
||||
assert out["auto"] == ["위에 줄 추가", "아래에 줄 추가", "-", "수량 자동으로 되돌리기"]
|
||||
assert out["hand"] == ["위에 줄 추가", "아래에 줄 추가", "-", "연결", "연결 풀기", "-",
|
||||
"줄 지우기"] # fmt: skip
|
||||
assert out["above"]["after"] == "sec:1.2" and out["above"]["name"] == "새 줄"
|
||||
assert out["qty"] == {}
|
||||
# hand:b 지움 — 남은 새 줄은 그대로(자기 자리) · 지운 줄 뒤에 붙던 줄은 지운 줄 자리로
|
||||
assert out["left"] == [["hand:", "sec:1.2"]]
|
||||
assert out["edit"] == [True, True, False]
|
||||
Reference in New Issue
Block a user