Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user