feat(793-3b): 불러오기 단산 줄 = 가져온 식 단가산출 모달 — 블록 → 가져온 식 SK 길 · 검산 · 엑셀/우리 단가 · 이음 고르기 · 근거 세 단 · 우리 기초로 바꾸기 · 입력 필요 · 줄 잇기 · 결과 칩

- 서버: 올림 때 산근 시트 둠(실무자료/산근/<HP>.json · HP 지울 때 같이) · POST …/sangeun/from-hopyo(블록 → 793-2a 내보내기 → 793-2 만들기 · 같은 내용 = 그 SK 다시 씀 · 옛 HP 409) · STmate 끝전 = 엑셀 계와 맞는 쪽
- 화면: [수정] 모달 단산 줄 · 오른쪽 블록 판 [가져온 식으로 열기](새) → 시트 모달 + 머리 판 · [이 줄에 잇기] = 줄 고른 것 SK(입력 필요 남으면 확인 막힘) · 단산 줄 결과 칩
- 시험: test_793_3b_import_screen 6

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 18:09:00 +09:00
co-authored by Claude Opus 5.5
parent 2a2822d650
commit ca72718e26
12 changed files with 981 additions and 3 deletions
+4
View File
@@ -36,6 +36,7 @@ from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_FromHopyo as from_hopyo
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
router = APIRouter(prefix="/api/m02", tags=["M02 Hopyo"])
@@ -245,6 +246,8 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
mark = hopyo_store.fingerprint(data)
hopyo_forget.sweep(store.root()) # 하루 지난 작업 중 HP 끝냄(743-1)
if same := next((r for r in hopyo_store.rows(store.root()) if r.get("지문") == mark), None):
if not from_hopyo.has(store.root(), same["키"]): # 793-3b 전에 올린 HP
from_hopyo.keep(store.root(), same["키"], name, data)
hopyo_judge.ensure(store.root(), same["키"])
return {**same, "이어서": True}
try:
@@ -258,6 +261,7 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
sheets, _ = hopyo_excel._open(name, data, hopyo_row.SHEETS)
parsed["사전"] = hopyo_row.lexicon(sheets) # 엑셀 사전(785-3 · 1차 줄 판정)
row = hopyo_store.save(store.root(), name, parsed, who, grid, mark)
from_hopyo.keep(store.root(), row["키"], name, data) # 산근 시트(793-3b 가져온 식 열기)
hopyo_judge.ensure(store.root(), row["키"]) # 블록 풀이 뒤 셈(파생 저장본 · 화면 안 기다림)
return row
+1
View File
@@ -166,6 +166,7 @@ def delete(folder: str | Path, key: str) -> None:
raise store.StoreError(404, f"없는 실무자료 「{key}」")
path.unlink()
_grid_path(folder, key).unlink(missing_ok=True)
(_dir(folder) / "산근" / f"{key}.json").unlink(missing_ok=True) # 793-3b 산근 시트
# ── 맞춤 기억 ─────────────────────────────────────────────────────────
@@ -5,6 +5,7 @@
* · 절마다 = 맡은 절 · 기초 후보 칩(여럿 고름) · 고른 후보마다 입력값 칸(출처 꼬리표 B1~B6 · 사용자 · 기본 ·
* 「입력 필요」 붉은 칸 · 엇갈림 고르기) · 안 씀 · 줄 대조(품 같음 · 품 다름 · 엑셀만 · 우리만 · 입력 필요) · 금액
* · 덧 줄(엑셀만) = 후보 칩 → 재료 · 인력 · 기계 고른 것
* · 머리 [가져온 식으로 열기](793-3b · 새) = `Hopyo_Imported` 모달(이 블록 → 가져온 식 SK · 시트 · 단가 · 기초로 바꾸기)
* · [검산] = 서버 셈(저장 없음 · 옛 요청 끊기) · 고름 · 입력은 캐시(`createJudgeState` = sessionStorage) — 옮김은 [옮기기] 뒤 서버
* 판정 · 금액은 서버 · 여기는 보이기 · 고르기만.
* ========================================================================== */
@@ -396,6 +397,11 @@ export function createJudgeBlock(opt: JudgeBlockOptions): {
});
ok.root.dataset.judgeOk = "block";
const actions: HTMLElement[] = [ok.root];
const open = createButton({ label: L("M02_CheckImported"), size: 24, variant: "ghost", disabled: opt.readOnly, onClick: () => void import("./M02_MasterTemplete_Hopyo_Imported").then((m) => m.openImported({ ...{ file: opt.file, key: opt.key, no: blk.번, target: opt.target, canWrite: !opt.readOnly }, bind: (code, given) => checkJudge(opt.file, opt.key, { 블록: blk.번, 고른: [bare({ 갈래: "기초", 키: code, 이름: code, 입력값: given, 출처: {}, 몫: null })] }).then((res) => ({ 입력값: res.고른[0]?.입력값 ?? given, 필요: res.고른[0]?.입력필요 ?? [] })) })) }); // prettier-ignore
open.dataset.judgeImported = String(blk.번);
const fresh = createBadge(L("M02_CheckNew"), "accent", { size: 18 }); // 「새」(793-3b) — CheckRows.newBadge 꼴(돌림 import 피함)
fresh.dataset.checkNew = "";
actions.push(open, fresh);
if (opt.onClose) actions.push(createButton({ label: L("Common_Btn_Close"), size: 24, variant: "ghost", onClick: opt.onClose })); // prettier-ignore
const title = `${L("M02_JudgeBlock").replace("{n}", String(blk.번))} ${blk.이름} (${blk.단위})`;
const sub = [
@@ -10,6 +10,7 @@
* · 고름 · 확인 = 판정 캐시(`createJudgeState` · 시트 칠 · 오른쪽 [옮기기 (n)] 과 한 벌) · 자동 확인 · 자동 옮김 없음
* · [사전조합 생성] = 옮기기 길(`runMove`) · 이 호표의 확인한 줄만 · 서버가 정본으로 다시 셈
* · [구조물 조합으로] = 이 호표가 사전조합으로 옮겨진 뒤만 · 구조물 조합 만들기 길(`Hopyo_ToItem`)
* · 단산 줄 [가져온 식으로 열기](793-3b) = `Hopyo_Imported` 모달(가져온 식 SK) · [이 줄에 잇기] = 그 줄 고른 것 = 그 SK
* · 행 번호 · [엑셀 보기] = 모달 닫고 시트 그 행(고름은 캐시로 남음 · 다시 [수정] 하면 그대로)
* · 옛 [맞음·별칭 일괄 체크] · [품셈으로 바꾸기] · [모두 고정으로] = 지운 서버 길 몫 — [호표 제안 모두 확인]이 갈음
* ========================================================================== */
@@ -44,6 +45,7 @@ import {
type JudgeState,
type SheetRole,
} from "./M02_MasterTemplete_Hopyo_Rows";
import { openImported } from "./M02_MasterTemplete_Hopyo_Imported";
import { toItem } from "./M02_MasterTemplete_Hopyo_ToItem";
import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
@@ -251,6 +253,25 @@ export function openHopyoCheck(opt: CheckOptions): { close: () => void } {
});
}
/** 기초 옵션 묶음 — 이 줄 검산(엑셀 값 · 입력 필요) */
const bindOf = (row: JudgeRow) => (code: string, given: Record<string, unknown>) =>
checkJudge(opt.file, opt.key, {
호표: no,
차례: row.차례,
고른: [bare({ 갈래: "기초", 키: code, 이름: code, 입력값: given, 출처: {}, 몫: null })],
}).then(
// prettier-ignore
(res) => ({ 입력값: res.고른[0]?.입력값 ?? given, 필요: res.고른[0]?.입력필요 ?? [] }),
);
/** [가져온 식으로 열기](793-3b) — 단산 블록 → 가져온 식 SK 모달 · [이 줄에 잇기] = 이 줄 고른 것 */
const imported = (row: JudgeRow): void =>
openImported({
...{ file: opt.file, key: opt.key, no: row.번!, target: opt.target, canWrite: opt.canWrite },
bind: bindOf(row),
onLink: (p) => kit.edit(row, [p]),
});
/** 추가(빈 값) = 제안으로 되돌림 · 로직없음 = 고정 줄(엑셀 숫자 그대로) */
function out(row: JudgeRow, value: string): void {
if (value === NO_LOGIC) {
@@ -315,7 +336,7 @@ export function openHopyoCheck(opt: CheckOptions): { close: () => void } {
],
});
const legend = el("div", { className: "ui-toolbar m02-judge__wrap", children: [newBadge(), note(L("M02_CheckNewHint"))] }); // prettier-ignore
const ctx = { kit, readOnly, price, pick, source: (r: JudgeRow) => void showSource(r), options, out }; // prettier-ignore
const ctx = { kit, readOnly, price, pick, source: (r: JudgeRow) => void showSource(r), options, out, imported }; // prettier-ignore
root.replaceChildren(head, legend, ...(h.줄.length ? checkTables(h.줄, ctx) : [note(L("M02_CheckNoLines"))])); // prettier-ignore
}
@@ -8,6 +8,8 @@
* · 자재 표 = [확인] · 행 · 코드 · 이름 · 규격 · 수량산출 · 단위 · 세 칸(글만) · 가격 다섯 · 엑셀 / 우리 합계 · 단추
* · 단추 = [고르기] · [원문 보기] · [옵션](기초마다) · 추가 / 로직없음(= 고정 줄) — 옛 「사용안함」(뺌)은 새 옮기기 길에 없어 안 살림
* · 「새」 배지(785 새 기능) = 머리 칸 [확인] · 구분(판정) · 이름(고름 · 블록 풀이) · 수량산출(단위 환산)
* · 단산 줄(793-3b) = 판정 칩 「2차 필요」 대신 결과 칩(가져온 식 있음 · 근거 확정 · 근접 · 사람 고름 · 입력 필요 n ·
* SK 안 이었으면 블록 풀이 판정) · 단추 [가져온 식으로 열기](새 · `Hopyo_Imported` 모달)
* 줄 표 = `tableOf`(고른 것이 모두 재료 · 유가 · 없으면 엑셀 갈래 · 별도계상 줄 = 자재 표).
* ========================================================================== */
@@ -21,6 +23,7 @@ import type { CalcMaterial } from "./M02_MasterTemplete_Labor_Calc";
import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks";
import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { rowChips } from "./logic/M02_Hopyo_Imported_Model";
import type { DraftPick } from "./logic/M02_MasterTemplete_Hopyo_Picks";
type Money4 = Record<"노무비" | "재료비" | "경비" | "계", number | null>;
@@ -44,6 +47,8 @@ export interface CheckCtx {
options: (row: JudgeRow, p: DraftPick) => void;
/** 추가(빈 값 = 제안으로 되돌림) · 로직없음(고정 줄) */
out: (row: JudgeRow, value: string) => void;
/** 단산 줄 [가져온 식으로 열기](793-3b) */
imported?: (row: JudgeRow) => void;
}
const COSTS = ["노무비", "재료비", "경비", "계"] as const;
@@ -128,6 +133,11 @@ function actsCell(row: JudgeRow, ctx: CheckCtx, picks: DraftPick[]): HTMLElement
};
const kids: HTMLElement[] = [];
if (!ctx.readOnly && !apartRow(row)) kids.push(ghost(L("M02_CheckPick"), () => ctx.pick(row), "pick")); // prettier-ignore
if (row.번 !== undefined && ctx.imported) {
const b = ghost(L("M02_CheckImported"), () => ctx.imported!(row), "imported");
(b as HTMLButtonElement).disabled = ctx.readOnly;
kids.push(el("span", { className: "m02-judge__wrap", children: [b, newBadge()] }));
}
kids.push(ghost(L("M02_CheckSource"), () => ctx.source(row), "source"));
for (const p of picks.filter((x) => x.갈래 === "기초")) {
const b = ghost(L("M02_CheckOption"), () => ctx.options(row, p), "option");
@@ -172,11 +182,25 @@ const rowOf = (
],
});
/** 단산 줄 결과 칩(793-3b) — SK 고른 것 표지 · 없으면 블록 풀이 판정 · 단산 아니면 판정 칩 */
function verdictOf(row: JudgeRow, ctx: CheckCtx): HTMLElement[] {
if (row.번 === undefined) return [ctx.kit.verdict(row)];
const got = rowChips(ctx.kit.picksOf(row));
const list = got ?? (row.풀이?.판정 ? [row.풀이.판정] : []);
if (!list.length) return [ctx.kit.verdict(row)];
return list.map((text) => {
const tone = text.startsWith("입력 필요") ? "danger" : text.includes("확정") || text.startsWith("가져온") ? "success" : text.includes("근접") ? "warning" : "info"; // prettier-ignore
const b = createBadge(text, tone);
b.dataset.judgeResult = text;
return b;
});
}
const kindCell = (row: JudgeRow, ctx: CheckCtx): HTMLElement =>
el("td", {
className: "m02-labrow__kind",
attrs: { title: row.근거 },
children: [bar([...row.갈래.map((k) => createBadge(k, "neutral")), ctx.kit.verdict(row)])],
children: [bar([...row.갈래.map((k) => createBadge(k, "neutral")), ...verdictOf(row, ctx)])],
});
function logicRow(row: JudgeRow, ctx: CheckCtx): HTMLElement[] {
@@ -0,0 +1,262 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Imported.ts
* 불러오기 단산 줄 [가져온 식으로 열기] 모달(PLAN 793-3b · 요구 646 · 647 · 652 갈래 C) —
* [수정] 모달 단산 줄(`Hopyo_Check`) · 오른쪽 목록 단가산출 탭 블록(`Hopyo_Block`)이 엶.
* · 열기 = 서버 `…/sangeun/from-hopyo`(그 블록 → 가져온 식 SK · 만들 층 · 엑셀 단가 그대로 · 같은 내용 = 그 SK 다시 씀)
* → 그 SK 를 읽어 우리 단가산출 화면(`Sangeun_Detail` 시트 · 고침 = 시트 그대로) + 머리 판(`Hopyo_ImportedPanel`)
* · [우리 단가로] · [엑셀 단가로] · [이음 고르기] · [우리 기초로 바꾸기] = 서버 `…/sangeun/imported/{price|link|to-base}`
* (화면 캐시 문서 고침 · 저장 없음) → 시트 다시 엶 · 기초로 바꿀 때 옵션 = 불러오기 검산(`bind` · 엑셀 값 묶음 · 입력 필요)
* · [저장] = 그 SK 저장(서버가 다시 셈) · [이 줄에 잇기] = 저장 뒤 단산 줄 고른 것 = 그 SK(갈래 단가산출 · 키) —
* 확인 · [사전조합 생성]은 기존 길(자동 확인 · 옮김 없음) · 입력 필요가 비면 그 줄 확인 막힘
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { el, showToast } from "@ui/ui_template_elements";
import { loadingError, showLoading } from "@ui/ui_template_loading";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { askConfirm, openModal } from "@ui/ui_template_modal";
import { call, readTemplate, saveTemplate } from "./M02_MasterTemplete_Api_Fetch";
import { openFindPicker } from "./M02_MasterTemplete_Find_Pick";
import { at, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
import { importedPanel, type PanelState } from "./M02_MasterTemplete_Hopyo_ImportedPanel";
import { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail";
import {
marksOf,
skPick,
type BasisCand,
type ImportedDoc,
type Money4,
type Tier,
} from "./logic/M02_Hopyo_Imported_Model";
import type { DraftPick } from "./logic/M02_MasterTemplete_Hopyo_Picks";
const TEXT = {
Title: ["가져온 식 단가산출 — 단산{n}", "Imported build-up — block {n}"],
Reused: ["같은 내용 {k} 를 다시 씀", "Reusing same content {k}"],
Made: ["{k} 만듦(엑셀 단가 그대로)", "{k} made (Excel prices)"],
Save: ["저장", "Save"],
Saved: ["{k} 저장함", "{k} saved"],
Link: ["이 줄에 잇기", "Use for this line"],
Linked: ["단산 줄 = {k} — [확인] 뒤 [사전조합 생성]", "Line uses {k} — confirm, then build"],
Discard: ["저장 안 한 고침이 있음 — 버리고 닫을까?", "Discard unsaved edits?"],
Close: ["닫기", "Close"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
const why = (e: unknown): string => (e instanceof Error ? e.message : String(e));
export interface ImportedOptions {
/** 실무자료 파일 층 */
file: HopyoScope;
key: string;
/** 단산 블록 번 */
no: number;
/** SK 를 만들 층 */
target: HopyoScope;
canWrite: boolean;
/** 기초 옵션 묶음(불러오기 검산) → 엔진 입력값 · 입력 필요 */
bind: (code: string, given: Record<string, unknown>) => Promise<{ 입력값: Record<string, unknown>; 필요: string[] }>; // prettier-ignore
/** 단산 줄에서 열었을 때만 — [이 줄에 잇기] */
onLink?: (pick: DraftPick) => void;
}
interface Made {
이름: string;
다시씀: boolean;
}
const fromHopyo = (o: ImportedOptions): Promise<Made> =>
call(at(o.target, "/sangeun/from-hopyo"), {
method: "POST",
body: JSON.stringify({ 층: o.file.layer, ...(o.file.owner ? { 주인: o.file.owner } : {}), 키: o.key, 번: o.no }), // prettier-ignore
});
interface EditAnswer {
문서: ImportedDoc;
전?: Money4 | null;
후?: Money4 | null;
이음없음?: { 자리: string; 이름: string }[];
}
export function openImported(o: ImportedOptions): void {
const readOnly = !o.canWrite;
const root = el("div", {
className: "m02-hopyo",
attrs: { "data-imported": `${o.key}|${o.no}` },
});
const top = el("div");
const host = el("div", { attrs: { "data-sk-body": "" } });
let sk = "";
let 판 = "";
let tier: Tier | null = null;
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 edit = (how: string, body: Record<string, unknown>): Promise<EditAnswer> =>
call(at(o.target, `/sangeun/imported/${how}`), { method: "POST", body: JSON.stringify(body) });
function mount(doc: ImportedDoc): void {
handle?.destroy();
st.doc = doc;
handle = mountSangeunDetail(host, doc, {
layer: o.target.layer,
owner: o.target.owner,
readOnly,
onChanged: (d) => (dirty ||= d),
});
draw();
}
const now = (): ImportedDoc => (handle?.getDoc() as ImportedDoc | undefined) ?? st.doc;
function draw(): void {
top.replaceChildren(importedPanel(st, { price, link, toBase, pickBase, fill }));
saveBtn.disabled = readOnly || st.busy;
linkBtn.disabled = readOnly || st.busy;
}
/** 서버 고침 한 번(눌림 막음) → 시트 다시 엶 */
async function run(how: string, body: Record<string, unknown>): Promise<EditAnswer | null> {
st.busy = true;
draw();
try {
const got = await edit(how, { 문서: now(), ...body });
dirty = true;
mount(got.문서);
return got;
} catch (e) {
showToast(why(e), "error");
return null;
} finally {
st.busy = false;
draw();
}
}
/** 이음 없는 자원 — 단가 기준 그대로 price 답(전 · 후 = 지금) */
async function unlinked(): Promise<void> {
if (!st.doc.가져온?.로직) return void (st.unlinked = []);
const got = await edit("price", { 문서: st.doc, 기준: st.doc.가져온.단가기준 ?? "엑셀" }).catch(() => null); // prettier-ignore
st.unlinked = got?.이음없음 ?? [];
draw();
}
async function price(basis: "엑셀" | "우리"): Promise<void> {
const got = await run("price", { 기준: basis });
if (got) st.price = { 전: got.전 ?? null, 후: got.후 ?? null };
draw();
}
function link(spot: string, name: string): void {
const done = (code: string): void => void run("link", { 자리: spot, 이음: code }).then(() => unlinked()); // prettier-ignore
openFindPicker({
layer: o.target.layer,
owner: o.target.owner,
query: name,
logic: (x) => done(`로직(${x.키})`),
element: (e) => done(e.코드),
machine: (e) => done(e.코드),
oil: (e) => done(e.코드),
});
}
async function toBase(code: string, cand: BasisCand | null): Promise<void> {
st.busy = true;
draw();
let bound: Awaited<ReturnType<ImportedOptions["bind"]>>;
try {
bound = await o.bind(code, cand?.옵션 ?? {});
} catch (e) {
st.busy = false;
draw();
return void showToast(why(e), "error");
}
st.busy = false;
const got = await run("to-base", { 로직: code, 입력값: bound.입력값 });
if (!got) return;
tier = cand?.단 ?? null;
st.need = bound.필요;
st.unlinked = [];
draw();
}
function pickBase(): void {
openFindPicker({ layer: o.target.layer, owner: o.target.owner, kinds: ["logic"], logic: (x) => void toBase(x.키, null) }); // prettier-ignore
}
function fill(name: string, value: string): void {
const doc = now();
const kept = { ...(doc.입력값 ?? {}) };
if (value.trim()) kept[name] = value.trim();
else delete kept[name];
dirty = true;
mount({ ...doc, 입력값: kept });
}
async function save(): Promise<boolean> {
try {
const doc = now();
const info = await saveTemplate(o.target.layer, "sangeun", sk, null, 판, doc, o.target.owner);
판 = info.판;
st.doc = doc;
dirty = false;
showToast(t("Saved").replace("{k}", sk), "success");
return true;
} catch (e) {
showToast(why(e), "error");
return false;
}
}
const saveBtn = createButton({ label: t("Save"), variant: "filled", onClick: () => void save() });
saveBtn.dataset.importedSave = "";
const linkBtn = createButton({
label: t("Link"),
variant: "ghost",
onClick: async () => {
if (dirty && !(await save())) return;
o.onLink!(skPick(sk, st.doc, o.target, st.need, marksOf(st.doc, tier)));
showToast(t("Linked").replace("{k}", sk), "info");
dirty = false;
close();
},
});
linkBtn.dataset.importedLinkRow = "";
linkBtn.hidden = !o.onLink;
saveBtn.disabled = linkBtn.disabled = true;
async function load(): Promise<void> {
const stop = showLoading(root);
try {
const made = await fromHopyo(o);
sk = made.이름;
showToast((made.다시씀 ? t("Reused") : t("Made")).replace("{k}", sk), "info");
const got = await readTemplate(o.target.layer, "sangeun", sk, null, o.target.owner);
판 = got.판 ?? "";
root.replaceChildren(top, host);
mount(got.문서 as ImportedDoc);
void unlinked();
} catch (e) {
root.replaceChildren(loadingError(why(e), () => void load()));
} finally {
stop();
}
}
({ close } = openModal({
title: t("Title").replace("{n}", String(o.no)),
closeLabel: t("Close"),
width: "1100px",
wide: true,
dialogClass: "m02-modal m02-hopyo__imported-modal",
actions: [saveBtn, linkBtn],
beforeClose: async () => {
if (dirty && !readOnly && !(await askConfirm({ message: t("Discard"), danger: true })))
return false;
handle?.destroy();
return true;
},
mount: (body) => body.append(root),
}));
void load();
}
@@ -0,0 +1,247 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_ImportedPanel.ts
* 가져온 식 단가산출 모달 머리 판(PLAN 793-3b · 요구 646 · 647 · 652) — `Hopyo_Imported` 가 엶 · 시트(`Sangeun_Detail`) 위.
* · 줄 1 = 「새」 · 원본(파일 · 시트 · 행) · 검산 표지(같음 / 다름 n · 줄마다 까닭) · 단가 기준 [우리 단가로] / [엑셀 단가로] ·
* 바꾸기 전후 금액 · [원본 글 보기]
* · 줄 2 = 이음 없는 자원 = 표지 + [이음 고르기]
* · 줄 3 = 근거 제안 세 단(확정 · 근접(차이 · 까닭) · 사람 고름 · 793-3a) — 후보마다 [우리 기초로 바꾸기] · [기초 고르기]
* · 우리 기초로 바꾼 뒤 = 전후 차이 띠 · 「입력 필요」 붉은 칸(채우기 전엔 그 줄 확인 · 옮김 막힘)
* 부품 = 단추 · 배지 · 글 · 입력 · 띠 · 표 · 모달(날 단추 · 입력 없음) · 서버 길은 부르는 쪽.
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createBanner } from "@ui/ui_template_banner";
import { createButton } from "@ui/ui_template_button";
import { el } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
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";
import { formatMoney } from "../common_util/common_util_money";
import { newBadge } from "./M02_MasterTemplete_Hopyo_CheckRows";
import {
gapText,
isImported,
stillNeed,
tiers,
TIERS,
wrongLines,
type BasisCand,
type ImportedDoc,
type Money4,
type Tier,
} from "./logic/M02_Hopyo_Imported_Model";
const TEXT = {
Imported: ["가져온 식", "Imported formula"],
Based: ["우리 기초로 바꿈", "Converted to our basis"],
Same: ["검산 같음", "Check matches"],
Diff: ["검산 다름 {n}", "Check differs {n}"],
NoCheck: ["검산 없음", "No check"],
Basis: ["단가 기준", "Price basis"],
ToOurs: ["우리 단가로", "Use our prices"],
ToExcel: ["엑셀 단가로", "Use Excel prices"],
Source: ["원본 글 보기", "Show source text"],
SourceTitle: ["원본 글", "Source text"],
Row: ["행", "Row"],
Text: ["글", "Text"],
Excel: ["엑셀", "Excel"],
Now: ["지금", "Now"],
Unlinked: ["이음 없는 자원 {n}", "Unlinked resources {n}"],
Link: ["이음 고르기", "Pick link"],
Basis3: ["근거 제안", "Basis suggestions"],
확정: ["확정", "Confirmed"],
근접: ["근접", "Near"],
사람: ["사람 고름", "Human pick"],
None: ["없음", "None"],
NoCand: ["근거 제안 없음 — [기초 고르기]로 고름", "No suggestion — pick a basis"],
ToBase: ["우리 기초로 바꾸기", "Convert to our basis"],
PickBase: ["기초 고르기", "Pick basis"],
Gap: ["바꾸기 전 {a} → 후 {b} ({d})", "Before {a} → after {b} ({d})"],
Need: ["입력 필요", "Input needed"],
NeedHint: ["채워야 이 줄을 확인 · 옮김", "Fill to confirm and move this line"],
Close: ["닫기", "Close"],
} as const satisfies Record<string, readonly [string, string]>;
export const tx = (k: keyof typeof TEXT): string =>
TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
export interface PanelState {
doc: ImportedDoc;
/** 이음 없는 자원(서버 price 답) */
unlinked: { 자리: string; 이름: string }[];
/** 마지막 단가 바꿈 전후 */
price: { 전: Money4 | null; 후: Money4 | null } | null;
/** 우리 기초로 바꾼 뒤 입력 필요 칸 */
need: string[];
busy: boolean;
readOnly: boolean;
}
export interface PanelActs {
price: (basis: "엑셀" | "우리") => void;
link: (spot: string, name: string) => void;
toBase: (code: string, cand: BasisCand | null) => void;
pickBase: () => void;
fill: (name: string, value: string) => void;
}
const won = (m: Money4 | null | undefined): string => (m ? formatMoney(m.계) : "—");
const muted = (s: string): HTMLElement => createText(s, "muted", 14);
/** [원본 글 보기] — 엑셀 글줄 · 엑셀 값 · 지금 셈 */
function showSource(doc: ImportedDoc): void {
const src = doc.가져온?.원본;
const lines = new Map((doc.가져온?.검산?.줄 ?? []).map((x) => [x.행, x]));
const rows = (src?.글줄 ?? []).map((g) => {
const hit = g.행 != null ? lines.get(g.행) : undefined;
return [
String(g.행 ?? ""),
g.글 ?? "",
g.값 == null ? "" : formatMoney(g.값),
hit?.셈 == null ? "" : formatMoney(hit.셈),
hit ? createBadge(hit.같음 ? "=" : (hit.까닭 ?? "≠"), hit.같음 ? "success" : "danger") : "",
];
});
openModal({
title: `${tx("SourceTitle")} — ${[src?.파일, src?.시트].filter(Boolean).join(" · ")}`,
closeLabel: tx("Close"),
wide: true,
dialogClass: "m02-modal m02-hopyo__src",
mount: (body) => {
const heads = [tx("Row"), tx("Text"), tx("Excel"), tx("Now"), ""];
const t = createTable(heads.map((h, i) => ({ label: h, ...(i > 1 && i < 4 ? { align: "end" as const } : {}) })), rows, { density: "data" }); // prettier-ignore
t.dataset.importedSource = "";
body.append(t);
},
});
}
function checkBadge(doc: ImportedDoc): HTMLElement {
const chk = doc.가져온?.검산;
const bad = wrongLines(doc);
const b = !chk
? createBadge(tx("NoCheck"), "neutral")
: chk.같음
? createBadge(tx("Same"), "success")
: createBadge(tx("Diff").replace("{n}", String(bad.length || 1)), "danger");
b.dataset.importedCheck = chk ? String(chk.같음) : "";
b.title = [chk?.까닭, ...bad.map((x) => `${tx("Row")} ${x.행 ?? "?"}: ${x.까닭 ?? ""}`)]
.filter(Boolean)
.join("\n");
return b;
}
function cand(c: BasisCand, s: PanelState, a: PanelActs): HTMLElement {
const opts = Object.entries(c.옵션 ?? {})
.map(([k, v]) => `${k} ${String(v)}`)
.join(" · ");
const go = createButton({ label: tx("ToBase"), size: 24, variant: "ghost", disabled: s.readOnly || s.busy, onClick: () => a.toBase(c.기초, c) }); // prettier-ignore
go.dataset.importedToBase = c.기초;
return el("div", {
className: "ui-toolbar m02-judge__wrap",
attrs: { "data-basis-cand": c.기초, "data-basis-tier": c.단 },
children: [
createBadge(`${c.기초}${c.이름 ? ` ${c.이름}` : ""}`, "accent"),
...(opts ? [muted(opts)] : []),
...(c.차이 !== undefined ? [muted(String(c.차이))] : []),
...(c.까닭 ? [muted(c.까닭)] : []),
go,
],
});
}
/** 근거 제안 세 단 */
function basisPart(s: PanelState, a: PanelActs): HTMLElement {
const by = tiers(s.doc.가져온?.근거후보);
const any = TIERS.some((k) => by[k].length);
const pick = createButton({ label: tx("PickBase"), size: 24, variant: "ghost", disabled: s.readOnly || s.busy, onClick: a.pickBase }); // prettier-ignore
pick.dataset.importedPickBase = "";
const tierRow = (k: Tier): HTMLElement =>
el("div", {
attrs: { "data-basis-tiers": k },
children: [
createBadge(`${tx(k)} ${by[k].length}`, k === "확정" ? "success" : k === "근접" ? "warning" : "neutral"), // prettier-ignore
...(by[k].length ? by[k].map((c) => cand(c, s, a)) : [muted(tx("None"))]),
],
});
return el("div", {
attrs: { "data-imported-basis": "" },
children: [
createToolbar({ items: [newBadge(), createText(tx("Basis3"), "muted", 14), pick] }),
...(any ? TIERS.map(tierRow) : [muted(tx("NoCand"))]),
],
});
}
/** 머리 판 */
export function importedPanel(s: PanelState, a: PanelActs): HTMLElement {
const meta = s.doc.가져온 ?? {};
const src = meta.원본;
const here = isImported(s.doc);
const head: (HTMLElement | "divider")[] = [
newBadge(),
createBadge(here ? tx("Imported") : tx("Based"), here ? "accent" : "info"),
muted([src?.파일, src?.시트, src?.행?.join("~")].filter(Boolean).join(" · ")),
checkBadge(s.doc),
];
if (here) {
const ours = meta.단가기준 === "우리";
const flip = createButton({
label: ours ? tx("ToExcel") : tx("ToOurs"),
size: 28,
variant: "ghost",
disabled: s.readOnly || s.busy,
onClick: () => a.price(ours ? "엑셀" : "우리"),
});
flip.dataset.importedPrice = ours ? "엑셀" : "우리";
head.push("divider", muted(`${tx("Basis")} ${meta.단가기준 ?? "엑셀"}`), flip);
if (s.price) {
const g = tx("Gap").replace("{a}", won(s.price.전)).replace("{b}", won(s.price.후));
const t = muted(g.replace("{d}", gapText(s.price.전, s.price.후) || "0"));
t.dataset.importedGap = "";
head.push(t);
}
}
const srcBtn = createButton({ label: tx("Source"), size: 28, variant: "ghost", disabled: !src?.글줄?.length, onClick: () => showSource(s.doc) }); // prettier-ignore
srcBtn.dataset.importedSourceBtn = "";
head.push("divider", srcBtn);
const kids: HTMLElement[] = [createToolbar({ items: head })];
if (here && s.unlinked.length) {
const items = s.unlinked.map((u) => {
const b = createButton({ label: `${u.이름} · ${tx("Link")}`, size: 24, variant: "ghost", disabled: s.readOnly || s.busy, onClick: () => a.link(u.자리, u.이름) }); // prettier-ignore
b.dataset.importedLink = u.자리;
return b;
});
const tag = createBadge(tx("Unlinked").replace("{n}", String(s.unlinked.length)), "warning");
kids.push(createToolbar({ items: [tag, ...items] }));
}
const gone = meta.바꿈;
if (!here && gone) {
const g = tx("Gap").replace("{a}", won(gone.전)).replace("{b}", won(gone.후));
kids.push(createBanner({ tone: "info", text: g.replace("{d}", gapText(gone.전, gone.후) || "0") })); // prettier-ignore
}
const left = stillNeed(s.need, s.doc.입력값);
if (s.need.length) {
const fields = s.need.map((k) => {
const f = createInputField({ label: k, size: 28, value: String(s.doc.입력값?.[k] ?? "") });
f.input.dataset.importedNeed = k;
f.input.disabled = s.readOnly || s.busy;
if (left.includes(k)) f.setError(tx("Need"));
f.input.addEventListener("change", () => a.fill(k, f.input.value));
return f.root;
});
kids.push(
el("div", {
attrs: { "data-imported-needs": String(left.length) },
children: [
createToolbar({ items: [createBadge(`${tx("Need")} ${left.length}`, left.length ? "danger" : "success"), muted(tx("NeedHint"))] }), // prettier-ignore
el("div", { className: "ui-toolbar m02-judge__wrap", children: fields }),
],
}),
);
}
kids.push(basisPart(s, a));
return el("div", { className: "m02-std-sheet__body", attrs: { "data-imported-panel": "" }, children: kids }); // prettier-ignore
}
+107
View File
@@ -0,0 +1,107 @@
"""M02 불러오기 단산 블록 → 가져온 식 단가산출(PLAN 793-3b · 요구 646 · 647 · 652) — 서버 단독.
올림 때 경쟁사 산근 시트(`M02_San_Book.sheets` · STmate 숨은 G 열 포함)를
`실무자료/산근/<HP>.json` 에 둠
(원본 엑셀은 안 둠 · HP 지울 때 같이 지움) → [가져온 식으로 열기] = 그 블록을 793-2a 내보내기
(`M02_San_Emit` · 이음 = 1차 줄 판정) → 793-2 만들기(`M02_Sangeun_Imported.create` · 개인 층 ·
엑셀 단가 그대로) · 같은 내용 SK 가 있으면 그것(다시씀).
블록 번 = 판정 블록 번(STmate = 코드 「D00102」 숫자 · EST = 단가산출 블록 번).
STmate 블록 끝전(비목버림 | 합버림_큰나머지)은 파일 설정 — 참조 평가기로 둘 다 셈해
엑셀 계와 맞는 것.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
DIRNAME = "산근"
RELOAD = "가져온 식 자료 없음 — 엑셀을 다시 올림"
RULES = ("비목버림", "합버림_큰나머지")
def _path(folder: str | Path, key: str) -> Path:
return hopyo_store._dir(folder) / DIRNAME / f"{key}.json"
def keep(folder: str | Path, key: str, name: str, data: bytes) -> None:
"""올린 엑셀 → 산근 시트 둠(STmate · EST 꼴만 · 못 읽으면 안 둠 — 올리기는 됨)."""
from M02_MasterTemplete import M02_San_Book as book
try:
sheets = book.sheets(name, data)
if not book.kind_of(sheets):
return
except Exception: # noqa: BLE001 — 산근은 덤(가져온 식 열기만 막힘)
return
path = _path(folder, key)
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(path, {"파일": name, "시트": sheets})
def has(folder: str | Path, key: str) -> bool:
return _path(folder, key).is_file()
def _rule(blk: dict, nodes: list, prices: dict) -> str:
"""STmate 블록 끝전 — 엑셀 계(꼬리 · 없으면 머리)와 맞는 쪽 · 둘 다 아니면 비목버림."""
from M02_MasterTemplete import M02_San_Eval as ev
tails = [t for t in blk["꼬리"] if t["A"].startswith("계") and not t["A"].startswith("계약")]
want = (tails or [blk["머리"]])[0].get("C")
for rule in RULES:
try:
got = ev.stmate_block(blk, nodes, prices, rule)["계"]["T"]
except Exception: # noqa: BLE001 — 못 셈 = 다음 갈래
continue
if want is not None and abs(float(got) - float(want)) < 0.5:
return rule
return RULES[0]
def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]:
"""HP 단산 블록 하나 → `{로직, 원본}`(793-2a 꼴 · 이음 = M01 키)."""
from M02_MasterTemplete import M02_San_Book as book
from M02_MasterTemplete import M02_San_Emit as em
from M02_MasterTemplete import M02_San_Est as est
from M02_MasterTemplete import M02_San_Stmate as stm
hopyo_store.read(folder, key) # 없는 HP = 404
try:
saved = json.loads(_path(folder, key).read_bytes())
except (OSError, ValueError) as e:
raise store.StoreError(409, RELOAD) from e
sheets, name = saved["시트"], str(saved.get("파일") or "")
if book.kind_of(sheets) == "STmate":
blk = next((b for b in book.stmate_blocks(sheets) if int(b["코드"][1:]) == int(no)), None)
if blk is None:
raise store.StoreError(404, f"없는 단산{no}")
prices = book.stmate_prices(sheets)
nodes = stm.parse_block(blk["몸"])
return em.stmate(blk, nodes, prices, _rule(blk, nodes, prices), True, name)
prices, total = book.est_prices(sheets)
blk = next((b for b in book.est_blocks(sheets) if int(b["번"]) == int(no)), None)
if blk is None or blk["번"] not in total:
raise store.StoreError(404, f"없는 단산{no}")
return em.est(blk, est.parse_block(blk["글"], prices), total[blk["번"]], True, name)
def make(source: Path, key: str, no: int, name: str = "") -> dict[str, Any]:
"""지금 층(만들 층)에 가져온 식 SK — 같은 내용이 있으면 그 SK(`다시씀`)."""
from M02_MasterTemplete import M02_Sangeun_Imported as imp
out = emit(source, key, no)
title = str(name or "").strip() or str(out["로직"].get("이름") or f"단산{no}")
try:
return {**imp.create(out["로직"], out["원본"], title), "다시씀": False}
except store.StoreError as e:
same = (e.detail or {}).get("같은것") if isinstance(e.detail, dict) else None
if e.status != 409 or not same:
raise
got = imp.create(out["로직"], out["원본"], title, use=same[0]["키"])
return {**got, "다시씀": True}
+26 -1
View File
@@ -2,7 +2,9 @@
[단가산출로 저장](715-3) `POST /api/m02/layers/{층}/sangeun/from-line`(층 쓰기 권한) ·
식 칸 검사(792-1) `POST /api/m02/layers/{층}/sangeun/formula-check`(셈 없음 · 층 읽기 권한) ·
가져온 식(793-2 · `M02_Sangeun_Imported`) `…/sangeun/imported`(만들기 · 층 쓰기) · `/price` ·
`/link` · `/to-base`(화면 캐시 문서 고침 · 저장 없음 · 층 읽기).
`/link` · `/to-base`(화면 캐시 문서 고침 · 저장 없음 · 층 읽기) ·
불러오기 단산 블록 → 가져온 식(793-3b · `M02_Sangeun_FromHopyo`) `…/sangeun/from-hopyo`
(만들 층 쓰기).
저장 안 함 · 로그인 사용자 · 층 읽기 권한(`Router_Items._root`) · 화면은 최신 응답만.
몸 = `문서`(저장 전 화면 문서) 또는 `키`(그 층 저장본) + `입력값`(열린입력 · 시험 값) ·
@@ -23,6 +25,7 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Calc as calc
from M02_MasterTemplete import M02_Sangeun_Doc as sd
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.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
@@ -120,6 +123,28 @@ def sangeun_imported(
return _call(folder, _sd(imp.create), *args)
class FromHopyoBody(BaseModel):
층: Layer
주인: str | None = None
키: str
번: int
이름: str = ""
@router.post("/layers/{layer}/sangeun/from-hopyo")
def sangeun_from_hopyo(
layer: Layer,
body: FromHopyoBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""불러오기 단산 블록 → 가져온 식 SK(793-3b) — 경로 층 = 만들 층 · 몸 층 = 파일 층 ·
답 = {이름, 검산, 단가기준, 다시씀}(같은 내용 = 그 SK)."""
folder = _root(session, layer, owner, write=True)
source = _root(session, body.층, body.주인)
return _call(folder, _sd(from_hopyo.make), source, body.키, body.번, body.이름)
class ImportedEditBody(BaseModel):
문서: dict[str, Any]
기준: str | None = None # price
@@ -0,0 +1,134 @@
/* =============================================================================
* logic/M02_Hopyo_Imported_Model.ts
* 불러오기 단산 줄 = 가져온 식 단가산출(PLAN 793-3b · 요구 646 · 647) — 꼴 · 읽기 · 바꾸기만.
* · SK `가져온` 덧칸 꼴(793-2 `M02_Sangeun_Imported.py` 머리) · 근거 제안 세 단(확정 · 근접 · 사람 고름 · 793-3a 가 채움)
* · 단산 줄 고른 것 = {갈래 단가산출, 키 SK-…, 층, 주인, 필요(입력 필요), 표지(결과 칩)} — 필요가 비어야 확인 · 옮김
* · 줄 결과 칩 = 가져온 식 있음 · 근거 확정 · 근접 · 사람 고름 · 입력 필요 n(「2차 필요」 대신)
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
* ========================================================================== */
import type { DraftPick } from "./M02_MasterTemplete_Hopyo_Picks";
export type Money4 = Record<"노무비" | "재료비" | "경비" | "계", number>;
export type Tier = "확정" | "근접" | "사람";
export const TIERS: Tier[] = ["확정", "근접", "사람"];
/** 근거 후보 하나(793-3a) */
export interface BasisCand {
기초: string;
이름?: string;
옵션?: Record<string, unknown>;
품검산?: unknown;
단: Tier;
까닭?: string;
차이?: string | number;
}
export interface CheckLine {
자리: string;
행?: number | null;
셈?: number | null;
엑셀?: number | null;
같음: boolean;
까닭?: string;
}
/** SK 문서 덧칸 `가져온` */
export interface ImportedMeta {
로직?: { 이름?: string; 호표?: { 이름?: string; 이음?: string | null }[] };
단가기준?: "엑셀" | "우리";
원본?: {
파일?: string;
시트?: string;
행?: number[];
갈래?: string;
엑셀금액?: Money4;
글줄?: { 행?: number | null; 글?: string; 자리?: string; 값?: number | null }[];
};
검산?: {
같음: boolean;
까닭?: string | null;
셈?: Money4 | null;
엑셀?: Money4;
줄: CheckLine[];
};
근거후보?: BasisCand[];
바꿈?: { 전?: Money4 | null; 후?: Money4 | null; 차이?: Money4 | null };
}
export interface ImportedDoc {
이름?: string;
단위?: string;
바탕?: { 로직?: string } | null;
입력값?: Record<string, unknown>;
가져온?: ImportedMeta;
[k: string]: unknown;
}
/** 가져온 식(아직 우리 기초로 안 바꿈) */
export const isImported = (d: ImportedDoc | null | undefined): boolean => !!d?.가져온?.로직;
/** 근거 후보 세 단 */
export function tiers(list: BasisCand[] | undefined): Record<Tier, BasisCand[]> {
const out: Record<Tier, BasisCand[]> = { 확정: [], 근접: [], 사람: [] };
for (const c of list ?? []) if (TIERS.includes(c.단)) out[c.단].push(c);
return out;
}
/** 검산 다른 줄 */
export const wrongLines = (d: ImportedDoc): CheckLine[] =>
(d.가져온?.검산?.줄 ?? []).filter((x) => !x.같음);
const blank = (v: unknown): boolean => v === undefined || v === null || String(v).trim() === "";
/** 입력 필요 중 아직 빈 칸 */
export const stillNeed = (need: string[], values: Record<string, unknown> | undefined): string[] =>
need.filter((k) => blank(values?.[k]));
/** 단산 줄 고른 것 — 그 SK(필요 = 빈 입력 필요 칸 · `missing` 이 막음) */
export function skPick(
sk: string,
doc: ImportedDoc,
scope: { layer: string; owner: string | null },
need: string[],
marks: string[],
): DraftPick & { 표지: string[] } {
const values = doc.입력값 ?? {};
return {
갈래: "단가산출",
키: sk,
이름: String(doc.이름 ?? sk),
단위: String(doc.단위 ?? ""),
입력값: Object.fromEntries(need.map((k) => [k, values[k] ?? ""])),
출처: {},
몫: null,
층: scope.layer,
주인: scope.owner,
필요: need,
표지: marks,
};
}
/** 결과 표지 — 가져온 식 있음 | 우리 기초(+ 근거 단) */
export function marksOf(doc: ImportedDoc, tier: Tier | null): string[] {
if (isImported(doc)) return ["가져온 식 있음"];
return tier ? [`근거 ${tier}`] : ["사람 고름"];
}
/** 단산 줄 결과 칩 글 — SK 고른 것이 있으면 그 표지 + 입력 필요 n · 없으면 null(옛 판정 칩) */
export function rowChips(picks: (DraftPick & { 표지?: string[] })[]): string[] | null {
const sk = picks.find((p) => p.갈래 === "단가산출" && /^SK-/.test(String(p.키 ?? "")));
if (!sk) return null;
const need = stillNeed(sk.필요 ?? [], sk.입력값);
return [...(sk.표지 ?? ["가져온 식 있음"]), ...(need.length ? [`입력 필요 ${need.length}`] : [])];
}
/** 금액 차이 글(후 − 전) */
export function gapText(
before: Money4 | null | undefined,
after: Money4 | null | undefined,
): string {
if (!before || !after) return "";
const d = after.계 - before.계;
return `${d > 0 ? "+" : ""}${d.toLocaleString("ko-KR")}`;
}
@@ -0,0 +1,146 @@
# -*- coding: utf-8 -*-
"""793-3b 지킴 — 불러오기 단산 줄 = 가져온 식 단가산출 모달(요구 646 · 647 · 652 갈래 C).
- 서버 길 `…/sangeun/from-hopyo` — 올림 때 산근 시트 둠(`실무자료/산근/<HP>.json`) →
블록 → 793-2a 내보내기 → 793-2 가져온 식 SK(만들 층 · 엑셀 단가 그대로 · 검산 = 엑셀) ·
같은 내용 = 그 SK 다시 씀 · 산근 없는 옛 HP = 409 · HP 지우면 산근도 지움 ·
STmate 블록 끝전 = 엑셀 계와 맞는 쪽
- 화면 소스 — [수정] 모달 단산 줄 · 오른쪽 블록 판 [가져온 식으로 열기] · 시트 모달
(`Sangeun_Detail`) · 머리 판(검산 · [우리 단가로] / [엑셀 단가로] · [원본 글 보기] ·
[이음 고르기] · 근거 세 단 · [우리 기초로 바꾸기] · 입력 필요 붉은 칸) ·
[이 줄에 잇기] = 줄 고른 것 SK · 결과 칩 · 「새」 배지 · 부품 상자만 · 700 줄
임시 storage(`client` 마당) · M01 원본 그대로.
"""
import re
from pathlib import Path
import helper_m02_hopyo_golden as golden
import pytest
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
from M02_MasterTemplete import M02_Sangeun_Router as sangeun_router
from M02_MasterTemplete import M02_Template_Layers as layers
# ruff: noqa: F811
from resources.tester.test_m02_hopyo_excel import _post, client, master_hash # noqa: F401
ROOT = Path(__file__).resolve().parents[2]
M02 = ROOT / "M02_MasterTemplete"
API = "/api/m02/layers/personal"
KEY = "HP-000001"
MINE = ("Hopyo_Imported", "Hopyo_ImportedPanel", "Hopyo_Check", "Hopyo_CheckRows", "Hopyo_Block")
def _src(name: str) -> str:
return (M02 / f"M02_MasterTemplete_{name}.ts").read_text(encoding="utf-8")
def _skcount() -> int:
folder = layers.personal_dir(7, 42) / "sangeun"
return len(list(folder.glob("*.json"))) if folder.exists() else 0
def _open(client, no=18):
body = {"층": "personal", "키": KEY, "번": no}
return client.post(f"{API}/sangeun/from-hopyo", json=body)
# ── 서버 길 ───────────────────────────────────────────────────────────
def test_단산_블록_가져온_식_만들기_다시씀_지움(client):
client.app.include_router(sangeun_router.router)
before = master_hash()
assert _post(client).status_code == 200
folder = layers.personal_dir(7, 42)
assert fh.has(folder, KEY)
made = _open(client)
assert made.status_code == 200, made.text
got = made.json()
assert got["이름"].startswith("SK-") and got["다시씀"] is False
assert got["단가기준"] == "엑셀" and got["검산"]["같음"] is True
assert round(got["검산"]["셈"]["계"]) == 30369 # 북부청임도 단산18 문양거푸집 = 엑셀
n = _skcount()
again = _open(client).json()
assert again["이름"] == got["이름"] and again["다시씀"] is True and _skcount() == n
with store.at(folder):
doc = store.read("sangeun", got["이름"])["문서"]
assert doc["바탕"] is None and doc["가져온"]["원본"]["엑셀금액"]["계"] == 30369
assert _open(client, 99999).status_code == 404
fh._path(folder, KEY).unlink() # 산근 없는 옛 HP = 다시 올림
old = _open(client)
assert old.status_code == 409 and old.json()["detail"] == fh.RELOAD
assert _post(client).status_code == 200 and fh.has(
folder, KEY
) # 같은 엑셀 다시 올림 = 산근 채움
assert client.delete(f"{API}/hopyo/{KEY}").status_code == 200
assert not fh.has(folder, KEY) # HP 지우면 산근도
assert master_hash() == before
RULES = [("봉화", "D00102", "비목버림"), ("동부청영월", "D00003", "합버림_큰나머지")]
@pytest.mark.parametrize(("tag", "code", "rule"), RULES)
def test_STmate_끝전_엑셀_계와_맞는_쪽(tag, code, rule):
from M02_MasterTemplete import M02_San_Book as book
from M02_MasterTemplete import M02_San_Stmate as stm
if not golden.FILES[tag].exists():
pytest.skip(f"실무 엑셀 없음 {tag}")
data = book.load(golden.FILES[tag])
blk = next(b for b in data["블록"] if b["코드"] == code)
assert fh._rule(blk, stm.parse_block(blk["몸"]), data["단가"]) == rule
# ── 화면 소스 ─────────────────────────────────────────────────────────
def test_단산_줄_길_잇기():
check, rows, block = _src("Hopyo_Check"), _src("Hopyo_CheckRows"), _src("Hopyo_Block")
assert "openImported({" in check and "onLink: (p) => kit.edit(row, [p])" in check
assert "imported }" in check.split("const ctx = ")[1].split(";")[0]
assert 'L("M02_CheckImported")' in rows and "row.번 !== undefined && ctx.imported" in rows
assert 'import("./M02_MasterTemplete_Hopyo_Imported")' in block # 돌림 import 피함
assert 'L("M02_CheckImported")' in block and "dataset.checkNew" in block
imp = _src("Hopyo_Imported")
assert '"/sangeun/from-hopyo"' in imp and "/sangeun/imported/${how}" in imp
assert "mountSangeunDetail(" in imp and "saveTemplate(" in imp and "openModal({" in imp
assert "skPick(sk" in imp and "o.onLink" in imp # [이 줄에 잇기] = 줄 고른 것 SK
for how in ('run("price"', 'run("link"', 'run("to-base"'):
assert how in imp, how
locale = (ROOT / "ui_template/ui_template_locale_m2_judge.ts").read_text(encoding="utf-8")
assert 'M02_CheckImported: ["가져온 식으로 열기"' in locale
def test_머리_판_결과_칩():
panel = _src("Hopyo_ImportedPanel")
for word in (
"ToOurs",
"ToExcel",
"Source",
"Link",
"ToBase",
"PickBase",
"Need",
"Same",
"Diff",
):
assert f'tx("{word}")' in panel, word
assert "newBadge()" in panel and "data-basis-tiers" in panel and "TIERS.map(" in panel
assert 'f.setError(tx("Need"))' in panel # 입력 필요 = 붉은 칸
model = (M02 / "logic/M02_Hopyo_Imported_Model.ts").read_text(encoding="utf-8")
assert '["확정", "근접", "사람"]' in model and "필요: need" in model
assert "입력 필요 ${need.length}" in model and '"가져온 식 있음"' in model
rows = _src("Hopyo_CheckRows")
assert "rowChips(ctx.kit.picksOf(row))" in rows and "dataset.judgeResult" in rows
assert not re.search(r"from \"@ui|document\.|fetch\(|sessionStorage\.", model) # 순수
def test_부품상자만_700줄_공용_안_고침():
for name in MINE:
text = _src(name)
assert len(text.splitlines()) <= 700, name
assert not re.search(r"""\bel\(\s*["'](button|input|select)["']""", text), name
assert not re.search(r"""createElement\(\s*["'](button|input|select)["']""", text), name
assert "#" not in "".join(re.findall(r"color:\s*[^;]+", text)), name
for name in ("Labor_Rows", "Labor_SangeunModal", "Sangeun_Detail", "Sangeun_Sheet"):
assert "Hopyo_Imported" not in _src(name), name # 공용 쪽은 새 모달을 모름
@@ -57,6 +57,7 @@ export const ui_locales_m2_judge = {
M02_JudgeOutside: ["일위대가 밖", "Not called by items"],
M02_CheckTitle: ["확인 — 제{n}호표", "Check — item {n}"],
M02_CheckNew: ["새", "New"],
M02_CheckImported: ["가져온 식으로 열기", "Open as imported formula"],
M02_CheckNewHint: ["「새」 = 785 에서 더한 기능", "「New」 = added in 785"],
M02_CheckApart: ["별도계상 · 품목은 프로젝트에서", "Separate · item chosen in project"],
M02_CheckExcelPrice: ["엑셀 단가", "Excel unit price"],