From e171dda4e3c0074446639ecc896479e11680fa7f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 15:23:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(797-2):=20=EC=97=91=EC=85=80=20=EB=B6=88?= =?UTF-8?q?=EB=9F=AC=EC=98=A4=EA=B8=B0=20=EC=86=8C=EC=9A=94=EB=9F=89=20?= =?UTF-8?q?=C2=B7=20=EA=B3=84=EC=88=98=20=EC=9D=B4=EC=9D=8C=20=E2=80=94=20?= =?UTF-8?q?=EA=B0=92=20=EC=97=AD=EC=83=89=EC=9D=B8=20=ED=9B=84=EB=B3=B4=20?= =?UTF-8?q?+=20=EC=82=AC=EB=9E=8C=20=ED=99=95=EC=9D=B8=20=C2=B7=20?= =?UTF-8?q?=EA=B3=A0=EB=A6=84=20=3D=20=ED=91=9C=20=EC=B9=B8=20=EC=B0=B8?= =?UTF-8?q?=EC=A1=B0=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 `M02_Sangeun_QtyLinks` — 가져온 식 줄(호표 수량 · 덧줄 식)의 수를 M01 소요량(QC/QF) · 계수(CC/CF) 칸과 값 역색인(`San_Basis_Index.lookup`)으로 맞춤 · 같은 값 + 같은 단위 성격 + 공종 문맥(인력 = 직종 · 재료 = 칸 이름 추가) · 일반 수 · 비교 글자 · 중간 변수는 자리 아님 · 유일해도 자동 확정 없음 - 고름 = `가져온.연결` 기록(층 M01 · 표 · 줄 · 칸 · 값 · 꼴) · 엑셀 수 = 칸 값일 때만(다르면 400 까닭) · 버림 · 되돌림 · 기록만(셈 · 금액 · 단가 갱신 안 건드림) · 저장 거름(`clean`) = 자리 · 수 맞는 기록만 - 길 `…/sangeun/imported/{qty|qty-pick|qty-drop}` · 가져온 식 만들 때 표지 「참고 · 소요량 후보 N · 사람 확인」 · 「표 칸 없음」(못 옮김 표지 아님) - 화면 — 가져온 식 모달 머리 판 한 줄(집계 · [이음 보기]) + 모달(줄마다 후보 · [고르기] · [풀기] · [버리기] · [되돌리기] · 부품 상자만) - 시험 test_797_2_qty_coef_links 9 · 낡은 시험 없음 Co-Authored-By: Claude Sonnet 5.5 --- .../M02_MasterTemplete_Hopyo_Imported.ts | 39 +- .../M02_MasterTemplete_Hopyo_ImportedPanel.ts | 13 + .../M02_MasterTemplete_Hopyo_QtyLinks.ts | 204 ++++++++++ M02_MasterTemplete/M02_Sangeun_FromHopyo.py | 6 + M02_MasterTemplete/M02_Sangeun_Imported.py | 7 +- M02_MasterTemplete/M02_Sangeun_QtyLinks.py | 361 ++++++++++++++++++ M02_MasterTemplete/M02_Sangeun_Router.py | 10 +- resources/tester/test_797_2_qty_coef_links.py | 294 ++++++++++++++ 8 files changed, 930 insertions(+), 4 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_QtyLinks.ts create mode 100644 M02_MasterTemplete/M02_Sangeun_QtyLinks.py create mode 100644 resources/tester/test_797_2_qty_coef_links.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts index 3ed485e82..359c3ae8e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts @@ -7,6 +7,7 @@ * · [우리 단가로] · [엑셀 단가로] · [이음 고르기] · [우리 기초로 바꾸기] = 서버 `…/sangeun/imported/{price|link|to-base}` * (화면 캐시 문서 고침 · 저장 없음) → 시트 다시 엶 · 기초로 바꿀 때 옵션 = 블록 풀이 그 기초 후보 바인딩(엑셀 값 · 기본) → * 블록 검산으로 입력 필요 · 단위 성격이 다르면 「환산 계수」도 입력 필요 + * · [이음 보기](797-2) = 소요량 · 계수 후보 모달(`Hopyo_QtyLinks`) — 고르기 · 버리기 = 서버 `…/imported/{qty|qty-pick|qty-drop}`(기록만 · 금액 그대로) * · [저장] = 그 SK 저장(서버가 다시 셈) · [이 줄에 잇기] = 저장 뒤 단산 줄 고른 것 = 그 SK(갈래 단가산출 · 키) — * 확인 · [사전조합 생성]은 기존 길(자동 확인 · 옮김 없음) · 입력 필요가 비면 그 줄 확인 막힘 * ========================================================================== */ @@ -22,6 +23,7 @@ import { openFindPicker } from "./M02_MasterTemplete_Find_Pick"; import { at, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; import { checkJudge, readJudgeBlock } from "./M02_MasterTemplete_Hopyo_JudgeApi"; import { importedPanel, tx, type PanelState } from "./M02_MasterTemplete_Hopyo_ImportedPanel"; +import { openQtyLinks, type QtyRow, type QtyTally } from "./M02_MasterTemplete_Hopyo_QtyLinks"; import { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail"; import { marksOf, @@ -92,6 +94,9 @@ interface EditAnswer { 전?: Money4 | null; 후?: Money4 | null; 이음없음?: { 자리: string; 이름: string }[]; + /** qty 답(797-2) */ + 줄?: QtyRow[]; + 집계?: QtyTally; } export function openImported(o: ImportedOptions): void { @@ -110,7 +115,7 @@ export function openImported(o: ImportedOptions): void { let dirty = false; let close = (): void => {}; let handle: ReturnType | null = null; - const st: PanelState = { doc: {}, unlinked: [], price: null, need: [], busy: false, readOnly }; + const st: PanelState = { doc: {}, unlinked: [], qty: null, price: null, need: [], busy: false, readOnly }; // prettier-ignore const edit = (how: string, body: Record): Promise => call(at(o.target, `/sangeun/imported/${how}`), { method: "POST", body: JSON.stringify(body) }); @@ -136,7 +141,7 @@ export function openImported(o: ImportedOptions): void { const now = (): ImportedDoc => (handle?.getDoc() as ImportedDoc | undefined) ?? st.doc; function draw(): void { - top.replaceChildren(importedPanel(st, { price, link, toBase, pickBase, fill })); + top.replaceChildren(importedPanel(st, { price, link, toBase, pickBase, fill, qty: openQty })); saveBtn.disabled = readOnly || st.busy; linkBtn.disabled = readOnly || st.busy; } @@ -172,6 +177,34 @@ export function openImported(o: ImportedOptions): void { draw(); } + /** 소요량 · 계수 이음 집계(797-2) — 가져온 식일 때만 */ + async function qtyTally(): Promise { + if (!st.doc.가져온?.로직) return void (st.qty = null); + const got = await edit("qty", { 문서: st.doc }).catch(() => null); + st.qty = got?.집계 ?? null; + draw(); + } + + function openQty(): void { + openQtyLinks( + { + view: async () => { + const got = await edit("qty", { 문서: now() }); + return { 줄: got.줄 ?? [], 집계: got.집계! }; + }, + pick: async (spot, c) => { + await run("qty-pick", { 자리: spot, 칸: { 표: c.표, 줄: c.줄, 칸: c.칸 } }); + await qtyTally(); + }, + drop: async (spot, undo) => { + await run("qty-drop", { 자리: spot, 되돌리기: undo }); + await qtyTally(); + }, + }, + readOnly, + ); + } + async function price(basis: "엑셀" | "우리"): Promise { const got = await run("price", { 기준: basis }); if (got) st.price = { 전: got.전 ?? null, 후: got.후 ?? null }; @@ -210,6 +243,7 @@ export function openImported(o: ImportedOptions): void { tier = cand?.단 ?? null; st.need = [...new Set([...bound.필요, ...(cand?.입력필요 ?? []), ...(got.필요 ?? [])])]; st.unlinked = []; + st.qty = null; draw(); } @@ -276,6 +310,7 @@ export function openImported(o: ImportedOptions): void { root.replaceChildren(top, host); mount(got.문서 as ImportedDoc); void unlinked(); + void qtyTally(); } catch (e) { root.replaceChildren(loadingError(why(e), () => void load())); } finally { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts index 251ea4375..163a83e21 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts @@ -4,6 +4,7 @@ * · 줄 1 = 「새」 · 원본(파일 · 시트 · 행) · 검산 표지(같음 / 다름 n · 줄마다 까닭) · 단가 기준 [우리 단가로] / [엑셀 단가로] · * 바꾸기 전후 금액 · [원본 글 보기] * · 줄 2 = 이음 없는 자원 = 표지 + [이음 고르기] + * · 줄 2-2 = 소요량 · 계수 이음(797-2 · 후보 집계 + [이음 보기] → `Hopyo_QtyLinks` 모달) * · 줄 3 = 근거 제안 세 단(확정 · 근접(차이 · 까닭) · 사람 고름 · 793-3a) — 후보마다 [우리 기초로 바꾸기] · [기초 고르기] * · 우리 기초로 바꾼 뒤 = 전후 차이 띠 · 「입력 필요」 붉은 칸(채우기 전엔 그 줄 확인 · 옮김 막힘) * 부품 = 단추 · 배지 · 글 · 입력 · 띠 · 표 · 모달(날 단추 · 입력 없음) · 서버 길은 부르는 쪽. @@ -21,6 +22,7 @@ import { createText } from "@ui/ui_template_text"; import { createToolbar } from "@ui/ui_template_toolbar"; import { formatMoney } from "../common_util/common_util_money"; import { newBadge } from "./M02_MasterTemplete_Hopyo_CheckRows"; +import { tallyBadges, type QtyTally } from "./M02_MasterTemplete_Hopyo_QtyLinks"; import { gapText, isImported, @@ -66,6 +68,8 @@ const TEXT = { Need: ["입력 필요", "Input needed"], Swap: ["환산 계수(기초 1 단위 = 이 단가산출 몇 단위)", "Unit factor (1 basis unit = n of this)"], NeedHint: ["채워야 이 줄을 확인 · 옮김", "Fill to confirm and move this line"], + QtyLinks: ["소요량 · 계수 이음 {n}", "Quantity / coefficient links {n}"], + QtyOpen: ["이음 보기", "Show links"], Close: ["닫기", "Close"], } as const satisfies Record; export const tx = (k: keyof typeof TEXT): string => @@ -75,6 +79,8 @@ export interface PanelState { doc: ImportedDoc; /** 이음 없는 자원(서버 price 답) */ unlinked: { 자리: string; 이름: string }[]; + /** 소요량 · 계수 이음 집계(서버 qty 답 · 797-2) */ + qty: QtyTally | null; /** 마지막 단가 바꿈 전후 */ price: { 전: Money4 | null; 후: Money4 | null } | null; /** 우리 기초로 바꾼 뒤 입력 필요 칸 */ @@ -89,6 +95,7 @@ export interface PanelActs { toBase: (code: string, cand: BasisCand | null) => void; pickBase: () => void; fill: (name: string, value: string) => void; + qty: () => void; } const won = (m: Money4 | null | undefined): string => (m ? formatMoney(m.계) : "—"); @@ -224,6 +231,12 @@ export function importedPanel(s: PanelState, a: PanelActs): HTMLElement { const tag = createBadge(tx("Unlinked").replace("{n}", String(s.unlinked.length)), "warning"); kids.push(createToolbar({ items: [tag, ...items] })); } + if (here && s.qty?.자리) { + const open = createButton({ label: tx("QtyOpen"), size: 24, variant: "ghost", onClick: a.qty }); + open.dataset.importedQty = String(s.qty.자리); + const tag = createBadge(tx("QtyLinks").replace("{n}", String(s.qty.자리)), "info"); + kids.push(createToolbar({ items: [tag, ...tallyBadges(s.qty), open] })); + } const gone = meta.바꿈; if (!here && gone) { const g = tx("Gap").replace("{a}", won(gone.전)).replace("{b}", won(gone.후)); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_QtyLinks.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_QtyLinks.ts new file mode 100644 index 000000000..195dffabc --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_QtyLinks.ts @@ -0,0 +1,204 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_QtyLinks.ts + * 가져온 식 소요량 · 계수 이음 화면(PLAN 797-2 · 요구 655-3) — `Hopyo_ImportedPanel` 한 줄의 [이음 보기] 가 엶. + * · 줄마다 「소요량 후보 N · 사람 확인」(0 = 「표 칸 없음」) + 후보 칸(표 · 칸 · 값 · 단위 · 맥락) [고르기] · [버리기] + * · 고른 것 = 그 줄에 M01 표 칸 참조 기록(층 · 표 · 줄 · 칸) — 기록만(금액 그대로) · 값이 다르면 서버가 거절(까닭 토스트) + * · M01 칸 값이 그새 바뀌었으면 「M01 값 바뀜」 표시 · 유일해도 자동 고름 없음 + * 부품 = 모달 · 표 · 배지 · 단추 · 글 · 띠(날 단추 · 입력 없음) · 서버 길은 부르는 쪽(`Hopyo_Imported`). + * ========================================================================== */ + +import { createBadge } from "@ui/ui_template_badge"; +import { createButton } from "@ui/ui_template_button"; +import { el } from "@ui/ui_template_elements"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { openModal } from "@ui/ui_template_modal"; +import { createTable } from "@ui/ui_template_table"; +import { createText } from "@ui/ui_template_text"; +import { createToolbar } from "@ui/ui_template_toolbar"; + +const TEXT = { + Title: ["소요량 · 계수 이음", "Quantity / coefficient links"], + Head: [ + "엑셀 수와 같은 값의 M01 표 칸만 후보 — 고르면 기록만 남고 금액은 그대로", + "Only M01 cells with the same value are candidates — picking only records the link", + ], + Line: ["줄", "Line"], + Num: ["수", "Value"], + State: ["상태", "State"], + Cells: ["후보 칸", "Candidate cells"], + Cand: ["소요량 후보 {n} · 사람 확인", "{n} candidates · confirm"], + Empty: ["표 칸 없음", "No table cell"], + Unique: ["후보 유일", "One candidate"], + Many: ["후보 여럿", "Several candidates"], + Picked: ["고름", "Picked"], + Dropped: ["버림", "Dropped"], + Pick: ["고르기", "Pick"], + Drop: ["버리기", "Drop"], + Undo: ["되돌리기", "Undo"], + Free: ["풀기", "Release"], + Changed: ["M01 값 바뀜 {v}", "M01 value changed {v}"], + More: ["외 {n}", "+{n} more"], + Close: ["닫기", "Close"], + None: ["가져온 식에 소요량 · 계수 수가 없음", "No quantity / coefficient values"], + Ctx: ["공종", "Work"], +} as const satisfies Record; +const tx = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; + +export interface QtyCell { + 표: string; + 줄: number; + 칸: string; + 값: number; + 단위: string; + 꼴: "eq" | "pct" | "kgt"; + 맥락: string; + 이름?: string; + 번호?: string; + 갈래: "소요량" | "계수"; +} + +export interface QtyPicked { + 표: string; + 줄: number; + 칸: string; + 값: number; + 지금?: number | null; + 같음?: boolean; +} + +export interface QtyRow { + 자리: string; + 줄: string; + 단위: string; + 수: number; + 후보수: number; + 후보: QtyCell[]; + 고른: QtyPicked | null; + 상태: "후보" | "없음" | "고름" | "버림"; +} + +export interface QtyTally { + 자리: number; + 줄: number; + 유일: number; + 여럿: number; + 없음: number; + 고름: number; + 버림: number; +} + +export interface QtyView { + 줄: QtyRow[]; + 집계: QtyTally; +} + +export interface QtyApi { + view: () => Promise; + pick: (spot: string, cell: QtyCell) => Promise; + drop: (spot: string, undo: boolean) => Promise; +} + +const muted = (s: string): HTMLElement => createText(s, "muted", 14); + +/** 집계 배지 띠(패널 줄 · 모달 머리 공용) */ +export function tallyBadges(t: QtyTally): HTMLElement[] { + return [ + createBadge(`${tx("Unique")} ${t.유일}`, t.유일 ? "warning" : "neutral"), + createBadge(`${tx("Many")} ${t.여럿}`, t.여럿 ? "warning" : "neutral"), + createBadge(`${tx("Empty")} ${t.없음}`, "neutral"), + createBadge(`${tx("Picked")} ${t.고름}`, t.고름 ? "success" : "neutral"), + ]; +} + +function cellRow(c: QtyCell, spot: string, locked: boolean, go: () => void): HTMLElement { + const pick = createButton({ label: tx("Pick"), size: 24, variant: "ghost", disabled: locked, onClick: go }); // prettier-ignore + pick.dataset.qtyPick = `${spot}|${c.표}|${c.줄}|${c.칸}`; + return el("div", { + className: "ui-toolbar m02-judge__wrap", + attrs: { "data-qty-cand": c.표 }, + children: [ + createBadge(`${c.표} ${c.이름 ?? ""}`.trim(), c.갈래 === "소요량" ? "accent" : "info"), + muted([c.번호, `${c.칸} ${c.값}${c.단위 ? ` ${c.단위}` : ""}`].filter(Boolean).join(" · ")), + createBadge(c.맥락 === "공종" ? tx("Ctx") : c.맥락, "neutral"), + pick, + ], + }); +} + +/** 모달 — 자리마다 후보 · 고름 · 버림 */ +export function openQtyLinks(api: QtyApi, readOnly: boolean): void { + const root = el("div", { attrs: { "data-qty-links": "" } }); + let busy = false; + + const act = (job: () => Promise): void => { + if (busy) return; + busy = true; + void job() + .catch(() => {}) + .finally(() => { + busy = false; + void draw(); + }); + }; + + function state(r: QtyRow): HTMLElement { + if (r.상태 === "고름" && r.고른) { + const bad = r.고른.같음 === false; + return el("div", { + className: "ui-toolbar m02-judge__wrap", + children: [ + createBadge(`${tx("Picked")} ${r.고른.표} ${r.고른.칸} ${r.고른.값}`, "success"), + ...(bad ? [createBadge(tx("Changed").replace("{v}", String(r.고른.지금 ?? "—")), "warning")] : []), // prettier-ignore + ], + }); + } + if (r.상태 === "버림") return createBadge(tx("Dropped"), "neutral"); + if (r.상태 === "없음") return createBadge(tx("Empty"), "neutral"); + return createBadge(tx("Cand").replace("{n}", String(r.후보수)), "warning"); + } + + function cells(r: QtyRow): HTMLElement { + const locked = readOnly || busy; + const list = r.상태 === "후보" || r.상태 === "고름" ? r.후보 : []; + const more = r.후보수 - r.후보.length; + return el("div", { + children: [ + ...list.map((c) => cellRow(c, r.자리, locked, () => act(() => api.pick(r.자리, c)))), + ...(more > 0 && list.length ? [muted(tx("More").replace("{n}", String(more)))] : []), + ], + }); + } + + function side(r: QtyRow): HTMLElement | string { + if (r.상태 === "없음") return ""; + const undo = r.상태 !== "후보"; // 버림 → 되돌리기 · 고름 → 풀기(후보로) · 후보 → 버리기 + const label = r.상태 === "버림" ? tx("Undo") : r.상태 === "고름" ? tx("Free") : tx("Drop"); + const b = createButton({ label, size: 24, variant: "ghost", disabled: readOnly || busy, onClick: () => act(() => api.drop(r.자리, undo)) }); // prettier-ignore + b.dataset.qtyDrop = r.자리; + return b; + } + + async function draw(): Promise { + const got = await api.view().catch(() => null); + if (!got) return; + const rank = { 고름: 0, 후보: 1, 버림: 2, 없음: 3 }; + const sorted = [...got.줄].sort((a, b) => rank[a.상태] - rank[b.상태]); // 사람이 볼 것 먼저(안정 정렬) + const rows = sorted.map((r) => [`${r.줄}`, `${r.수}${r.단위 ? ` ${r.단위}` : ""}`, state(r), cells(r), side(r)]); // prettier-ignore + const heads = [tx("Line"), tx("Num"), tx("State"), tx("Cells"), ""]; + const table = createTable(heads.map((h, i) => ({ label: h, ...(i === 1 ? { align: "end" as const } : {}), ...(i === 4 ? { width: "84px" } : {}) })), rows, { density: "data" }); // prettier-ignore + table.dataset.qtyTable = String(got.줄.length); + root.replaceChildren( + createToolbar({ items: [muted(tx("Head")), ...tallyBadges(got.집계)] }), + got.줄.length ? table : createText(tx("None"), "muted", 14), + ); + } + + openModal({ + title: tx("Title"), + closeLabel: tx("Close"), + wide: true, + dialogClass: "m02-modal m02-hopyo__qty", + mount: (body) => body.append(root), + }); + void draw(); +} diff --git a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py index 92377b283..e97ec5d64 100644 --- a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py +++ b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py @@ -172,6 +172,12 @@ def make(source: Path, key: str, no: int, name: str = "", _trail: tuple = ()) -> out = emit(source, key, no) _link_inner(source, key, no, out, _trail) + try: # 소요량 · 계수 후보 표지(797-2 · 사람 확인) — 못 셈 = 표지 없이 만듦 + from M02_MasterTemplete import M02_Sangeun_QtyLinks as qty + + out["원본"]["표지"] += qty.flags(out["로직"]) + except Exception: # noqa: BLE001 — 역색인 짓기 실패가 가져온 식 만들기를 막지 않음 + pass title = str(name or "").strip() or str(out["로직"].get("이름") or f"단산{no}") try: basis = san_basis.block(source, key, no).get("근거후보") or None diff --git a/M02_MasterTemplete/M02_Sangeun_Imported.py b/M02_MasterTemplete/M02_Sangeun_Imported.py index cf1014422..1fd41a664 100644 --- a/M02_MasterTemplete/M02_Sangeun_Imported.py +++ b/M02_MasterTemplete/M02_Sangeun_Imported.py @@ -53,7 +53,7 @@ LINE = ("종류", "이름", "규격", "단위", "수량", "비목", "단가", " LINE += ("자리", "출처", "비고", "자원") # 자원 = 경쟁사 코드 · 엑셀 단가 원본(793-2a · 셈 안 씀) LINE += ("참조", "참조비목") # 산근 속 산근 = 같은 엑셀의 형제 가져온 식 SK(797-1 · 로직 줄만) EXTRA = ("이름", "식", "비목", "번지", "합밖", "자리", "출처", "비고") -META = ("원본", "검산", "근거후보", "바꿈") # 검산 = 저장 때 서버가 다시 적음(`stamp`) +META = ("원본", "검산", "근거후보", "바꿈", "연결") # 검산 = 저장 때 서버가 다시 적음(`stamp`) MANY = 300 # 호표 · 덧줄 · 중간 줄 수 BIG = 400_000 # 메타 글 크기(바이트) _KEYED = ("덮개", "고름", "자재표시", "상수덮개", "표값덮개", "식덮개", "까닭", "M01판") @@ -270,6 +270,11 @@ def clean(raw: Any, out: dict) -> dict: got |= {"로직": clean_row(meta["로직"]), "단가기준": basis} if got.get("원본"): got["원본"] = _named(got["원본"], got["로직"]) + if got.get("연결"): # 소요량 · 계수 이음 기록(797-2) — 자리 · 수 맞는 것만 + from M02_MasterTemplete import M02_Sangeun_QtyLinks as qty + + got["연결"] = qty.tidy(got["연결"], got["로직"]) + got = {k: v for k, v in got.items() if v or k != "연결"} return out | ({KEY: got} if got else {}) diff --git a/M02_MasterTemplete/M02_Sangeun_QtyLinks.py b/M02_MasterTemplete/M02_Sangeun_QtyLinks.py new file mode 100644 index 000000000..d5b3452a6 --- /dev/null +++ b/M02_MasterTemplete/M02_Sangeun_QtyLinks.py @@ -0,0 +1,361 @@ +"""M02 가져온 식 소요량 · 계수 이음(PLAN 797-2 · 요구 655-3 · 요구서_655) — 서버 단독. + +가져온 식 줄(엑셀 그대로)에 쓰인 수가 M01 소요량(QC/QF) · 계수(CC/CF) 표 칸의 값인지 **값 역색인** +(`M02_San_Basis_Index.lookup` 한 벌)으로 후보를 냄 — 같은 값 + 같은 단위 성격 + 이름 · 공종 문맥이 +맞는 칸만 후보 · 유일해도 자동 확정 없음(사람이 고름) · 값을 지어 넣지 않음. + +- 자리(slot) = `호표:i:n` | `덧줄:i:n` — 로직 줄 i 의 식(수량 | 식)의 n 번째 수(중간 변수는 단위 · + 이름 문맥이 없어 자리 아님). 일반 수(1 · 10 · 60 · 100 · 1000 · 8) · 비교 글자 · 찾기 · 로직 부름 + 인자는 안 셈. +- 고름 = 엑셀 수와 표 칸 값이 같을 때만(같은 값 · 율 ↔ 소수 · t ↔ kg) — 다르면 못 고름 + 까닭. + 기록(`가져온.연결` = {고름 [..], 버림 [..]})만 — 셈 · 금액 · 엔진 안 건드림 · [단가 갱신]은 자원 + 단가만(요구 656-1) · [재생성]이 이 기록을 따라가는 길은 아직 없음(기록만). +- 고름 = {자리, 수, 층 「M01」, 표, 줄, 칸, 값, 꼴 eq|pct|kgt} · 로직이 바뀌어 자리의 수가 달라지면 + 그 기록은 버려진 것으로 보고 화면에 안 내놓음. + +길(`M02_Sangeun_Router` `imported/{qty|qty-pick|qty-drop}`) · 표지(`flags` · 가져온 식 만들 때). +""" + +from __future__ import annotations + +import re +from decimal import Decimal +from typing import Any + +from M02_MasterTemplete import M02_San_Basis_Index as bi +from M02_MasterTemplete import M02_San_Emit as em +from M02_MasterTemplete import M02_Sangeun_Doc as sd +from M02_MasterTemplete import M02_Sangeun_Imported as imp + +KEY = "연결" +LAYER = "M01" +GENERAL = (0, 1, 8, 10, 60, 100, 1000) # 일반 수(793-3a N9) +SHOWN = 12 # 자리마다 화면에 내는 후보 상한(후보 수는 전부 셈) +FORMS = ("eq", "pct", "kgt") +FIELDS = (("호표", "수량"), ("덧줄", "식")) # 중간 변수 = 단위 · 이름 문맥 없음 → 자리 아님 +_ctx: dict[int, dict[str, set[str]]] = {} + + +def _flat(s: Any) -> str: + return re.sub(r"[^가-힣A-Za-z0-9]", "", str(s or "")) + + +def _hangul(s: Any) -> str: + return re.sub(r"[^가-힣]", "", str(s or "")) + + +def _literals(node: Any, out: list[Decimal]) -> None: + """식 나무 → 수 글자(차례대로) — 비교 글자 · 찾기 · 로직 부름 인자는 뺌.""" + if not isinstance(node, tuple): + return + kind = node[0] + if kind == "num": + out.append(node[1]) + elif kind == "bin": + cmp_ = node[1] in bi.mf._OPS + for side in node[2:]: + if not (cmp_ and isinstance(side, tuple) and side[0] == "num"): + _literals(side, out) + elif kind in ("neg", "field"): + _literals(node[1], out) + elif kind == "call": + for arg in node[2]: + _literals(arg, out) + + +def literals(text: Any) -> list[Decimal]: + out: list[Decimal] = [] + try: + _literals(bi.mf.parse(str(text)), out) + except bi.mf.FormulaError: + pass + return out + + +def slots(row: dict) -> list[dict]: + """가져온 로직 줄 → 소요량 · 계수 자리 `[{자리, 갈래, 줄, 이름, 단위, 수, d, 직종?}]`.""" + out = [] + for kind, field in FIELDS: + for i, one in enumerate(row.get(kind) or []): + if not isinstance(one, dict): + continue + labor = kind == "호표" and one.get("종류") == "인력" + for n, x in enumerate(literals(one.get(field))): + if float(x) in GENERAL: + continue + out.append( + { + "자리": f"{kind}:{i}:{n}", + "갈래": kind, + "종류": str(one.get("종류") or ""), + "줄": str(one.get("이름") or ""), + "단위": "인" if labor else str(one.get("단위") or ""), + "수": float(x), + "d": max(0, -x.as_tuple().exponent), + "직종": str(one.get("이름") or "") if labor else None, + } + ) + return out + + +def _context(idx: bi.Index) -> dict[str, set[str]]: + """표 → 이름 · 공종 · 구분 글(한글만) — 역색인 한 판마다 한 번.""" + got = _ctx.get(id(idx)) + if got is None: + _ctx.clear() + got = _ctx[id(idx)] = { + k: _tokens( + " ".join( + str(x or "") + for x in (t.get("이름"), (t.get("용도") or {}).get("공종"), t.get("상세구분")) + ) + ) + for k, t in idx.tables.items() + } + return got + + +STOP = re.compile( + "및|설치|해체|운반|조립|가공|제작|시공|공사|현장|작업|기계|인력|보통|일반|사용|적용|기준|표준|구간|공통" +) + + +def _tokens(s: str) -> set[str]: + return {t for t in re.split(r"[^가-힣]+", STOP.sub(" ", _hangul_keep(s))) if len(t) >= 2} + + +def _hangul_keep(s: Any) -> str: + """한글 · 공백만 남김(낱말 가름 유지).""" + return re.sub(r"[^가-힣]+", " ", str(s or "")) + + +def _on_topic(title: set[str], table: set[str]) -> bool: + """공종 맞음 — 블록 이름 낱말과 표 이름 · 공종 낱말이 같거나 한쪽이 다른 쪽에 들어 있음.""" + return any( + a == b or min(len(a), len(b)) >= 3 and (a in b or b in a) for a in title for b in table + ) + + +def _near(a: str, b: str) -> bool: + x, y = _flat(a), _flat(b) + return len(x) >= 2 and len(y) >= 2 and (x in y or y in x) + + +def _same(how: str, cell: float, v: float) -> bool: + """엑셀 수와 칸 값이 같은 꼴(eq 같음 · pct 율 ↔ 소수 · kgt t ↔ kg).""" + scale = {"eq": (1,), "pct": (100, 0.01), "kgt": (1000, 0.001)}[how] + return any(abs(cell - v * m) <= 1e-9 * max(1.0, abs(cell)) for m in scale) + + +def _named(slot: dict, cell: dict, table: dict) -> bool: + """줄 이름이 칸 이름(칸 · 같은 줄 글 칸)과 맞는가 — 인력 = 직종 맞음.""" + name = slot["직종"] or slot["줄"] + if _near(name, str(cell["칸"]).split("/")[0]): + return True + row = (table.get("줄") or [{}])[cell["줄"]] if cell["줄"] < len(table.get("줄") or []) else {} + return any(isinstance(v, str) and _near(name, v) for v in row.values()) + + +def find(idx: bi.Index, slot: dict, title: str) -> list[dict]: + """자리 하나 → 후보 칸 `[{표, 줄, 칸, 값, 단위, 꼴, 맥락, 이름, 번호, 갈래}]`(이름 맥락 먼저)""" + k = {"v": slot["수"], "d": slot["d"], "단위": slot["단위"], "직종": slot["직종"]} + ctx, want = _context(idx), _tokens(title.split("(")[0]) # 이름 앞부분(규격 괄호 앞) + out, seen = [], set() + for cid, how in idx.lookup(k): + c = idx.cells[cid] + t = idx.tables.get(c["표"]) + if t is None or c["종류"] in ("eq", "cond") or how not in FORMS: + continue + if not _same(how, c["v"], slot["수"]) or (c["표"], c["줄"], c["칸"]) in seen: + continue + if not _on_topic(want, ctx.get(c["표"], set())): + continue # 공종 문맥이 맞아야 후보 + named = slot["직종"] is not None or _named(slot, c, t) # 인력 = lookup 이 직종 맞춤 + if not named and slot["종류"] == "재료": + continue # 재료는 칸 이름도 맞아야 + why = "이름 · 공종" if named else "공종" + seen.add((c["표"], c["줄"], c["칸"])) + out.append( + { + "표": c["표"], + "줄": c["줄"], + "칸": c["칸"], + "값": c["v"], + "단위": c["단위"], + "꼴": how, + "맥락": why, + "이름": t.get("이름"), + "번호": t.get("원문번호"), + "갈래": "소요량" if c["표"].startswith("Q") else "계수", + } + ) + return sorted(out, key=lambda x: (x["맥락"] == "공종", x["표"], x["줄"])) + + +def scan(row: dict, idx: bi.Index | None = None) -> list[dict]: + """로직 줄 → 자리마다 `후보`(전부) · 수 — 순수(저장 안 함).""" + idx = idx or bi.get() + title = str(row.get("이름") or "") + return [{**s, "후보": find(idx, s, title)} for s in slots(row)] + + +def flags(row: dict, idx: bi.Index | None = None) -> list[str]: + """만들 때 표지(「참고 · …」) — 자리마다 한 줄 · 사람 확인 · 후보 0 = 표 칸 없음.""" + out = [] + for s in scan(row, idx): + n = len(s["후보"]) + what = f"소요량 후보 {n} · 사람 확인" if n else "표 칸 없음" + out.append(f"{em.NOTE}{what} — {s['줄']} {s['수']:g}") + return out + + +def tally(scanned: list[dict]) -> dict[str, int]: + """자리 수 · 줄 수 · 후보 유일 | 여럿 | 없음 자리 수.""" + return { + "자리": len(scanned), + "줄": len({s["자리"].rsplit(":", 1)[0] for s in scanned}), + "유일": sum(len(s["후보"]) == 1 for s in scanned), + "여럿": sum(len(s["후보"]) > 1 for s in scanned), + "없음": sum(not s["후보"] for s in scanned), + } + + +# ── 고름 · 버림(문서 기록) ───────────────────────────────────────────── +def _need(doc: Any) -> dict: + if not imp.is_imported(doc): + raise sd.DocError("가져온 식 단가산출만") + return doc + + +def _state(doc: dict) -> dict[str, list]: + meta = doc[imp.KEY].get(KEY) or {} + return {"고름": list(meta.get("고름") or []), "버림": list(meta.get("버림") or [])} + + +def _with(doc: dict, state: dict) -> dict: + meta = {k: v for k, v in state.items() if v} + rest = {k: v for k, v in doc[imp.KEY].items() if k != KEY} + return {**doc, imp.KEY: rest | ({KEY: meta} if meta else {})} + + +def view(doc: Any, idx: bi.Index | None = None) -> dict[str, Any]: + """화면 답 — 자리마다 {자리, 줄, 수, 단위, 후보수, 후보, 상태 후보|없음|고름|버림, 고른}.""" + doc = _need(doc) + state, rows = _state(doc), [] + scanned = scan(doc[imp.KEY]["로직"], idx) + picked = {x["자리"]: x for x in state["고름"] if x.get("수") is not None} + dropped = set(state["버림"]) + for s in scanned: + mine = picked.get(s["자리"]) + if mine and abs(mine["수"] - s["수"]) > 1e-12: + mine = None # 로직이 바뀜 — 옛 기록 안 씀 + if mine: # M01 칸 값이 그새 바뀌었으면 알림(기록은 그대로) + now = next( + ( + c["v"] + for c in (idx or bi.get()).cells_of_table.get(mine["표"], []) + if c["줄"] == mine["줄"] and c["칸"] == mine["칸"] + ), + None, + ) + mine = { + **mine, + "지금": now, + "같음": now is not None and _same(mine["꼴"], now, s["수"]), + } + rows.append( + { + **{k: s[k] for k in ("자리", "갈래", "줄", "단위", "수")}, + "후보수": len(s["후보"]), + "후보": s["후보"][:SHOWN], + "고른": mine, + "상태": ( + "고름" + if mine + else "버림" + if s["자리"] in dropped + else "후보" + if s["후보"] + else "없음" + ), + } + ) + count = tally(scanned) | { + "고름": sum(r["상태"] == "고름" for r in rows), + "버림": sum(r["상태"] == "버림" for r in rows), + } + return {"줄": rows, "집계": count} + + +def pick(doc: Any, spot: str, cell: dict, idx: bi.Index | None = None) -> dict[str, Any]: + """후보 칸 고름 — 엑셀 수 = 칸 값일 때만(다르면 400 까닭) · 기록만 · 금액 그대로.""" + doc = _need(doc) + idx = idx or bi.get() + slot = next((s for s in slots(doc[imp.KEY]["로직"]) if s["자리"] == spot), None) + if slot is None: + raise sd.DocError(f"없는 자리 「{spot}」") + table = idx.tables.get(str((cell or {}).get("표"))) + rows = (table or {}).get("줄") or [] + at, col = (cell or {}).get("줄"), (cell or {}).get("칸") + found = next( + ( + c + for c in idx.cells_of_table.get(str((cell or {}).get("표")), []) + if c["줄"] == at and c["칸"] == col and c["종류"] not in ("eq", "cond") + ), + None, + ) + if table is None or found is None or not isinstance(at, int) or at >= len(rows): + raise sd.DocError("없는 표 칸") + how = next((h for h in FORMS if _same(h, found["v"], slot["수"])), None) + if how is None: + raise sd.DocError( + f"엑셀 수 {slot['수']:g} ≠ 표 칸 값 {found['v']:g} — 같은 값인 칸만 고를 수 있음" + ) + one = { + "자리": spot, + "수": slot["수"], + "층": LAYER, + "표": found["표"], + "줄": at, + "칸": col, + "값": found["v"], + "꼴": how, + } + state = _state(doc) + state["고름"] = [x for x in state["고름"] if x.get("자리") != spot] + [one] + state["버림"] = [x for x in state["버림"] if x != spot] + return {"문서": _with(doc, state)} + + +def drop(doc: Any, spot: str, undo: bool = False) -> dict[str, Any]: + """자리 후보 버림(또는 [되돌리기]) — 고른 것이 있으면 같이 풂 · 기록만.""" + doc = _need(doc) + if not any(s["자리"] == spot for s in slots(doc[imp.KEY]["로직"])): + raise sd.DocError(f"없는 자리 「{spot}」") + state = _state(doc) + state["고름"] = [x for x in state["고름"] if x.get("자리") != spot] + state["버림"] = [x for x in state["버림"] if x != spot] + ([] if undo else [spot]) + return {"문서": _with(doc, state)} + + +def tidy(raw: Any, row: dict) -> dict[str, list]: + """저장 때 서버가 다시 거름(`M02_Sangeun_Imported.clean`) — 자리가 있고 수가 같은 기록만 · + 칸 값 대조는 고를 때(`pick`)만(M01 이 바뀌었으면 화면이 알림).""" + have = {s["자리"]: s["수"] for s in slots(row)} + raw = raw if isinstance(raw, dict) else {} + keep = ("자리", "수", "층", "표", "줄", "칸", "값", "꼴") + picked = [] + for x in raw.get("고름") or []: + if not (isinstance(x, dict) and x.get("자리") in have and x.get("꼴") in FORMS): + continue + num = x.get("수") + if ( + isinstance(num, (int, float)) + and abs(num - have[x["자리"]]) <= 1e-12 + and x.get("층") == LAYER + ): + picked.append({k: x[k] for k in keep if k in x}) + done = {p["자리"] for p in picked} + gone = [x for x in raw.get("버림") or [] if isinstance(x, str) and x in have and x not in done] + return {k: v for k, v in (("고름", picked), ("버림", gone)) if v} diff --git a/M02_MasterTemplete/M02_Sangeun_Router.py b/M02_MasterTemplete/M02_Sangeun_Router.py index c684372dc..8787d2897 100644 --- a/M02_MasterTemplete/M02_Sangeun_Router.py +++ b/M02_MasterTemplete/M02_Sangeun_Router.py @@ -33,6 +33,7 @@ from M02_MasterTemplete import M02_Sangeun_Equation as eqm from M02_MasterTemplete import M02_Sangeun_Formula as fx from M02_MasterTemplete import M02_Sangeun_FromHopyo as from_hopyo from M02_MasterTemplete import M02_Sangeun_Imported as imp +from M02_MasterTemplete import M02_Sangeun_QtyLinks as qty from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Sangeun"]) @@ -159,6 +160,8 @@ class ImportedEditBody(BaseModel): 로직: str | None = None # to-base 입력값: dict[str, Any] | None = None # to-base 환산: dict[str, Any] | None = None # to-base(793-3b · SK 단위 환산 계수) + 칸: dict[str, Any] | None = None # qty-pick(797-2 · 고른 M01 표 칸 {표, 줄, 칸}) + 되돌리기: bool = False # qty-drop(797-2 · 버림 풀기) def _sd(fn: Any) -> Any: @@ -177,6 +180,9 @@ _EDIT = { "price": lambda b: (imp.to_price, b.문서, b.기준), "link": lambda b: (imp.link, b.문서, b.자리, b.이음), "to-base": lambda b: (imp.to_base, b.문서, b.로직, b.입력값, b.환산), + "qty": lambda b: (qty.view, b.문서), + "qty-pick": lambda b: (qty.pick, b.문서, b.자리, b.칸), + "qty-drop": lambda b: (qty.drop, b.문서, b.자리, b.되돌리기), } @@ -191,7 +197,9 @@ def sangeun_imported_edit( """가져온 식 고치기(793-2) — price [우리 · 엑셀 단가로] · link 이음 고름 · to-base [우리 기초로 바꾸기] · 답 = 고친 문서(화면 캐시 · 저장 없음) + 전 · 후 금액.""" if how not in _EDIT: - raise HTTPException(status_code=404, detail="price · link · to-base") + raise HTTPException( + status_code=404, detail="price · link · to-base · qty · qty-pick · qty-drop" + ) fn, *args = _EDIT[how](body) return _call(_root(session, layer, owner), _sd(fn), *args) diff --git a/resources/tester/test_797_2_qty_coef_links.py b/resources/tester/test_797_2_qty_coef_links.py new file mode 100644 index 000000000..3bd738280 --- /dev/null +++ b/resources/tester/test_797_2_qty_coef_links.py @@ -0,0 +1,294 @@ +"""797-2 지킴 — 엑셀 불러오기 소요량 · 계수 이음(값 역색인 후보 + 사람 확인 · 요구 655-3). + +작은 M01(소요량 · 계수 표 몇 칸 · 임시 폴더)로 규칙을, 실무 엑셀(북부청 임도)로 실물을 본다. +① 후보 유일 · 여럿 · 없음 — 같은 값 + 같은 단위 성격 + 이름 · 공종 문맥 · 단위 다르면 후보 아님 +② 고름 = 칸 참조 기록(층 · 표 · 줄 · 칸) · 금액 그대로 · 값 다르면 못 고름(까닭) · 버림 · 되돌림 +③ 저장 거름(`clean`) — 자리 · 수가 맞는 기록만 · 서버 길 qty · qty-pick · qty-drop +④ 만들 때 표지(「참고 · 소요량 후보 N · 사람 확인」 · 「표 칸 없음」) — 못 옮김 표지는 안 늘림 +실무 엑셀 없으면 그 시험만 skip. M01 원본은 읽기만. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import helper_m02_hopyo_golden as golden +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Hopyo_Store as hs +from M02_MasterTemplete import M02_San_Basis_Index as bi +from M02_MasterTemplete import M02_San_Book as book +from M02_MasterTemplete import M02_San_Emit as em +from M02_MasterTemplete import M02_Sangeun_Doc as sd +from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh +from M02_MasterTemplete import M02_Sangeun_Imported as imp +from M02_MasterTemplete import M02_Sangeun_QtyLinks as qty +from M02_MasterTemplete import M02_Sangeun_Router as sangeun_router +from resources.tester.test_m02_hopyo_excel import client # noqa: F401 + +API = "/api/m02/layers/personal" + + +def _put(root: Path, name: str, body: dict) -> None: + (root / name).write_text(json.dumps(body, ensure_ascii=False), encoding="utf-8") + + +def _table(key: str, name: str, work: str, cols: dict, rows: list[dict]) -> dict: + return { + "키": key, + "원문번호": key[-1], + "이름": name, + "용도": {"공종": work}, + "값칸": cols, + "줄": rows, + } + + +@pytest.fixture(scope="module") +def idx(tmp_path_factory) -> bi.Index: + root = tmp_path_factory.mktemp("m01mini") + work = "견본채집" + _put( + root, + "소요량_견본.json", + { + "표": [ + _table( + "QC900001", + "견본채집(인력)", + work, + {"보통인부": "인", "특별인부": "인"}, + [{"보통인부": 0.17, "특별인부": 0.05}], + ), + _table("QC900002", "견본채집 보충", work, {"보통인부": "인"}, [{"보통인부": 0.17}]), + _table("QC900003", "견본채집 시멘트", work, {"시멘트": "kg"}, [{"시멘트": 40}]), + _table("QC900004", "다른일 품", "다른일", {"보통인부": "인"}, [{"보통인부": 0.09}]), + ] + }, + ) + _put( + root, + "계수_견본.json", + { + "표": [ + _table("CC900001", "견본채집 할증", work, {"값": "%"}, [{"항목": "할증", "값": 3}]) + ] + }, + ) + _put(root, "로직_견본.json", {"줄": []}) + _put(root, "기계.json", {"줄": []}) + return bi.Index(root) + + +def _row(lines: list[dict], extras: list[dict] | None = None) -> dict: + return { + "이름": "견본채집(굴삭기)", + "결과단위": "원/식", + "호표": lines, + "덧줄": extras or [], + } + + +def _line(kind: str, name: str, qty_: str, unit: str = "", price: float = 1000.0) -> dict: + part = {"인력": "노무비", "재료": "재료비"}[kind] + return {"종류": kind, "이름": name, "단위": unit, "수량": qty_, "비목": part, "단가": price} + + +def _doc(row: dict) -> dict: + meta = {"로직": row, "단가기준": "엑셀", "원본": {"엑셀금액": {}}} + return {"키": "SK-000001", "이름": "견본", "단위": "식", "바탕": None, imp.KEY: meta} + + +ROW = _row( + [ + _line("인력", "보통인부", "((0.17 * 10) / 100)"), # 칸 둘(QC900001 · QC900002) → 여럿 + _line("인력", "특별인부", "0.05"), # 칸 하나 → 유일 + _line("인력", "보통인부 2", "0.09"), # 값은 있으나 다른 공종 → 없음 + _line("재료", "시멘트", "40", "kg"), # 단위 kg → 유일 + _line("재료", "시멘트 2", "40", "㎥"), # 단위 성격 다름(부피 ≠ 무게) → 없음 + ], + [{"이름": "줄_2", "식": "계 * 0.03", "비목": "노무비"}], # 3 % ↔ 0.03 → 유일(계수) +) + + +def _by(scanned: list[dict]) -> dict[str, list]: + return {s["자리"]: s["후보"] for s in scanned} + + +# ── ① 후보 ───────────────────────────────────────────────────────────── +def test_candidates_unique_many_none_and_unit(idx) -> None: + got = _by(qty.scan(ROW, idx)) + assert sorted(x["표"] for x in got["호표:0:0"]) == ["QC900001", "QC900002"] # 여럿 + assert [x["표"] for x in got["호표:1:0"]] == ["QC900001"] # 유일 + assert got["호표:2:0"] == [] # 다른 공종 칸은 후보 아님 + assert [x["표"] for x in got["호표:3:0"]] == ["QC900003"] + assert got["호표:4:0"] == [] # 단위 다르면 후보 아님 + ratio = got["덧줄:0:1"] if "덧줄:0:1" in got else got["덧줄:0:0"] + assert [(x["표"], x["꼴"], x["갈래"]) for x in ratio] == [("CC900001", "pct", "계수")] + assert {s["자리"] for s in qty.slots(ROW)} >= {"호표:0:0", "호표:1:0", "덧줄:0:0"} + assert not any( + s["자리"].startswith("중간") + for s in qty.slots({**ROW, "중간": [{"이름": "q", "식": "20"}]}) + ) + + +def test_general_numbers_and_compare_are_not_slots() -> None: + row = _row( + [_line("인력", "보통인부", "(1 / 100) * 10")], [{"이름": "x", "식": "만약(L > 30, 0.5, 1)"}] + ) + assert [s["수"] for s in qty.slots(row)] == [0.5] # 1 · 10 · 100 일반 수 · 비교 30 뺌 + + +def test_flags_say_count_and_empty(idx) -> None: + msgs = qty.flags(ROW, idx) + assert f"{em.NOTE}소요량 후보 2 · 사람 확인 — 보통인부 0.17" in msgs + assert f"{em.NOTE}표 칸 없음 — 보통인부 2 0.09" in msgs + assert all(m.startswith(em.NOTE) for m in msgs) # 못 옮김 표지 아님 + t = qty.tally(qty.scan(ROW, idx)) + assert (t["유일"], t["여럿"], t["없음"]) == (3, 1, 2) and t["자리"] == 6 + + +# ── ② 고름 · 금액 그대로 ─────────────────────────────────────────────── +def _money(doc: dict) -> dict: + whole = m01.loaded()[1] + return {"검산": imp.check(doc, whole)["셈"], "엑셀": imp.to_price(doc, "엑셀")["후"]} + + +def test_pick_records_cell_and_money_unchanged(idx) -> None: + doc = _doc(ROW) + cell = {"표": "QC900001", "줄": 0, "칸": "특별인부"} + got = qty.pick(doc, "호표:1:0", cell, idx)["문서"] + one = got[imp.KEY]["연결"]["고름"][0] + assert (one["층"], one["표"], one["줄"], one["칸"], one["꼴"]) == ( + "M01", + "QC900001", + 0, + "특별인부", + "eq", + ) + assert one["수"] == one["값"] == 0.05 + assert _money(got) == _money(doc) # 기록만 — 금액 그대로 + assert got[imp.KEY]["로직"] == doc[imp.KEY]["로직"] # 엑셀 줄 안 건드림 + view = {x["자리"]: x for x in qty.view(got, idx)["줄"]} + assert view["호표:1:0"]["상태"] == "고름" and view["호표:1:0"]["고른"]["같음"] is True + assert view["호표:0:0"]["상태"] == "후보" and view["호표:2:0"]["상태"] == "없음" + + +def test_pick_pct_form_and_wrong_value_refused(idx) -> None: + doc = _doc(ROW) + cell = {"표": "CC900001", "줄": 0, "칸": "값"} + got = qty.pick(doc, "덧줄:0:0", cell, idx)["문서"] + assert got[imp.KEY]["연결"]["고름"][0]["꼴"] == "pct" + with pytest.raises(sd.DocError) as why: # 0.17 인데 칸은 0.05 + qty.pick(doc, "호표:0:0", {"표": "QC900001", "줄": 0, "칸": "특별인부"}, idx) + assert "0.17" in str(why.value) and "0.05" in str(why.value) + for bad in ( + {"표": "없는표", "줄": 0, "칸": "값"}, + {"표": "QC900001", "줄": 9, "칸": "특별인부"}, + ): + with pytest.raises(sd.DocError): + qty.pick(doc, "호표:1:0", bad, idx) + with pytest.raises(sd.DocError): + qty.pick(doc, "호표:9:0", {"표": "QC900001", "줄": 0, "칸": "특별인부"}, idx) + + +def test_pick_twice_replaces_and_drop_undo(idx) -> None: + doc = _doc(ROW) + a = qty.pick(doc, "호표:0:0", {"표": "QC900001", "줄": 0, "칸": "보통인부"}, idx)["문서"] + b = qty.pick(a, "호표:0:0", {"표": "QC900002", "줄": 0, "칸": "보통인부"}, idx)["문서"] + assert [x["표"] for x in b[imp.KEY]["연결"]["고름"]] == ["QC900002"] # 자리마다 하나 + gone = qty.drop(b, "호표:0:0")["문서"] # 버림 = 고른 것도 풂 + assert "고름" not in gone[imp.KEY]["연결"] + states = {x["자리"]: x["상태"] for x in qty.view(gone, idx)["줄"]} + assert states["호표:0:0"] == "버림" + back = qty.drop(gone, "호표:0:0", True)["문서"] + assert "연결" not in back[imp.KEY] # 기록 없으면 칸 자체가 없음 + assert {x["자리"]: x["상태"] for x in qty.view(back, idx)["줄"]}["호표:0:0"] == "후보" + with pytest.raises(sd.DocError): + qty.drop(doc, "호표:9:0") + with pytest.raises(sd.DocError): + qty.view({"키": "SK-000001"}, idx) # 가져온 식 아님 + + +# ── ③ 저장 거름 · 서버 길 ────────────────────────────────────────────── +def test_clean_keeps_valid_and_drops_stale(idx) -> None: + whole = m01.loaded()[1] + got = qty.pick(_doc(ROW), "호표:1:0", {"표": "QC900001", "줄": 0, "칸": "특별인부"}, idx)[ + "문서" + ] + kept = sd.clean(got, None, whole)[imp.KEY]["연결"] + assert kept["고름"][0]["표"] == "QC900001" + got[imp.KEY]["연결"]["고름"][0]["수"] = 0.5 # 로직 수와 안 맞음 → 거름 + got[imp.KEY]["연결"]["버림"] = ["호표:0:0", "호표:99:0", {"x": 1}] + again = sd.clean(got, None, whole)[imp.KEY]["연결"] + assert "고름" not in again and again["버림"] == ["호표:0:0"] + got[imp.KEY]["연결"] = "엉뚱" + with pytest.raises(sd.DocError): + sd.clean(got, None, whole) + + +def test_server_routes(client, monkeypatch, idx) -> None: # noqa: F811 + monkeypatch.setattr(bi, "get", lambda: idx) + client.app.include_router(sangeun_router.router) + doc = _doc(ROW) + seen = client.post(f"{API}/sangeun/imported/qty", json={"문서": doc}) + assert seen.status_code == 200, seen.text + assert seen.json()["집계"]["유일"] == 3 + cell = {"표": "QC900001", "줄": 0, "칸": "특별인부"} + done = client.post( + f"{API}/sangeun/imported/qty-pick", json={"문서": doc, "자리": "호표:1:0", "칸": cell} + ) + assert done.status_code == 200, done.text + got = done.json()["문서"] + assert got[imp.KEY]["연결"]["고름"][0]["칸"] == "특별인부" + bad = client.post( + f"{API}/sangeun/imported/qty-pick", json={"문서": doc, "자리": "호표:0:0", "칸": cell} + ) + assert bad.status_code == 400 and "0.17" in bad.text + cut = client.post(f"{API}/sangeun/imported/qty-drop", json={"문서": got, "자리": "호표:1:0"}) + assert cut.status_code == 200 and cut.json()["문서"][imp.KEY]["연결"]["버림"] == ["호표:1:0"] + undo = client.post( + f"{API}/sangeun/imported/qty-drop", + json={"문서": cut.json()["문서"], "자리": "호표:1:0", "되돌리기": True}, + ) + assert "연결" not in undo.json()["문서"][imp.KEY] + + +# ── ④ 실물(북부청 임도) ──────────────────────────────────────────────── +def _north(tmp_path): + path = golden.FILES["북부청임도"] + if not path.exists(): + pytest.skip("실무 엑셀 없음") + hs._dir(tmp_path).mkdir(parents=True, exist_ok=True) + (hs._dir(tmp_path) / "HP-000001.json").write_text( + f'{{"키": "HP-000001", "판": {hs.BOOK}}}', "utf-8" + ) + fh.keep(tmp_path, "HP-000001", path.name, path.read_bytes()) + sheets = json.loads(fh._path(tmp_path, "HP-000001").read_bytes())["시트"] + return sheets + + +def test_real_north_block_has_unique_candidate_and_flags(tmp_path) -> None: + sheets = _north(tmp_path) + _, total = book.est_prices(sheets) + nos = [int(b["번"]) for b in book.est_blocks(sheets) if b["번"] in total] + found = None + for no in nos: + out = fh.emit(tmp_path, "HP-000001", no) + if "야면석채집및운반(계곡내)" in out["로직"]["이름"]: + found = out + break + assert found, "야면석채집및운반(계곡내) 블록" + row = found["로직"] + got = _by(qty.scan(row)) + labor = next(i for i, x in enumerate(row["호표"]) if x["이름"] == "보통인부") + cells = got[f"호표:{labor}:0"] + assert [x["표"] for x in cells] == ["QF000428"] and cells[0]["값"] == 0.17 # 야면석 채집(인력) + flags = qty.flags(row) + assert f"{em.NOTE}소요량 후보 1 · 사람 확인 — 보통인부 0.17" in flags + assert all(f.startswith(em.NOTE) for f in flags) + doc = _doc(row) + kept = qty.pick(doc, f"호표:{labor}:0", {k: cells[0][k] for k in ("표", "줄", "칸")})["문서"] + assert imp.to_price(kept, "엑셀")["전"] == imp.to_price(doc, "엑셀")["전"]