feat(827-3): 불러오기 재료 걸음 3 — 고를 재료 판 · 그 파일 안 고름(별칭 아님) · SK 상세 같은 파일 물음

- 새 M02_San_MatPick: 종 열쇠(이름 · 규격 · 단위 · 단서) · 고름 자리 = 개인 층 실무자료/재료고름/<파일 지문>.json([끝내기] 뒤 남음 · 같은 파일 다시 올리면 그대로) · 판(블록마다 재료찾기 판정 → 종 · 첫 판 한 번 셈 → 산근 저장본 칸) · 만들 때 얹기 · 이미 만든 SK 얹기(가져온 그대로인 줄만 · 바꿈 · 따로 고른 줄 그대로 · 풀면 가져온 그대로)
- 만들기: from-hopyo 가 그 파일 고름째 만듦(저장 안 한 판 고름 = 만들 때 저장) · 원본 지문 · 고른 줄 = 이음 + 재료찾기.고름 · 문서 셈 우리 값(엑셀 값 기억 없음) · 별도계상 = 품목만(줄 이음 · 단가 0 그대로)
- 문서 머리 가져온.재료(판정 · 후보 · 단서 · 처음 연결) · 가져온.지문
- 길: GET/POST hopyo-materials/{HP}(판 · [저장] · 만든 것 적용은 물음 예일 때만) · GET/POST material-picks/{지문}(SK 상세 물음)
- 화면: 단가산출 목록 위 「고를 재료」 판(후보 고름 · 찾기 · 고름 풀기 · n 블록에 적용 · 이어진 재료 접힘 · [저장]) · 저장 안 한 고름 = 떠날 때 물음 · 줄 쌓기 재료 줄 판정 배지 · 풍선말(판정 · 단서 · 후보) · 이름 단추 · [이음 고르기] 뒤 「같은 파일 n 줄」 물음
- 별도계상 설명 = 「별도계상 · 품목: 이름 규격」(엔진 이은 품목은 자재표 이름) — test_827_2 기대 글 고침(품목 이음 → 품목 이름)
- 시험 test_827_3 셋(파일 고름 블록 둘 · 다른 파일 0 · 별칭 · 마스터 그대로 · 풀기 · 다시 올림 유지 · 물음 예일 때만 · 따로 바꾼 줄 보존 · 별도계상 품목 + 단가 0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 12:51:04 +09:00
co-authored by Claude Opus 5.5
parent 2d6ae7e248
commit 520bb33909
18 changed files with 1131 additions and 21 deletions
+14 -2
View File
@@ -37,6 +37,7 @@ import {
type StackItem,
} from "../common_util/common_util_line_stack";
import { placeNames } from "../common_util/common_util_line_stack_copy";
import { askFileWide, materialMark, matEntry } from "./line_stack_material";
import {
draftsOf,
machineNow,
@@ -170,11 +171,13 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
// 가져온 기계 줄(812-9) — 찾기 판정 · 조회어 = 기종 이름만(규격 뺌 · 이은 우리 기계 › 정규화 이름)
const found = kind === "기계" ? machineFound(d, own) : undefined;
const query = found?.찾기어 ?? String(mat?.검색어 ?? line.이름 ?? "");
const swap = (p: Picked): void =>
const swap = (p: Picked): void => {
h.change((doc) => {
repick(doc, i, own, kind, p);
pruneDrafts(doc); // 827-1 안 쓰게 된 초안 뺌
});
if (kind === "재료") void askFileWide(h.machine, d, own, p); // 827-3 같은 파일 같은 재료
};
const pick = createButton({
label: (size && !name.includes(size) ? `${name} ${size}` : name) || "—",
variant: "ghost",
@@ -202,6 +205,11 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
head.push(pick);
const mark = machineMark(found, link);
if (mark) head.push(mark);
const matMark = kind === "재료" ? materialMark(matEntry(d, own), link) : null; // 827-3 판정 · 단서
if (matMark) {
head.push(matMark);
pick.title += ` — ${matMark.title}`;
}
// 823-1 가져온 줄 바꿔 끼움 = 우리 값 · 「바꿈」 + 되돌리기(원래 것 · 엑셀 값)
const sw = (link as { 바꿈?: { 이름?: string; 규격?: string } }).바꿈;
if (sw) {
@@ -220,11 +228,15 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
back.dataset.unswap = String(i);
head.push(tag, back);
}
} else
} else {
head.push(
field(lt("Name"), line.이름, (t) => put((l) => (l.이름 = t)), "m"),
field(lt("Price"), shown(line.단가), (t) => put((l) => (l.단가 = real(t))), "l"),
);
const sym = String(line.단가 ?? "").replace(/\.계$/, ""); // 827-3 못 이은 재료 판정 · 별도계상 품목
const unlinked = materialMark(matEntry(d, sym), d.변수?.find((x) => varId(x) === sym)?.연결 ?? {}); // prettier-ignore
if (unlinked) head.push(unlinked);
}
const why = (h.answer.줄?.[i] as { 까닭?: string } | undefined)?.까닭;
// 율 밑 번지(복사해 온 조각) — 칸 아래 읽는 이름(804-5)
const places = /번지\./.test(`${line.단가} ${line.수량 ?? ""}`) ? placeNames(d, h.items) : null;
+144
View File
@@ -0,0 +1,144 @@
/* =============================================================================
* line_stack_material.ts
* 줄 쌓기 가져온 재료 줄 판정 표지 · 같은 파일 물음(PLAN 827-3 · 요구 811-2 · 812-1)
* 표지 = 문서 머리 `가져온.재료`(서버 `M02_San_MatPick.entry`) — 아직 못 이은 줄 「규격 없음 — 고르기」 ·
* 「후보 여럿」 · 「우리 표에 없음」 / 그 파일 고름 줄 「파일 고름」 / 별도계상 품목 · 풍선말 = 판정 · 방법 · 단서 · 후보
* 물음 = 이름 단추로 고른 뒤 한 번 — 「같은 파일의 같은 재료 n 줄에도」 예 = 개인 층 그 파일 고름 +
* 그 파일 다른 단가산출의 가져온 그대로인 줄(서버 `…/material-picks/{지문}`) · 이 줄 = 화면 [저장] · 별칭 아님
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { showToast } from "@ui/ui_template_elements";
import { askConfirm } from "@ui/ui_template_modal";
import type { FormulaDoc } from "../common_util/common_util_formula_doc";
import type { Picked } from "../common_util/common_util_line_stack_pick";
import { call, type Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { at } from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api";
import type { MachineCtx } from "./line_stack_machine";
interface Cand {
키: string;
이름?: string;
규격?: string;
단위?: string;
}
export interface MatEntry {
기호: string;
종: string;
원래: { 이름: string; 규격: string; 단위: string };
판정: string;
방법?: string;
키?: string | null;
후보?: Cand[];
단서?: string[];
별도계상?: boolean;
고름?: Cand;
}
interface Head {
재료?: MatEntry[];
지문?: string;
파일?: string;
}
const LINKED = "이어짐";
/** 같은 파일 고름을 저장했음(문서 이벤트) — 불러오기 판이 다시 읽음 */
export const MAT_PICKED = "aislo:mat-picked";
const enc = encodeURIComponent;
const headOf = (d: FormulaDoc): Head | undefined => (d as { 가져온?: Head }).가져온;
const text = (c: { 이름?: string; 규격?: string }): string =>
`${c.이름 ?? ""} ${c.규격 ?? ""}`.trim();
/** 그 단가 기호 줄의 재료 판정 칸 */
export const matEntry = (d: FormulaDoc, sym: string | undefined): MatEntry | undefined =>
sym ? headOf(d)?.재료?.find((x) => x.기호 === sym) : undefined;
/** 풍선말 글 — 판정 · 방법 · 단서 · 후보(앞 다섯) */
export function matTip(e: MatEntry): string {
const cands = (e.후보 ?? []).slice(0, 5).map((c, i) => `${i ? "" : "1 순위 "}${text(c)}`);
return [
`가져온 「${text(e.원래)}」 — ${e.판정}${e.방법 ? ` (${e.방법})` : ""}`,
...(e.고름 ? [`파일 고름: ${text(e.고름)}`] : []),
...(e.단서 ?? []).map((x) => `단서: ${x}`),
...(cands.length ? [`후보: ${cands.join(" · ")}`] : []),
].join("\n");
}
/** 줄 표지 — 못 이은 줄 판정 · 파일 고름 · 별도계상 품목(이어진 줄 = 없음) */
export function materialMark(
e: MatEntry | undefined,
link: { 갈래?: string; 키?: string },
): HTMLElement | null {
if (!e) return null;
const filePick = !!e.고름 && link.키 === e.고름.키;
const label = e.별도계상
? `별도계상 · ${e.고름 ? `품목 ${text(e.고름)}` : e.키 ? "품목 이음" : e.판정}`
: filePick
? "파일 고름"
: e.판정 !== LINKED && link.갈래 === "숫자"
? e.판정
: "";
if (!label) return null;
const done = filePick || (e.별도계상 && !!(e.고름 || e.키));
const badge = createBadge(label, done ? "info" : e.판정 === "우리 표에 없음" ? "neutral" : "warning", { size: 18 }); // prettier-ignore
badge.title = matTip(e);
badge.dataset.matMark = e.판정;
return badge;
}
/** 같은 파일 물음 몸 — 층 · 이 SK · 파일 지문 · 종 · 가져온 글 · 고른 것 */
export interface SameFile {
scope: { layer: Layer; owner: string | null };
me: string;
지문: string;
파일: string;
종: string;
원래: string;
p: Picked;
}
/** 이름 단추로 고른 뒤 — 같은 파일 같은 재료에도(물음 하나 · 예일 때만 서버) */
export async function askFileWide(
ctx: MachineCtx | undefined,
d: FormulaDoc,
sym: string | undefined,
p: Picked,
): Promise<void> {
const e = matEntry(d, sym);
const head = headOf(d);
if (!e || e.별도계상 || !head?.지문 || !ctx?.scope || !ctx.me || p.키 === e.키) return;
await askSameFile({ scope: ctx.scope, me: ctx.me, 지문: head.지문, 파일: head.파일 ?? "", 종: e.종, 원래: text(e.원래), p }); // prettier-ignore
}
/** 같은 파일 같은 재료 물음 — 예 = 개인 층 그 파일 고름 + 그 파일 다른 SK(가져온 그대로인 줄) · 이 SK = 화면 [저장] */
export async function askSameFile(a: SameFile): Promise<void> {
const path = `/material-picks/${enc(a.지문)}`;
const got = await call<{ 수: { 블록: number; 줄: number } | null; 만든: string[] }>(
at(a.scope, `${path}?종=${enc(a.종)}&me=${enc(a.me)}`),
).catch(() => null);
if (!got) return;
const many = got.수 ? `${got.수.줄} 줄(${got.수.블록} 블록)` : "줄";
const made = got.만든.length ? ` · 이미 만든 단가산출 ${got.만든.length} 건의 가져온 그대로인 줄도 바뀜` : ""; // prettier-ignore
const ok = await askConfirm({
message: `같은 파일(${a.파일})의 같은 재료 「${a.원래}」 ${many}에도 「${text(a.p)}」 적용할까? — 다음에 그 파일을 올리면 고른 것이 보임${made} · 별칭 아님`,
okLabel: "같은 파일에 적용",
cancelLabel: "이 줄만",
});
if (!ok) return;
try {
const out = await call<{ 고침: string[] }>(at(a.scope, path), {
method: "POST",
body: JSON.stringify({
종: a.종,
고름: { 키: a.p.키, 이름: a.p.이름, 규격: a.p.규격 ?? "", 단위: a.p.단위 ?? "" },
파일: a.파일,
빼기: [a.me],
}),
});
document.dispatchEvent(new CustomEvent(MAT_PICKED)); // 불러오기 「고를 재료」 판 다시 읽기
showToast(`같은 파일 고름 저장 · 단가산출 ${out.고침.length} 건 고침 — 이 줄은 [저장] 때`, "success"); // prettier-ignore
} catch (err) {
showToast(err instanceof Error ? err.message : String(err), "error");
}
}
+12 -2
View File
@@ -22,6 +22,7 @@ from typing import Any
from M02_MasterTemplete import M02_Doc_Copy as dc
from M02_MasterTemplete import M02_Doc_Import_Shape as shape
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_San_MatPick as mp
from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_Imported as imp
@@ -36,7 +37,8 @@ HEAD = (
"원코드",
"규격",
"확정",
) # 뒤 셋 = 원파일(810-3)
"지문",
) # 규격 · 확정 = 원파일(810-3) · 지문 = 올린 파일(827-3 재료 고름)
GUESS = "추정" # 원파일 품셈 조건 글 표지(810-2 `M02_Doc_NativeEst.GUESS`)
SUB = "원파일 소계" # 원파일 소계 덧줄(810-2 `M02_Doc_NativeEst`) — 합 밖 · 비목 칸 비움
PICK = "소요량" # 고른 표 칸 기호 이름 머리
@@ -304,6 +306,7 @@ def _choice(key: str, below: str, items: list[str], value: str) -> dict:
MACHINES = "_기계" # 문서 셈 동안 잠깐 — `derive` 가 머리 `가져온.기계` 로 옮김
MATS = "_재료" # 같은 꼴 — 머리 `가져온.재료`(827-3 `M02_San_MatPick.entry`)
def _machine_entry(item: dict, sym: str) -> dict:
@@ -329,12 +332,15 @@ def _attach(doc: dict, row: dict, ids: list[str], src: dict[str, dict]) -> None:
기계 찾기 판정(812-9) = 문서 `가져온.기계`(단가 기호마다 · 화면 고르기 · 저장 때 별칭)."""
by_id = {dc.md.var_id(v): v for v in doc.get("변수") or []}
found: list[dict] = doc.setdefault(MACHINES, [])
mats: list[dict] = doc.setdefault(MATS, [])
for at, line in enumerate((doc.get("금액") or [])[: len(row["호표"])]):
item = src.get(ids[at]) if at < len(ids) else None
price = str(line.get("단가") or "")
var = by_id.get(price.removesuffix(".계"))
if item is not None and var is not None and (item.get("자원") or {}).get("찾기"):
found.append(_machine_entry(item, dc.md.var_id(var)))
if item is not None and var is not None and (one := mp.entry(item, dc.md.var_id(var), _excel(item))): # fmt: skip
mats.append(one) # 827-3 재료 찾기 판정 · 그 파일 고름
if item is None or var is None or (var.get("연결") or {}).get("갈래") != "숫자":
continue
excel = _excel(item)
@@ -361,7 +367,7 @@ def _attach(doc: dict, row: dict, ids: list[str], src: dict[str, dict]) -> None:
if item.get("참조비목") and isinstance(excel, dict):
excel = excel[item["참조비목"]]
line["단가"], line["비목"] = dc.md.var_id(var), item["참조비목"]
var["연결"] = {**link, XL: excel}
var["연결"] = link if mp.chosen(item) else {**link, XL: excel} # 827-3 고른 줄 = 우리 값
def _numbers(doc: dict, keep: dict) -> None:
@@ -419,12 +425,16 @@ def derive(sk: dict, ctx: tuple | None = None, whole: Any = None) -> dict:
_empty(doc) # 빈 산근 = 엑셀 0 원(줄 없음 합) — 「—」 아님
marks.append(EMPTY)
found = doc.pop(MACHINES, None)
mats = doc.pop(MATS, None)
head |= {"기계": found} if found else {} # 812-9 기계 찾기 판정(줄 쌓기 고르기 · 저장 때 별칭)
doc["가져온"] = head | {"표지": marks} | ({"알림": notes} if notes else {})
shape.shape(doc, meta) # 809-1 · 요구 704 — 지은 이름 → 엑셀 글 머리 · EST 기계 세 줄 = 한 줄
if found: # 세 줄을 한 줄로 합쳐 사라진 기호의 기계 판정은 뺌
alive = {dc.md.var_id(v) for v in doc.get("변수") or []}
doc["가져온"]["기계"] = [x for x in found if x["기호"] in alive]
if mats: # 827-3 재료 찾기 판정(줄 쌓기 풍선말 · 이미 만든 SK 고름 얹기)
alive = {dc.md.var_id(v) for v in doc.get("변수") or []}
doc["가져온"]["재료"] = [x for x in mats if x["기호"] in alive]
return {**doc, "키": str(sk.get("키") or key), "이름": str(sk.get("이름") or doc.get("이름"))}
+104 -1
View File
@@ -26,11 +26,12 @@ from M02_MasterTemplete import M02_Hopyo_Forget as hopyo_forget
from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid
from M02_MasterTemplete import M02_Hopyo_Item as hopyo_item
from M02_MasterTemplete import M02_Hopyo_Marks as hopyo_marks
from M02_MasterTemplete import M02_Hopyo_View as hopyo_view_
from M02_MasterTemplete import M02_Hopyo_Paste as hopyo_paste
from M02_MasterTemplete import M02_Hopyo_Relink as hopyo_relink
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Hopyo_View as hopyo_view_
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_San_MatPick as mat_pick
from M02_MasterTemplete import M02_Sangeun_FromHopyo as from_hopyo
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
@@ -330,3 +331,105 @@ def hopyo_marks_read(
folder = _root(session, layer, owner)
target = _root(session, to, to_owner) if to else folder
return _call(folder, lambda: {"블록": hopyo_marks.blocks(folder, key, target)})
# ── 불러오기 재료 고름(827-3 · 별칭 아님 · 고름 자리 = 개인 층) ──────────────────
class MatPickBody(_Strict):
"""`고름` {종: 고른 것 | null(풂)} · `만든적용` = 이미 만든 SK 에도(물음 예) · `종` = 얹을 종(없으면 `고름` 의 종)."""
고름: dict[str, dict[str, Any] | None] = Field(default_factory=dict, max_length=500)
만든적용: bool = False
종: list[str] | None = Field(default=None, max_length=500)
@router.get("/layers/{layer}/hopyo-materials/{key}")
def hopyo_materials(
layer: Layer,
key: str,
owner: str | None = None,
to: Layer | None = None,
to_owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""고를 재료 판 — `{지문, 파일, 종 [판정 · 후보 · 단서 · 블록 · 고름?], 만든 [SK]}`(첫 판 = 블록 셈 한 번)."""
folder = _root(session, layer, owner)
target = _root(session, to, to_owner) if to else folder
mine = _root(session, "personal", None)
def run() -> dict:
fp, name = mat_pick.fingerprint(folder, key)
picks = mat_pick.read(mine, fp)["고름"]
kinds = [g | ({"고름": picks[g["종"]]} if g["종"] in picks else {}) for g in mat_pick.census(folder, key)] # fmt: skip
return {"지문": fp, "파일": name, "종": kinds, "만든": mat_pick.made(target, fp)}
return _call(folder, run)
@router.post("/layers/{layer}/hopyo-materials/{key}")
def hopyo_materials_save(
layer: Layer,
key: str,
body: MatPickBody,
owner: str | None = None,
to: Layer | None = None,
to_owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""[저장] — 고름을 개인 층 그 파일 자리에 · 만든적용 = 그 파일 SK(만들 층)의 가져온 그대로인 줄에도 → `{고름, 고침?, 그대로?}`."""
folder = _root(session, layer, owner)
mine = _root(session, "personal", None, write=True)
fp, name = _call(folder, mat_pick.fingerprint, folder, key)
out: dict[str, Any] = {"고름": _call(mine, mat_pick.write, mine, fp, name, body.고름)}
if body.만든적용:
target = _root(session, to or layer, to_owner if to else owner, write=True)
kinds = set(body.종 if body.종 is not None else body.고름)
out |= _call(target, mat_pick.apply_made, fp, out["고름"], kinds)
return out
class SkPickBody(_Strict):
"""SK 상세에서 고른 재료 줄 → 같은 파일 같은 종(물음 예) — `빼기` = 지금 SK(화면 [저장] 몫)."""
종: str = Field(max_length=4000)
고름: dict[str, Any] | None = None
파일: str = Field(default="", max_length=200)
빼기: list[str] = Field(default_factory=list, max_length=20)
@router.get("/layers/{layer}/material-picks/{fp}")
def material_pick_uses(
layer: Layer,
fp: str,
종: str,
owner: str | None = None,
me: str = "",
session: Session = Depends(verify_session),
) -> dict:
"""SK 상세 물음 — `{수 {블록, 줄}(작업 중 HP 판 · 없으면 null), 만든 [그 종 줄 있는 다른 SK], 고름?}`."""
folder = _root(session, layer, owner)
mine = _root(session, "personal", None)
def run() -> dict:
n = mat_pick.tally(folder, fp, 종)
n = n if n is not None or mine == folder else mat_pick.tally(mine, fp, 종)
return {"수": n, "만든": mat_pick.uses(fp, 종, (me,)), "고름": mat_pick.read(mine, fp)["고름"].get(종)} # fmt: skip
return _call(folder, run)
@router.post("/layers/{layer}/material-picks/{fp}")
def material_pick_save(
layer: Layer,
fp: str,
body: SkPickBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""그 종 고름을 개인 층 파일 자리에 + 이 층 그 파일 다른 SK 의 가져온 그대로인 줄에 → `{고름, 고침, 그대로}`."""
folder = _root(session, layer, owner, write=True)
mine = _root(session, "personal", None, write=True)
picks = _call(mine, mat_pick.write, mine, fp, body.파일, {body.종: body.고름})
return {
"고름": picks,
**_call(folder, mat_pick.apply_made, fp, picks, {body.종}, tuple(body.빼기)),
}
@@ -191,6 +191,22 @@
box-shadow: inset 3px 0 0 var(--color-accent);
}
/* 827-3 고를 재료 판 — 단가산출 목록 위 · 판만 굴림(목록은 늘 보임) */
.m02-hopyo-matpick {
flex: none;
max-height: 45%;
overflow: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--spacing-4) var(--spacing-8);
}
.m02-hopyo-matpick__body {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m02-hopyo-sheet__list {
flex: 1;
min-height: 0;
@@ -47,7 +47,7 @@ export interface HopyoImport {
root: HTMLElement;
/** 화면 떠남 — 이 화면에서 올리거나 이은 파일 지움(743-1) */
leave: () => void;
/** 안 옮긴 고름 — 판정 고름 캐시 걷음(809-1) 뒤 늘 거짓 */
/** 저장 안 한 재료 고름(827-3) — 판정 고름 캐시는 809-1 에서 걷음 */
dirty: () => boolean;
}
@@ -121,6 +121,6 @@ export function buildHopyoImport(a: HopyoImportArgs): HopyoImport {
return {
root,
leave,
dirty: () => false,
dirty: () => body?.matDirty() ?? false, // 827-3 저장 안 한 재료 고름
};
}
@@ -17,6 +17,7 @@ import { currentLanguageIndex } from "@ui/ui_template_locale";
import { askConfirm, openModal } from "@ui/ui_template_modal";
import { call, readTemplate, saveTemplate } from "./M02_MasterTemplete_Api_Fetch";
import { openFindPicker } from "./M02_MasterTemplete_Find_Pick";
import { askSameFile } from "../A00_Common/line_stack_material";
import { at, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
import { importedPanel, type PanelState } from "./M02_MasterTemplete_Hopyo_ImportedPanel";
import { openQtyLinks, type QtyRow, type QtyTally } from "./M02_MasterTemplete_Hopyo_QtyLinks";
@@ -44,6 +45,10 @@ export interface ImportedOptions {
/** SK 를 만들 층 */
target: HopyoScope;
canWrite: boolean;
/** 827-3 저장 안 한 재료 고름(만들 때 서버가 저장 · 그 고름째 만듦) */
picks?: Record<string, unknown>;
/** 만들었음(재료 판 다시 읽기) */
onMade?: () => void;
}
interface Made {
@@ -54,14 +59,14 @@ interface Made {
const fromHopyo = (o: ImportedOptions): Promise<Made> =>
call(at(o.target, "/sangeun/from-hopyo"), {
method: "POST",
body: JSON.stringify({ 층: o.file.layer, ...(o.file.owner ? { 주인: o.file.owner } : {}), 키: o.key, 번: o.no }), // prettier-ignore
body: JSON.stringify({ 층: o.file.layer, ...(o.file.owner ? { 주인: o.file.owner } : {}), 키: o.key, 번: o.no, ...(o.picks ? { 고름: o.picks } : {}) }), // prettier-ignore
});
interface EditAnswer {
문서: ImportedDoc;
전?: Money4 | null;
후?: Money4 | null;
이음없음?: { 자리: string; 이름: string; 까닭?: string; 견적?: boolean }[];
이음없음?: { 자리: string; 이름: string; 까닭?: string; 견적?: boolean; 종?: string }[];
/** qty 답(797-2) */
줄?: QtyRow[];
집계?: QtyTally;
@@ -168,12 +173,21 @@ export function openImported(o: ImportedOptions): void {
function link(spot: string, name: string): void {
const done = (code: string): void => void run("link", { 자리: spot, 이음: code }).then(() => unlinked()); // prettier-ignore
const kind = st.unlinked.find((u) => u.자리 === spot)?.종; // 827-3 재료 줄 = 같은 파일 물음
const src = (st.doc.가져온 as { 원본?: { 지문?: string; 파일?: string } } | undefined)?.원본;
const same = (e: { 코드: string; 이름: string; 규격?: string; 단위?: string }): void => {
if (kind && src?.지문 && !readOnly)
void askSameFile({ scope: o.target, me: sk, 지문: src.지문, 파일: src.파일 ?? "", 종: kind, 원래: name, p: { ...e, 키: e.코드 } }); // prettier-ignore
};
openFindPicker({
layer: o.target.layer,
owner: o.target.owner,
query: name,
logic: (x) => done(`로직(${x.키})`),
element: (e) => done(e.코드),
element: (e) => {
done(e.코드);
same(e);
},
machine: (e) => done(e.코드),
oil: (e) => done(e.코드),
});
@@ -202,6 +216,7 @@ export function openImported(o: ImportedOptions): void {
const stop = showLoading(root);
try {
const made = await fromHopyo(o);
o.onMade?.();
sk = made.이름;
showToast((made.다시씀 ? t("Reused") : t("Made")).replace("{k}", sk), "info");
const got = await readTemplate(o.target.layer, "sangeun", sk, null, o.target.owner);
@@ -62,7 +62,7 @@ export const tx = (k: keyof typeof TEXT): string =>
export interface PanelState {
doc: ImportedDoc;
/** 이음 없는 자원(서버 price 답) */
unlinked: { 자리: string; 이름: string; 까닭?: string; 견적?: boolean }[];
unlinked: { 자리: string; 이름: string; 까닭?: string; 견적?: boolean; 종?: string }[];
/** 소요량 · 계수 이음 집계(서버 qty 답 · 797-2) */
qty: QtyTally | null;
/** 마지막 단가 바꿈 전후 */
@@ -0,0 +1,240 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_MatPick.ts
* 불러오기 「고를 재료」 판(PLAN 827-3 · 요구 811-2 · 812-1) — 단가산출 목록(`Hopyo_SheetSk`) 머리에 붙음.
* · 그 파일 재료 줄을 종(이름 · 규격 · 단위 · 단서)으로 묶어 판정 · 쓰는 블록 수 · 단서 글 · 후보(엔진 차례 · 1 순위)
* = 서버 `…/hopyo-materials/{키}`(첫 판 = 블록 셈 한 번 · 그 뒤 저장본)
* · 후보 고름 · [찾기](재료 찾기 창 `openStackPick`) → 그 파일 같은 종 줄 전부 · [고름 풀기]
* · 「우리 표에 없음」 = 안 골라도 됨(가져온 값) · 이어진 재료 = 접힌 목록(방법 · 단서 — 틀린 이음도 같은 길로 바꿈)
* · 고름 = 화면 캐시 → [저장](개인 층 그 파일 자리 · 이미 만든 단가산출 n 건 적용은 물어 고를 때만) ·
* 저장 전 블록을 만들면 그 고름째 만듦(`pending` → from-hopyo 몸 · 서버가 저장) · 별칭 · 마스터에 안 씀
* ========================================================================== */
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
import { attachCollapsible, createCollapsibleHead } from "@ui/ui_template_collapsible";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { createSelectField } from "@ui/ui_template_input";
import { createListRow } from "@ui/ui_template_list_row";
import { askChoice } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import { MAT_PICKED } from "../A00_Common/line_stack_material";
import { openStackPick } from "../A00_Common/line_stack_pick";
import { call } from "./M02_MasterTemplete_Api_Fetch";
import { at, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
export interface MatCand {
키: string;
이름: string;
규격: string;
단위: string;
단서?: string;
}
interface MatKind {
종: string;
이름: string;
규격: string;
단위: string;
판정: string;
방법: string;
키: string | null;
후보: MatCand[];
단서: string[];
별도계상: boolean;
블록: number[];
줄: number;
고름?: MatCand;
}
interface MatBoard {
지문: string;
파일: string;
종: MatKind[];
만든: string[];
}
export type MatPicks = Record<string, MatCand | null>;
const LINKED = "이어짐";
const TONE: Record<string, BadgeTone> = { [LINKED]: "success", "우리 표에 없음": "neutral" };
const enc = encodeURIComponent;
const why = (e: unknown): string => (e instanceof Error ? e.message : String(e));
const label = (c: { 이름: string; 규격: string }): string => `${c.이름} ${c.규격}`.trim();
export function createMatPick(opt: { scope: HopyoScope; key: string; target: HopyoScope }) {
const to = `?to=${opt.target.layer}${opt.target.owner ? `&to_owner=${enc(opt.target.owner)}` : ""}`;
const url = at(opt.scope, `/hopyo-materials/${enc(opt.key)}${to}`);
const title = createCollapsibleHead({ title: "고를 재료", sub: "고른 것은 이 파일 안에서만 · 별칭 아님 · 「우리 표에 없음」은 안 골라도 됨" }); // prettier-ignore
const body = el("div", { className: "m02-hopyo-matpick__body" });
const root = el("section", {
className: "ui-collapsible m02-hopyo-matpick",
attrs: { "data-matpick": opt.key },
children: [title.root, body],
});
attachCollapsible(root);
document.addEventListener(MAT_PICKED, () => asked && root.isConnected && void load()); // SK 상세에서 같은 파일 고름
let board: MatBoard | null = null;
let asked = false;
let busy = false;
let linkedOpen = false;
const edits = new Map<string, MatCand | null>();
const now = (k: MatKind): MatCand | null =>
edits.has(k.종) ? (edits.get(k.종) ?? null) : (k.고름 ?? null);
const set = (k: MatKind, c: MatCand | null): void => {
if ((c?.키 ?? null) === (k.고름?.키 ?? null)) edits.delete(k.종);
else edits.set(k.종, c);
draw();
};
function controls(k: MatKind): HTMLElement {
const mine = now(k);
const parts: HTMLElement[] = [];
if (k.후보.length) {
const pick = createSelectField({
options: [
{ value: "", text: mine ? `고름: ${label(mine)}` : "후보에서 고름" },
...k.후보.map((c, i) => ({
value: String(i),
text: `${i ? "" : "1 순위 · "}${label(c)} ${c.단위 ? `(${c.단위})` : ""}`.trim(),
})),
],
compact: true,
size: 24,
ariaLabel: "후보에서 고름",
onChange: (v) => v && set(k, k.후보[Number(v)]),
});
pick.select.dataset.matCands = k.종;
parts.push(pick.root);
}
const find = createButton({
label: "찾기",
variant: "ghost",
size: 24,
iconStart: "search",
onClick: () =>
openStackPick(
"재료",
(p) => set(k, { 키: p.키, 이름: p.이름, 규격: p.규격 ?? "", 단위: p.단위 ?? "" }),
undefined,
k.이름,
),
});
find.dataset.matFind = k.종;
parts.push(find);
if (mine) {
const tag = createBadge(`고름: ${label(mine)} · ${k.블록.length} 블록에 적용`, "info", { size: 18 }); // prettier-ignore
tag.dataset.matPicked = k.종;
const undo = createButton({ label: "고름 풀기", variant: "ghost", size: 24, iconStart: "undo", onClick: () => set(k, null) }); // prettier-ignore
undo.dataset.matUnpick = k.종;
parts.push(tag, undo);
}
return el("div", { className: "ui-toolbar", children: parts });
}
function kindRow(k: MatKind): HTMLElement {
const what = `${label(k)}${k.단위 ? ` (${k.단위})` : ""}` || "—";
const sub = [k.방법, `${k.블록.length} 블록 · ${k.줄} 줄`, ...k.단서]
.filter(Boolean)
.join(" · ");
const badge = createBadge(k.별도계상 ? `별도계상 · ${k.판정}` : k.판정, TONE[k.판정] ?? "warning", { size: 18 }); // prettier-ignore
const row = createListRow({ label: what, sub, trailing: badge, size: 36, title: `단산 ${k.블록.join(" · ")}` }); // prettier-ignore
const box = el("div", { attrs: { "data-mat-kind": k.종 }, children: [row, controls(k)] });
if (edits.has(k.종)) box.dataset.matEdited = "";
return box;
}
function draw(): void {
if (!board) return;
const open = board.종.filter((k) => k.판정 !== LINKED);
const linked = board.종.filter((k) => k.판정 === LINKED);
title.setTitle(`고를 재료 ${open.length} · 이어진 재료 ${linked.length}`);
root.dataset.matOpen = String(open.length);
const save = createButton({
label: edits.size ? `저장(${edits.size} 종)` : "저장",
variant: "filled",
size: 24,
disabled: !edits.size || busy,
onClick: () => void persist(),
});
save.dataset.matSave = "";
const head = el("div", {
className: "ui-toolbar",
children: [save],
});
const more = el("section", {
className: `ui-collapsible${linkedOpen ? "" : " is-collapsed"}`,
attrs: { "data-mat-linked": "" },
children: [createCollapsibleHead({ title: `이어진 재료 ${linked.length}`, sub: "엔진이 이은 것 — 틀린 이음도 같은 길로 바꿈", level: 4 }).root, ...linked.map(kindRow)], // prettier-ignore
});
more.addEventListener("click", () =>
queueMicrotask(() => (linkedOpen = !more.classList.contains("is-collapsed"))),
); // 다시 그려도 펼침 그대로
body.replaceChildren(head, ...open.map(kindRow), ...(linked.length ? [more] : []));
}
async function load(): Promise<void> {
try {
board = await call<MatBoard>(url);
draw();
} catch (e) {
body.replaceChildren(createText(why(e), "error"));
}
}
async function persist(): Promise<void> {
if (!board || !edits.size) return;
const kinds = new Set(edits.keys());
const used = board.만든.length;
let apply = false;
if (used) {
const got = await askChoice({
message: `이미 만든 단가산출 ${used} 건에도 적용할까? — 가져온 그대로인 줄만 바뀜(따로 고친 줄은 그대로)`,
choices: [
{ label: "이 파일 고름만", value: "no" },
{ label: `단가산출 ${used} 건에도 적용`, value: "yes" },
],
});
if (got === null) return;
apply = got === "yes";
}
busy = true;
draw();
try {
const out = await call<{ 고침?: string[] }>(url, {
method: "POST",
body: JSON.stringify({ 고름: Object.fromEntries(edits), 만든적용: apply, 종: [...kinds] }),
});
edits.clear();
showToast(`재료 고름 저장 — ${kinds.size} 종${apply ? ` · 단가산출 ${out.고침?.length ?? 0} 건 고침` : ""}`, "success"); // prettier-ignore
} catch (e) {
showToast(why(e), "error");
} finally {
busy = false;
}
await load();
}
return {
root,
/** 처음 단가산출 탭에 올 때 한 번(첫 판 = 서버가 블록을 한 번 셈) */
ensure(): void {
if (asked) return;
asked = true;
body.replaceChildren(
createText("재료 판정 읽는 중… (처음 한 번 — 블록 수만큼 걸림)", "hint"),
);
void load();
},
/** 저장 안 한 고름 */
dirty: (): boolean => edits.size > 0,
/** 블록 만들 때 같이 보낼 저장 안 한 고름(없으면 undefined) */
pending: (): MatPicks | undefined => (edits.size ? Object.fromEntries(edits) : undefined),
/** 만들기가 고름을 저장했음 — 캐시 비우고 다시 읽음 */
reload(): void {
edits.clear();
if (asked) void load();
},
};
}
export type MatPickPanel = ReturnType<typeof createMatPick>;
@@ -69,6 +69,8 @@ export interface HopyoSheetBody {
skCount: () => { 블록: number; 밖: number; 줄: number } | null;
/** 그 단가산출 블록으로 뜀 */
showSk: (번: number) => void;
/** 저장 안 한 재료 고름(827-3) */
matDirty: () => boolean;
}
const ILWI = "일위대가";
@@ -331,5 +333,6 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
destroy: finish,
skCount: () => sk?.count() ?? null,
showSk: (번) => sk?.show(번),
matDirty: () => sk?.dirty() ?? false,
};
}
@@ -7,6 +7,7 @@
* (경쟁사 로직 그대로 · 엑셀 단가 · 요소 이음 · 소요량 이음)
* · 시트 칸 고름 → 그 행 블록을 목록에서 강조(`SheetNav.blockAt`)
* · 목록은 처음 단가산출 탭에 올 때 읽음 · 기초 판정 · 블록 판 · [옮기기] 없음(요구 703)
* · 머리 = 「고를 재료」 판(827-3 `Hopyo_MatPick`) — 저장 안 한 고름은 블록 만들 때 같이 감
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
@@ -16,6 +17,7 @@ import { createListRow } from "@ui/ui_template_list_row";
import { createText } from "@ui/ui_template_text";
import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
import { openImported } from "./M02_MasterTemplete_Hopyo_Imported";
import { createMatPick } from "./M02_MasterTemplete_Hopyo_MatPick";
import { readHopyoMarks, type SheetNav } from "./M02_MasterTemplete_Hopyo_SheetNav";
import type { BlockMark } from "./logic/M02_MasterTemplete_Hopyo_SheetMap";
@@ -35,7 +37,8 @@ const STRIPE = { done: "var(--color-success)", ready: "var(--color-info)", out:
export function createSheetSk(opt: SheetSkOptions) {
const list = el("div", { className: "m02-hopyo-sheet__list" });
const root = el("div", { className: "m02-hopyo-sheet__side m02-hopyo-sheet__sk", children: [list] }); // prettier-ignore
const mat = createMatPick({ scope: opt.scope, key: opt.key, target: opt.target }); // 827-3
const root = el("div", { className: "m02-hopyo-sheet__side m02-hopyo-sheet__sk", children: [mat.root, list] }); // prettier-ignore
root.setAttribute("data-hopyo-sk-side", "");
root.hidden = true;
opt.side.after(root);
@@ -46,7 +49,7 @@ export function createSheetSk(opt: SheetSkOptions) {
const mineAt = { layer: "personal", owner: null } as const; // [내 단가산출로](807-1) = 늘 개인 층
const open = (no: number, mine: boolean): void =>
openImported({ file: opt.scope, key: opt.key, no, target: mine ? mineAt : opt.target, canWrite: mine || opt.canWrite }); // prettier-ignore
openImported({ file: opt.scope, key: opt.key, no, target: mine ? mineAt : opt.target, canWrite: mine || opt.canWrite, picks: mat.pending(), onMade: mat.reload }); // prettier-ignore
function draw(): void {
if (!blocks) return;
@@ -79,6 +82,7 @@ export function createSheetSk(opt: SheetSkOptions) {
function ensure(): void {
if (asked) return;
asked = true;
mat.ensure();
if (!blocks) list.replaceChildren(createText(L("M02_JudgeLoading"), "hint"));
void readHopyoMarks(opt.scope, opt.key, opt.target)
.then((got) => {
@@ -130,6 +134,8 @@ export function createSheetSk(opt: SheetSkOptions) {
}
},
destroy(): void {},
/** 저장 안 한 재료 고름(827-3) */
dirty: mat.dirty,
};
}
+5 -2
View File
@@ -177,8 +177,10 @@ def why(item: dict) -> str:
from M02_MasterTemplete import M02_Hopyo_Elem as elem
from M02_MasterTemplete import M02_Hopyo_Row as hrow
if apart({"이름": name, "규격": res.get("규격") or ""}):
return APART_ITEM if (res.get("재료찾기") or {}).get("키") else APART
if apart({"이름": name, "규격": res.get("규격") or ""}): # 827-3 품목 이름까지
from M02_MasterTemplete import M02_San_MatPick as mp
return mp.apart_note(res.get("재료찾기") or {})
if elem.oil_rows(name):
return "유가 후보 여럿 — 고름"
line = {"이름": name, "규격": res.get("규격") or "", "단위": res.get("단위") or ""}
@@ -202,6 +204,7 @@ def tally(logic: dict) -> dict:
or x.get("참조")
or (x.get("자원") or {}).get("조합식")
or ((x.get("자원") or {}).get("재료찾기") or {}).get("키") # 827-2 별도계상 품목
or ((x.get("자원") or {}).get("재료찾기") or {}).get("고름") # 827-3 사람이 고른 품목
)
inner = [x for x in items if (x.get("자원") or {}).get("갈래") == "단가산출" or x.get("참조")]
return {"됨": done, "줄": len(items), "조합": bool(items) and len(inner) == len(items)}
+344
View File
@@ -0,0 +1,344 @@
"""불러오기 재료 고름(PLAN 827-3 · 요구 811-2 · 812-1) — 서버 단독.
고름 = 사람이 그 파일 안 한 종(이름 · 규격 · 단위 · 단서 — `kind_of`)에 고른 우리 자재 · 별칭 아님
(마스터 · M01 · 별칭 칸 · 다른 파일에 안 씀 · 다른 것으로 바뀔 수 있음).
자리 = 개인 층 `실무자료/재료고름/<파일 지문>.json` {파일, 고름 {종: {키, 이름, 규격, 단위}}} — [끝내기] 뒤에도 남음 ·
같은 파일(지문)을 다시 올리면 그대로 보임.
판(`census`) = 그 HP 블록마다 재료 줄 판정(`자원["재료찾기"]` · 827-2)을 종으로 묶음 —
첫 판 열 때 한 번 셈 → 산근 저장본 칸 `재료판`(HP 지울 때 같이 지움).
만들기(`using` → `M02_Sangeun_FromHopyo._emit` 이 `apply`) = 고른 종 줄: 이음 = 고른 키 · `재료찾기.고름`
(문서 셈 = 우리 값 · 엑셀 값 기억 없음 · `M02_Doc_Import._attach`) · 별도계상 줄 = `고름` 만(줄 이음 · 단가 0 그대로).
문서 머리 `가져온.재료` = [{기호, 종, 원래, 판정, 방법, 키, 후보, 단서, 별도계상?, 고름?, 처음}](`entry` ·
처음 = 가져온 그대로의 연결 — 고름 풀기 · 사람이 따로 바꾼 줄 가름).
이미 만든 SK(`apply_made`) = 원본 지문이 같은 SK 문서 — 가져온 그대로인 줄만(「바꿈」 · 사람이 고른 줄 · 고친 값 뺌) →
수식 길 저장(서버가 다시 셈).
"""
from __future__ import annotations
import copy as cp
import json
from contextlib import contextmanager
from contextvars import ContextVar
from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
DIRNAME = "재료고름"
CENSUS = "재료판"
KINDS = (
"규격 없음 — 고르기",
"후보 여럿",
"우리 표에 없음",
"이어짐",
) # 판 차례(`M02_San_MatFind`)
MAX_PICKS = 2000
_PICKS: ContextVar[dict | None] = ContextVar("재료고름", default=None)
def _text(v: Any) -> str:
return " ".join(str(v or "").split())
def kind_of(res: dict) -> str:
"""종 열쇠 — 가져온 자원 이름 · 규격 · 단위 + 판정 단서(같은 이름이라도 산근 머리 Φ800 · Φ1000 은 다른 종)."""
found = res.get("재료찾기") or {}
head = "|".join(_text(res.get(k)) for k in ("이름", "규격", "단위"))
return head + "|" + ";".join(_text(x) for x in found.get("단서") or [])
def _apart(res: dict) -> bool:
from M02_MasterTemplete import M02_San_Link as link
return link.apart({"이름": res.get("이름") or "", "규격": res.get("규격") or ""})
def _pick(raw: Any) -> dict | None:
if not isinstance(raw, dict) or not str(raw.get("키") or "").strip():
return None
return {k: _text(raw.get(k)) for k in ("키", "이름", "규격", "단위")}
# ── 고름 자리(개인 층) ────────────────────────────────────────────────────
def _path(folder: str | Path, fp: str) -> Path:
if not fp or not fp.isalnum() or len(fp) > 128:
raise store.StoreError(400, "파일 지문")
return hopyo_store._dir(folder) / DIRNAME / f"{fp}.json"
def read(folder: str | Path, fp: str) -> dict[str, Any]:
"""{파일, 고름 {종: 고른 것}} — 없으면 빈 것."""
try:
got = json.loads(_path(folder, fp).read_bytes())
except (OSError, ValueError):
return {"파일": "", "고름": {}}
picks = got.get("고름") if isinstance(got.get("고름"), dict) else {}
return {
"파일": str(got.get("파일") or ""),
"고름": {k: p for k, v in picks.items() if (p := _pick(v))},
}
def write(folder: str | Path, fp: str, name: str, picks: dict) -> dict[str, dict]:
"""종마다 고른 것(None = 고름 풀기)을 얹어 씀 → 지금 고름 전부."""
now = read(folder, fp)
mine = dict(now["고름"])
for k, v in (picks or {}).items():
if (p := _pick(v)) is None:
mine.pop(str(k), None)
else:
mine[str(k)] = p
if len(mine) > MAX_PICKS:
raise store.StoreError(400, "고름이 너무 많음")
path = _path(folder, fp)
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(path, {"파일": name or now["파일"], "고름": mine})
return mine
# ── 만들기 ───────────────────────────────────────────────────────────────
@contextmanager
def using(picks: dict | None):
"""이 안에서 만드는 가져온 식 SK(안쪽 블록 포함)에 그 파일 고름을 얹음."""
token = _PICKS.set(picks or None)
try:
yield
finally:
_PICKS.reset(token)
def apply(logic: dict) -> int:
"""만들 로직 줄에 고름 얹음 → 얹은 줄 수(고름 없으면 0 · 엔진 판정 그대로)."""
picks, n = _PICKS.get(), 0
for item in (logic or {}).get("호표") or [] if picks else []:
res = item.get("자원") or {}
found = res.get("재료찾기")
if not found or not (p := picks.get(kind_of(res))):
continue
res["재료찾기"] = {**found, "고름": p}
if not _apart(res):
item["이음"] = p["키"]
n += 1
return n
def chosen(item: dict) -> dict | None:
"""그 줄 고름(별도계상 아님) — 문서 셈 = 우리 값."""
res = item.get("자원") or {}
got = (res.get("재료찾기") or {}).get("고름")
return got if got and not _apart(res) else None
def item_key(res: dict) -> str | None:
"""줄 품목 키 — 고름 › 엔진 이음."""
found = res.get("재료찾기") or {}
return (found.get("고름") or {}).get("키") or found.get("키")
def apart_note(found: dict) -> str:
"""별도계상 줄 설명 — 품목이 있으면 「별도계상 · 품목: 이름 규격」."""
from M02_MasterTemplete import M02_San_Link as link
key = (found.get("고름") or {}).get("키") or found.get("키")
if not key:
return link.APART
one = found.get("고름") or next(
(c for c in found.get("후보") or [] if c.get("키") == key), None
)
if one is None: # 엔진이 이은 품목 = 우리 자재표 줄
from M02_MasterTemplete import M02_San_MatFind as mf
one = ((mf._row(key) or {}).get("row") or {}).get("one") or {}
what = f"{_text(one.get('이름'))} {_text(one.get('규격'))}".strip() or key
return f"별도계상 · 품목: {what} · 셈은 프로젝트에서"
def entry(item: dict, sym: str, excel: Any) -> dict | None:
"""문서 머리 `가져온.재료` 한 칸(재료 찾기 판정 있는 줄만)."""
res = item.get("자원") or {}
found = res.get("재료찾기")
if not found:
return None
one = {
"기호": sym,
"종": kind_of(res),
"원래": {k: _text(res.get(k)) for k in ("이름", "규격", "단위")},
}
one |= {k: found.get(k) for k in ("판정", "방법", "키", "후보", "단서")}
if _apart(res):
one["별도계상"] = True
elif found.get("키"):
one["처음"] = {"갈래": "요소", "키": found["키"], "엑셀": excel}
else:
one["처음"] = {"갈래": "숫자", "값": excel}
return one | ({"고름": found["고름"]} if found.get("고름") else {})
# ── 판(그 파일 재료 종) ──────────────────────────────────────────────────
def census(folder: str | Path, key: str) -> list[dict]:
"""HP 블록마다 재료 줄 판정 → 종 [{종, 이름, 규격, 단위, 판정, 방법, 키, 후보, 단서, 별도계상, 블록, 줄}]."""
from M02_MasterTemplete import M02_Doc_FromNative as fn
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
hopyo_store.read(folder, key) # 없는 HP = 404
path = fh._path(folder, key)
try:
saved = json.loads(path.read_bytes())
except (OSError, ValueError) as e:
raise store.StoreError(409, fh.RELOAD) from e
if isinstance(saved.get(CENSUS), list):
return saved[CENSUS]
native = saved.get("원파일")
src = native if isinstance(native, dict) else fh._excel_of(path, saved) if saved.get("시트") else {} # fmt: skip
groups: dict[str, dict] = {}
# ponytail: 블록마다 셈 전부(기계 찾기 포함 · 70 블록 10~20 초) — 첫 판 한 번 · 느리면 재료 줄만 도는 길
for no in sorted(fn.numbers(src)) if src else []:
with using(None):
try:
out = fn.emit(src, no)
except Exception: # noqa: BLE001 — 못 옮기는 블록 = 판에서 뺌(만들기 때 그 까닭이 보임)
continue
for item in out["로직"].get("호표") or []:
res = item.get("자원") or {}
found = res.get("재료찾기")
if not found:
continue
k = kind_of(res)
g = groups.setdefault(
k,
{"종": k, **{x: _text(res.get(x)) for x in ("이름", "규격", "단위")},
**{x: found.get(x) for x in ("판정", "방법", "키", "후보", "단서")},
"별도계상": _apart(res), "블록": [], "줄": 0},
) # fmt: skip
g["줄"] += 1
if no not in g["블록"]:
g["블록"].append(no)
out_ = sorted(groups.values(), key=lambda g: (KINDS.index(g["판정"]) if g["판정"] in KINDS else 9, -len(g["블록"]), g["이름"])) # fmt: skip
atomic_write_json(path, {**saved, CENSUS: out_})
return out_
def fingerprint(folder: str | Path, key: str) -> tuple[str, str]:
hp = hopyo_store.read(folder, key)
return str(hp.get("지문") or ""), str(hp.get("파일") or "")
# ── 이미 만든 SK ─────────────────────────────────────────────────────────
def made(folder: str | Path, fp: str) -> list[str]:
"""그 층 가져온 식 SK 가운데 원본 지문이 같은 것."""
out = []
for path in sorted((Path(folder) / "sangeun").glob("SK-*.json")):
try:
doc = json.loads(path.read_bytes())
except (OSError, ValueError):
continue
src = ((doc.get("가져온") or {}).get("원본") or {}) if isinstance(doc, dict) else {}
if fp and src.get("지문") == fp:
out.append(path.stem)
return out
def _same(a: Any, b: Any) -> bool:
"""연결이 가져온 그대로(갈래 · 키 · 숫자 값)."""
if not isinstance(a, dict) or not isinstance(b, dict) or a.get("바꿈"):
return False
if a.get("갈래") != b.get("갈래") or a.get("키") != b.get("키"):
return False
if a.get("갈래") == "숫자":
return json.dumps(a.get("값"), default=str) == json.dumps(b.get("값"), default=str) or _num(a.get("값")) == _num(b.get("값")) # fmt: skip
return a.get("갈래") != "요소" or "엑셀" in a
def _num(v: Any) -> Any:
try:
return round(float(v), 6)
except (TypeError, ValueError):
return v
def patch(doc: dict, picks: dict, kinds: set | None = None) -> int:
"""문서 `가져온.재료` 줄에 고름 얹음 · 풂 → 고친 줄 수(사람이 따로 바꾼 줄 · 「바꿈」 줄 그대로)."""
import master_doc as md # `M02_Doc_Copy` 가 scripts 를 길에 둔 뒤
by = {md.var_id(v): v for v in doc.get("변수") or []}
n = 0
for e in (doc.get("가져온") or {}).get("재료") or []:
if kinds is not None and e.get("종") not in kinds:
continue
p, was = picks.get(e.get("종")), e.get("고름")
var = by.get(e.get("기호"))
if (p or {}).get("키") == (was or {}).get("키") or var is None:
continue
link = var.get("연결") or {}
if e.get("별도계상"):
e.pop("고름", None) if p is None else e.__setitem__("고름", p)
var["설명"] = (
f"이음 없음 — {apart_note(e)} · 엑셀 단가 그대로" # `Doc_Import._attach` 꼴
)
n += 1
continue
mine = was and link.get("갈래") == "요소" and link.get("키") == was.get("키") and "엑셀" not in link and not link.get("바꿈") # fmt: skip
if not (mine or _same(link, e.get("처음"))):
continue
if p:
var["연결"] = {"갈래": "요소", "키": p["키"]}
e["고름"] = p
else:
var["연결"] = cp.deepcopy(e.get("처음"))
e.pop("고름", None)
n += 1
return n
def apply_made(fp: str, picks: dict, kinds: set | None = None, skip: tuple = ()) -> dict[str, list]:
"""지금 층(`store.root`) 그 파일 SK 에 고름 얹어 저장 → {고침, 그대로}(가져온 그대로인 줄만 바뀜)."""
from M02_MasterTemplete import M02_Doc_Sk_Path as skpath
from M02_MasterTemplete import M02_Sangeun_Doc as sd
fixed, kept = [], []
for key in made(store.root(), fp):
if key in skip:
continue
got = store.read(sd.KIND, key)
doc = cp.deepcopy(skpath.current(got["문서"])["문서"])
if not patch(doc, picks, kinds):
kept.append(key)
continue
skpath.save(key, {"문서": doc, "판": got["판"]})
fixed.append(key)
return {"고침": fixed, "그대로": kept}
def uses(fp: str, kind: str, skip: tuple = ()) -> list[str]:
"""지금 층 그 파일 SK 가운데 그 종 줄이 있는 것(SK 상세 물음 · 저장 없음)."""
from M02_MasterTemplete import M02_Sangeun_Doc as sd
out = []
for key in made(store.root(), fp):
if key in skip:
continue
doc = json.loads((store.root() / sd.KIND / f"{key}.json").read_bytes())
lines = ((doc.get("가져온") or {}).get("로직") or {}).get("호표") or []
if any(kind_of(x.get("자원") or {}) == kind for x in lines if (x.get("자원") or {}).get("재료찾기")): # fmt: skip
out.append(key)
return out
def tally(folder: str | Path, fp: str, kind: str) -> dict | None:
"""그 층 작업 중 HP(같은 지문) 판에서 그 종 {블록, 줄} 수 — 판을 아직 안 셈 · HP 없음 = None."""
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
for row in hopyo_store.rows(folder):
if row.get("지문") != fp:
continue
try:
got = json.loads(fh._path(folder, row["키"]).read_bytes()).get(CENSUS)
except (OSError, ValueError):
continue
for g in got or []:
if g.get("종") == kind:
return {"블록": len(g.get("블록") or []), "줄": g.get("줄") or 0}
return None
+9 -2
View File
@@ -25,6 +25,7 @@ from M02_MasterTemplete import M02_Doc_FromNative as fn
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_San_Emit as em
from M02_MasterTemplete import M02_San_MatPick as mp
from M02_MasterTemplete import M02_San_MyMachine as mm
DIRNAME = "산근"
@@ -127,13 +128,17 @@ def _emit(folder: str | Path, key: str, no: int) -> tuple[dict[str, Any], dict]:
raise store.StoreError(409, RELOAD) from e
native = saved.get("원파일")
if isinstance(native, dict) and int(no) in fn.numbers(native): # 원파일 우선(810-2)
return fn.emit(native, int(no)), native.get("기계구성") or {}
out = fn.emit(native, int(no))
mp.apply(out["로직"]) # 827-3 그 파일 재료 고름
return out, native.get("기계구성") or {}
if not saved.get("시트"):
raise store.StoreError(404, f"없는 단산{no}")
excel = _excel_of(path, saved)
if int(no) not in fn.numbers(excel):
raise store.StoreError(404, f"없는 단산{no}")
return fn.emit(excel, int(no)), mm.xlsx_makes(saved["시트"])
out = fn.emit(excel, int(no))
mp.apply(out["로직"])
return out, mm.xlsx_makes(saved["시트"])
def _inner(
@@ -184,6 +189,8 @@ def make(source: Path, key: str, no: int, name: str = "", _trail: tuple = ()) ->
out, makes = _emit(source, key, no)
out["원본"]["가져온때"] = utc_now_text() # 원문 패널 「어디서 왔나」(807-1)
if fp := hopyo_store.read(source, key).get("지문"): # 827-3 그 파일 고름 · 이미 만든 SK 찾기
out["원본"]["지문"] = fp
_link_inner(source, key, no, out, _trail)
try: # 소요량 · 계수 후보 표지(797-2 · 사람 확인) — 못 셈 = 표지 없이 만듦
from M02_MasterTemplete import M02_Sangeun_QtyLinks as qty
+4 -2
View File
@@ -319,15 +319,17 @@ def same_part(doc: Any) -> dict | None:
def unlinked(doc: dict) -> list[dict]:
"""이음 없는 자원 줄(표지 · 사람이 고름) `[{자리, 이름, 까닭, 견적?}]`(까닭 = 814-1 `M02_San_Link.why` ·
견적 = 815-1 K5 그대로 쓰는 견적 · 운임 · 수수료 줄 — 못 이음 수에서 뺌)."""
"""이음 없는 자원 줄(표지 · 사람이 고름) `[{자리, 이름, 까닭, 견적?, 종?}]`(까닭 = 814-1 `M02_San_Link.why` ·
견적 = 815-1 K5 그대로 쓰는 견적 · 운임 · 수수료 줄 — 못 이음 수에서 뺌 · 종 = 827-3 재료 종 열쇠)."""
from M02_MasterTemplete import M02_San_Link as link
from M02_MasterTemplete import M02_San_MatPick as mp
row = row_of(doc) or {}
ids = sd.line_ids(row)
return [
{"자리": ids[n], "이름": one.get("이름") or "", "까닭": link.why(one)}
| ({"견적": True} if (one.get("자원") or {}).get("갈래") == "고정" else {})
| ({"종": mp.kind_of(one["자원"])} if (one.get("자원") or {}).get("재료찾기") else {})
for n, one in enumerate(doc[KEY]["로직"].get("호표") or [])
if not one.get("이음") and not one.get("참조") and not (one.get("자원") or {}).get("내기계")
]
+15 -1
View File
@@ -29,6 +29,7 @@ from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_San_MatPick as mat_pick
from M02_MasterTemplete import M02_Sangeun_Block as blk
from M02_MasterTemplete import M02_Sangeun_Calc as calc
from M02_MasterTemplete import M02_Sangeun_Copy as skc
@@ -160,6 +161,9 @@ class FromHopyoBody(BaseModel):
키: str
번: int
이름: str = ""
고름: dict[str, dict[str, Any] | None] | None = (
None # 827-3 판에서 저장 안 한 재료 고름(만들 때 저장)
)
@router.post("/layers/{layer}/sangeun/from-hopyo")
@@ -173,7 +177,17 @@ def sangeun_from_hopyo(
답 = {이름, 검산, 단가기준, 다시씀}(같은 내용 = 그 SK)."""
folder = _root(session, layer, owner, write=True)
source = _root(session, body.층, body.주인)
return _call(folder, _sd(from_hopyo.make), source, body.키, body.번, body.이름)
mine = _root(session, "personal", None, write=body.고름 is not None)
fp, name = _call(source, mat_pick.fingerprint, source, body.키) # 827-3 그 파일 재료 고름
if body.고름 is not None and fp:
_call(mine, mat_pick.write, mine, fp, name, body.고름)
picks = mat_pick.read(mine, fp)["고름"] if fp else {}
def run(*args: Any) -> Any:
with mat_pick.using(picks):
return _sd(from_hopyo.make)(*args)
return _call(folder, run, source, body.키, body.번, body.이름)
class ImportedEditBody(BaseModel):
@@ -150,7 +150,12 @@ def test_apart_linked_item_keeps_zero():
"""별도계상 줄 = 품목이 하나로 정해지면 판정 키 · 줄 이음은 비움(셈 제외 · 단가 0 그대로)."""
key, mark, found = _go("요소(별도계상)", "", "KG")
assert key is None and found["키"] == "MT006763" and mark == f"{sl.NOTE}{sl.APART_ITEM}"
assert sl.why({"자원": {"갈래": "재료", "이름": "요소(별도계상)", "규격": "", "재료찾기": found}}) == sl.APART_ITEM # fmt: skip
why = sl.why(
{"자원": {"갈래": "재료", "이름": "요소(별도계상)", "규격": "", "재료찾기": found}}
)
assert why.startswith("별도계상 · 품목: 요") and why.endswith(
"셈은 프로젝트에서"
) # 827-3 품목 이름까지
assert sl.tally({"호표": [{"이름": "요소", "자원": {"재료찾기": found}}]})["됨"] == 1
@@ -0,0 +1,186 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""827-3 지킴 — 불러오기 재료 고름(요구 811-2 · 812-1 · 임시 storage).
① 파일 고름 → 그 파일 같은 종 줄 전부 이음(블록 둘) · 고른 줄 = 우리 값(엑셀 값 기억 없음) · 문서 머리 `가져온.재료`
② 다른 파일 영향 0 · 별칭 · 마스터 안 바뀜 · 고름 풀기 · [끝내기] 뒤 같은 파일 다시 올림 = 고름 그대로
③ 이미 만든 SK = 물음 예(`apply_made`)일 때만 · 사람이 따로 바꾼 줄 그대로 · 풀면 가져온 그대로로
④ 별도계상 줄 = 품목 「별도계상 · 품목: 이름 규격」 + 단가 0 그대로(줄 이음 없음)
"""
from __future__ import annotations
import copy as cp
import json
from functools import lru_cache
from pathlib import Path
import pytest
from M02_MasterTemplete import M02_Doc_Import as dim
from M02_MasterTemplete import M02_Doc_Sk_Path as skpath
from M02_MasterTemplete import M02_Elem_Alias as ea
from M02_MasterTemplete import M02_Hopyo_Forget as forget
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_San_MatPick as mp
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
from M02_MasterTemplete import M02_Sangeun_Imported as imp
from resources.tester import test_810_1_stc as stc
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
WIRE = "결속선(철선)|#20 , 0.9M/M|KG|" # 봉화 산근 98 · 99 — 후보 여럿
PIPE_BLOCK = 71 # 동부청영월 파형강관(별도계상) Φ800 머리
@lru_cache(maxsize=2)
def _raw(tag: str) -> bytes:
p = stc.FILES[tag]
if not p.exists():
pytest.skip(f"STC 없음 {tag}")
return p.read_bytes()
def _upload(folder: Path, tag: str) -> str:
return fh.upload_native(folder, stc.FILES[tag].name, _raw(tag), {"이름": "sub3임시"})["키"]
def _lines(doc: dict, kind: str) -> list[dict]:
return [x for x in doc[imp.KEY]["로직"]["호표"] if mp.kind_of(x.get("자원") or {}) == kind]
def _var(conv: dict, sym: str) -> dict:
return next(v for v in conv["변수"] if dim.dc.md.var_id(v) == sym)
def _entry(conv: dict, kind: str) -> dict:
return next(e for e in conv["가져온"]["재료"] if e["종"] == kind)
def test_pick_whole_file(world) -> None:
me = world["personal"]
me.mkdir(parents=True, exist_ok=True)
alias = json.dumps(ea.book()["별칭"], ensure_ascii=False, sort_keys=True, default=str)
with store.at(me):
hp = _upload(me, "봉화")
fp, name = mp.fingerprint(me, hp)
groups = {g["종"]: g for g in mp.census(me, hp)}
wire = groups[WIRE]
assert wire["판정"] == "후보 여럿" and wire["블록"] == [98, 99] and len(wire["후보"]) > 1
assert all(g["판정"] == "이어짐" or g["후보"] is not None for g in groups.values())
pick = wire["후보"][1]
assert mp.write(me, fp, name, {WIRE: pick})[WIRE]["키"] == pick["키"]
assert mp.tally(me, fp, WIRE) == {"블록": 2, "줄": 2}
with mp.using(mp.read(me, fp)["고름"]):
made = [fh.make(me, hp, no)["이름"] for no in (98, 99)]
for key in made:
doc = store.read("sangeun", key)["문서"]
assert doc[imp.KEY]["원본"]["지문"] == fp
lines = _lines(doc, WIRE)
assert lines and all(x["이음"] == pick["키"] for x in lines)
assert all(x["자원"]["재료찾기"]["고름"]["키"] == pick["키"] for x in lines)
assert all(
x["자원"]["재료찾기"]["판정"] == "후보 여럿" for x in lines
) # 엔진 판정 그대로
conv = dim.derive(doc, None, imp._whole())
e = _entry(conv, WIRE)
assert e["고름"]["키"] == pick["키"] and e["처음"]["갈래"] == "숫자"
assert _var(conv, e["기호"])["연결"] == {"갈래": "요소", "키": pick["키"]} # 우리 값
assert sorted(mp.made(me, fp)) == sorted(made)
assert mp.uses(fp, WIRE, (made[0],)) == [made[1]]
# 다른 파일 = 영향 0
other = _upload(me, "울진대흥")
fp2, _ = mp.fingerprint(me, other)
assert fp2 != fp and mp.read(me, fp2)["고름"] == {}
# 고름 풀기
assert mp.write(me, fp, name, {WIRE: None}) == {}
assert mp.read(me, fp)["고름"] == {}
# [끝내기] 뒤 같은 파일 다시 올림 = 고름 그대로
mp.write(me, fp, name, {WIRE: pick})
forget.finish(me, hp)
again = _upload(me, "봉화")
assert again != hp and mp.fingerprint(me, again)[0] == fp
assert mp.read(me, fp)["고름"][WIRE]["키"] == pick["키"]
# 별칭 · 마스터 안 바뀜 · 고름 자리 = 개인 층 실무자료/재료고름 한 파일씩
assert json.dumps(ea.book()["별칭"], ensure_ascii=False, sort_keys=True, default=str) == alias
assert sorted(p.name for p in (me / "실무자료" / mp.DIRNAME).iterdir()) == [f"{fp}.json"]
assert not list(world["system"].rglob(f"{fp}*"))
def test_made_only_on_yes_and_keeps_user_lines(world) -> None:
me = world["personal"]
me.mkdir(parents=True, exist_ok=True)
with store.at(me):
hp = _upload(me, "봉화")
fp, name = mp.fingerprint(me, hp)
sk98, sk99 = (fh.make(me, hp, no)["이름"] for no in (98, 99)) # 고름 전에 만듦
conv = skpath.current(store.read("sangeun", sk98)["문서"])["문서"]
e = _entry(conv, WIRE)
before = _var(conv, e["기호"])["연결"]
assert before["갈래"] == "숫자" and "고름" not in e
pick = e["후보"][0]
mp.write(me, fp, name, {WIRE: pick}) # 판 [저장](물음 아니오) = SK 그대로
assert _var(skpath.current(store.read("sangeun", sk98)["문서"])["문서"], e["기호"])["연결"] == before # fmt: skip
# 99 = 사람이 그 줄을 따로 고름(줄 쌓기 이름 단추)
got = store.read("sangeun", sk99)
doc = cp.deepcopy(skpath.current(got["문서"])["문서"])
other = e["후보"][2]["키"]
_var(doc, _entry(doc, WIRE)["기호"])["연결"] = {"갈래": "요소", "키": other}
skpath.save(sk99, {"문서": doc, "판": got["판"]})
money = skpath.read(sk98)["셈"]
out = mp.apply_made(fp, mp.read(me, fp)["고름"], {WIRE}) # 물음 예
assert out == {"고침": [sk98], "그대로": [sk99]}
now = skpath.read(sk98)
e98 = _entry(now["문서"], WIRE)
assert _var(now["문서"], e98["기호"])["연결"] == {"갈래": "요소", "키": pick["키"]}
assert e98["고름"]["키"] == pick["키"] and now["셈"] != money # 우리 값으로 다시 셈
assert (
_var(skpath.read(sk99)["문서"], e["기호"])["연결"]["키"] == other
) # 따로 고친 줄 그대로
# 풂 = 가져온 그대로(엑셀 값)
mp.write(me, fp, name, {WIRE: None})
assert mp.apply_made(fp, {}, {WIRE})["고침"] == [sk98]
back = skpath.read(sk98)
assert _num(_var(back["문서"], e98["기호"])["연결"]) == _num(before)
assert "고름" not in _entry(back["문서"], WIRE) and back["셈"] == money
def _num(link: dict) -> tuple:
return link["갈래"], float(link["값"])
def test_apart_item_zero(world) -> None:
me = world["personal"]
me.mkdir(parents=True, exist_ok=True)
with store.at(me):
hp = _upload(me, "동부청영월")
out = fh.emit(me, hp, PIPE_BLOCK)
line = next(x for x in out["로직"]["호표"] if "파형강관" in str((x.get("자원") or {}).get("이름"))) # fmt: skip
kind = mp.kind_of(line["자원"])
found = line["자원"]["재료찾기"]
assert found["판정"] == "규격 없음 — 고르기" and line["이음"] is None
gov = next(c for c in found["후보"] if "관급" in c["이름"])
fp, name = mp.fingerprint(me, hp)
plain = fh.make(me, hp, PIPE_BLOCK)
mp.write(me, fp, name, {kind: gov})
with mp.using(mp.read(me, fp)["고름"]):
got = fh.make(me, hp, PIPE_BLOCK)
assert got["이름"] != plain["이름"] and got["검산"] == plain["검산"] # 금액 그대로(단가 0)
doc = store.read("sangeun", got["이름"])["문서"]
mine = _lines(doc, kind)[0]
assert mine["이음"] is None and mine["자원"]["재료찾기"]["고름"]["키"] == gov["키"]
conv = dim.derive(doc, None, imp._whole())
e = _entry(conv, kind)
var = _var(conv, e["기호"])
assert e["별도계상"] and var["연결"]["갈래"] == "숫자"
assert f"별도계상 · 품목: {gov['이름']} {gov['규격']} · 셈은 프로젝트에서" in var["설명"]
assert mp.item_key(mine["자원"]) == gov["키"]
assert skpath.read(got["이름"])["셈"] == skpath.read(plain["이름"])["셈"]
# 이미 만든(고름 전) SK 에 물음 예 = 품목 설명만 · 단가 0 그대로
money = skpath.read(plain["이름"])["셈"]
assert plain["이름"] in mp.apply_made(fp, {kind: gov}, {kind})["고침"]
now = skpath.read(plain["이름"])
assert _entry(now["문서"], kind)["고름"]["키"] == gov["키"] and now["셈"] == money
assert "품목: 파형강관(관급)" in _var(now["문서"], e["기호"])["설명"]
# 이음 없는 줄 목록(가져온 식 머리 [이음 고르기]) = 종 열쇠(같은 파일 물음)
assert any(
u.get("종") == kind for u in imp.unlinked(store.read("sangeun", plain["이름"])["문서"])
)