diff --git a/A00_Common/line_stack_edit.ts b/A00_Common/line_stack_edit.ts index 77965cad0..92f067e88 100644 --- a/A00_Common/line_stack_edit.ts +++ b/A00_Common/line_stack_edit.ts @@ -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; diff --git a/A00_Common/line_stack_material.ts b/A00_Common/line_stack_material.ts new file mode 100644 index 000000000..a16aeb9c4 --- /dev/null +++ b/A00_Common/line_stack_material.ts @@ -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 { + 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 { + 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"); + } +} diff --git a/M02_MasterTemplete/M02_Doc_Import.py b/M02_MasterTemplete/M02_Doc_Import.py index 511a943a9..921b5a796 100644 --- a/M02_MasterTemplete/M02_Doc_Import.py +++ b/M02_MasterTemplete/M02_Doc_Import.py @@ -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("이름"))} diff --git a/M02_MasterTemplete/M02_Hopyo_Router.py b/M02_MasterTemplete/M02_Hopyo_Router.py index 0a88c6f3f..90abb00a5 100644 --- a/M02_MasterTemplete/M02_Hopyo_Router.py +++ b/M02_MasterTemplete/M02_Hopyo_Router.py @@ -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.빼기)), + } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 71662a21a..9c8d4102b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -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; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Import.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Import.ts index 5d41aa753..c3fe645d7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Import.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Import.ts @@ -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 저장 안 한 재료 고름 }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts index 2ef66425b..0729d7bf1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts @@ -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; + /** 만들었음(재료 판 다시 읽기) */ + onMade?: () => void; } interface Made { @@ -54,14 +59,14 @@ interface Made { const fromHopyo = (o: ImportedOptions): Promise => 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); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts index 2290bcb87..efa8e188e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts @@ -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; /** 마지막 단가 바꿈 전후 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_MatPick.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_MatPick.ts new file mode 100644 index 000000000..b81fed18a --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_MatPick.ts @@ -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; + +const LINKED = "이어짐"; +const TONE: Record = { [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(); + + 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 { + try { + board = await call(url); + draw(); + } catch (e) { + body.replaceChildren(createText(why(e), "error")); + } + } + + async function persist(): Promise { + 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; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index 8e0f9632e..4c43295b4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -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, }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetSk.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetSk.ts index 39195b8e3..3d16affb4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetSk.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetSk.ts @@ -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, }; } diff --git a/M02_MasterTemplete/M02_San_Link.py b/M02_MasterTemplete/M02_San_Link.py index 1edc8d0ed..31d7fc0cb 100644 --- a/M02_MasterTemplete/M02_San_Link.py +++ b/M02_MasterTemplete/M02_San_Link.py @@ -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)} diff --git a/M02_MasterTemplete/M02_San_MatPick.py b/M02_MasterTemplete/M02_San_MatPick.py new file mode 100644 index 000000000..d53e141e1 --- /dev/null +++ b/M02_MasterTemplete/M02_San_MatPick.py @@ -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 diff --git a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py index 30613e4a9..8f717ab09 100644 --- a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py +++ b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py @@ -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 diff --git a/M02_MasterTemplete/M02_Sangeun_Imported.py b/M02_MasterTemplete/M02_Sangeun_Imported.py index ddfa375dd..56fce215c 100644 --- a/M02_MasterTemplete/M02_Sangeun_Imported.py +++ b/M02_MasterTemplete/M02_Sangeun_Imported.py @@ -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("내기계") ] diff --git a/M02_MasterTemplete/M02_Sangeun_Router.py b/M02_MasterTemplete/M02_Sangeun_Router.py index c4f8a862d..d5bcbf51c 100644 --- a/M02_MasterTemplete/M02_Sangeun_Router.py +++ b/M02_MasterTemplete/M02_Sangeun_Router.py @@ -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): diff --git a/resources/tester/test_827_2_material_clues.py b/resources/tester/test_827_2_material_clues.py index c0d25f03b..260b9b1e2 100644 --- a/resources/tester/test_827_2_material_clues.py +++ b/resources/tester/test_827_2_material_clues.py @@ -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 diff --git a/resources/tester/test_827_3_material_pick.py b/resources/tester/test_827_3_material_pick.py new file mode 100644 index 000000000..9a1d7a8a2 --- /dev/null +++ b/resources/tester/test_827_3_material_pick.py @@ -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["이름"])["문서"]) + )