Merge remote-tracking branch 'origin/sub_desktop_1' into sub_laptop_6
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음.
|
||||
|
||||
⚠ 짝 = `B09_Estimation_Calc_Bill.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
||||
거울 시험 `resources/tester/test_145_3_b09_bill.py`.
|
||||
거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨).
|
||||
|
||||
실무 엑셀 설계내역서 꼴 · 공종 나무 = 엑셀 꼴 고정(`SECTIONS` · 공구 안 나눔):
|
||||
1 토공사(1.1~1.6) · 2 구조물공(2.1~2.3) · 3 운반공 · 4 자재대 · 관급자재대(제외금액)
|
||||
@@ -18,6 +18,9 @@
|
||||
단가 = `ref_price(ref)` 하나 — dg = 일위대가목록표 · sg = 단가산출근거목록표 ·
|
||||
logic = M01 로직 새 산근(서버가 `sg_hand{줄 키: 산근 키}` 로 줌 · 그전엔 빈칸 「[저장] 뒤」) ·
|
||||
그 밖 = `inputs.hopyo` · 비고 = 「대가n」 · 「산근 n호표」(155-3 꼴).
|
||||
레벨(182) = 손 줄 `kind: "group"`(묶음 줄 · 수량 · 연결 없음) · `parent`(묶음 손 줄 키 · 없음 = 공종 바로
|
||||
아래) · 줄마다 `level`(1 = 1 토공사 · 2 = 1.1 · 3 = 그 안 항목 · 손 묶음 + 1) ·
|
||||
묶음 금액 = 자손 항목 합 · 공종 합 · `sums` = 항목만(묶음 줄 안 셈 · 원가계산서 그대로).
|
||||
원가계산서로 넘길 합 = `sums`(직접재료비 · 직접노무비 · 산출경비 · 관급 순자재대 — 145-4 이음 꼴 ·
|
||||
빈 줄이 있으면 None) · 화면 표시용 = `totals`(아는 금액 합 · 빈 줄 수 · 조달수수료).
|
||||
"""
|
||||
@@ -153,6 +156,62 @@ def hand_items(user: dict[str, Any], books: dict[str, Any], hopyo: dict) -> list
|
||||
return out
|
||||
|
||||
|
||||
def _group_row(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""묶음 손 줄(182) — 이름만 · 금액 = 자손 항목 합(`_nest`)."""
|
||||
return {
|
||||
"kind": "group",
|
||||
"key": item["key"],
|
||||
"section": item["section"],
|
||||
"name": item.get("name") or "",
|
||||
"spec": item.get("spec") or "",
|
||||
"unit": item.get("unit") or "",
|
||||
"qty": None,
|
||||
"note": "",
|
||||
"ref": None,
|
||||
"hand": True,
|
||||
"sum": 0.0,
|
||||
"labor": 0.0,
|
||||
"material": 0.0,
|
||||
"expense": 0.0,
|
||||
"blank": 0,
|
||||
}
|
||||
|
||||
|
||||
def _nest(rows: list[dict[str, Any]]) -> None:
|
||||
"""182 레벨 — 부모 = 같은 공종 묶음 손 줄(못 찾거나 돌면 공종 바로 아래) · level · 묶음 금액."""
|
||||
groups = {row["key"]: row for row in rows if row["kind"] == "group"}
|
||||
for row in rows:
|
||||
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:
|
||||
"""손 줄 끼우기 — `after` 줄 바로 뒤 · 제목(`sec:`) = 그 공종 첫 자리 · 못 찾으면 끝."""
|
||||
at = next((n + 1 for n, row in enumerate(items) if row["key"] == after), None)
|
||||
@@ -213,8 +272,13 @@ def compute_bill(
|
||||
prices = None if price is None else {"labor": 0, "material": price, "expense": 0}
|
||||
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"]
|
||||
if item.get("kind") == "group":
|
||||
row = _group_row(item)
|
||||
else:
|
||||
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
|
||||
for row in owner:
|
||||
net += row["material"] or 0
|
||||
@@ -248,6 +312,8 @@ def compute_bill(
|
||||
for code, name, parent in SECTIONS:
|
||||
sums[code] = {"sum": 0.0, "labor": 0.0, "material": 0.0, "expense": 0.0, "blank": 0}
|
||||
for row in items:
|
||||
if row["kind"] != "item":
|
||||
continue
|
||||
code = row["section"]
|
||||
chain = [code]
|
||||
parent = next(p for c, _n, p in SECTIONS if c == code)
|
||||
@@ -276,6 +342,7 @@ def compute_bill(
|
||||
}
|
||||
)
|
||||
rows.extend(row for row in items if row["section"] == code)
|
||||
_nest(rows)
|
||||
direct = {p: 0.0 for p in ("sum", *PARTS)}
|
||||
blank = 0
|
||||
for code in DIRECT:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · import 는 짝 바탕 셈 하나(시험이 이어 붙임).
|
||||
*
|
||||
* ⚠ 짝 = `B09_Estimation_Calc_Bill.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
||||
* 거울 시험 `resources/tester/test_145_3_b09_bill.py`.
|
||||
* 거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨).
|
||||
*
|
||||
* 공종 나무 = 엑셀 꼴 고정(1 토공사 · 2 구조물공 · 3 운반공 · 4 자재대 · 관급자재대(제외금액)) · 공구 안 나눔
|
||||
* 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료
|
||||
@@ -11,6 +11,9 @@
|
||||
* 제목 줄 = 아래 항목 금액 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(빈 줄 있으면 null)
|
||||
* 손질(166-2) = `user.bill` — qty{줄 키: 수} 자동 줄 수량 덮어쓰기 · rows[] 손 줄(after 뒤에 끼움) ·
|
||||
* 단가 = `refPrice(ref)` 하나(dg 대가 목록 · sg 산근 목록 · logic = 서버 sg_hand 산근 · 그 밖 hopyo)
|
||||
* 레벨(182) = 손 줄 `kind: "group"`(묶음 줄 · 수량 · 연결 없음) · `parent`(묶음 손 줄 키 · 없음 = 공종 바로 아래) ·
|
||||
* 줄마다 `level`(1 = 1 토공사 · 2 = 1.1 · 3 = 그 안 항목 · 손 묶음 + 1) · 묶음 금액 = 자손 항목 합 ·
|
||||
* 공종 합 · sums = 항목만(묶음 줄 안 셈 · 원가계산서 그대로)
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -143,6 +146,62 @@ export function handItems(user: Row, books: Row, hopyo: Row): Array<[Row, Row |
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 묶음 손 줄(182) — 이름만 · 금액 = 자손 항목 합(`nest`) */
|
||||
function groupRow(item: Row): Row {
|
||||
return {
|
||||
kind: "group",
|
||||
key: item.key,
|
||||
section: item.section,
|
||||
name: item.name || "",
|
||||
spec: item.spec || "",
|
||||
unit: item.unit || "",
|
||||
qty: null,
|
||||
note: "",
|
||||
ref: null,
|
||||
hand: true,
|
||||
sum: 0,
|
||||
labor: 0,
|
||||
material: 0,
|
||||
expense: 0,
|
||||
blank: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** 182 레벨 — 부모 = 같은 공종 묶음 손 줄(못 찾거나 돌면 공종 바로 아래) · level · 묶음 금액 */
|
||||
function nest(rows: Row[]): void {
|
||||
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:) = 그 공종 첫 자리 · 못 찾으면 끝 */
|
||||
function place(items: Row[], hand: Row, after: string): void {
|
||||
let at = items.findIndex((row) => row.key === after);
|
||||
@@ -204,9 +263,12 @@ export function computeBill(
|
||||
const prices = price === null ? null : { labor: 0, material: price, expense: 0 };
|
||||
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");
|
||||
for (const [item, prices, why] of handItems(user, book, hopyo)) {
|
||||
const row = item.kind === "group" ? groupRow(item) : itemRow({ ...item, why }, prices, gaps);
|
||||
row.parent = item.parent ?? null;
|
||||
place(items, row, item.after || "");
|
||||
}
|
||||
const owner = items.filter((row) => row.section === "G" && row.kind === "item");
|
||||
let net = 0;
|
||||
for (const row of owner) net += row.material || 0;
|
||||
const pct = num((user.estimate || {}).procurement_fee_pct);
|
||||
@@ -238,6 +300,7 @@ export function computeBill(
|
||||
for (const [code] of SECTIONS)
|
||||
sums[code] = { sum: 0, labor: 0, material: 0, expense: 0, blank: 0 };
|
||||
for (const row of items) {
|
||||
if (row.kind !== "item") continue;
|
||||
const code = row.section;
|
||||
const chain = [code];
|
||||
const parent = SECTIONS.find(([c]) => c === code)![2];
|
||||
@@ -264,6 +327,7 @@ export function computeBill(
|
||||
});
|
||||
rows.push(...items.filter((row) => row.section === code));
|
||||
}
|
||||
nest(rows);
|
||||
const direct: Row = { sum: 0, labor: 0, material: 0, expense: 0 };
|
||||
let blank = 0;
|
||||
for (const code of DIRECT) {
|
||||
|
||||
@@ -109,7 +109,8 @@ def _clean_ref(ref: Any) -> dict[str, Any] | None:
|
||||
|
||||
|
||||
def clean_bill(raw: Any) -> dict[str, Any]:
|
||||
"""설계내역서 손질(166-2) — 수량 덮어쓰기(수만) · 손 줄(`hand:` 키 · 아는 공종만)."""
|
||||
"""설계내역서 손질(166-2) — 수량 덮어쓰기(수만) · 손 줄(`hand:` 키 · 아는 공종만) ·
|
||||
182 레벨 = `kind`(group) · `parent`(`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}
|
||||
@@ -119,14 +120,18 @@ def clean_bill(raw: Any) -> dict[str, Any]:
|
||||
continue
|
||||
if row.get("section") not in codes:
|
||||
continue
|
||||
group = row.get("kind") == "group" # 182 묶음 줄 = 이름만
|
||||
parent = str(row.get("parent") or "")
|
||||
rows.append(
|
||||
{
|
||||
"key": str(row["key"]),
|
||||
"section": row["section"],
|
||||
"after": _text(row.get("after")),
|
||||
**({"kind": "group"} if group else {}),
|
||||
**({"parent": parent} if parent.startswith("hand:") else {}),
|
||||
**{k: _text(row.get(k)) for k in ("name", "spec", "unit")},
|
||||
"qty": _num(row.get("qty")),
|
||||
"ref": _clean_ref(row.get("ref")),
|
||||
"qty": None if group else _num(row.get("qty")),
|
||||
"ref": None if group else _clean_ref(row.get("ref")),
|
||||
}
|
||||
)
|
||||
out: dict[str, Any] = {}
|
||||
|
||||
@@ -44,6 +44,39 @@
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 182 설계내역서 레벨 — 글자 크기(줄 높이 28px 고정 → 16px 까지) · 첫 칸 들여쓰기 1em × (레벨 − 1) */
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-lv1 td {
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr:is(.is-lv2, .is-lv3) td {
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr:is(.is-lv4, .is-lv5, .is-lv6) td {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-group td {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-lv2 td:first-child {
|
||||
padding-left: calc(var(--spacing-8) + 1em);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-lv3 td:first-child {
|
||||
padding-left: calc(var(--spacing-8) + 2em);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-lv4 td:first-child {
|
||||
padding-left: calc(var(--spacing-8) + 3em);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr:is(.is-lv5, .is-lv6) td:first-child {
|
||||
padding-left: calc(var(--spacing-8) + 4em);
|
||||
}
|
||||
|
||||
/* 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);
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* 고칠 칸 = 관급자재대 조달수수료 율(%) · 항목 수량(166-2 · 덮어쓰기 · 빈칸 = 자동) ·
|
||||
* 손 줄 명칭 · 규격 · 단위 · 수량 · 빈칸 = 「연결 없음」(수량 · 단가 원천 없음)
|
||||
* 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표)
|
||||
* 우클릭 = 줄 추가 · 지우기 · 연결(`…_UI_Tabs_Bill_Edit`)
|
||||
* 우클릭 = 줄 추가 · 지우기 · 연결 · 레벨(`…_UI_Tabs_Bill_Edit`)
|
||||
* 레벨(182) = 줄 `level`(셈이 냄) → class is-lvN · 글자 크기 · 첫 칸 들여쓰기(CSS) · 묶음 줄 = 이름만 고침
|
||||
* 「연결 ▾」 칸(182) = 손 항목 줄만 · 누름 = 우클릭 [연결] 과 같은 메뉴(연결됨 = 호표 번호)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
editQty,
|
||||
editText,
|
||||
handEditable,
|
||||
openLinkMenu,
|
||||
type BillTable,
|
||||
} from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
||||
|
||||
@@ -25,7 +28,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
const bill: Row = sheets.bill || { rows: [], totals: null };
|
||||
const rows: DataRow[] = bill.rows.map((r: Row) => ({
|
||||
...r,
|
||||
name: r.kind === "title" ? r.name : ` ${r.name}`,
|
||||
connect: r.hand && r.kind === "item" ? `${r.note || "연결"} ▾` : "",
|
||||
}));
|
||||
const direct: Row = bill.totals?.direct || {};
|
||||
const pair = (key: string, label: string) => [
|
||||
@@ -42,6 +45,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
...pair("labor", "노무비"),
|
||||
...pair("material", "재료비"),
|
||||
...pair("expense", "경비"),
|
||||
text("connect", "연결", 96),
|
||||
text("note", "비고", 200),
|
||||
],
|
||||
rows,
|
||||
@@ -49,10 +53,12 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
frozen: 1,
|
||||
hint:
|
||||
"공종 나무 = 엑셀 꼴 고정 · 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재 · 조달수수료 율(%)은 수량 칸에 넣음 · " +
|
||||
"수량 칸 = 덮어쓰기(빈칸 = 자동) · 우클릭 = 줄 추가 · 지우기 · 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · " +
|
||||
"수량 칸 = 덮어쓰기(빈칸 = 자동) · 우클릭 · 「연결 ▾」 = 줄 추가 · 지우기 · 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · " +
|
||||
"레벨 = 우클릭 묶음 줄로 · 하위 줄 추가 · 들여쓰기 · 내어쓰기(묶음 금액 = 아래 항목 합 · 공종 합은 항목만) · " +
|
||||
`빈 줄 ${direct.blank ?? 0} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`,
|
||||
editable: (i, key) => {
|
||||
const r = rows[i];
|
||||
if (r?.kind === "group") return key === "name";
|
||||
if (!r || r.kind !== "item") return false;
|
||||
return r.hand ? handEditable(key) : key === "qty";
|
||||
},
|
||||
@@ -71,9 +77,19 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
if (key !== "qty" || !r || !("qty_auto" in r)) return null;
|
||||
return `자동 값 ${r.qty_auto ?? "빈칸"} — 우클릭 [수량 자동으로 되돌리기]`;
|
||||
},
|
||||
rowClass: (i) => (rows[i]?.hand ? "is-hand" : null),
|
||||
// 182 레벨 — 글자 크기 · 들여쓰기(is-lvN) · 제목 · 묶음 줄 굵게
|
||||
rowClass: (i) => {
|
||||
const r = rows[i];
|
||||
if (!r) return null;
|
||||
const title = r.kind === "title" ? "is-title" : r.kind === "group" ? "is-group" : "";
|
||||
return [`is-lv${r.level ?? 1}`, title, r.hand ? "is-hand" : ""].filter((c) => c).join(" ");
|
||||
},
|
||||
menu: (i) => billMenu(ctx, rows, i),
|
||||
link: (i) => {
|
||||
onCell: (i, key) => {
|
||||
if (key === "connect") openLinkMenu(ctx, rows, i);
|
||||
},
|
||||
link: (i, key) => {
|
||||
if (key === "connect") return null; // 「연결 ▾」 = 메뉴(이동 안 함)
|
||||
const ref = rows[i]?.ref as Row | null | undefined;
|
||||
return ref?.key ? { tab: ref.tab as TabId, key: String(ref.key) } : null;
|
||||
},
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* rows[] = 손 줄 {key hand:… · section · after(앞 줄 키 · 제목 = sec:공종) · 명칭 · 규격 · 단위 · 수량 · ref}
|
||||
* 연결 = 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가)
|
||||
* 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리) · 로직 고르기 · 입력값 = M02 창 재사용
|
||||
* 레벨(182) = 손 줄 kind(group = 묶음 줄 · 이름만) · parent(묶음 손 줄 키) — 하위 줄 추가 · 묶음 줄로 ·
|
||||
* 항목으로 · 들여쓰기 · 내어쓰기 · 손 묶음 깊이 2 · 「연결 ▾」 칸 = 우클릭 [연결] 과 같은 메뉴
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
@@ -29,6 +31,8 @@ export type BillTable = TabTable & { menu?: (index: number) => MapContextMenuIte
|
||||
|
||||
const NEW_NAME = "새 줄";
|
||||
const TEXT_KEYS = ["name", "spec", "unit"];
|
||||
/** 182 손 묶음 깊이 — 묶음 안 묶음까지 */
|
||||
const MAX_GROUP = 2;
|
||||
|
||||
interface Bill {
|
||||
qty: Record<string, number>;
|
||||
@@ -69,35 +73,138 @@ export function editText(ctx: TabContext, key: string, field: string, value: str
|
||||
put(ctx, bill);
|
||||
}
|
||||
|
||||
/** 새 줄 자리 — 공종 · 앞 줄 키(위 = 앞 줄 · 공종 첫 줄이면 제목 sec:공종) */
|
||||
function slot(rows: DataRow[], i: number, below: boolean): { section: string; after: string } {
|
||||
const lv = (row: DataRow | undefined): number => Number(row?.level ?? 0);
|
||||
|
||||
/** 줄 i 자손 끝(다음 줄 번호) — 더 깊은 레벨 줄이 이어지는 데까지 */
|
||||
function subtreeEnd(rows: DataRow[], i: number): number {
|
||||
let k = i + 1;
|
||||
while (k < rows.length && lv(rows[k]) > lv(rows[i])) k += 1;
|
||||
return k;
|
||||
}
|
||||
|
||||
/** 부모 바꿈 — 없음 = 공종 바로 아래(키 지움) */
|
||||
function reparent(h: Row, parent: unknown): void {
|
||||
if (parent) h.parent = parent;
|
||||
else delete h.parent;
|
||||
}
|
||||
|
||||
/** 손 묶음 깊이 지킴 — 묶음 줄 위 묶음 ≤ 1 · 항목 위 묶음 ≤ 2(도는 부모는 셈이 끊음 · 여기선 멈춤) */
|
||||
function fits(bill: Bill): boolean {
|
||||
const byKey = new Map(bill.rows.map((h) => [String(h.key), h]));
|
||||
return bill.rows.every((h) => {
|
||||
let n = 0;
|
||||
const seen = new Set([h.key]);
|
||||
let at = byKey.get(String(h.parent ?? ""));
|
||||
while (at && at.kind === "group" && !seen.has(at.key)) {
|
||||
seen.add(at.key);
|
||||
n += 1;
|
||||
at = byKey.get(String(at.parent ?? ""));
|
||||
}
|
||||
return n <= (h.kind === "group" ? MAX_GROUP - 1 : MAX_GROUP);
|
||||
});
|
||||
}
|
||||
|
||||
/** 새 줄 자리 — 공종 · 앞 줄 키(위 = 앞 줄 · 공종 첫 줄이면 제목 sec:공종 · 아래 = 자손 끝) · 부모 = 그 줄 부모 */
|
||||
function slot(rows: DataRow[], i: number, below: boolean): Row {
|
||||
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 parent = row.hand && row.parent ? { parent: String(row.parent) } : {};
|
||||
if (below) return { section, after: String(rows[subtreeEnd(rows, i) - 1].key), ...parent };
|
||||
const prev = rows[i - 1];
|
||||
const same = prev && prev.kind !== "title" && prev.section === section;
|
||||
return { section, after: same ? String(prev.key) : `sec:${section}` };
|
||||
return { section, after: same ? String(prev.key) : `sec:${section}`, ...parent };
|
||||
}
|
||||
|
||||
function addRow(ctx: TabContext, rows: DataRow[], i: number, below: boolean): void {
|
||||
function newRow(ctx: TabContext, at: Row): 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 addRow(ctx: TabContext, rows: DataRow[], i: number, below: boolean): void {
|
||||
newRow(ctx, slot(rows, i, below));
|
||||
}
|
||||
|
||||
/** 묶음 줄 아래 하위 줄 — 자리 = 마지막 자손 뒤 */
|
||||
function addChild(ctx: TabContext, rows: DataRow[], i: number): void {
|
||||
const after = String(rows[subtreeEnd(rows, i) - 1].key);
|
||||
newRow(ctx, { section: String(rows[i].section), after, parent: String(rows[i].key) });
|
||||
}
|
||||
|
||||
/** 손 줄 지움 — 그 줄 뒤에 붙던 손 줄은 지운 줄 자리로 · 자식은 지운 줄 부모로 옮김 */
|
||||
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;
|
||||
for (const h of bill.rows) if (h.parent === key) reparent(h, gone.parent);
|
||||
put(ctx, bill);
|
||||
}
|
||||
|
||||
/** 레벨 바꿈 — 깊이 한도 안이면 [동작] · 넘으면 null(메뉴에 안 보임) */
|
||||
function levelAction(
|
||||
ctx: TabContext,
|
||||
key: string,
|
||||
change: (own: Row, bill: Bill) => void,
|
||||
): (() => void) | null {
|
||||
const bill = billOf(ctx.user);
|
||||
const own = bill.rows.find((h) => h.key === key);
|
||||
if (!own) return null;
|
||||
change(own, bill);
|
||||
return fits(bill) ? () => put(ctx, bill) : null;
|
||||
}
|
||||
|
||||
/** 들여쓰기 대상 — 바로 위 같은 레벨 형제가 같은 공종 묶음 줄이면 그 줄 */
|
||||
function indentTarget(rows: DataRow[], i: number): DataRow | null {
|
||||
const row = rows[i];
|
||||
for (let j = i - 1; j >= 0; j -= 1) {
|
||||
const r = rows[j];
|
||||
if (lv(r) > lv(row)) continue;
|
||||
return lv(r) === lv(row) && r.kind === "group" && r.section === row.section ? r : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 레벨 메뉴 — 묶음 줄로 · 항목으로 · 들여쓰기 → · 내어쓰기 ←(막내만 · 자리 안 어긋남) */
|
||||
function levelMenu(ctx: TabContext, rows: DataRow[], i: number): MapContextMenuItem[] {
|
||||
const row = rows[i];
|
||||
const key = String(row.key);
|
||||
const out: MapContextMenuItem[] = [];
|
||||
const add = (label: string, action: (() => void) | null): void => {
|
||||
if (action) out.push([label, action]);
|
||||
};
|
||||
if (row.kind === "group")
|
||||
add(
|
||||
"항목으로 바꾸기",
|
||||
levelAction(ctx, key, (own, bill) => {
|
||||
delete own.kind;
|
||||
for (const h of bill.rows) if (h.parent === key) reparent(h, own.parent);
|
||||
}),
|
||||
);
|
||||
else
|
||||
add(
|
||||
"묶음 줄로 바꾸기",
|
||||
levelAction(ctx, key, (own) => Object.assign(own, { kind: "group", qty: null, ref: null })),
|
||||
);
|
||||
const target = indentTarget(rows, i);
|
||||
if (target)
|
||||
add(
|
||||
"들여쓰기 →",
|
||||
levelAction(ctx, key, (own) => reparent(own, String(target.key))),
|
||||
);
|
||||
if (row.parent && lv(rows[subtreeEnd(rows, i)]) < lv(row))
|
||||
add(
|
||||
"내어쓰기 ←",
|
||||
levelAction(ctx, key, (own, bill) =>
|
||||
reparent(own, bill.rows.find((h) => h.key === row.parent)?.parent),
|
||||
),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 연결 — 명칭이 비었거나 「새 줄」이면 고른 호표 명칭 · 규격 · 단위를 채움 */
|
||||
function setRef(ctx: TabContext, key: string, ref: Row | null, from?: Row): void {
|
||||
const bill = billOf(ctx.user);
|
||||
@@ -182,7 +289,24 @@ function pickLogic(ctx: TabContext, key: string): void {
|
||||
openLogicPicker((logic) => askInputs(ctx, key, logic.키, logic.이름));
|
||||
}
|
||||
|
||||
/** 줄 우클릭 메뉴 — 위 · 아래 줄 추가 · (손 줄) 연결 · 연결 풀기 · 지우기 · (자동 줄) 수량 자동으로 */
|
||||
type Leaf = [string, () => void];
|
||||
|
||||
/** 손 항목 줄 연결 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] */
|
||||
function linkItems(ctx: TabContext, key: string): [Leaf[], Leaf[]] {
|
||||
const ref: Row | null = billOf(ctx.user).rows.find((h) => h.key === key)?.ref ?? null;
|
||||
const more: Leaf[] = [];
|
||||
if (ref?.tab === "logic")
|
||||
more.push(["로직 입력값 고치기", () => askInputs(ctx, key, ref.code, ref.name || "", ref)]);
|
||||
if (ref) more.push(["연결 풀기", () => setRef(ctx, key, null)]);
|
||||
const picks: Leaf[] = [
|
||||
["일위대가 호표", () => pickHopyo(ctx, key, "dg")],
|
||||
["단가산출 호표", () => pickHopyo(ctx, key, "sg")],
|
||||
["단가산출 로직(M01 · 새 산근)", () => pickLogic(ctx, key)],
|
||||
];
|
||||
return [picks, more];
|
||||
}
|
||||
|
||||
/** 줄 우클릭 메뉴 — 위 · 아래(· 하위) 줄 추가 · (손 줄) 연결 · 연결 풀기 · 레벨 · 지우기 · (자동 줄) 수량 자동으로 */
|
||||
export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContextMenuItem[] {
|
||||
const row = rows[i];
|
||||
if (!row) return [];
|
||||
@@ -191,19 +315,13 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex
|
||||
const items: MapContextMenuItem[] = [];
|
||||
if (row.kind !== "title") items.push(["위에 줄 추가", () => addRow(ctx, rows, i, false)]);
|
||||
items.push(["아래에 줄 추가", () => addRow(ctx, rows, i, true)]);
|
||||
if (row.kind === "group") items.push(["아래에 하위 줄 추가", () => addChild(ctx, rows, i)]);
|
||||
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)]);
|
||||
if (row.kind !== "group") {
|
||||
const [picks, more] = linkItems(ctx, key);
|
||||
items.push("-", { label: "연결", children: picks }, ...more);
|
||||
}
|
||||
items.push("-", ...levelMenu(ctx, rows, i));
|
||||
items.push("-", ["줄 지우기", () => removeRow(ctx, key)]);
|
||||
} else if (row.kind === "item" && key in bill.qty) {
|
||||
items.push("-", [
|
||||
@@ -218,6 +336,17 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex
|
||||
}
|
||||
|
||||
let shared: MapContextMenu | null = null;
|
||||
/** 마지막으로 누른 칸 — 「연결 ▾」 단추 메뉴 자리 */
|
||||
let lastCell: Element | null = null;
|
||||
|
||||
/** 「연결 ▾」 칸 누름(182) — 우클릭 [연결] 과 같은 메뉴를 그 칸 아래에 엶 */
|
||||
export function openLinkMenu(ctx: TabContext, rows: DataRow[], i: number): void {
|
||||
const row = rows[i];
|
||||
const box = lastCell?.getBoundingClientRect();
|
||||
if (!shared || !box || !row?.hand || row.kind !== "item") return;
|
||||
const [picks, more] = linkItems(ctx, String(row.key));
|
||||
shared.open(box.left, box.bottom, more.length ? [...picks, "-", ...more] : picks);
|
||||
}
|
||||
|
||||
/** 표에 줄 우클릭 메뉴를 붙임(공용 표는 안 고침) — 글 칸 위는 브라우저 메뉴(붙여넣기) 그대로 */
|
||||
export function attachRowMenu(
|
||||
@@ -230,6 +359,12 @@ export function attachRowMenu(
|
||||
ctxMenu.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y)
|
||||
document.body.append(ctxMenu.element);
|
||||
}
|
||||
// 칸 누름보다 먼저(잡기 단계) — 「연결 ▾」 메뉴를 그 칸 자리에 엶
|
||||
root.addEventListener(
|
||||
"click",
|
||||
(event) => (lastCell = (event.target as Element).closest("td")),
|
||||
true,
|
||||
);
|
||||
root.addEventListener("contextmenu", (event) => {
|
||||
const target = event.target as Element;
|
||||
if (target.closest("input")) return;
|
||||
|
||||
@@ -223,7 +223,7 @@ console.log(JSON.stringify(out));
|
||||
assert out["title"] == ["아래에 줄 추가"]
|
||||
assert out["auto"] == ["위에 줄 추가", "아래에 줄 추가", "-", "수량 자동으로 되돌리기"]
|
||||
assert out["hand"] == ["위에 줄 추가", "아래에 줄 추가", "-", "연결", "연결 풀기", "-",
|
||||
"줄 지우기"] # fmt: skip
|
||||
"묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip # 182 레벨
|
||||
assert out["above"]["after"] == "sec:1.2" and out["above"]["name"] == "새 줄"
|
||||
assert out["qty"] == {}
|
||||
# hand:b 지움 — 남은 새 줄은 그대로(자기 자리) · 지운 줄 뒤에 붙던 줄은 지운 줄 자리로
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
"""182 B09 설계내역서 손 줄 레벨 — 묶음 줄 · 부모 · 상위 합계 · 거울 · 저장 왕복 · 우클릭 레벨 메뉴.
|
||||
|
||||
- 손 줄 `kind: "group"` = 묶음 줄(수량 · 연결 없음) · `parent` = 같은 공종 묶음 손 줄 키
|
||||
- `level` = 제목(1 · 2) · 줄 = 공종 깊이 + 1 + 손 묶음 조상 수 · 부모 못 찾음 · 다른 공종 · 돎 = 공종 바로 아래
|
||||
- 묶음 금액 = 자손 항목 합 + 「빈 줄 n」 · 공종 합 · `sums` = 항목만(원가계산서 그대로)
|
||||
"""
|
||||
|
||||
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_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": "절토(토사)", "unit": "㎥", "qty": 10,
|
||||
"ref": {"tab": "sg_list", "key": "D01341"}},
|
||||
{"key": "earth:5", "section": "1.2", "name": "절토(연암)", "unit": "㎥", "qty": 9, "ref": None},
|
||||
{"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},
|
||||
}
|
||||
INPUTS = {"materials": [], "bill": {"items": ITEMS}, "hopyo": HOPYO}
|
||||
BOOKS = {
|
||||
"daega": [{"no": 3, "key": "DG:a", "labor": 1000, "material": 200, "expense": 30,
|
||||
"sum": 1230}],
|
||||
} # fmt: skip
|
||||
DG = {"tab": "dg", "key": "DG:a"}
|
||||
|
||||
|
||||
def _hand(key, after, section="1.2", **more):
|
||||
row = {"key": key, "section": section, "after": after, "name": key[5:], "spec": "",
|
||||
"unit": "㎡", "qty": None, "ref": None} # fmt: skip
|
||||
return {**row, **more}
|
||||
|
||||
|
||||
HAND = [
|
||||
_hand("hand:g1", "earth:4", kind="group"),
|
||||
_hand("hand:a", "hand:g1", parent="hand:g1", qty=10, ref=DG),
|
||||
_hand("hand:g2", "hand:a", kind="group", parent="hand:g1"),
|
||||
_hand("hand:b", "hand:g2", parent="hand:g2", qty=2, ref=DG),
|
||||
_hand("hand:c", "hand:b", parent="hand:g2", qty=1), # 연결 없음 = 빈 줄
|
||||
_hand("hand:x", "hand:c", parent="hand:없음", qty=1, ref=DG), # 부모 없음 → 공종 아래
|
||||
_hand("hand:y", "haul:1", "3", parent="hand:g1", qty=1, ref=DG), # 다른 공종 부모
|
||||
_hand("hand:p", "earth:1", "1.1", kind="group", parent="hand:q"), # 돌기
|
||||
_hand("hand:q", "hand:p", "1.1", kind="group", parent="hand:p"),
|
||||
]
|
||||
USER = {"bill": {"rows": HAND}}
|
||||
#: 같은 손 줄 · 묶음 없이(묶음 줄 빼고 부모 지움) — 공종 합 · sums 비교 밑
|
||||
FLAT = {
|
||||
"bill": {
|
||||
"rows": [
|
||||
{k: v for k, v in h.items() if k != "parent"} for h in HAND if h.get("kind") != "group"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _rows(sheets):
|
||||
return {row["key"]: row for row in sheets["bill"]["rows"]}
|
||||
|
||||
|
||||
def _node(source: str) -> str:
|
||||
# 표준입력으로 줌 — 「-e」 는 윈도 명령 줄 길이(32k)를 넘음
|
||||
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 _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_레벨_차례_부모():
|
||||
sheets = compute_estimation(INPUTS, USER, None, BOOKS)
|
||||
got = [(r["key"], r["level"]) for r in sheets["bill"]["rows"] if r["section"] == "1.2"]
|
||||
assert got == [
|
||||
("sec:1.2", 2), ("earth:4", 3), ("hand:g1", 3), ("hand:a", 4), ("hand:g2", 4),
|
||||
("hand:b", 5), ("hand:c", 5), ("hand:x", 3), ("earth:5", 3),
|
||||
] # fmt: skip
|
||||
rows = _rows(sheets)
|
||||
assert rows["sec:1"]["level"] == 1 and rows["sec:3"]["level"] == 1
|
||||
assert (rows["haul:1"]["level"], rows["hand:y"]["level"], rows["hand:y"]["parent"]) == (
|
||||
2,
|
||||
2,
|
||||
None,
|
||||
)
|
||||
assert rows["hand:x"]["parent"] is None and rows["hand:b"]["parent"] == "hand:g2"
|
||||
# 돌기 — 먼저 나온 줄이 끊김(p = 공종 아래 · q = p 아래)
|
||||
assert (rows["hand:p"]["parent"], rows["hand:p"]["level"]) == (None, 3)
|
||||
assert (rows["hand:q"]["parent"], rows["hand:q"]["level"]) == ("hand:p", 4)
|
||||
# 묶음 줄 = 이름만 · 「연결 없음」 목록에 안 듦
|
||||
g1 = rows["hand:g1"]
|
||||
assert (g1["kind"], g1["qty"], g1["ref"], g1["hand"]) == ("group", None, None, True)
|
||||
assert not {g["key"] for g in sheets["gaps"]} & {"hand:g1", "hand:g2", "hand:p", "hand:q"}
|
||||
|
||||
|
||||
def test_묶음_금액_자손_합_공종_합_그대로():
|
||||
sheets = compute_estimation(INPUTS, USER, None, BOOKS)
|
||||
rows = _rows(sheets)
|
||||
g2, g1 = rows["hand:g2"], rows["hand:g1"]
|
||||
assert (g2["sum"], g2["labor"], g2["material"], g2["expense"]) == (2460, 2000, 400, 60)
|
||||
assert (g2["blank"], g2["note"]) == (1, "빈 줄 1")
|
||||
assert (g1["sum"], g1["labor"], g1["blank"]) == (12300 + 2460, 12000, 1) # a + b(+ c 빈 줄)
|
||||
assert rows["hand:p"]["sum"] == 0 and rows["hand:p"]["note"] == ""
|
||||
# 공종 합 · 원가계산서 sums · 계 = 묶음 없을 때와 같음(묶음 줄 안 셈)
|
||||
flat = compute_estimation(INPUTS, FLAT, None, BOOKS)
|
||||
titles = [r for r in sheets["bill"]["rows"] if r["kind"] == "title"]
|
||||
assert titles == [{**r} for r in flat["bill"]["rows"] if r["kind"] == "title"]
|
||||
assert sheets["bill"]["sums"] == flat["bill"]["sums"]
|
||||
assert sheets["bill"]["totals"] == flat["bill"]["totals"]
|
||||
assert rows["sec:1.2"]["sum"] == 19390 + 12300 + 2460 + 1230 # earth:4 + a + b + x
|
||||
|
||||
|
||||
def test_거울_TS_가_같은_값():
|
||||
calc = _ts(["Calc_Base", "Calc_Bill"])
|
||||
for user in (USER, FLAT, {}):
|
||||
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_저장_왕복_kind_parent(tmp_path):
|
||||
raw = {
|
||||
"bill": {
|
||||
"rows": [
|
||||
{**HAND[0], "qty": 5, "ref": DG}, # 묶음 줄 = 수량 · 연결 버림
|
||||
HAND[1],
|
||||
{**HAND[3], "parent": "x:1", "kind": "zz"}, # hand: 아닌 부모 · 모르는 꼴 버림
|
||||
]
|
||||
}
|
||||
}
|
||||
clean = store.write_user(tmp_path, raw)
|
||||
assert store.read_user(tmp_path)["bill"] == clean["bill"]
|
||||
g1, a, b = clean["bill"]["rows"]
|
||||
assert (g1["kind"], g1["qty"], g1["ref"], "parent" in g1) == ("group", None, None, False)
|
||||
assert (a["parent"], "kind" in a, a["ref"]) == ("hand:g1", False, DG)
|
||||
assert "parent" not in b and "kind" not in b
|
||||
# 다시 읽은 값으로 셈 = 원래 값으로 셈
|
||||
back = store.read_user(tmp_path)
|
||||
assert _rows(compute_estimation(INPUTS, back, None, BOOKS))["hand:g1"]["sum"] == 12300
|
||||
|
||||
|
||||
MENU_JS = """
|
||||
const rows = __ROWS__;
|
||||
const start = __USER__;
|
||||
let user = start;
|
||||
const ctx = { sheets: {}, payload: {}, get user() { return user; }, setUser: (u) => (user = u) };
|
||||
const name = (x) => (x === "-" ? "-" : Array.isArray(x) ? x[0] : x.label);
|
||||
const at = (key) => rows.findIndex((r) => r.key === key);
|
||||
const labels = (key) => billMenu(ctx, rows, at(key)).map(name);
|
||||
const run = (key, label) => {
|
||||
user = start;
|
||||
billMenu(ctx, rows, at(key)).find((x) => Array.isArray(x) && x[0] === label)[1]();
|
||||
return user.bill.rows;
|
||||
};
|
||||
const out = {
|
||||
g1: labels("hand:g1"), a: labels("hand:a"), g2: labels("hand:g2"),
|
||||
b: labels("hand:b"), x: labels("hand:x"), auto: labels("earth:4"),
|
||||
};
|
||||
out.child = run("hand:g2", "아래에 하위 줄 추가").at(-1);
|
||||
out.below = run("hand:g1", "아래에 줄 추가").at(-1);
|
||||
out.belowB = run("hand:b", "아래에 줄 추가").at(-1);
|
||||
out.indent = run("hand:x", "들여쓰기 →").find((r) => r.key === "hand:x");
|
||||
out.outdent = run("hand:g2", "내어쓰기 ←").find((r) => r.key === "hand:g2");
|
||||
out.toItem = run("hand:g2", "항목으로 바꾸기").filter((r) => ["hand:g2", "hand:b"].includes(r.key));
|
||||
out.toGroup = run("hand:x", "묶음 줄로 바꾸기").find((r) => r.key === "hand:x");
|
||||
out.removed = run("hand:g1", "줄 지우기").filter((r) => ["hand:a", "hand:g2"].includes(r.key));
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def test_우클릭_레벨_메뉴_TS():
|
||||
rows = compute_estimation(INPUTS, USER, None, BOOKS)["bill"]["rows"]
|
||||
body = MENU_JS.replace("__ROWS__", json.dumps(rows, ensure_ascii=False)).replace(
|
||||
"__USER__", json.dumps(USER, ensure_ascii=False)
|
||||
)
|
||||
out = json.loads(_node(_ts(["UI_Tabs", "UI_Tabs_Bill_Edit"]) + body))
|
||||
level = {"묶음 줄로 바꾸기", "항목으로 바꾸기", "들여쓰기 →", "내어쓰기 ←"}
|
||||
pick = lambda key: [x for x in out[key] if x in level] # noqa: E731
|
||||
assert out["g1"][:3] == ["위에 줄 추가", "아래에 줄 추가", "아래에 하위 줄 추가"]
|
||||
assert "연결" not in out["g1"] and pick("g1") == ["항목으로 바꾸기"]
|
||||
assert pick("a") == ["묶음 줄로 바꾸기"] # 뒤에 형제(g2) → 내어쓰기 없음
|
||||
assert pick("g2") == ["항목으로 바꾸기", "내어쓰기 ←"] # 막내
|
||||
assert pick("b") == [] # 묶음 안 묶음 안 = 깊이 한도 · 형제 c 뒤 → 내어쓰기 없음
|
||||
assert pick("x") == ["묶음 줄로 바꾸기", "들여쓰기 →"] # 위 형제 g1
|
||||
assert pick("auto") == [] and "줄 지우기" not in out["auto"]
|
||||
assert {k: out["child"][k] for k in ("section", "after", "parent")} == {
|
||||
"section": "1.2", "after": "hand:c", "parent": "hand:g2"} # fmt: skip
|
||||
assert (out["below"]["after"], "parent" in out["below"]) == ("hand:c", False) # g1 자손 끝
|
||||
assert (out["belowB"]["after"], out["belowB"]["parent"]) == ("hand:b", "hand:g2")
|
||||
assert out["indent"]["parent"] == "hand:g1"
|
||||
assert "parent" not in out["outdent"] # g1 이 맨 위 → 공종 아래
|
||||
assert [(r["key"], r.get("kind"), r["parent"]) for r in out["toItem"]] == [
|
||||
("hand:g2", None, "hand:g1"), ("hand:b", None, "hand:g1")] # fmt: skip
|
||||
assert (out["toGroup"]["kind"], out["toGroup"]["qty"], out["toGroup"]["ref"]) == (
|
||||
"group",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
# g1 지움 — 자식은 공종 아래로 · 바로 뒤 줄은 g1 자리(earth:4 뒤)로
|
||||
assert [(r["key"], r["after"], r.get("parent")) for r in out["removed"]] == [
|
||||
("hand:a", "earth:4", None), ("hand:g2", "hand:a", None)] # fmt: skip
|
||||
# 들여쓴 x = g1 금액에 듦(셈 짝 그대로)
|
||||
user = {"bill": {"rows": [r if r["key"] != "hand:x" else out["indent"] for r in HAND]}}
|
||||
rows2 = _rows(compute_estimation(INPUTS, user, None, BOOKS))
|
||||
assert (rows2["hand:x"]["level"], rows2["hand:g1"]["sum"]) == (4, 12300 + 2460 + 1230)
|
||||
|
||||
|
||||
LINK_JS = """
|
||||
const opened = [];
|
||||
const picked = {};
|
||||
globalThis.createMapContextMenu = () => ({
|
||||
element: { isConnected: true, style: {} },
|
||||
open: (x, y, items) => opened.push({ x, y, items }),
|
||||
});
|
||||
globalThis.requestAnimationFrame = () => 0;
|
||||
globalThis.createFindList = () => ({
|
||||
search: { value: "", addEventListener() {}, focus() {} },
|
||||
hint: {},
|
||||
root: {},
|
||||
setRows: (rows) => (picked.rows = rows),
|
||||
});
|
||||
globalThis.openModal = (o) => {
|
||||
picked.title = o.title;
|
||||
o.mount({ append() {} }, () => (picked.closed = true));
|
||||
};
|
||||
let user = __USER__;
|
||||
const ctx = { sheets: { bill: __BILL__, daega: __DAEGA__ }, payload: {},
|
||||
get user() { return user; }, setUser: (u) => (user = u) };
|
||||
const listeners = {};
|
||||
const root = { addEventListener: (type, fn) => (listeners[type] = fn), contains: () => true };
|
||||
attachRowMenu(root, () => []);
|
||||
const cell = { getBoundingClientRect: () => ({ left: 10, bottom: 40 }) };
|
||||
listeners.click({ target: { closest: () => cell } });
|
||||
const table = buildBill(ctx);
|
||||
const at = (key) => table.rows.findIndex((r) => r.key === key);
|
||||
const name = (x) => (x === "-" ? "-" : x[0]);
|
||||
const out = {
|
||||
columns: table.columns.map((c) => c.key).slice(-2),
|
||||
connect: ["hand:a", "hand:c", "hand:g1", "earth:4"].map((k) => table.rows[at(k)].connect),
|
||||
link: [table.link(at("hand:a"), "connect"), table.link(at("hand:a"), "name")],
|
||||
cls: ["sec:1", "hand:g1", "hand:a", "earth:4"].map((k) => table.rowClass(at(k))),
|
||||
edit: [table.editable(at("hand:g1"), "name"), table.editable(at("hand:g1"), "qty")],
|
||||
};
|
||||
table.onCell(at("hand:a"), "connect");
|
||||
table.onCell(at("hand:g1"), "connect");
|
||||
table.onCell(at("earth:4"), "connect");
|
||||
table.onCell(at("hand:c"), "connect");
|
||||
out.menus = opened.map((m) => [m.x, m.y, m.items.map(name)]);
|
||||
opened[1].items[0][1](); // hand:c → 일위대가 호표 고르기 창
|
||||
picked.rows[0].onPick();
|
||||
out.picked = [picked.title, picked.closed, picked.rows.map((r) => r.tag)];
|
||||
out.ref = user.bill.rows.find((r) => r.key === "hand:c").ref;
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def test_연결_단추_칸_메뉴_고르기_창_TS():
|
||||
sheets = compute_estimation(INPUTS, USER, None, BOOKS)
|
||||
body = (
|
||||
LINK_JS.replace("__USER__", json.dumps(USER, ensure_ascii=False))
|
||||
.replace("__BILL__", json.dumps(sheets["bill"], ensure_ascii=False))
|
||||
.replace("__DAEGA__", json.dumps(BOOKS["daega"], ensure_ascii=False))
|
||||
)
|
||||
src = _ts(["UI_Tabs", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"])
|
||||
out = json.loads(_node(src + body))
|
||||
assert out["columns"] == ["connect", "note"] # 비고 앞 「연결」 칸
|
||||
assert out["connect"] == ["대가3 ▾", "연결 ▾", "", ""] # 손 항목 줄만 · 연결됨 = 호표 번호
|
||||
assert out["link"] == [None, {"tab": "dg", "key": "DG:a"}] # 단추 칸 = 이동 안 함
|
||||
assert out["cls"] == [
|
||||
"is-lv1 is-title", "is-lv3 is-group is-hand", "is-lv4 is-hand", "is-lv3"] # fmt: skip
|
||||
assert out["edit"] == [True, False] # 묶음 줄 = 이름만
|
||||
picks = ["일위대가 호표", "단가산출 호표", "단가산출 로직(M01 · 새 산근)"]
|
||||
# 누른 칸 아래(left · bottom) · 묶음 줄 · 자동 줄 = 안 엶 · 연결된 줄 = 연결 풀기 더함
|
||||
assert out["menus"] == [[10, 40, [*picks, "-", "연결 풀기"]], [10, 40, picks]]
|
||||
assert out["picked"] == ["일위대가 호표 고르기", True, ["대가3"]] # 166-2 고르기 창 재사용
|
||||
assert out["ref"] == DG
|
||||
Reference in New Issue
Block a user