feat(797-2): 엑셀 불러오기 소요량 · 계수 이음 — 값 역색인 후보 + 사람 확인 · 고름 = 표 칸 참조 기록

- 새 `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 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 15:23:12 +09:00
co-authored by Claude Sonnet 5.5
parent ceb62d65c5
commit e171dda4e3
8 changed files with 930 additions and 4 deletions
@@ -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<typeof mountSangeunDetail> | 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<string, unknown>): Promise<EditAnswer> =>
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<void> {
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<void> {
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 {
@@ -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<string, readonly [string, string]>;
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.후));
@@ -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<string, readonly [string, string]>;
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<QtyView>;
pick: (spot: string, cell: QtyCell) => Promise<void>;
drop: (spot: string, undo: boolean) => Promise<void>;
}
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>): 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<void> {
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();
}
@@ -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
+6 -1
View File
@@ -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 {})
+361
View File
@@ -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}
+9 -1
View File
@@ -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)
@@ -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, "엑셀")["전"]