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:
@@ -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
|
||||
|
||||
@@ -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 {})
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user