Merge remote-tracking branch 'origin/sub_desktop_1' into sub_laptop_4

This commit is contained in:
2026-10-10 11:57:08 +09:00
8 changed files with 658 additions and 36 deletions
+70 -3
View File
@@ -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:
+68 -4
View File
@@ -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) {
+8 -3
View File
@@ -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] = {}
+33
View File
@@ -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);
+21 -5
View File
@@ -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;
+1 -1
View File
@@ -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 지움 — 남은 새 줄은 그대로(자기 자리) · 지운 줄 뒤에 붙던 줄은 지운 줄 자리로
+302
View File
@@ -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