Merge remote-tracking branches 'origin/sub_laptop_5', 'origin/sub_laptop_7' and 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-31 18:49:50 +09:00
30 changed files with 374 additions and 48 deletions
+17 -1
View File
@@ -16,6 +16,7 @@ from typing import Any
from B08_Quantity.B08_Quantity_Haul import bill_key, haul_groups
from B08_Quantity.B08_Quantity_Material_Calc import _structure_row
from M02_MasterTemplete.M02_Item_Source import item_name
WHY_EARTH = "조합 밖 토공 산근 — 연결 없음"
WHY_READY = "준비공 대가 — 연결 없음"
@@ -47,6 +48,20 @@ EARTH: list[tuple[str, str, str, tuple[str, str, str | None] | None]] = [
#: 구조물 집계표 열 id 머리 → 공종(그 밖 = 2.2 배수공) · 커플링 = 자재라 뺌
STRUCTURE_SECTION = {"pipe:revet|": "2.1", "pipe:chute|": "2.3", "pipe:wall|": "2.3"}
SKIP_COLUMNS = ("pipe:cp|",)
#: 층 이름 — 화면 낱말(번호 · 영문 층 이름은 화면에 안 냄)
LAYER_WORD = {"system": "마스터", "company": "회사", "personal": "내 자료집", "project": "이 공사"}
def combo_text(combo: str, names: dict[str, str]) -> str:
"""조합 id 「{층}|{주인}|{키}」 → 「내 자료집 · 조합 이름」(이름을 못 읽으면 층 낱말만)."""
layer, _, rest = combo.partition("|")
owner, _, key = rest.partition("|")
if combo not in names:
try:
names[combo] = item_name({"layer": layer, "owner": owner or None, "key": key})
except (LookupError, KeyError, ValueError, OSError):
names[combo] = ""
return " · ".join(t for t in (LAYER_WORD.get(layer, ""), names[combo]) if t)
def _same(row: dict[str, Any], want: tuple[str, str, str | None]) -> bool:
@@ -80,13 +95,14 @@ def structure_items(summary: dict[str, Any] | None) -> list[dict[str, Any]]:
columns = (summary or {}).get("columns") or []
total = (summary or {}).get("total") or {}
out = []
names: dict[str, str] = {}
for column in columns:
cid = str(column.get("id") or "")
if cid.startswith(SKIP_COLUMNS):
continue
section = next((s for head, s in STRUCTURE_SECTION.items() if cid.startswith(head)), "2.2")
name, spec, unit = _structure_row(column)
combos = [c for c in column.get("combos") or [] if c]
combos = [combo_text(c, names) for c in column.get("combos") or [] if c]
out.append(
{
"key": f"st:{cid}",
+1 -1
View File
@@ -148,7 +148,7 @@ def suggest(rows: list[dict[str, Any]], memory: dict[str, dict[str, Any]]) -> li
hit = _resolve(got, free) if got else None
if hit:
keys, tag = [hit], RECALL
elif row["kind"] == "item":
elif row["kind"] == "item" and not (memory.get(key_of(row)) or {}).get(FORGOT):
keys, tag = [EARTH_KEY[n] for n in table(row["name"], row["spec"])], TABLE
keys = [k for k in keys if k in free]
else:
+7 -1
View File
@@ -171,7 +171,13 @@ def ref_price(
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")}}
blank = tab == "dg" and row.get("blank") # 빈 줄 있는 대가(줄 0 개 포함) = 단가 모름
return {
"no": no,
"tab": tab,
"key": key,
**{p: None if blank else row.get(p) for p in (*PARTS, "sum")},
}
return hopyo.get(key) if key else None
+2 -1
View File
@@ -132,7 +132,8 @@ export function refPrice(ref: Row | null, books: Row, hopyo: Row, rowKey = ""):
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;
const blank = tab === "dg" && row.blank; // 빈 줄 있는 대가(줄 0 개 포함) = 단가 모름
for (const p of [...PARTS, "sum"]) out[p] = blank ? null : (row[p] ?? null);
return out;
}
return key ? (hopyo[key] ?? null) : null;
+5 -1
View File
@@ -17,7 +17,8 @@
% 줄 = ROUNDDOWN(밑수 × % / 100, 1) · 관급 · 별도계상 자재 = 대가 안 0원 ·
블록 비목 합 = ROUNDDOWN(Σ, 0) · 합계 = 세 비목 합.
빈칸 = 단가 · 수량을 낸 원천이 없으면 None · 「연결 없음」 목록(gaps).
빈 줄(166-6) = 블록 합 = 아는 줄 합 + `blank` n · `blank_names` — 내역 단가 · 부른 대가로는 안 넘김.
빈 줄(166-6) = 블록 합 = 아는 줄 합 + `blank` n · `blank_names` — 내역 단가 · 부른 대가로는 안 넘김 ·
줄 0 개 블록 = 빈 줄 하나(`NO_LINES`).
고친 칸 덮개(702-2) = 사용자 값 `daega_cover` {블록 cover: {줄 cid: {qty · 노 · 재 · 경 단가}}} —
있는 칸만 셈 값 대신 · 줄 `base`(품셈 값 = 셈 수량 · 단가) · `covered`(고친 칸) ·
짝 잃은 덮개(그 블록 · 줄 없음) = `daega_orphans` [{cover, cid}] — 지우지 않고 알림만.
@@ -36,6 +37,7 @@ from B09_Estimation.B09_Estimation_Calc_Base import SUPPLY, gap, num, rounddown,
PARTS = ("labor", "material", "expense")
COVER_KEYS = ("qty", *PARTS)
TAB = "dg"
NO_LINES = "구성 줄 없음" # 줄 0 개 일위대가의 빈 줄 이름
COPY = "@copy"
ADD = "@add"
WHY = {
@@ -158,6 +160,8 @@ def _block(
known = [ln for ln in lines if ln["amount"] is not None]
parts = {part: rounddown(sum(ln["amounts"][part] for ln in known), 0) for part in PARTS}
blank_names = [ln.get("name") or "" for ln in lines if ln["amount"] is None]
if not lines: # 줄 0 개 일위대가 = 금액 0 이 아니라 모름(빈 줄)
blank_names = [NO_LINES]
return {
**{k: block.get(k) for k in ("id", "kind", "name", "spec", "unit", "cite", "cover")},
**({"spec_raw": block["spec_raw"]} if block.get("spec_raw") else {}), # 157-4 풍선
+6 -2
View File
@@ -14,7 +14,8 @@
* 줄 금액 ROUNDDOWN(단가 × 수량, 1) · 블록 비목 합 ROUNDDOWN(Σ, 0) · 합계 = 세 비목 합
* 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락에서 셈)
* 빈칸 = 원천이 없으면 null · 「연결 없음」 목록(gaps)
* 빈 줄(166-6) = 블록 합 = 아는 줄 합 + blank n · blank_names — 내역 단가 · 부른 대가로는 안 넘김
* 빈 줄(166-6) = 블록 합 = 아는 줄 합 + blank n · blank_names — 내역 단가 · 부른 대가로는 안 넘김 ·
* 줄 0 개 블록 = 빈 줄 하나(NO_LINES)
* 고친 칸 덮개(702-2) = 사용자 값 daega_cover {블록 cover: {줄 cid: {qty · labor · material · expense}}} —
* 있는 칸만 셈 값 대신 · 줄 base(품셈 값) · covered(고친 칸) · 짝 잃은 덮개 = daega_orphans(알림만)
* 복사본(703-2) = 덮개 K:… 의 @copy {from · name · spec} — 원본 cover 블록을 본뜬 새 호표
@@ -36,6 +37,7 @@ import {
const PARTS = ["labor", "material", "expense"] as const;
const COVER_KEYS = ["qty", ...PARTS] as const;
const TAB = "dg";
const NO_LINES = "구성 줄 없음"; // 줄 0 개 일위대가의 빈 줄 이름(파이썬 짝)
export const COPY = "@copy";
/** 붙인 줄 덮개 열쇠(705-2) */
export const ADD = "@add";
@@ -153,7 +155,9 @@ function block(b: Row, books: Row, sums: Record<string, Row>, gaps: Gap[], own:
known.reduce((acc, ln) => acc + ln.amounts[part], 0),
0,
);
const blankNames = lines.filter((ln) => ln.amount === null).map((ln) => ln.name || "");
const blankNames = lines.length
? lines.filter((ln) => ln.amount === null).map((ln) => ln.name || "")
: [NO_LINES]; // 줄 0 개 일위대가 = 금액 0 이 아니라 모름(빈 줄)
return {
id: b.id,
key: b.id,
@@ -33,6 +33,7 @@ from typing import Any
from B09_Estimation import B09_Estimation_Bill_Library as library
from B09_Estimation import B09_Estimation_Sangeun as sg
from B09_Estimation import B09_Estimation_Sangeun_Text as st
from B09_Estimation.B09_Estimation_Bill_Items import LAYER_WORD
from M02_MasterTemplete import M02_Item_Prices as item_prices
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
from M02_MasterTemplete import M02_Labor_Fixed as fixed
@@ -266,7 +267,8 @@ class Builder:
{
"kind": "사전조합",
"name": doc.get("이름") or ref.get("키"),
"spec": str(ref.get("키") or ""),
"spec": LAYER_WORD.get(str(ref.get("층")), ""), # 번호(키)는 풍선 글로만
"spec_raw": str(ref.get("키") or ""),
"unit": doc.get("단위") or "",
"cite": "",
"origin": {"참조": refs.ref_id(ref)},
@@ -25,6 +25,7 @@ import {
} from "@ui/ui_template_context_menu";
import type { Row } from "./B09_Estimation_Calc_Base";
import type { TabContext, TabTable } from "./B09_Estimation_UI_Tabs";
import { showToast } from "@ui/ui_template_elements";
import { linkItems, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link";
import { linkMenu } from "./B09_Estimation_UI_Link";
@@ -208,6 +209,9 @@ export function editCell(
put(ctx, w.bill);
}
/** 단위 비교 꼴 — 띄어쓰기 · 대소문자 · 호환 글자(㎥ = m3) 무시 */
const unitKey = (u: string): string => u.normalize("NFKC").replace(/\s+/g, "").toLowerCase();
/** 연결 적용 — 새 줄이 「새 줄」 · 빈 이름이면 고른 호표 명칭 · 규격 · 단위를 채움 */
function applyRef(ctx: TabContext, rows: DataRow[], i: number): ApplyRef {
return (ref, from) => {
@@ -222,6 +226,10 @@ function applyRef(ctx: TabContext, rows: DataRow[], i: number): ApplyRef {
e.unit = e.unit || from.unit || "";
}
put(ctx, w.bill);
const mine = String(e.unit ?? row.unit ?? "");
const theirs = String(from?.unit ?? "");
if (ref && unitKey(mine) && unitKey(theirs) && unitKey(mine) !== unitKey(theirs))
showToast(`단위 다름 ${mine} ↔ ${theirs} — 연결은 됨 · 수량 단위 확인`, "warning");
};
}
@@ -38,7 +38,7 @@ async function copyBlock(ctx: TabContext, raw: DataRow[], row: Row, block: Row):
note: `「${block.name}」 을 이 공사 안 새 호표로 — 고친 칸까지 복사`,
fields: [
{ key: "name", label: "이름", value: `${block.name} 복사`, required: true },
{ key: "spec", label: "규격", value: block.spec || "" },
{ key: "spec", label: "규격", value: block.kind === "사전조합" ? "" : block.spec || "" },
],
okLabel: "만들기",
closeLabel: "닫기",
@@ -106,7 +106,7 @@ async function savePersonal(ctx: TabContext, block: Row): Promise<void> {
const tail = left
? ` · 안 담은 고친 칸 ${left}(품 ${skip.품} · 단가 ${skip.단가}) — 품은 고정 줄(PLAN 705) 뒤 담김`
: "";
showToast(`내 자료집에 저장 「${got.이름}」(${got.키})${tail}`, "success", 6000);
showToast(`내 자료집에 저장 「${got.이름}」${tail}`, "success", 6000);
return;
}
if (status !== 409 || !same) {
@@ -116,7 +116,7 @@ async function savePersonal(ctx: TabContext, block: Row): Promise<void> {
}
const over = await askConfirm({
title: "같은 이름",
message: `내 자료집에 「${name.trim()}」(${same}) 이 있음 — 덮어쓰기 · 새 이름`,
message: `내 자료집에 「${name.trim()}」 이 있음 — 덮어쓰기 · 새 이름`,
okLabel: "덮어쓰기",
cancelLabel: "새 이름",
});
+115
View File
@@ -0,0 +1,115 @@
"""M02 기준 목록 · 계산서 끝수(PLAN 704-2 딸림) — B09 산근 끝수 규칙으로 셈(③ 서버 단독 ·
`B09_Estimation_Sangeun` 과 규칙 짝 · 거울 시험 `test_704_settle`).
줄 금액 = ROUNDDOWN(단가 × 수량, 1) 비목마다 · 비목 총계 = ROUNDDOWN(Σ 줄, 0) · 계 = 셋 합 ·
작업량 Q(중간 이름 Q · Q1 …) = ROUND(식, 2) 뒤 쓰임 · % 덧줄 밑수 = 그때까지 줄 합(줄 끝수 뒤).
근거 = 지식DB `technical_info/01_임도/05_원가정보/단수처리_규칙.md`(행 금액 절사 · 관측 후보 규칙).
M01 엔진(`master_formula.run`)의 구조 그대로 — 끝수 자리만 다름(엔진은 줄 끝수 없이 계에서 한 번).
하위 로직(기계경비 포함)은 같은 규칙으로 다시 셈 · 흐름 · 결과 로직은 엔진 값 그대로.
"""
from __future__ import annotations
import re
from decimal import ROUND_DOWN, ROUND_HALF_UP, Decimal
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
mf = m01.cm.mf
_Q = re.compile(r"Q\d*")
def _down(value: Any, places: int) -> Any:
return None if value is None else mf._round(Decimal(value), places, ROUND_DOWN)
def _bundle(whole: Any, item: dict, env: dict, depth: int) -> dict:
"""로직 호표 줄이 부르는 하위 로직 — 같은 규칙으로 다시 셈(엔진 `logic` 마디 그대로)."""
node = mf.parse(item["요소"])
if node[0] != "logic":
return mf.evaluate(node, env, whole, depth)
given = {k: mf.evaluate(v, env, whole, depth) for k, v in node[2]}
got = settle(whole, mf.fill(node[1], env), given, depth + 1)
return got["결과"] if "결과" in got else {k: got[k] for k in mf.BUNDLE}
def settle(whole: Any, ref: str, given: dict, depth: int = 0) -> dict:
"""`master_formula.run` 과 같은 꼴 `{줄, 노무비, 재료비, 경비, 계, 중간}` — 끝수만 B09 규칙."""
row = whole.logic(ref)
if isinstance(row.get("흐름"), dict) or "결과" in row:
return mf.run(whole, ref, given, depth)
env = mf._inputs(row, given)
for step in row.get("중간", []):
try:
value = mf.evaluate(mf.parse(step["식"]), env, whole, depth)
except mf.EmptyInput as e:
env[step["이름"]] = mf.Blank(str(e))
continue
if _Q.fullmatch(step["이름"]) and isinstance(value, Decimal):
value = value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
env[step["이름"]] = value
lines: list[dict] = []
for n, item in enumerate(row.get("호표", [])):
try:
qty = mf.evaluate(mf.parse(item["수량"]), env, whole, depth)
if item["종류"] == "로직":
bundle = _bundle(whole, item, env, depth)
price = bundle["계"]
split = (
{k: qty * bundle[k] for k in mf.COST_ITEMS}
if "비목" not in item
else {item["비목"]: qty * price}
)
else:
ref_ = (
{
k: mf.fill(v, env) if isinstance(v, str) else v
for k, v in item["요소"].items()
}
if isinstance(item["요소"], dict)
else mf.fill(item["요소"], env)
)
if isinstance(ref_, str) and qty != 0:
whole.get(ref_)
price = mf.mp.priced(whole, item, ref_, env, n if depth == 0 else None)[0]
if price is None and qty == 0:
price = Decimal(0)
split = {item["비목"]: None if price is None else qty * price}
except mf.EmptyInput as e:
lines.append(mf._blank_line(item, str(e)))
continue
lines.append(_line(item["이름"], split))
sums = {k: sum((x["비목"].get(k) or 0 for x in lines), Decimal(0)) for k in mf.COST_ITEMS}
env["줄"] = {x["이름"]: x["금액"] for x in lines if x["이름"] and x["금액"] is not None}
for extra in row.get("덧줄", []):
env.update(sums)
try:
value = mf.evaluate(mf.parse(extra["식"]), env, whole, depth)
env[extra["이름"]] = value
except mf.FormulaError:
value = None
sums[extra["비목"]] += _down(value, 1) or 0
lines.append(_line(extra["이름"], {extra["비목"]: value}))
if value is not None:
env["줄"][extra["이름"]] = lines[-1]["금액"]
sums = {k: mf.cut(row, _down(v, 0), "계") for k, v in sums.items()}
return {"줄": lines, **sums, "계": sum(sums.values()), "중간": mf._shown(env)}
def _line(name: str, split: dict) -> dict:
cut = {k: _down(v, 1) for k, v in split.items()}
whole_sum = None if all(v is None for v in cut.values()) else sum(v or 0 for v in cut.values())
return {"이름": name, "비목": cut, "금액": whole_sum}
def apply(groups: list[dict], engine: dict, done: dict) -> None:
"""계산서 묶음의 줄 금액 · 소계를 `settle` 값으로 바꿈 — 줄 차례는 엔진과 같음."""
seq: dict[str, list] = {k: [] for k in mf.COST_ITEMS}
for eng, mine in zip(engine["줄"], done["줄"]):
for part, _ in m01.mtx._shares(eng):
seq[part].append(mine["비목"].get(part))
for group in groups:
for line, amount in zip(group["줄"], seq.get(group["비목"], [])):
line["금액"] = amount
group["소계"] = done[group["비목"]]
+16 -6
View File
@@ -5,12 +5,13 @@
위 끝) · 그 밖 연속 값 입력은 견본 자동값(`master_auto.values`) 한 줄 · 지역 = 자동값(전국평균).
펼침 = 바탕(자동값 · 규격 축은 첫 대표 값) 한 줄 + 규격 축마다 값 하나씩 바꾼 줄(곱하지 않음 ·
바꾼 줄이 0 원이면 표에 없는 조합으로 보고 뺌).
금액 = M01 엔진(`master_formula.run`) 「계」 — M01 시험 계산과 같은 셈 · 최신 판 노임(마스터 판).
금액 = M01 엔진 식 + B09 산근 끝수(줄마다 버림 · `M02_Logic_Settle` · 근거 지식DB
`technical_info/01_임도/05_원가정보/단수처리_규칙.md`) · 최신 판 노임(마스터 판).
목록은 서버가 뒤에서 한 번 셈(프로세스 `WORKERS` 개 · 수 분) → 프로세스 메모리 + 파생 저장본
(storage `_derived/m02_logic_standard/`) · 열쇠 = 마스터 내용 지문 + `SHAPE` · 같으면 안 셈 ·
셈 도중 목록 = 빈 줄 `준비: false`(화면은 로직 줄 그대로 · 금액 빈칸).
실무 낱말(`WORDS`) = 별칭 칸에 덧붙여 찾기(공용 `matchLogic`)가 「석축」 → 돌쌓기를 찾음.
계산서 = `master_text.lines`(줄 「석공 | 268,908 * 0.1 = 26,891」 · 비목 소계 · 계 = 엔진 값).
계산서 = `master_text.lines`(줄 「석공 | 268,908 * 0.1 = 26,891」 · 비목 소계 · 계) + 같은 끝수 값.
M01 은 읽기만 · 권한은 부르는 쪽 몫(로그인).
"""
@@ -27,9 +28,10 @@ from typing import Any
from common_util.common_util_derived_store import derived_key, read_derived, write_derived
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
from M02_MasterTemplete import M02_Logic_Settle as settle
from M02_MasterTemplete import M02_Template_Layers as layers
SHAPE = 2 # 줄 꼴이 바뀌면 올림 — 옛 저장본을 안 씀
SHAPE = 3 # 줄 꼴이 바뀌면 올림 — 옛 저장본을 안 씀
KIND = "m02_logic_standard"
REGION = "지역" # 지역 = 자동값 한 벌(정함 8)
QUIET = ("해당 없음", "없음") # 규격 글에서 뺄 값
@@ -83,7 +85,10 @@ def kind_of(row: dict) -> str:
def words_of(row: dict) -> list[str]:
name = _sq(row.get("이름"))
return [w for w, targets in WORDS.items() if any(_sq(t) in name for t in targets)]
out = [w for w, targets in WORDS.items() if any(_sq(t) in name for t in targets)]
if "터파기" in name: # 내역서 낱말 「측구파기」 ↔ 로직 「측구터파기」
out.append(str(row.get("이름")).replace("터파기", "파기"))
return out
def _range_label(table: dict, low: Any, high: Any, unit: str) -> str:
@@ -168,7 +173,7 @@ def _money(whole: Any, logic: dict, given: dict) -> Decimal | None:
if not str(logic.get("결과단위") or "").startswith("원"):
return None
try:
got = m01.cm.mf.run(whole, str(logic["키"]), m01.cm.mf.fill_empty(logic, given))
got = settle.settle(whole, str(logic["키"]), m01.cm.mf.fill_empty(logic, given))
except (m01.cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
return None
return got.get("계", got.get("결과"))
@@ -325,7 +330,12 @@ def sheet(key: str, change: dict) -> dict:
except m01.cm.mf.FormulaError as e:
raise m01.StoreError(404, str(e)) from e
given, ax = _given(whole, logic, change)
got = m01.mtx.lines(files, key, m01.cm.mf.fill_empty(logic, given))
filled = m01.cm.mf.fill_empty(logic, given)
got = m01.mtx.lines(files, key, filled)
done = settle.settle(whole, key, filled)
if "결과" not in done: # 돈 로직 — 줄마다 버림(B09 산근 규칙)
settle.apply(got.get("groups") or [], m01.cm.mf.run(whole, key, filled), done)
got["계"] = done["계"]
groups = [
{
"비목": g["비목"],
@@ -455,7 +455,7 @@ export function mountLaborDetail(
if (!ref) return;
void openRefEdit({
ref,
title: `${ref.키} ${lineOf(lineId({ 참조: ref }) ?? "")?.이름 ?? ""}`.trim(),
title: lineOf(lineId({ 참조: ref }) ?? "")?.이름 ?? "",
readOnly: !edit || ref.층 !== opt.layer,
closed: () => alive && recalc(),
});
@@ -466,7 +466,7 @@ export function mountLaborDetail(
const row = cur.담은로직[i];
const line = lineOf(lineId(row) ?? "");
openQtyFor(
`${row.참조?.키 ?? row.로직} ${(row.참조 ? line?.이름 : logics.get(row.로직)?.이름) ?? ""}`.trim(),
`${row.참조 ? "" : row.로직} ${(row.참조 ? line?.이름 : logics.get(row.로직)?.이름) ?? ""}`.trim(),
row,
line,
line?.분모 ?? "",
@@ -491,7 +491,7 @@ export function mountLaborDetail(
pick: (ref: LaborRef, name: string, 별칭: string | null = null) => {
const id = lineId({ 참조: ref });
if (cur.담은로직.some((r) => lineId(r) === id))
return showToast(tc("Labor_AlreadyRef").replace("{value}", `${ref.키} ${name}`), "error");
return showToast(tc("Labor_AlreadyRef").replace("{value}", name), "error");
const 수량 = lib ? { 손: 1 } : null; // 사전조합 줄 기본 1(51-6)
cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량 });
changed();
@@ -34,6 +34,13 @@ const money = (m: LaborCost | null | undefined, times = 1): HopyoMoney =>
}
: {};
/** 층 낱말 — 번호(키)는 화면 글에 안 냄 */
const layerWord = (layer: string): string =>
layer === "personal" ? tc("Grid_Mine") : tc(layer === "company" ? "Lib_Company" : "Lib_System");
/** 일위대가번 — 번호꼴(SU-000011 · SU01010101)은 사람 글이 아니라 뺌 */
const humanNo = (no?: string): string => (no && !/^S[A-Z]\d?-?\d{6,8}$/.test(no) ? no : "");
/** 문서 + calc 답 → 호표 하나 — calc 없으면(계산 중) 금액 빈 칸 · 빈 줄 0 */
export function laborBlock(
doc: LaborDoc,
@@ -68,7 +75,9 @@ export function laborBlock(
unit: line?.분모 ?? (row.참조 ? "" : (logic?.결과단위 ?? "")),
prices: money(line?.기준, line?.배 || 1),
amounts: money(line?.반영),
note: line?.까닭 ?? (calc && qty === null ? tc("Grid_NoQty") : (row.참조?.키 ?? row.로직)),
note:
line?.까닭 ??
(calc && qty === null ? tc("Grid_NoQty") : row.참조 ? layerWord(row.참조.층) : row.로직),
tag: HOPYO_TAG.pum,
};
});
@@ -96,7 +105,7 @@ export function laborBlock(
const lines = [...logicLines, ...matLines];
const block: HopyoBlock = {
key: doc.열,
name: doc.일위대가번 || doc.열,
name: humanNo(doc.일위대가번) || (doc as { 이름?: string }).이름 || tc("Grid_Head"),
unit: doc.단위 ?? "",
total: money(calc?.반영합),
lines,
@@ -15,7 +15,7 @@ import { tc } from "./M02_MasterTemplete_Labor_Text";
export interface RefEditOptions {
ref: LaborRef;
/** 「SC-000001 돌붙임」 */
/** 「돌붙임」 */
title: string;
readOnly: boolean;
closed: () => void;
@@ -179,6 +179,8 @@ const TEXT = {
Grid_Apart: ["별도계상 · 합계 밖", "Separate · not in total"],
Grid_Linked: ["이 공사 단가에 이음", "Linked to project price"],
Grid_Hand: ["손 단가(붙인 값)", "Hand price (pasted)"],
Grid_Head: ["일위대가", "Unit price"],
Grid_Mine: ["내 자료집", "My library"],
} as const satisfies Record<string, readonly [string, string]>;
export type ComboTextKey = keyof typeof TEXT;
@@ -3,7 +3,7 @@
* 회사에 올리기 요청 · 승인(PLAN 706-1) — 서버 `M02_Share_Request.py` 짝(장부 = 회사 층 파일 하나).
* 일반 사용자 = 자기 개인 층 구조물 조합 · 사전조합 나무 오른쪽 클릭 「회사에 올리기 요청」 → 글(선택) ·
* 같은 것 요청이 남아 있으면 「요청 중」(흐림) · 결과는 다음에 M02 열 때 위 띠(닫으면 봄)
* 회사 관리자 = 회사 층 위 띠 「올리기 요청 n」 → 목록(요청자 · 항목 · 날짜 · 글) · [미리 보기] ·
* 회사 관리자 = M02 위 띠 「올리기 요청 n」(어느 층이든) → 목록(요청자 · 항목 · 날짜 · 글) · [미리 보기] ·
* [승인](폴더 고름 = 「회사로 복사…」와 같은 길 · 원본 그대로) · [거절](사유 한 줄)
* 관리자(회사 층을 고칠 수 있는 사람)는 「회사로 복사…」가 있어 요청 메뉴를 안 둠.
* ========================================================================== */
@@ -50,7 +50,7 @@ export interface ShareOptions {
user: SessionUser | null;
/** 볼 회사(시스템 관리자는 고른 회사) */
company: string | null;
/** 지금 페이지 층 — 회사 층이면 관리자 띠 */
/** 지금 페이지 층 — 개인 층이면 요청 메뉴 */
layer: Layer;
owner: string | null;
showView: (label: string, node: HTMLElement) => void;
@@ -77,10 +77,7 @@ export function createShare(opt: ShareOptions): ShareHandle {
// 요청할 사람 = 회사가 있고 회사 층을 못 고침(고칠 수 있으면 「회사로 복사…」)
const asker =
!!ownOwner("personal", opt.user) && !accessFor("company", companyOwner, opt.user).canWrite;
const boss =
opt.layer === "company" &&
!!companyOwner &&
accessFor("company", companyOwner, opt.user).canWrite;
const boss = !!companyOwner && accessFor("company", companyOwner, opt.user).canWrite;
const q = opt.company ? `?company=${encodeURIComponent(opt.company)}` : "";
let rows: ShareRow[] = [];
let listHost: HTMLElement | null = null;
@@ -157,11 +157,15 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
go: (next) => void go("personal", next, open),
})
: panel;
// 704-4 [구조물 조합으로] 만든 항목 → 나무 · 옆 목록 다시(페이지가 내려가면 듣기 끝)
// 704-4 [구조물 조합으로] 만든 항목 → 나무 · 옆 목록 · 열린 표(행렬)를 다시(페이지가 내려가면 듣기 끝)
const afterItem = async (): Promise<void> => {
await Promise.all([side.refresh(), tree.refresh()]);
const now = main.current();
const shown = now?.kind === "table" && now.name === STRUCTURE_TABLE;
if (shown && (await main.settle()) !== false) await main.openTable(layer, null, true);
};
const onItem = (): void =>
tree.root.isConnected
? void Promise.all([side.refresh(), tree.refresh()])
: document.removeEventListener(ITEM_BUILT, onItem);
tree.root.isConnected ? void afterItem() : document.removeEventListener(ITEM_BUILT, onItem);
document.addEventListener(ITEM_BUILT, onItem);
const main = createMain(
isAdmin,
@@ -44,3 +44,9 @@
2. 보여주는 꼴은 익숙한게 좋을수 있음.
3. 전체 신규계획과 요구서를 작성하여 진행.
4. 조심해야될 부분은 우리 프로그램은 후발주자로서 다른 경쟁사의 프로그램 결과물을 불러오기가 되어야할것같음. 일단 현재도 일부는 되어 있지만 불편함. 매칭이 쉽지않음.
## 내역 9 (10-31 · 사용자 · 통합 점검 아티팩트 보고 뒤)
1. 원가계산서의 현행 비목의 요율은 다른 프로그램에서도 반영한거야?
2. 저장속도를 개선되기만 하면 좋지.
3. 브레인의 종료는 내가 정함. 이어서 진행
+24 -1
View File
@@ -196,7 +196,7 @@ def test_새_산근_사본_뒤_번호_호표_탭_집계표(tmp_path):
def test_우클릭_메뉴_항목_추가_줄_삭제_연결_TS():
# 184-3 — 모든 줄 메뉴 · 셈 출력 줄 = 계약 꼴(parent · auto · auto_{칸}) · 첫 손질 = v2 로 굳힘
src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit"])
src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Link", "UI_Tabs_Bill_Edit"])
rows = [
{"kind": "title", "key": "sec:1", "parent": None, "auto": True},
{"kind": "title", "key": "sec:1.2", "parent": "sec:1", "auto": True},
@@ -243,3 +243,26 @@ console.log(JSON.stringify(out));
# 새 줄 지움 = 덮개에서 빠짐(gone 은 자동 줄만)
assert [e["key"] for e in out["left"]["rows"]] == ["sec:1", "sec:1.2", "earth:4"]
assert out["left"]["gone"] == []
def test_줄_0개_일위대가_이으면_빈_줄():
"""703-1 가져오기 흠 — 줄 0 개 대가(금액 0)를 이은 내역 줄 = 금액 0 이 아니라 빈 줄."""
empty = {"no": 4, "key": "DG:e", "labor": 0, "material": 0, "expense": 0, "sum": 0,
"blank": 1} # fmt: skip
books = {**BOOKS, "daega": [*BOOKS["daega"], empty]}
hand = {"key": "hand:e", "section": "1.1", "after": "earth:1", "name": "빈 대가", "spec": "",
"unit": "㎡", "qty": 3, "ref": {"tab": "dg", "key": "DG:e"}} # fmt: skip
user = {"bill": {"rows": [hand]}}
sheets = compute_estimation(INPUTS, user, None, books)
row = _rows(sheets)["hand:e"]
assert row["sum"] is None and row["note"] == "대가4"
ok = _rows(compute_estimation(INPUTS, {"bill": {"rows": [HAND[0]]}}, None, books))
assert ok["hand:a"]["sum"] == 12300 # 빈 줄 없는 대가는 그대로
calc = _ts(["Calc_Base", "Calc_Bill"])
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)) == sheets # 거울
+5 -3
View File
@@ -218,7 +218,9 @@ def test_우클릭_레벨_메뉴_TS():
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_Link", "UI_Tabs_Bill_Edit"]) + body))
out = json.loads(
_node(_ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Link", "UI_Tabs_Bill_Edit"]) + body)
)
level = {"묶음 줄로 바꾸기", "항목으로 바꾸기", "들여쓰기 →", "내어쓰기 ←"}
pick = lambda key: [x for x in out[key] if x in level] # noqa: E731
assert [x for x in out["g1"] if "추가" in x] == ["항목 추가"] # 246-1 더함 하나
@@ -312,7 +314,7 @@ def test_연결_단추_칸_메뉴_고르기_창_TS():
.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_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"])
src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"])
out = json.loads(_node(src + body))
assert out["columns"] == ["connect", "note"] # 비고 앞 「연결」 칸
# 184-3 모든 항목 줄 · 새 줄 연결됨 = 호표 번호 · 자동 줄 연결됨 = 「연결됨」 · 묶음 = 없음
@@ -357,7 +359,7 @@ console.log(JSON.stringify(out));
def test_글_칸_위_우클릭도_줄_메뉴_TS():
src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"])
src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"])
out = json.loads(_node(src + ROW_MENU_JS))
assert out["onInput"] is True and out["onCell"] is True
assert out["opened"] == [[5, 6, ["줄 7"]], [5, 6, ["줄 7"]]]
+1 -1
View File
@@ -95,7 +95,7 @@ def _ts(names: list[str]) -> str:
)
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit"]
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Link", "UI_Tabs_Bill_Edit"]
def _menu(user, labels=(), runs=(), edits=(), rows=ROWS):
@@ -124,7 +124,7 @@ out.keyAct = [
console.log(JSON.stringify(out));
"""
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag",
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag",
"UI_Tabs_Bill_Level"] # fmt: skip
IN, OUT, UP, DOWN = "들여쓰기 →", "내어쓰기 ←", "위로 ↑", "아래로 ↓"
+1 -1
View File
@@ -33,7 +33,7 @@ if str(ROOT) not in sys.path:
from B09_Estimation import B09_Estimation_Store as store # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag",
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag",
"UI_Tabs_Bill_Level"] # fmt: skip
@@ -26,7 +26,7 @@ from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa:
IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE)
TABLE = IMPORTS.sub("", (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8"))
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag",
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag",
"UI_Tabs_Bill_Level"] # fmt: skip
IN, OUT = "들여쓰기 →", "내어쓰기 ←"
+2 -2
View File
@@ -27,7 +27,7 @@ from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa:
IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE)
TABLE = IMPORTS.sub("", (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8"))
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"]
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"]
SUMS = ["sum", "labor", "material", "expense"]
FOLDER_SVG, LEAF_SVG = "M1.5 3.5h4", "M3.5 1.5h6"
@@ -303,7 +303,7 @@ def test_고른_줄_Enter_F2_입력_시작():
"bar = levelBar(root, table);", "bar = levelBar(root, table, (i) => edits.push(i));"
)
body = (setup + KEYS_JS).replace("__ROWS__", json.dumps(FAKE_ROWS, ensure_ascii=False))
names = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill",
names = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill",
"UI_Tabs_Bill_Drag", "UI_Tabs_Bill_Level"] # fmt: skip
out = json.loads(_node(_ts(names) + body))
at = [r["key"] for r in FAKE_ROWS].index("earth:5")
@@ -125,4 +125,4 @@ def test_B09_금액_길이_곱하기_대가(out):
inputs = {"bill": {"items": structure_items(out)}}
sheets = compute_estimation(inputs, {}, {"s-wall": price}.get)
row = next(r for r in sheets["bill"]["rows"] if r["key"].endswith("#blk:w"))
assert (row["qty"], row["sum"], row["note"]) == (12.0, 1495128, "대가1 · system||SS000201")
assert (row["qty"], row["sum"], row["note"]) == (12.0, 1495128, "대가1 · 마스터")
+18 -2
View File
@@ -58,7 +58,7 @@ def test_머리_구성_합계_줄_꼬리표_빈_줄():
STUBS = r"""
const tc = (k) => ({ Grid_NoQty: "수량 없음", Grid_Apart: "별도계상 · 합계 밖", Labor_Layer: "켜쌓기 {n}%" })[k] ?? k;
const tc = (k) => ({ Grid_NoQty: "수량 없음", Grid_Apart: "별도계상 · 합계 밖", Labor_Layer: "켜쌓기 {n}%", Grid_Head: "일위대가", Grid_Mine: "내 자료집", Lib_Company: "회사", Lib_System: "마스터" })[k] ?? k;
const valueLine = (inputs, values) => ({ text: Object.values(values ?? {}).join(" · "), empty: 0 });
"""
@@ -108,7 +108,7 @@ def test_M02_문서_calc_를_격자_줄로():
"\nconsole.log(JSON.stringify({ b, foot: hopyoGrid([b]).rows.at(-1), wait: hopyoGrid([wait]).rows.at(-1) }));"
), STUBS) # fmt: skip
b = out["b"]
assert b["name"] == "SU-000006" and b["unit"] == "m" and b["total"]["sum"] == 112559.55
assert b["name"] == "일위대가" and b["unit"] == "m" and b["total"]["sum"] == 112559.55
one, two, mt1, mt2 = b["lines"]
assert (
one["name"] == "돌쌓기 찰쌓기"
@@ -141,3 +141,19 @@ def test_세_곳_같은_부품():
assert '"@ui/ui_template_hopyo_grid"' in m02 and '"@ui/ui_template_hopyo_grid"' in b09
kit = (ROOT / "ui_template/ui_template_ui_kit_parts.ts").read_text("utf-8")
assert 'key: "hopyo-grid"' in kit # 부품 모음 견본
def test_번호는_글에_안_냄_사람_글은_그대로():
files = [GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts",
"M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts",
"M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip
ref = {"로직": "", "별칭": None, "입력값": {}, "수량": {"손": 1},
"참조": {"층": "personal", "주인": "1/3", "키": "SC-000002"}} # fmt: skip
named = {**DOC, "일위대가번": "제5호표", "담은로직": [ref]}
out = _node(files, (
f"\nconst a = laborBlock({json.dumps(named, ensure_ascii=False)}, null, new Map());"
f"\nconst c = laborBlock({json.dumps({**named, '일위대가번': 'SP3-000015'}, ensure_ascii=False)}, null, new Map());"
"\nconsole.log(JSON.stringify({ a, c }));"
), STUBS) # fmt: skip
assert out["a"]["name"] == "제5호표" and out["c"]["name"] == "일위대가"
assert out["a"]["lines"][0]["note"] == "내 자료집"
+14 -2
View File
@@ -34,13 +34,14 @@ def _given(key: str, change: dict) -> dict:
@pytest.mark.parametrize("key", THREE)
def test_줄_금액은_M01_시험_계산(key: str) -> None:
def test_줄_금액은_M01_시험_계산에_끝수만_버림(key: str) -> None:
rows = _rows(key)
assert rows
for row in rows:
got = m01.calc(key, _given(key, row["바꿈"]))
assert got["ok"], got
assert row["금액"] == pytest.approx(float(got["sums"]["계"]), abs=0.01), row["규격"]
gap = float(got["sums"]["계"]) - row["금액"] # 줄 · 비목 버림(B09 산근 규칙) — 최대 3 원
assert -0.01 <= gap < 3.2, row["규격"]
def test_대표_규격_펼침() -> None:
@@ -126,3 +127,14 @@ def test_화면_기준_목록_계산서_담기() -> None:
router = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py").read_text("utf-8")
assert '@router.get("/logic-lib/standard", dependencies=[Depends(verify_session)])' in router
assert '@router.post("/logic-lib/sheet", dependencies=[Depends(verify_session)])' in router
def test_내역서_낱말_측구파기로_측구터파기를_찾음() -> None:
logic = next(
logic
for _, data in m01._logic_files(m01.loaded()[0])
for logic in data.get("줄") or []
if logic.get("키") == "GF000099" # 9-12-1 측구터파기 토사
)
row = {**logic, "별칭": ", ".join(st.words_of(logic))}
assert hit(row, "측구파기")
+75
View File
@@ -0,0 +1,75 @@
"""704-2 딸림 — 기준 목록 · 계산서 끝수 = B09 산근 규칙(줄마다 ROUNDDOWN 1 · 비목마다 ROUNDDOWN 0).
거울: 엔진 줄(수량 · 단가)에 B09 `_money` · `block_totals` 를 그대로 씌운 값 = `settle` 값.
"""
import re
import sys
from decimal import Decimal
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation import B09_Estimation_Sangeun as sg # noqa: E402
from M01_MasterData import M01_MasterData_Store as m01 # noqa: E402
from M02_MasterTemplete import M02_Logic_Settle as st # noqa: E402
from M02_MasterTemplete import M02_Logic_Standard as ls # noqa: E402
mf = m01.cm.mf
PART_OF = {"노무비": "labor", "재료비": "material", "경비": "expense"}
def _plain(row: dict) -> bool:
"""로직 줄 · 덧줄 · 작업량 Q 가 없는 돈 로직 — 엔진 줄만으로 B09 값을 낼 수 있음."""
return (
str(row.get("결과단위") or "").startswith("원")
and not row.get("덧줄")
and not row.get("끝수")
and not any(i.get("종류") == "로직" for i in row.get("호표") or [])
and not any(re.fullmatch(r"Q\d*", s["이름"]) for s in row.get("중간") or [])
)
def test_줄마다_버림_비목마다_버림_B09와_같음():
files, whole = m01.loaded()
checked = 0
for _, data in m01._logic_files(files):
for row in data.get("줄") or []:
if not _plain(row) or checked >= 60:
continue
base, _ = ls.axes(whole, row)
given = mf.fill_empty(row, base)
got = mf.run(whole, row["키"], given)
lines = []
for line in got["줄"]:
one = dict.fromkeys(PART_OF.values(), 0.0)
for part, money in line["비목"].items():
one[PART_OF[part]] = sg._money(1.0, float(money)) if money is not None else None
lines.append(one)
if any(v is None for one in lines for v in one.values()):
continue
want = sg.block_totals(lines)
mine = st.settle(whole, row["키"], given)
for name, part in PART_OF.items():
assert float(mine[name]) == want[part], (row["키"], name)
assert float(mine["계"]) == want["sum"], row["키"]
checked += 1
assert checked >= 30
def test_계산서_줄_소계_계가_맞물림():
files, whole = m01.loaded()
key = "GF000089"
got = ls.sheet(key, {})
assert got["묶음"], key
for group in got["묶음"]:
lines = [Decimal(str(x["금액"] or 0)) for x in group["줄"]]
assert all(v == v.quantize(Decimal("0.1")) for v in lines), group["비목"]
assert group["소계"] == int(group["소계"])
assert group["소계"] <= sum(lines) < group["소계"] + 1 + Decimal("0.05"), group["비목"]
assert got["계"] == sum(g["소계"] for g in got["묶음"])
assert got["계"] == ls._money(
whole, whole.logic(key), ls._given(whole, whole.logic(key), {})[0]
)
+14
View File
@@ -301,3 +301,17 @@ def test_잊은_짝_느슨한_기억에도_안_나옴(tmp_path, monkeypatch):
assert lk.recall(memory, third) is None # 잊은 짝 곁에서는 느슨하게 안 좁힘
route.keep(user, {lk.key_of(row): live}) # 다시 이음 = 풀림
assert lk.recall(route.recalled(user), row) == live
def test_잊은_짝_짝_표_제안에도_안_나옴(geo):
_user, rows = _load(geo)
now = {s["key"]: s for s in lk.suggest(rows, {})}
line = next(r for r in rows if (r["name"], r.get("spec")) == ("토사절취", "백호0.7㎥"))
assert now[line["key"]]["꼬리표"] == lk.TABLE
memory = {lk.key_of(line): {lk.FORGOT: True}}
after = {s["key"]: s for s in lk.suggest(rows, memory)}
assert line["key"] not in after # 짝 표 제안도 잊음
assert set(now) - set(after) == {line["key"]} # 다른 줄은 그대로
memory[lk.key_of(line)] = {"확정": "earth:4", "이름": "절토(토사)"} # 다시 이음 = 풀림
assert lk.suggest(rows, memory)[0] is not None
assert line["key"] in {s["key"] for s in lk.suggest(rows, memory)}