Merge remote-tracking branch 'origin/안티그래비티' into sub_laptop_3
This commit is contained in:
@@ -3,11 +3,14 @@
|
||||
* 수식 문서 옵션 가로(PLAN 802-3 · 802-4b-2 · 요구 655 「옵션 선택은 상단에 수평」) — 선택 · 입력 기호마다 칸 하나
|
||||
* 값 = 화면 값(`given`) › 문서 값 · 고침은 부른 쪽 `set`(M01 = 화면 값만 · M02 사본 = 문서 값) · `lock` = 읽기만
|
||||
* 목록이 모두 기계 원문번호(`0101-0007`)면 글 = 「이름 규격 (원문번호)」(요구 692-2 · 값은 그대로 · 이름은 한 번 받아 둠)
|
||||
* `view` 있음 + 기계 목록 = 곁에 [조회](827-1 · 기계 창 읽기 전용 · 고른 기계)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import { varId, type DocVar, type FormulaDoc } from "../common_util/common_util_formula_doc";
|
||||
import { cfMachineNames } from "../M02_MasterTemplete/DataFlow/M02_DF_Api";
|
||||
import { mt } from "../M02_MasterTemplete/M02_MyMachine_Text";
|
||||
|
||||
const MACHINE = /^\d{4}-\d{4}$/;
|
||||
let names: Record<string, string> = {};
|
||||
@@ -31,6 +34,7 @@ export function buildOptions(
|
||||
given: Record<string, unknown>,
|
||||
lock: boolean,
|
||||
set: (key: string, v: DocVar, value: unknown) => void,
|
||||
view?: (code: string) => void,
|
||||
): HTMLElement[] {
|
||||
const out: HTMLElement[] = [];
|
||||
for (const v of doc?.변수 ?? []) {
|
||||
@@ -54,6 +58,18 @@ export function buildOptions(
|
||||
f.root.dataset.option = k;
|
||||
nameMachines(list, () => f.setOptions(items(), f.select.value));
|
||||
out.push(f.root);
|
||||
const codes = list.filter(Boolean);
|
||||
if (view && codes.length && codes.every((x) => MACHINE.test(x))) {
|
||||
const b = createButton({
|
||||
label: mt("Win_View"),
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
iconStart: "search",
|
||||
onClick: () => view(f.select.value),
|
||||
});
|
||||
b.dataset.optionView = k;
|
||||
out.push(b);
|
||||
}
|
||||
} else {
|
||||
const f = createInputField({
|
||||
label,
|
||||
|
||||
@@ -452,3 +452,24 @@
|
||||
width: auto;
|
||||
min-width: 8em;
|
||||
}
|
||||
|
||||
/* 827-1 기계 창 — 왼쪽 찾기 | 오른쪽 기계 양식 */
|
||||
.lstk-mwin {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
|
||||
gap: var(--spacing-12);
|
||||
align-items: start;
|
||||
min-height: 0;
|
||||
}
|
||||
.lstk-mwin__list,
|
||||
.lstk-mwin__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.lstk-mwin {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { varId, type FormulaDoc, type MoneyLine } from "../common_util/common_ut
|
||||
import { isNum, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
|
||||
import { lineMap, offLine, type Values } from "../common_util/common_util_formula_doc_view";
|
||||
import type { DocTable } from "../common_util/common_util_formula_doc_table";
|
||||
import type { DocRecord } from "../common_util/common_util_formula_doc_cache";
|
||||
import {
|
||||
dropSyms,
|
||||
docOf,
|
||||
@@ -36,8 +37,15 @@ import {
|
||||
type StackItem,
|
||||
} from "../common_util/common_util_line_stack";
|
||||
import { placeNames } from "../common_util/common_util_line_stack_copy";
|
||||
import {
|
||||
draftsOf,
|
||||
machineNow,
|
||||
pruneDrafts,
|
||||
putDraft,
|
||||
} from "../common_util/common_util_line_stack_draft";
|
||||
import {
|
||||
addPicked,
|
||||
type Picked,
|
||||
aliasText,
|
||||
repick,
|
||||
shownUnit,
|
||||
@@ -47,7 +55,7 @@ import {
|
||||
import { createUnitPick, NO_UNIT } from "../M02_MasterTemplete/DataFlow/M02_DF_UnitPick";
|
||||
import { reach } from "./formula_doc_render";
|
||||
import { attachDrag } from "./line_stack_drag";
|
||||
import { toMineButton } from "./line_stack_mine";
|
||||
import { openMachineWindow, type MachineCtx } from "./line_stack_machine";
|
||||
import { openStackPick, type StackTake } from "./line_stack_pick";
|
||||
import { buildBlock, sourceTag } from "./line_stack_rows";
|
||||
import { lt, type LsKey } from "./line_stack_text";
|
||||
@@ -69,6 +77,10 @@ export interface EditHooks {
|
||||
take?: StackTake;
|
||||
/** 저장본 식 줄 {기호: 식} — 만약 식을 고쳐 굳은 줄 표지(요구 696) */
|
||||
before?: ReadonlyMap<string, unknown>;
|
||||
/** 827-1 기계 줄 = 기계 창(조회 · 바꾸기 · 고쳐 쓰기) — 층 · 권한 · 사본 줄 */
|
||||
machine?: MachineCtx;
|
||||
/** 827-1 초안 기계 셈 기록 → 계산기 캐시(브라우저 즉시 셈 · 서버 미리 셈과 같은 값) */
|
||||
prime?: (record: DocRecord) => void;
|
||||
}
|
||||
|
||||
const field = (
|
||||
@@ -157,6 +169,12 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
|
||||
: "";
|
||||
// 가져온 기계 줄(812-9) — 찾기 판정 · 조회어 = 기종 이름만(규격 뺌 · 이은 우리 기계 › 정규화 이름)
|
||||
const found = kind === "기계" ? machineFound(d, own) : undefined;
|
||||
const query = found?.찾기어 ?? String(mat?.검색어 ?? line.이름 ?? "");
|
||||
const swap = (p: Picked): void =>
|
||||
h.change((doc) => {
|
||||
repick(doc, i, own, kind, p);
|
||||
pruneDrafts(doc); // 827-1 안 쓰게 된 초안 뺌
|
||||
});
|
||||
const pick = createButton({
|
||||
label: (size && !name.includes(size) ? `${name} ${size}` : name) || "—",
|
||||
variant: "ghost",
|
||||
@@ -164,12 +182,19 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
|
||||
size: 24,
|
||||
iconStart: "search",
|
||||
onClick: () =>
|
||||
openStackPick(
|
||||
kind,
|
||||
(p) => h.change((doc) => repick(doc, i, own, kind, p)),
|
||||
kind === "기계" ? h.take : undefined, // 821-3 개인 층 = 내 기계 묶음도
|
||||
found?.찾기어 ?? String(mat?.검색어 ?? line.이름 ?? ""),
|
||||
),
|
||||
kind === "기계" && h.machine // 827-1 기계 창
|
||||
? openMachineWindow({
|
||||
ctx: h.machine,
|
||||
drafts: draftsOf(d),
|
||||
now: machineNow(d, link as { 갈래?: string; 입력?: Record<string, string> }),
|
||||
query,
|
||||
pick: swap,
|
||||
draft: (item, p, record) => {
|
||||
h.prime?.(record);
|
||||
h.change((doc) => putDraft(doc, i, own, item, p));
|
||||
},
|
||||
})
|
||||
: openStackPick(kind, swap, undefined, query),
|
||||
});
|
||||
pick.classList.add("lstk-res");
|
||||
pick.title = `${lt("Repick")}${res ? ` — ${res}` : ""}`;
|
||||
@@ -195,14 +220,6 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
|
||||
back.dataset.unswap = String(i);
|
||||
head.push(tag, back);
|
||||
}
|
||||
// 821-3 품셈 기계 줄 → 복사해 내 기계로(개인 층 SK)
|
||||
const mine =
|
||||
kind === "기계"
|
||||
? toMineButton(h.take, link as { 갈래?: string; 입력?: Record<string, string> }, (p) =>
|
||||
h.change((doc) => repick(doc, i, own, kind, p)),
|
||||
)
|
||||
: null;
|
||||
if (mine) head.push(mine);
|
||||
} else
|
||||
head.push(
|
||||
field(lt("Name"), line.이름, (t) => put((l) => (l.이름 = t)), "m"),
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
/* =============================================================================
|
||||
* line_stack_machine.ts
|
||||
* 줄 쌓기 기계 창(PLAN 827-1 · 요구 810 · 811) — 기계 줄 이름 단추 · 옵션 가로 [조회]가 띄움.
|
||||
* 왼쪽 = 기계 찾기 — 맨 위 「지금 이 줄」 · 이 단가산출 초안 · 개인 층 내 기계 · M01 기계(줄마다 시간당)
|
||||
* 오른쪽 = 공통 기계 양식(`ui_template_machine_form` · 읽기 전용) — 지금 줄 = 이 단가산출 사본에 박힌 값
|
||||
* (원본 지금 값과 다르면 「새 값 있음」) · 고른 줄 = 그 기계 지금 값
|
||||
* [이 기계로 바꾸기] = `pick`(부른 쪽 `repick` · 가져온 식 823-1 규칙 그대로)
|
||||
* [고쳐 쓰기](개인 층 · 쓰기 권한 · `ctx.canEdit`) = 양식 편집 → [확인] = 서버 확인(쓰기 없음) → `draft`
|
||||
* (부른 쪽이 문서 칸 `내기계초안` 에 · 디스크는 단가산출 [저장] 때 서버) · 그 밖 = 까닭 한 줄
|
||||
* M01 기계에서 시작 = 새 내 기계(본뜬 = 그 키) · 내 기계 = 그 기계 고침 · 초안 = 그 초안 고침 · 즉시 저장 없음
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { createDataTable } from "@ui/ui_template_data_table";
|
||||
import { createInputField, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { createMachineForm, HOURLY_KEY, type MachineValues } from "@ui/ui_template_machine_form";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { isDraftNo, newTag, type DraftItem } from "../common_util/common_util_line_stack_draft";
|
||||
import type { DocRecord } from "../common_util/common_util_formula_doc_cache";
|
||||
import type { Picked } from "../common_util/common_util_line_stack_pick";
|
||||
import { call, type Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
checkDraft,
|
||||
fetchMyMachineLists,
|
||||
listMyMachines,
|
||||
machineArg,
|
||||
machineRows,
|
||||
readMyMachine,
|
||||
type MachineBrief,
|
||||
type MyHourly,
|
||||
type MyMachineRow,
|
||||
} from "../M02_MasterTemplete/M02_MyMachine_Api";
|
||||
import { bodyOfForm, hourlyText, valuesOf } from "../M02_MasterTemplete/M02_MyMachine_Form";
|
||||
import { mt } from "../M02_MasterTemplete/M02_MyMachine_Text";
|
||||
|
||||
/** 사본 줄(서버 `copy_row`) — 키 · 값 · 방식(내 기계) · 시간당(직접) */
|
||||
interface CopyRowData {
|
||||
키: string;
|
||||
원문번호?: string;
|
||||
이름?: string;
|
||||
규격?: string;
|
||||
값?: MachineValues;
|
||||
방식?: string;
|
||||
시간당?: Record<string, number>;
|
||||
}
|
||||
export interface CopyAnswer {
|
||||
사본: CopyRowData | null;
|
||||
지금: CopyRowData | null;
|
||||
새값: boolean;
|
||||
}
|
||||
|
||||
export interface MachineCtx {
|
||||
/** 층 · 주인 — null = M01 단가산출 기초(M01 기계만) */
|
||||
scope: { layer: Layer; owner: string | null } | null;
|
||||
/** [고쳐 쓰기] 됨(개인 층 SK · 쓰기 권한) */
|
||||
canEdit: boolean;
|
||||
/** 못 고칠 까닭(M01 · 회사 · 마스터 층) */
|
||||
why: string;
|
||||
/** 이 SK 키(쓰는 곳 셈에서 뺌) */
|
||||
me?: string;
|
||||
/** 이 SK 사본에 박힌 기계 줄(키 있는 M02 SK) */
|
||||
copyRow?: (no: string) => Promise<CopyAnswer>;
|
||||
}
|
||||
|
||||
export interface MachineWindowOptions {
|
||||
ctx: MachineCtx;
|
||||
drafts: DraftItem[];
|
||||
/** 지금 줄 기계 입력 글(따옴표 뗌 · 원문번호 · 개인 EQP… · 개인 EQP초안-N) · 없음 = null */
|
||||
now: string | null;
|
||||
query: string;
|
||||
/** 없으면 보기만(옵션 가로 조회) */
|
||||
pick?: (p: Picked) => void;
|
||||
/** 고쳐 쓰기 [확인] — 초안 · 줄 연결 · 그 식 셈 기록(계산기 캐시 · 즉시 셈) */
|
||||
draft?: (d: DraftItem, p: Picked, record: DocRecord) => void;
|
||||
}
|
||||
|
||||
type Kind = "now" | "draft" | "mine" | "m01";
|
||||
interface Cand {
|
||||
kind: Kind;
|
||||
/** 기계 입력 글 */
|
||||
no: string;
|
||||
이름: string;
|
||||
규격: string;
|
||||
계: number | null;
|
||||
구분: string;
|
||||
draft?: DraftItem;
|
||||
mine?: MyMachineRow;
|
||||
/** M01 키(EQ…) */
|
||||
key?: string;
|
||||
}
|
||||
interface Shown {
|
||||
values: MachineValues;
|
||||
name: string;
|
||||
spec: string;
|
||||
lines: HTMLElement[];
|
||||
logic?: string | null;
|
||||
/** [고쳐 쓰기] 대상 */
|
||||
target: { 표지?: string; 키?: string; 본뜬?: string };
|
||||
/** 내 기계 = 쓰는 곳 셈 키 */
|
||||
mineKey?: string;
|
||||
}
|
||||
|
||||
const kindOf = (no: string): Kind =>
|
||||
isDraftNo(no) ? "draft" : no.startsWith("개인 ") ? "mine" : "m01";
|
||||
|
||||
/** 사본 · 원본 줄 → 양식 값 — 직접 = 시간당 · 바로(셈 꼴) = 계수 셋 · 빈 연료 · 조종원 0 뺌 */
|
||||
function formValues(row: CopyRowData): MachineValues {
|
||||
if (row.방식 === "직접") {
|
||||
const v = row.시간당 ?? row.값 ?? {};
|
||||
return { [HOURLY_KEY]: { 노무비: v.노무비, 재료비: v.재료비, 경비: v.경비 } };
|
||||
}
|
||||
const v: MachineValues = { ...(row.값 ?? {}) };
|
||||
if (row.방식 === "바로") {
|
||||
for (const k of ["상각비계수", "정비비계수", "관리비계수"]) delete v[k];
|
||||
for (const k of ["주연료", "잡재료율", "조종원"]) if (v[k] === 0) delete v[k];
|
||||
for (const k of ["주연료종류", "조종원직종"]) if (v[k] === "") delete v[k];
|
||||
}
|
||||
return v;
|
||||
}
|
||||
/** 내 기계 목록 줄 → 양식 값(직접 = 서버 시간당 노 · 재 · 경) */
|
||||
const mineValues = (m: MyMachineRow): MachineValues =>
|
||||
valuesOf({
|
||||
방식: m.방식,
|
||||
값: m.값,
|
||||
시간당: {
|
||||
노무비: m.시간당.노무비 ?? 0,
|
||||
재료비: m.시간당.재료비 ?? 0,
|
||||
경비: m.시간당.경비 ?? 0,
|
||||
},
|
||||
});
|
||||
const hourOf = (h: MyHourly | undefined): number | null => (h?.ok ? (h.계 ?? null) : null);
|
||||
const same = (a: Cand | undefined, b: Cand | null): boolean =>
|
||||
!!a && !!b && a.kind === b.kind && a.no === b.no && a.key === b.key;
|
||||
|
||||
export function openMachineWindow(o: MachineWindowOptions): void {
|
||||
const { ctx } = o;
|
||||
const owner = ctx.scope?.owner ?? null;
|
||||
const personal = ctx.scope?.layer === "personal";
|
||||
let mines: MyMachineRow[] = [];
|
||||
let m01: { ref: string; 이름: string; 규격?: string | null; 구분?: string | null }[] = [];
|
||||
const briefs = new Map<string, MachineBrief>();
|
||||
let cands: Cand[] = [];
|
||||
let sel: Cand | null = null;
|
||||
let editing = false;
|
||||
let formDirty = (): boolean => false;
|
||||
let seq = 0;
|
||||
let showSeq = 0;
|
||||
let timer = 0;
|
||||
let close = (): void => {};
|
||||
|
||||
const search = createInputField({ type: "search", placeholder: mt("Find") });
|
||||
search.input.value = o.query;
|
||||
const hint = createText("");
|
||||
const right = el("div", { className: "lstk-mwin__form", attrs: { "data-mwin-form": "" } });
|
||||
const table = createDataTable({
|
||||
caption: mt("Win_Title"),
|
||||
columns: [
|
||||
{ key: "품명", label: mt("Name"), width: 150 },
|
||||
{ key: "규격", label: mt("Spec"), width: 80 },
|
||||
{ key: "금액", label: mt("Hourly"), kind: "number", decimals: 0, width: 84 },
|
||||
{ key: "구분", label: mt("Win_Kind"), width: 110 },
|
||||
],
|
||||
rows: [],
|
||||
rowClass: (i) => (same(cands[i], sel) ? "is-title" : null),
|
||||
onRowClick: (i) => {
|
||||
if (cands[i]) void choose(cands[i]);
|
||||
},
|
||||
});
|
||||
|
||||
/* --- 목록 --- */
|
||||
const nowCand = (): Cand | null => {
|
||||
if (!o.now) return null;
|
||||
const d = o.drafts.find((x) => `개인 ${x.표지}` === o.now);
|
||||
const m = mines.find((x) => x.원문번호 === o.now);
|
||||
const b = [...briefs.values()].find((x) => x.원문번호 === o.now);
|
||||
const name = d?.줄.이름 ?? m?.이름 ?? b?.이름 ?? o.now;
|
||||
return {
|
||||
kind: "now",
|
||||
no: o.now,
|
||||
이름: name,
|
||||
규격: d?.줄.규격 ?? m?.규격 ?? b?.규격 ?? "",
|
||||
계: d ? hourOf(d.시간당) : m ? hourOf(m.시간당) : hourOf(b?.시간당),
|
||||
구분: mt("Win_Now"),
|
||||
};
|
||||
};
|
||||
const draw = (): void => {
|
||||
const words = search.input.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const hit = (t: string): boolean => words.every((w) => t.toLowerCase().includes(w));
|
||||
const now = nowCand();
|
||||
cands = [
|
||||
...(now ? [now] : []),
|
||||
...o.drafts
|
||||
.filter((d) => hit(`${d.줄.이름} ${d.줄.규격}`))
|
||||
.map((d): Cand => ({
|
||||
kind: "draft",
|
||||
no: `개인 ${d.표지}`,
|
||||
이름: d.줄.이름,
|
||||
규격: d.줄.규격,
|
||||
계: hourOf(d.시간당),
|
||||
구분: mt("Win_Draft"),
|
||||
draft: d,
|
||||
})),
|
||||
...mines
|
||||
.filter((r) => hit(`${r.이름} ${r.규격} ${r.키}`))
|
||||
.map((r): Cand => ({
|
||||
kind: "mine",
|
||||
no: r.원문번호,
|
||||
이름: r.이름,
|
||||
규격: r.규격,
|
||||
계: hourOf(r.시간당),
|
||||
구분: `${mt("Pick_Group")} · ${mt(r.방식)}`,
|
||||
mine: r,
|
||||
})),
|
||||
...m01.map((x): Cand => ({
|
||||
kind: "m01",
|
||||
no: briefs.get(x.ref)?.원문번호 ?? "",
|
||||
이름: x.이름,
|
||||
규격: x.규격 ?? "",
|
||||
계: hourOf(briefs.get(x.ref)?.시간당),
|
||||
구분: [mt("Win_M01"), x.구분].filter(Boolean).join(" · "),
|
||||
key: x.ref,
|
||||
})),
|
||||
];
|
||||
hint.textContent = `${cands.length}`;
|
||||
table.setRows(cands.map((c) => ({ 품명: c.이름, 규격: c.규격, 금액: c.계, 구분: c.구분 })));
|
||||
void fill();
|
||||
};
|
||||
/** M01 줄 시간당 — 보이는 앞 40 줄만 뒤에서(색인 메모 · 없는 것만) */
|
||||
const fill = async (): Promise<void> => {
|
||||
const want = cands
|
||||
.filter((c) => c.kind === "m01" && c.key && !briefs.has(c.key))
|
||||
.slice(0, 40)
|
||||
.map((c) => c.key!);
|
||||
if (!want.length) return;
|
||||
const mine = seq;
|
||||
const got = await machineRows(want).catch(() => []);
|
||||
for (const b of got) briefs.set(b.키, b);
|
||||
if (mine === seq && got.length) draw();
|
||||
};
|
||||
const find = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
const q = search.input.value.trim();
|
||||
const got = await call<{ items: typeof m01 }>(
|
||||
`/logic-lib/elements?group=${encodeURIComponent("기계")}&q=${encodeURIComponent(q)}&limit=200`,
|
||||
).catch(() => ({ items: [] }));
|
||||
if (mine !== seq) return;
|
||||
m01 = got.items;
|
||||
draw();
|
||||
};
|
||||
search.input.addEventListener("input", () => {
|
||||
draw();
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => void find(), 250);
|
||||
});
|
||||
|
||||
/* --- 오른쪽 --- */
|
||||
const briefOf = async (no: string, key?: string): Promise<MachineBrief | undefined> => {
|
||||
const at = key ?? no;
|
||||
const have = key ? briefs.get(key) : [...briefs.values()].find((b) => b.원문번호 === no);
|
||||
if (have) return have;
|
||||
const [got] = await machineRows([at]).catch(() => []);
|
||||
if (got) briefs.set(got.키, got);
|
||||
return got;
|
||||
};
|
||||
|
||||
/** 고른 줄 → 양식 값 · 글 · 바꿀 연결 · 고칠 대상 */
|
||||
const resolve = async (c: Cand): Promise<Shown> => {
|
||||
const k = c.kind === "now" ? kindOf(c.no) : c.kind;
|
||||
const lines: HTMLElement[] = [];
|
||||
if (k === "draft") {
|
||||
const d = c.draft ?? o.drafts.find((x) => `개인 ${x.표지}` === c.no);
|
||||
if (!d) throw new Error(mt("Hourly_None"));
|
||||
lines.push(createText(mt("Win_Draft"), "muted"));
|
||||
if (d.시간당)
|
||||
lines.push(createText(`${mt("Server_Hourly")} ${hourlyText(d.시간당 as MyHourly)}`));
|
||||
const target = {
|
||||
표지: d.표지,
|
||||
...(d.키 ? { 키: d.키 } : {}),
|
||||
...(d.본뜬 ? { 본뜬: d.본뜬 } : {}),
|
||||
};
|
||||
return {
|
||||
values: valuesOf({
|
||||
방식: d.줄.방식 as never,
|
||||
값: (d.줄.값 ?? {}) as MachineValues,
|
||||
시간당: d.줄.시간당 as never,
|
||||
}),
|
||||
name: d.줄.이름,
|
||||
spec: d.줄.규격,
|
||||
lines,
|
||||
logic: d.시간당?.로직,
|
||||
target,
|
||||
mineKey: d.키,
|
||||
};
|
||||
}
|
||||
// 지금 줄 = 이 단가산출 사본 값(있으면)
|
||||
const copy = c.kind === "now" && ctx.copyRow ? await ctx.copyRow(c.no).catch(() => null) : null;
|
||||
const row = copy?.사본 ?? null;
|
||||
if (row) {
|
||||
lines.push(createText(mt("Win_Copy"), "muted"));
|
||||
if (copy?.새값) lines.push(createText(mt("Win_Newer"), "warning"));
|
||||
}
|
||||
if (k === "mine") {
|
||||
const m = c.mine ?? mines.find((r) => r.원문번호 === c.no);
|
||||
const key = m?.키 ?? c.no.replace(/^개인 /, "");
|
||||
const was = o.drafts.find((d) => d.키 === key); // 이미 고친 초안 = 그 초안을 다시 고침
|
||||
if (!row && m) lines.push(createText(`${mt("Server_Hourly")} ${hourlyText(m.시간당)}`));
|
||||
return {
|
||||
values: row ? formValues(row) : m ? mineValues(m) : {},
|
||||
name: row?.이름 ?? m?.이름 ?? c.이름,
|
||||
spec: row?.규격 ?? m?.규격 ?? c.규격,
|
||||
lines,
|
||||
logic: m?.시간당.로직,
|
||||
target: { 키: key, ...(was ? { 표지: was.표지 } : {}) },
|
||||
mineKey: key,
|
||||
};
|
||||
}
|
||||
const b = await briefOf(c.no, c.key);
|
||||
if (!row && b) lines.push(createText(`${mt("Win_Live")} · ${hourlyText(b.시간당)}`));
|
||||
if (!row && !b) throw new Error(mt("Hourly_None"));
|
||||
return {
|
||||
values: row ? formValues(row) : { ...(b?.값 ?? {}) },
|
||||
name: row?.이름 ?? b?.이름 ?? c.이름,
|
||||
spec: row?.규격 ?? b?.규격 ?? c.규격,
|
||||
lines,
|
||||
logic: b?.시간당.ok ? b.시간당.로직 : null,
|
||||
target: { 본뜬: row?.키 ?? b?.키 },
|
||||
};
|
||||
};
|
||||
|
||||
const pickedOf = (c: Cand, s: Shown): Picked | null => {
|
||||
if (!s.logic) return null;
|
||||
const no = c.kind === "mine" && c.mine ? machineArg(c.mine.원문번호) : `'${c.no}'`;
|
||||
return { 키: s.logic, 이름: s.name, 입력: { 기계: no }, ...(s.spec ? { 규격: s.spec } : {}) };
|
||||
};
|
||||
|
||||
const choose = async (c: Cand): Promise<void> => {
|
||||
if (editing && formDirty() && !(await showConfirmDialog(mt("Leave_Ask")))) return;
|
||||
editing = false;
|
||||
sel = c;
|
||||
table.setRows(cands.map((x) => ({ 품명: x.이름, 규격: x.규격, 금액: x.계, 구분: x.구분 })));
|
||||
await show(c);
|
||||
};
|
||||
|
||||
async function show(c: Cand, edit = false, blank = false): Promise<void> {
|
||||
const mine = ++showSeq;
|
||||
right.replaceChildren(createText(mt("Server_Hourly"), "muted"));
|
||||
let s: Shown;
|
||||
try {
|
||||
s = blank ? { values: {}, name: "", spec: "", lines: [], target: {} } : await resolve(c);
|
||||
} catch (error) {
|
||||
if (mine === showSeq)
|
||||
right.replaceChildren(
|
||||
createText(error instanceof Error ? error.message : mt("Failed"), "error"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const lists = await fetchMyMachineLists();
|
||||
if (mine !== showSeq) return;
|
||||
editing = edit;
|
||||
const notice = el("div", { className: "lstk-mwin__notice" });
|
||||
const form = createMachineForm({
|
||||
name: s.name,
|
||||
spec: s.spec,
|
||||
values: s.values,
|
||||
coefMode: "direct",
|
||||
allowHourly: true,
|
||||
fuels: lists.fuels,
|
||||
jobs: lists.jobs,
|
||||
power: lists.power,
|
||||
hour: lists.hour,
|
||||
readOnly: !edit,
|
||||
onChange: () => notice.replaceChildren(),
|
||||
});
|
||||
formDirty = () => edit && form.state().changed;
|
||||
const bar: HTMLElement[] = [];
|
||||
if (!edit) {
|
||||
const p = pickedOf(c, s);
|
||||
if (o.pick && c.kind !== "now") {
|
||||
const b = createButton({
|
||||
label: mt("Win_Swap"),
|
||||
variant: "filled",
|
||||
size: 28,
|
||||
disabled: !p,
|
||||
onClick: () => {
|
||||
if (!p) return;
|
||||
close();
|
||||
o.pick!(p);
|
||||
},
|
||||
});
|
||||
b.dataset.mwinSwap = "";
|
||||
if (!p) b.title = mt("Pick_Direct");
|
||||
bar.push(b);
|
||||
}
|
||||
if (o.draft && ctx.canEdit) {
|
||||
const b = createButton({
|
||||
label: mt("Win_Edit"),
|
||||
variant: c.kind === "now" ? "filled" : "ghost",
|
||||
size: 28,
|
||||
iconStart: "pencil",
|
||||
onClick: () => void show(c, true),
|
||||
});
|
||||
b.dataset.mwinEdit = "";
|
||||
bar.push(b);
|
||||
} else if (o.draft && ctx.why) bar.push(createText(ctx.why, "muted"));
|
||||
} else {
|
||||
const ok = createButton({ label: mt("Win_Ok"), variant: "filled", size: 28 });
|
||||
ok.dataset.mwinOk = "";
|
||||
ok.addEventListener("click", () => void confirm(form.state(), s, notice, ok));
|
||||
bar.push(
|
||||
ok,
|
||||
createButton({
|
||||
label: mt("Win_Cancel"),
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () => void (blank ? choose(c) : show(c)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const extra: HTMLElement[] = [];
|
||||
if (edit) extra.push(createText(mt("Win_Edit_Note"), "muted"));
|
||||
if (edit && s.target.키) {
|
||||
const uses = el("div");
|
||||
extra.push(uses);
|
||||
void readMyMachine(owner, s.target.키)
|
||||
.then((one) => {
|
||||
const n = (one.쓰는곳 ?? []).filter((u) => u.키 !== ctx.me).length;
|
||||
uses.replaceChildren(createText(mt("Win_Uses").replace("{value}", String(n)), "warning"));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
right.replaceChildren(
|
||||
el("div", {
|
||||
className: "ui-toolbar",
|
||||
children: [el("strong", { text: `${s.name} ${s.spec}`.trim() || mt("New") }), ...bar],
|
||||
}),
|
||||
...s.lines,
|
||||
...extra,
|
||||
notice,
|
||||
form.root,
|
||||
);
|
||||
right.dataset.mwinMode = edit ? "edit" : "view";
|
||||
}
|
||||
|
||||
/** [확인] — 서버 확인(쓰기 없음) → 초안 · 줄 연결 */
|
||||
async function confirm(
|
||||
state: ReturnType<ReturnType<typeof createMachineForm>["state"]>,
|
||||
s: Shown,
|
||||
notice: HTMLElement,
|
||||
btn: HTMLButtonElement,
|
||||
): Promise<void> {
|
||||
const { body, errors } = bodyOfForm(state);
|
||||
notice.replaceChildren(...errors.map((e) => createText(e, "error")));
|
||||
if (!body) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const tag = s.target.표지 ?? newTag();
|
||||
const got = await checkDraft(owner, body, { ...s.target, 표지: tag });
|
||||
if (!got.시간당.ok || !got.시간당.로직)
|
||||
throw new Error(hourlyText(got.시간당) || mt("Pick_Direct"));
|
||||
const item: DraftItem = {
|
||||
표지: tag,
|
||||
...(s.target.키 ? { 키: s.target.키 } : {}),
|
||||
...(!s.target.키 && s.target.본뜬 ? { 본뜬: s.target.본뜬 } : {}),
|
||||
줄: body,
|
||||
시간당: got.시간당,
|
||||
};
|
||||
const p: Picked = {
|
||||
키: got.시간당.로직,
|
||||
이름: body.이름,
|
||||
입력: { 기계: `'개인 ${tag}'` },
|
||||
...(body.규격 ? { 규격: body.규격 } : {}),
|
||||
};
|
||||
formDirty = () => false;
|
||||
close();
|
||||
o.draft!(item, p, got.연결);
|
||||
} catch (error) {
|
||||
notice.replaceChildren(
|
||||
createText(error instanceof Error ? error.message : mt("Failed"), "error"),
|
||||
);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
const blankBtn = createButton({
|
||||
label: mt("Win_Blank"),
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
const c: Cand = { kind: "draft", no: "", 이름: "", 규격: "", 계: null, 구분: "" };
|
||||
sel = null;
|
||||
void show(c, true, true);
|
||||
},
|
||||
});
|
||||
blankBtn.dataset.mwinBlank = "";
|
||||
({ close } = openModal({
|
||||
title: mt("Win_Title"),
|
||||
closeLabel: mt("Close"),
|
||||
width: "1100px",
|
||||
wide: true,
|
||||
...(o.draft && ctx.canEdit ? { actions: [blankBtn] } : {}),
|
||||
beforeClose: async () => !formDirty() || showConfirmDialog(mt("Leave_Ask")),
|
||||
mount: (body) =>
|
||||
body.append(
|
||||
el("div", {
|
||||
className: "lstk-mwin",
|
||||
attrs: { "data-machine-window": "" },
|
||||
children: [
|
||||
el("div", {
|
||||
className: "lstk-mwin__list",
|
||||
children: [
|
||||
el("div", { className: "ui-toolbar", children: [search.root, hint] }),
|
||||
table.root,
|
||||
],
|
||||
}),
|
||||
right,
|
||||
],
|
||||
}),
|
||||
),
|
||||
}));
|
||||
right.replaceChildren(createText(mt("Win_Hint"), "muted"));
|
||||
|
||||
void (async () => {
|
||||
if (personal) mines = await listMyMachines(owner).catch(() => []);
|
||||
if (o.now && kindOf(o.now) === "m01") await briefOf(o.now);
|
||||
await find();
|
||||
const first = cands[0];
|
||||
if (first && first.kind === "now") await choose(first);
|
||||
})().catch((error) => showToast(error instanceof Error ? error.message : mt("Failed"), "error"));
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/* =============================================================================
|
||||
* line_stack_mine.ts
|
||||
* 줄 쌓기 기계 줄 「복사해 내 기계로」(PLAN 821-3 · 요구서 796 3 장) — M02 개인 층 단가산출만.
|
||||
* 품셈 기계 줄(기계='원문번호') → 그 기계 값을 복사한 내 기계 양식 창 → 저장하면 줄이 그 내 기계로(기계='개인 EQP…')
|
||||
* M01 정본은 그대로 · 내 기계 줄(기계='개인 …') · M01 단가산출 기초 = 단추 없음
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import type { Picked } from "../common_util/common_util_line_stack_pick";
|
||||
import { findSimilar, machineArg, mineElement } from "../M02_MasterTemplete/M02_MyMachine_Api";
|
||||
import { openMyMachineModal } from "../M02_MasterTemplete/M02_MyMachine_Form";
|
||||
import { mt } from "../M02_MasterTemplete/M02_MyMachine_Text";
|
||||
import type { StackTake } from "./line_stack_pick";
|
||||
|
||||
/** 기계 줄 연결 → 품셈 기계 원문번호(내 기계 · 기계 입력 없음 = null) */
|
||||
export function m01MachineNo(link: {
|
||||
갈래?: string;
|
||||
입력?: Record<string, string>;
|
||||
}): string | null {
|
||||
const arg = link.갈래 === "단가산출" ? link.입력?.기계 : undefined;
|
||||
const no = typeof arg === "string" ? arg.trim().replace(/^'|'$/g, "") : "";
|
||||
return no && !no.startsWith("개인 ") ? no : null;
|
||||
}
|
||||
|
||||
/** 「복사해 내 기계로」 단추 — 개인 층 SK 품셈 기계 줄만 · 고르면 `done(새 줄 고름)` */
|
||||
export function toMineButton(
|
||||
take: StackTake | undefined,
|
||||
link: { 갈래?: string; 입력?: Record<string, string> },
|
||||
done: (p: Picked) => void,
|
||||
): HTMLElement | null {
|
||||
const no = m01MachineNo(link);
|
||||
if (!no || take?.layer !== "personal") return null;
|
||||
const b = createButton({
|
||||
label: mt("To_Mine"),
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
iconStart: "copy",
|
||||
onClick: async () => {
|
||||
const rows = await findSimilar(take.owner, no).catch(() => []);
|
||||
const seed = rows.find((r) => r.출처 === "M01" && r.원문번호 === no);
|
||||
if (!seed) return showToast(`${mt("Hourly_None")} — ${no}`, "warning");
|
||||
openMyMachineModal(
|
||||
take.owner,
|
||||
(got) => {
|
||||
const e = mineElement(got.줄, got.시간당);
|
||||
if (!e.로직) return showToast(mt("Pick_Direct"), "warning");
|
||||
done({
|
||||
키: e.로직,
|
||||
이름: e.이름,
|
||||
입력: { 기계: machineArg(got.줄.원문번호) },
|
||||
...(e.규격 ? { 규격: e.규격 } : {}),
|
||||
});
|
||||
},
|
||||
seed,
|
||||
);
|
||||
},
|
||||
});
|
||||
b.dataset.toMine = no;
|
||||
return b;
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
* 셈 = 브라우저 계산기(`calcDoc`) 즉시 · 캐시에 없는 연결만 뒤에서 `POST {길}/preview`(옛 요청 끊음)
|
||||
* 고침 = 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈) · 자동저장 없음
|
||||
* M02 사본(`copy`) — 저장 몸 {문서, 끊음(받은 그대로), 판} · 판 바뀜(409)은 문서가 그대로면 새 판으로 한 번 더
|
||||
* 기계 줄 · 옵션 가로 기계 = 기계 창(827-1 · `line_stack_machine`) — 초안은 문서 칸(캐시) · [저장] 때 서버가 내 기계로
|
||||
* [단가 갱신] · [재생성] = 미리 셈 → 알림 → 저장(`formula_doc_copy.ts` · 고친 것 먼저 저장)
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -53,6 +54,8 @@ import { ft } from "./formula_doc_text";
|
||||
import { addTaken, type Taken } from "../common_util/common_util_line_stack_copy";
|
||||
import type { Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import { buildEdit } from "./line_stack_edit";
|
||||
import { openMachineWindow, type CopyAnswer, type MachineCtx } from "./line_stack_machine";
|
||||
import { mt } from "../M02_MasterTemplete/M02_MyMachine_Text";
|
||||
import { buildOut } from "./line_stack_out";
|
||||
import { lt } from "./line_stack_text";
|
||||
import "./line_stack.css";
|
||||
@@ -269,6 +272,18 @@ export function createStackScreen(opt: StackScreenOptions) {
|
||||
|
||||
// ── 그림 ──
|
||||
const edit = (): boolean => opt.admin && (!!key || !cp);
|
||||
/** 827-1 기계 창 — 개인 층 M02 SK 만 [고쳐 쓰기] · M01 기초 = M01 기계만 · 사본 줄 = 수식 길 `/machine` */
|
||||
const machineCtx = (): MachineCtx => ({
|
||||
scope: cp && opt.scope ? opt.scope : null,
|
||||
canEdit: !!cp && !!key && opt.scope?.layer === "personal" && edit(),
|
||||
why: !cp ? mt("Why_M01") : opt.scope?.layer === "personal" ? "" : mt("Why_Company"),
|
||||
...(key ? { me: key } : {}),
|
||||
...(cp && key
|
||||
? { copyRow: (no: string) => call<CopyAnswer>(`/machine?no=${encodeURIComponent(no)}`) }
|
||||
: {}),
|
||||
});
|
||||
/** 초안 기계 셈 기록(서버 확인 길 · 요소 · 표 · 식 문서) → 계산기 캐시 — [확인] 직후 브라우저가 바로 셈 */
|
||||
const prime = (record: DocRecord): void => cache.merge(record);
|
||||
const draw = (): void => {
|
||||
if (!doc || !answer) {
|
||||
[head, left, right, foot].forEach((x) => x.replaceChildren());
|
||||
@@ -285,7 +300,9 @@ export function createStackScreen(opt: StackScreenOptions) {
|
||||
// 가져온 식 = 옵션 가로 안 그림(819-1 · 요구 787 — 값은 왼쪽 공식 묶음에 같이 보임)
|
||||
const options = (doc as { 가져온?: unknown }).가져온
|
||||
? []
|
||||
: buildOptions(doc, given, !!cp && !opt.admin, setOption);
|
||||
: buildOptions(doc, given, !!cp && !opt.admin, setOption, (no) =>
|
||||
openMachineWindow({ ctx: machineCtx(), drafts: [], now: no, query: "" }),
|
||||
);
|
||||
const busy = dirty();
|
||||
const tools = [
|
||||
...(mark?.단가기준 && opt.page ? [basisPick(mark.단가기준)] : []), // 모달 = 머리 판 단추(793-3b)
|
||||
@@ -343,6 +360,8 @@ export function createStackScreen(opt: StackScreenOptions) {
|
||||
open: blocks,
|
||||
pair: (loc) => pair(loc, "left"),
|
||||
take: takeCtx(),
|
||||
machine: machineCtx(),
|
||||
prime,
|
||||
before: saved ? lineMap(JSON.parse(saved) as StackDoc) : undefined,
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
POST /api/m02/layers/{층}/sangeun/{키}/doc/preview 미리 셈(층 쓰기) {문서?, 끊음?, 할일?}
|
||||
→ {…, 전, 후, 바뀐자원?, 버림?}
|
||||
POST /api/m02/layers/{층}/sangeun/{키}/doc/save 저장(층 쓰기) {문서?, 끊음?, 할일?, 판} → 읽기 꼴
|
||||
GET /api/m02/layers/{층}/sangeun/{키}/doc/machine?no= 기계 창(827-1 · 층 읽기) → {사본, 지금, 새값}
|
||||
옛 별칭 장부 쓰기(812-9) · 연결 찾기 길(810-4)은 걷음(815-2 · 요구 762-1 · 765).
|
||||
⚠ 등록은 `main.py`(로그인만 · 층 권한은 길 안에서).
|
||||
"""
|
||||
@@ -37,6 +38,17 @@ def read_doc(
|
||||
return _call(_root(session, layer, owner), path.read, key)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/sangeun/{key}/doc/machine")
|
||||
def machine_doc(
|
||||
layer: Layer,
|
||||
key: str,
|
||||
no: str,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
return _call(_root(session, layer, owner), path.machine, key, no)
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/sangeun/{key}/doc/preview")
|
||||
def preview_doc(
|
||||
layer: Layer,
|
||||
|
||||
@@ -24,6 +24,7 @@ from typing import Any
|
||||
from M02_MasterTemplete import M02_Doc_Copy as dc
|
||||
from M02_MasterTemplete import M02_Doc_Gap as gap
|
||||
from M02_MasterTemplete import M02_Doc_Import as dim
|
||||
from M02_MasterTemplete import M02_MyMachine_Draft as draft
|
||||
from M02_MasterTemplete import M02_Sangeun_Copy as skc
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
|
||||
@@ -185,6 +186,8 @@ def settle(out: dict, whole: Any, job: Any, raw: Any) -> dict:
|
||||
if mine is not None and mine.get("키") == out.get("키"): # 수식 길 저장 — 서버가 지은 사본
|
||||
if dim.is_imported(out): # 가져온 식 — 셈이 읽은 줄(바꿔 끼운 줄 등)을 로직 사본에(823-1)
|
||||
out = gap.fill_imported(out, mine["사본"].get("문서"))
|
||||
if skc.has(out): # 기계 창 초안으로 고친 내 기계 = 새 값(827-1)
|
||||
out = {**out, skc.KEY: draft.renew(out[skc.KEY])}
|
||||
return {**out, KEY: norm(mine["사본"])}
|
||||
file = stored(out.get("키")) or {}
|
||||
have = file.get(KEY) if dc.has(file) else (raw.get(KEY) if dc.has(raw) else None)
|
||||
|
||||
@@ -22,6 +22,7 @@ from M02_MasterTemplete import M02_Doc_Import as dim
|
||||
from M02_MasterTemplete import M02_Doc_Screen as screen
|
||||
from M02_MasterTemplete import M02_Doc_Sk as dsk
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_MyMachine_Draft as draft
|
||||
from M02_MasterTemplete import M02_Sangeun_Copy as skc
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
|
||||
@@ -135,12 +136,18 @@ def edited(copy: dict, body: dict, key: str, sk: dict | None = None) -> dict:
|
||||
if not dim.is_imported(sk):
|
||||
out["부름"] = dc.closure(dc.over(out), calls, copy.get("부름"))
|
||||
out = gap.fill(out) # 셈이 읽은 줄 가운데 사본 없는 것 = 지금 값으로 박음(822-1)
|
||||
out = draft.renew(out) # 기계 창 초안으로 만든 · 고친 내 기계 = 새 값(827-1)
|
||||
out["따로"] = True
|
||||
return out
|
||||
|
||||
|
||||
def preview(key: str, body: dict) -> dict[str, Any]:
|
||||
"""미리 셈(저장 없음) — {셈, 전, 후, 바뀐자원?, 버림?}."""
|
||||
"""미리 셈(저장 없음) — {셈, 전, 후, 바뀐자원?, 버림?} · 기계 창 초안(827-1) = 이 요청 안만 얹음."""
|
||||
with draft.overlay(body.get("문서")):
|
||||
return _preview(key, body)
|
||||
|
||||
|
||||
def _preview(key: str, body: dict) -> dict[str, Any]:
|
||||
doc = _file(key)["문서"]
|
||||
mine = _basis(doc, body)
|
||||
before = current(doc)
|
||||
@@ -176,10 +183,22 @@ def _basis(doc: dict, body: dict) -> dict:
|
||||
|
||||
|
||||
def save(key: str, body: dict) -> dict[str, Any]:
|
||||
"""저장(층 쓰기) — 서버가 다시 셈한 사본을 그 SK 에 씀 · {문서, 부름, 끊음, 셈, 사본, 판}."""
|
||||
"""저장(층 쓰기) — 서버가 다시 셈한 사본을 그 SK 에 씀 · {문서, 부름, 끊음, 셈, 사본, 판} ·
|
||||
기계 창 초안(827-1) = 같은 요청 안에서 내 기계 만들기 · 고침 → 키 바꿈 → 저장(실패 = 되돌림)."""
|
||||
got = _file(key)
|
||||
if str(body.get("판") or "") != got["판"]:
|
||||
raise _bad({"stale": [key], "판": got["판"]}, 409)
|
||||
return draft.saving(body, lambda b: _save(key, b, got))
|
||||
|
||||
|
||||
def machine(key: str, no: str) -> dict[str, Any]:
|
||||
"""기계 창(827-1) — 이 SK 사본에 박힌 기계 줄 · 지금 줄 · 새 값."""
|
||||
doc = _file(key)["문서"]
|
||||
copy = current(doc) if dsk.own(doc) and not dim.is_imported(doc) else None
|
||||
return draft.copy_row(doc, copy, no)
|
||||
|
||||
|
||||
def _save(key: str, body: dict, got: dict) -> dict[str, Any]:
|
||||
job = body.get("할일") or None
|
||||
raw = {**_basis(got["문서"], body), **({skc.JOB: job} if job else {})}
|
||||
token = None
|
||||
|
||||
@@ -75,6 +75,12 @@ def get_machine_hourly(key: str) -> dict:
|
||||
return machine.hourly(key)
|
||||
|
||||
|
||||
@router.get("/machine-rows")
|
||||
def get_machine_rows(keys: str) -> dict:
|
||||
"""기계 창(827-1) — M01 기계 키 · 원문번호 쉼표 목록(60 까지) → 줄 값 + 시간당."""
|
||||
return {"rows": machine.brief([k for k in keys.split(",") if k.strip()])}
|
||||
|
||||
|
||||
@router.get("/machine-parts")
|
||||
def get_machine_parts(no: str) -> dict:
|
||||
"""줄 쌓기 기계 넣기(813-2) — 기계 원문번호 → 그 기종 작업량 틀(문서째)."""
|
||||
|
||||
@@ -197,6 +197,46 @@ def hourly(key: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
_BRIEF: dict[tuple, dict] = {}
|
||||
|
||||
|
||||
def brief(keys: list[str]) -> list[dict[str, Any]]:
|
||||
"""기계 창 목록 · 양식(827-1) — M01 기계 줄 값 + 시간당(`hourly` 와 같은 식 · 입력 기본값 ·
|
||||
마스터를 다시 읽지 않고 색인 위에서 셈 · 색인마다 메모) · 키 · 원문번호로 찾음 · 없는 키 = 뺌."""
|
||||
import master_cut as mcut
|
||||
|
||||
from M02_MasterTemplete.M02_Item_Prices import to_json
|
||||
|
||||
index, mf = store.loaded()[1], cm.mf
|
||||
if len(_BRIEF) > 2000: # ponytail: 통째 비움 · 기계 수(655)의 세 배
|
||||
_BRIEF.clear()
|
||||
out = []
|
||||
for key in keys[:60]:
|
||||
if (got := _BRIEF.get((id(index), key))) is None:
|
||||
try:
|
||||
row = (index.numbers.get("EQ") or {}).get(key) or index.get(key)
|
||||
except mf.FormulaError:
|
||||
continue
|
||||
if not str(row.get("키")).startswith("EQ"):
|
||||
continue
|
||||
logic = pick(row)
|
||||
head = {k: row.get(k) for k in ("키", "원문번호", "이름", "규격")}
|
||||
if lack := why(row, logic):
|
||||
hour = {"ok": False, "reason": lack}
|
||||
else:
|
||||
given = {i["이름"]: (i.get("고르기") or [""])[0] for i in logic.get("입력") or []}
|
||||
if takes_machine(logic):
|
||||
given["기계"] = str(row.get("원문번호"))
|
||||
sums = mcut.engine(
|
||||
index, logic["키"], mf.run(index, logic["키"], mf.fill_empty(logic, given))
|
||||
)
|
||||
hour = {"ok": True, "로직": logic["키"]}
|
||||
hour |= {k: float(sums[k]) for k in (*cm.mf.COST_ITEMS, "계")}
|
||||
got = _BRIEF[(id(index), key)] = {**head, "값": to_json(row.get("값")), "시간당": hour}
|
||||
out.append(got)
|
||||
return out
|
||||
|
||||
|
||||
def combo(main: str, part: str) -> tuple[str, str] | None:
|
||||
"""조합 기계(굴착기 + 대형 브레이커 · 부착용 집게 · 814-1) → 우리 조합 식 (키, 부름 글)
|
||||
`로직(GC000271, 기계='…', 브레이커='…', 지역='전국평균', …)` — 두 기계가 그 식 고르기에 있을 때만."""
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
* · (785-3) `oil` = M01 유가전력(MO · `/logic-lib/elements?group=재료` 의 MO 줄) 고름 · 고정 줄 꼴 = 이음 유가 MO ·
|
||||
* 재료비 = 지금 값(셈은 M01 값) · 「M01 전체」 탭 재료 아래
|
||||
* · (821-3) `mine` = 개인 층 내 기계 묶음(품셈 기계 위 · 구분 「내 기계」 · 출처 「개인 키」 · 금액 = 서버 시간당) ·
|
||||
* 머리 [새 기계 만들기](양식 창 → 저장하면 그 기계로 고름) · 「시간당 값 직접」 내 기계 = 직접 값 식 GC001199(822-2) · 식 없는 내 기계 = 회색
|
||||
* 「시간당 값 직접」 내 기계 = 직접 값 식 GC001199(822-2) · 식 없는 내 기계 = 회색 ·
|
||||
* 머리 [새 기계 만들기](즉시 저장)는 827-1 에 걷음 — 만들기는 단가산출 기계 창 [고쳐 쓰기](초안 → [저장])
|
||||
* ========================================================================== */
|
||||
|
||||
import { createDataTable } from "@ui/ui_template_data_table";
|
||||
@@ -27,7 +28,6 @@ import { t as L } from "@ui/ui_template_locale";
|
||||
import { aliasNote, matchLogic } from "@ui/ui_template_logic_find";
|
||||
import { openNewMaterial } from "./M02_Material_Pick";
|
||||
import { listMyMachines, mineElement, type MyMachineRow } from "./M02_MyMachine_Api";
|
||||
import { openMyMachineModal } from "./M02_MyMachine_Form";
|
||||
import { mt } from "./M02_MyMachine_Text";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createTabs } from "@ui/ui_template_tabs";
|
||||
@@ -416,22 +416,7 @@ export function openFindPicker(opt: FindPickOptions): void {
|
||||
});
|
||||
newMat.dataset.materialNew = "";
|
||||
const canNew = opt.layer === "personal" && !!opt.material && ok("material");
|
||||
// 821-3 내 기계 묶음이면 머리 [새 기계 만들기] — 양식 창에서 저장하면 그 기계로 고름
|
||||
const newMach = createButton({
|
||||
label: mt("Pick_New"),
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
openMyMachineModal(opt.mine?.owner ?? null, (got) => {
|
||||
const e = mineElement(got.줄, got.시간당);
|
||||
if (!e.로직) return showToast(mt("Pick_Direct"), "warning");
|
||||
take(() => opt.machine?.(e))();
|
||||
}),
|
||||
});
|
||||
newMach.dataset.machineNew = "";
|
||||
const headBtns = [
|
||||
...(canNew ? [newMat] : []),
|
||||
...(opt.mine && opt.machine && ok("machine") ? [newMach] : []),
|
||||
];
|
||||
const headBtns = canNew ? [newMat] : [];
|
||||
({ close } = openModal({
|
||||
title: opt.title ?? L("M02_FindTitle"),
|
||||
...(headBtns.length ? { actions: headBtns } : {}),
|
||||
|
||||
@@ -23,6 +23,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
@@ -52,6 +53,10 @@ POWER, POWER_CREW, DIRECT_KEY = "GC001196", "GC001197", "GC001199"
|
||||
FUEL_POWER, FUEL_POWER_CREW = "GC001200", "GC001201" # 연료 + 전력(+ 조종원) — 경쟁사 구성(822-2b)
|
||||
|
||||
|
||||
#: 단가산출 기계 창 초안(827-1 · `M02_MyMachine_Draft`) — 셈 꼴 줄 · 미리 셈 · 저장 한 요청 안만 `whole()` 위에 얹음
|
||||
DRAFT: ContextVar[tuple] = ContextVar("내기계초안", default=())
|
||||
|
||||
|
||||
def mine(key: Any) -> bool:
|
||||
return str(key or "").startswith(PREFIX)
|
||||
|
||||
@@ -135,7 +140,15 @@ _MEMO: dict[str, tuple] = {}
|
||||
|
||||
|
||||
def whole(base: Any = None) -> Any:
|
||||
"""M01 색인 + 지금 층 내 기계 줄 — 파일 · M01 이 그대로면 같은 객체(셈 메모가 붙음)."""
|
||||
"""M01 색인 + 지금 층 내 기계 줄 — 파일 · M01 이 그대로면 같은 객체(셈 메모가 붙음) · 초안(`DRAFT`) = 그 위."""
|
||||
from M02_MasterTemplete import M02_Sangeun_Copy as skc
|
||||
|
||||
got = _whole(base)
|
||||
draft = DRAFT.get()
|
||||
return skc.Copy(got, list(draft), ("내기계", "초안", id(draft))) if draft else got
|
||||
|
||||
|
||||
def _whole(base: Any = None) -> Any:
|
||||
from M02_MasterTemplete import M02_Sangeun_Copy as skc
|
||||
|
||||
base = m01.loaded()[1] if base is None else base
|
||||
@@ -376,7 +389,7 @@ def delete(key: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def uses() -> dict[str, list[dict]]:
|
||||
"""{내 기계 키: [{키, 이름}]} — 이 층 단가산출 가운데 사본 단가 줄에 그 키를 가진 것."""
|
||||
"""{내 기계 키: [{키, 이름}]} — 이 층 단가산출 가운데 사본(옛 칸 · 수식) 단가 줄에 그 키를 가진 것."""
|
||||
from M02_MasterTemplete import M02_Sangeun_Copy as skc
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
|
||||
@@ -389,11 +402,14 @@ def uses() -> dict[str, list[dict]]:
|
||||
doc = json.loads(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
for row in (doc.get(skc.KEY) or {}).get("단가") or []:
|
||||
if mine(row.get("키")):
|
||||
out.setdefault(str(row["키"]), []).append(
|
||||
{"키": doc.get("키"), "이름": doc.get("이름")}
|
||||
)
|
||||
keys = { # 옛 칸 사본 · 수식 길 사본(827-1) — 한 SK 한 번
|
||||
str(row["키"])
|
||||
for part in (skc.KEY, "수식")
|
||||
for row in (doc.get(part) or {}).get("단가") or []
|
||||
if mine(row.get("키"))
|
||||
}
|
||||
for key in sorted(keys):
|
||||
out.setdefault(key, []).append({"키": doc.get("키"), "이름": doc.get("이름")})
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import type { MachinePick, MachineValues } from "@ui/ui_template_machine_form";
|
||||
import type { DocRecord } from "../common_util/common_util_formula_doc_cache";
|
||||
import { call } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { M01Element } from "./M02_MasterTemplete_Find_Pick";
|
||||
|
||||
@@ -154,3 +155,43 @@ export const mineElement = (
|
||||
로직: h.로직 ?? undefined,
|
||||
원문번호: row.원문번호,
|
||||
});
|
||||
|
||||
/** 기계 창 [확인](827-1) — 초안 몸 검사 · 셈 꼴 값 · 시간당(식 키) · 그 식 셈 기록(계산기 캐시) · 쓰기 없음 */
|
||||
export interface DraftCheck {
|
||||
줄: MyMachine;
|
||||
셈값: Record<string, unknown>;
|
||||
시간당: MyHourly;
|
||||
연결: DocRecord;
|
||||
}
|
||||
export const checkDraft = (
|
||||
owner: string | null,
|
||||
body: MyMachineBody,
|
||||
target: { 표지: string; 키?: string; 본뜬?: string },
|
||||
): Promise<DraftCheck> =>
|
||||
call(at(owner, "/draft"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
줄: body,
|
||||
표지: target.표지,
|
||||
키: target.키 ?? null,
|
||||
본뜬: target.본뜬 ?? null,
|
||||
}),
|
||||
});
|
||||
|
||||
/** M01 기계 줄 값 + 시간당(기계 창 목록 · 양식 · 60 까지) */
|
||||
export interface MachineBrief {
|
||||
키: string;
|
||||
원문번호: string;
|
||||
이름: string;
|
||||
규격: string;
|
||||
값: MachineValues;
|
||||
시간당: MyHourly;
|
||||
}
|
||||
export const machineRows = async (keys: string[]): Promise<MachineBrief[]> =>
|
||||
keys.length
|
||||
? (
|
||||
await call<{ rows: MachineBrief[] }>(
|
||||
`/logic-lib/machine-rows?keys=${encodeURIComponent(keys.join(","))}`,
|
||||
)
|
||||
).rows
|
||||
: [];
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""M02 단가산출 기계 창 초안(PLAN 827-1 · 요구 810 · 811) — 서버 단독.
|
||||
|
||||
초안 = SK 수식 문서 칸 `내기계초안` [{표지 「EQP초안-N」, 키?: 고칠 내 기계 키, 본뜬?: M01 · 내 기계 키, 줄: 몸}] ·
|
||||
줄 쌓기 기계 줄 입력 기계='개인 EQP초안-N' 이 가리킴 · 화면 캐시에만(디스크에 안 씀).
|
||||
확인(`check`) = 몸 검사 + 셈 꼴 값 + 시간당(식 키) — 쓰기 없음(기계 창 [확인]).
|
||||
미리 셈(`overlay`) = 그 요청 안만 `M02_MyMachine.DRAFT` 에 셈 꼴 줄을 얹음(사본 빈틈 = 초안 값으로 셈).
|
||||
저장(`saving`) = 문서가 쓰는 초안만 만들기 · 고침 → 표지를 진짜 키로 → 같은 요청 안에서 SK 저장 ·
|
||||
`TOUCH` = 사본 단가 줄을 새 값으로(`renew`) · 실패 = 만든 파일 지움 · 고친 줄 되돌림(번호표는 그대로).
|
||||
사본 줄 보기(`copy_row`) = 그 SK 사본에 박힌 기계 줄 · 지금 줄 · 새 값 여부.
|
||||
개인 층만(`mym._user` — 그 밖 404).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Item_Prices as prices
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_MyMachine as mym
|
||||
|
||||
FIELD = "내기계초안"
|
||||
TAG = re.compile(r"EQP초안-\d+")
|
||||
BODY = ("이름", "규격", "방식", "값", "시간당", "비고")
|
||||
#: 저장 한 요청 안 — 만든 · 고친 내 기계 키(사본 단가 줄을 새 값으로)
|
||||
TOUCH: ContextVar[frozenset] = ContextVar("초안고친키", default=frozenset())
|
||||
|
||||
|
||||
def _bad(text: str, status: int = 400) -> store.StoreError:
|
||||
return store.StoreError(status, text)
|
||||
|
||||
|
||||
def drafts(doc: Any) -> list[dict]:
|
||||
got = doc.get(FIELD) if isinstance(doc, dict) else None
|
||||
if got is not None and not isinstance(got, list):
|
||||
raise _bad(f"「{FIELD}」 은 목록")
|
||||
return [d for d in got or [] if isinstance(d, dict)]
|
||||
|
||||
|
||||
def _have() -> dict[str, dict]:
|
||||
mym._user()
|
||||
return {r["키"]: r for r in mym.rows()}
|
||||
|
||||
|
||||
def _row(item: dict, have: dict[str, dict]) -> dict:
|
||||
"""초안 하나 → 내 기계 줄(키 · 원문번호 = 표지) — 몸 검사 `mym.clean` · 고칠 키는 이 층에 있어야."""
|
||||
tag = str(item.get("표지") or "")
|
||||
if not TAG.fullmatch(tag):
|
||||
raise _bad(f"초안 표지 「{tag}」")
|
||||
body = mym.clean(item.get("줄"))
|
||||
key = str(item.get("키") or "")
|
||||
if key:
|
||||
if key not in have:
|
||||
raise _bad(f"없는 내 기계 「{key}」", 404)
|
||||
keep = {k: have[key][k] for k in ("원문", "구분", "넣은날", "본뜬") if k in have[key]}
|
||||
else:
|
||||
base = str(item.get("본뜬") or "")
|
||||
keep = {"원문": "개인", "구분": "개인", **({"본뜬": base} if base else {})}
|
||||
return {**keep, "키": tag, "원문번호": f"{mym.NO}{tag}", "상세구분": body["이름"], **body}
|
||||
|
||||
|
||||
def check(item: Any) -> dict[str, Any]:
|
||||
"""기계 창 [확인] — {줄, 셈값(셈 꼴 값 칸), 시간당(식 키), 연결(그 식 한 줄 셈 기록 = 화면 계산기 캐시)} ·
|
||||
쓰기 없음 · 틀리면 400 · `표지` = 화면이 정한 초안 표지(기록 요소 열쇠가 줄과 같게)."""
|
||||
item = item if isinstance(item, dict) else {}
|
||||
row = _row({**item, "표지": item.get("표지") or "EQP초안-0"}, _have())
|
||||
eng = mym.engine(row)
|
||||
token = mym.DRAFT.set((eng,))
|
||||
try:
|
||||
whole = mym.whole()
|
||||
hour = mym.hourly(eng, whole)
|
||||
rec = _record(whole, hour.get("로직"), row["원문번호"]) if hour.get("ok") else {}
|
||||
finally:
|
||||
mym.DRAFT.reset(token)
|
||||
out = {"줄": row, "셈값": prices.to_json(eng["값"]), "시간당": hour, "연결": rec}
|
||||
return json.loads(json.dumps(out, ensure_ascii=False, default=str))
|
||||
|
||||
|
||||
def _record(whole: Any, logic: Any, no: str) -> dict:
|
||||
"""그 식을 기계 하나로 부르는 한 줄 문서의 셈 기록(요소 · 표 · 문서) — 화면이 [확인] 직후 바로 셈."""
|
||||
import master_doc_record as mr
|
||||
|
||||
from M02_MasterTemplete import M02_Doc_Copy as dc
|
||||
|
||||
var = {"기호": "P", "아래": "기계", "위": "", "이름": "기계", "단위": "",
|
||||
"연결": {"갈래": "단가산출", "키": logic, "입력": {"기계": f"'{no}'"}}} # fmt: skip
|
||||
line = {"이름": "기계", "단위": "hr", "비목": "묶음", "수량": "1", "단가": "P_기계"}
|
||||
one = {"꼴": "수식문서", "판": 1, "키": "_기계창", "이름": "기계", "결과단위": "원",
|
||||
"변수": [var], "식": [], "금액": [line]} # fmt: skip
|
||||
rec = mr.Rec(dc.ml.Links(whole, dc.closure(whole, [str(logic)])))
|
||||
try:
|
||||
rec.prefetch(one)
|
||||
dc.mc.calc(one, rec)
|
||||
except dc._ERR:
|
||||
pass
|
||||
return dc.ml._norm(rec.out())
|
||||
|
||||
|
||||
@contextmanager
|
||||
def overlay(doc: Any) -> Iterator[None]:
|
||||
"""미리 셈 — 초안 셈 꼴 줄을 이 요청 안 `whole()` 위에 얹음(초안 없음 = 그대로)."""
|
||||
items = drafts(doc)
|
||||
if not items:
|
||||
yield
|
||||
return
|
||||
have = _have()
|
||||
token = mym.DRAFT.set(tuple(mym.engine(_row(i, have)) for i in items))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
mym.DRAFT.reset(token)
|
||||
|
||||
|
||||
def renew(copy: Any) -> Any:
|
||||
"""사본 단가 줄 — 미리 셈 = 문서가 가리키는 초안 줄을 더함 · 저장 = 만든 · 고친 내 기계 줄을 새 값으로
|
||||
(이 SK 는 「새 값」 아님)."""
|
||||
if not isinstance(copy, dict) or not isinstance(copy.get("단가"), list):
|
||||
return copy
|
||||
doc = copy.get("문서") if isinstance(copy.get("문서"), dict) else {}
|
||||
rest = {k: v for k, v in doc.items() if k != FIELD}
|
||||
used = set(TAG.findall(json.dumps(rest, ensure_ascii=False, default=str)))
|
||||
keys = TOUCH.get() | used
|
||||
if not keys:
|
||||
return copy
|
||||
from M02_MasterTemplete import M02_Sangeun_Copy as skc
|
||||
|
||||
live = skc.whole_().index.get("EQ") or {}
|
||||
rows = [
|
||||
prices.to_json(live[k]) if (k := str(r.get("키"))) in keys and k in live else r
|
||||
for r in copy["단가"]
|
||||
]
|
||||
have = {str(r.get("키")) for r in rows}
|
||||
rows += [prices.to_json(live[k]) for k in sorted(used - have) if k in live]
|
||||
return {**copy, "단가": rows, "단가판": skc._fp(rows)}
|
||||
|
||||
|
||||
def _new(row: dict) -> str:
|
||||
"""새 내 기계 파일(`mym.create` 와 같은 꼴 · 값은 초안 그대로 — 본뜬 값을 다시 섞지 않음)."""
|
||||
data = mym._read()
|
||||
key = f"{mym.PREFIX}{mym._user()}-{data['다음']:04d}"
|
||||
when = mym._now()
|
||||
head = {"키": key, "원문번호": f"{mym.NO}{key}", "원문": "개인", "구분": "개인"}
|
||||
body = {k: row[k] for k in ("상세구분", *BODY, "단위", "출처") if k in row}
|
||||
tail = {"넣은날": when, "고친날": when, **({"본뜬": row["본뜬"]} if row.get("본뜬") else {})}
|
||||
mym.atomic_write_json(mym._dir() / mym.NEXT, {"다음": data["다음"] + 1}) # 번호 먼저
|
||||
mym._put({**head, **body, **tail})
|
||||
return key
|
||||
|
||||
|
||||
def saving(body: dict, inner: Callable[[dict], Any]) -> Any:
|
||||
"""[저장] 한 번 — 문서가 쓰는 초안 만들기 · 고침 → 표지 = 진짜 키 → `inner(새 몸)` · 실패 = 되돌림."""
|
||||
doc = body.get("문서")
|
||||
if not isinstance(doc, dict) or FIELD not in doc:
|
||||
return inner(body)
|
||||
items = drafts(doc)
|
||||
rest = {k: v for k, v in doc.items() if k != FIELD}
|
||||
text = json.dumps(rest, ensure_ascii=False)
|
||||
used = set(TAG.findall(text))
|
||||
if not used:
|
||||
return inner({**body, "문서": rest})
|
||||
if lost := sorted(used - {str(i.get("표지")) for i in items}):
|
||||
raise _bad(f"초안 없는 기계 표지 {lost}")
|
||||
have = _have()
|
||||
todo = [(i, _row(i, have)) for i in items if str(i.get("표지")) in used] # 다 검사한 뒤 씀
|
||||
made: list[str] = []
|
||||
kept: list[dict] = []
|
||||
names: dict[str, str] = {}
|
||||
try:
|
||||
for item, row in todo:
|
||||
if key := str(item.get("키") or ""):
|
||||
kept.append(have[key])
|
||||
mym.update(key, {k: row[k] for k in BODY if k in row})
|
||||
else:
|
||||
made.append(key := _new(row))
|
||||
names[row["키"]] = key
|
||||
text = TAG.sub(lambda m: names[m.group(0)], text)
|
||||
token = TOUCH.set(frozenset(names.values()))
|
||||
try:
|
||||
return inner({**body, "문서": json.loads(text)})
|
||||
finally:
|
||||
TOUCH.reset(token)
|
||||
except BaseException:
|
||||
for key in made:
|
||||
(mym._dir() / f"{key}.json").unlink(missing_ok=True)
|
||||
for old in kept:
|
||||
mym._put(old)
|
||||
raise
|
||||
|
||||
|
||||
def copy_row(doc: dict, copy: dict | None, no: str) -> dict[str, Any]:
|
||||
"""기계 창 열 때 — {사본: 이 SK 사본에 박힌 줄 | None, 지금: 지금 줄 | None, 새값}(원문번호 · 키로 찾음)."""
|
||||
from M02_MasterTemplete import M02_Sangeun_Copy as skc
|
||||
|
||||
no = no.strip().strip("'")
|
||||
pools = [(copy or {}).get("단가"), (doc.get(skc.KEY) or {}).get("단가")]
|
||||
have = next(
|
||||
(r for rows in pools for r in rows or [] if no in (r.get("원문번호"), r.get("키"))),
|
||||
None,
|
||||
)
|
||||
whole = skc.whole_()
|
||||
live = (whole.numbers.get("EQ") or {}).get(no) or (whole.index.get("EQ") or {}).get(no)
|
||||
now = prices.to_json(live) if live is not None else None
|
||||
new = have is not None and now is not None and skc._fp(have) != skc._fp(now)
|
||||
return {"사본": have, "지금": now, "새값": new}
|
||||
@@ -6,7 +6,8 @@
|
||||
* [저장] 때만 서버(자동저장 없음) — 서버가 정본으로 다시 셈한 시간당을 받아 보임
|
||||
* 새 기계 = 「비슷한 기계에서 시작」(M01 · 내 기계 · 고른 키 = 서버 `본뜬`) 또는 빈 양식 · `seed` = 품셈 기계 복사
|
||||
* 지우기 = 쓰는 단가산출 이름 보이고 확인 + 「이미 만든 단가산출은 마지막 값 유지」
|
||||
* 부르는 곳 = 내 기계 화면(`M02_MyMachine_View`) · 기계 고르기 창 「새 기계 만들기」 · 줄 쌓기 「복사해 내 기계로」(창 `openMyMachineModal`)
|
||||
* 부르는 곳 = 내 기계 화면(`M02_MyMachine_View` · 827-1 = 읽기 전용 · 지우기만) — 만들기 · 고치기는
|
||||
* 단가산출 기계 창(`A00_Common/line_stack_machine` · 초안 → 단가산출 [저장])
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
@@ -15,10 +16,10 @@ import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
createMachineForm,
|
||||
HOURLY_KEY,
|
||||
type MachineFormState,
|
||||
type MachineSimilar,
|
||||
type MachineValues,
|
||||
} from "@ui/ui_template_machine_form";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import {
|
||||
createMyMachine,
|
||||
@@ -44,6 +45,8 @@ export interface MyMachineFormOptions {
|
||||
seed?: SimilarRow;
|
||||
/** 새 기계면 「비슷한 기계에서 시작」 을 펼침 */
|
||||
openSimilar?: boolean;
|
||||
/** 827-1 보기만(양식 읽기 전용 · [저장] 없음 · [지우기] 그대로) */
|
||||
readOnly?: boolean;
|
||||
onSaved: (got: MyMachineOne) => void;
|
||||
onDeleted?: () => void;
|
||||
onBack?: () => void;
|
||||
@@ -63,6 +66,25 @@ export function valuesOf(row: Pick<MyMachine, "방식" | "값" | "시간당">):
|
||||
return row.방식 === "직접" ? { [HOURLY_KEY]: { ...(row.시간당 ?? {}) } } : { ...row.값 };
|
||||
}
|
||||
|
||||
/** 양식 상태 → 보낼 몸(틀림 = 글 목록) — 내 기계 양식 · 기계 창 [확인] 한 벌 */
|
||||
export function bodyOfForm(s: MachineFormState): { body: MyMachineBody | null; errors: string[] } {
|
||||
const errors = [...s.errors, ...(s.name ? [] : [mt("Name_Needed")])];
|
||||
if (errors.length) return { body: null, errors };
|
||||
if (s.hourly)
|
||||
return {
|
||||
body: {
|
||||
이름: s.name,
|
||||
규격: s.spec,
|
||||
방식: "직접",
|
||||
시간당: (s.values[HOURLY_KEY] ?? {}) as MyMachineBody["시간당"],
|
||||
},
|
||||
errors,
|
||||
};
|
||||
const { [HOURLY_KEY]: _drop, ...vals } = s.values;
|
||||
const how = s.mode === "pumsem" ? "품셈" : "바로";
|
||||
return { body: { 이름: s.name, 규격: s.spec, 방식: how, 값: vals }, errors };
|
||||
}
|
||||
|
||||
/** 서버 시간당 한 줄 글 */
|
||||
export function hourlyText(h: MyHourly | undefined): string {
|
||||
if (!h?.ok) return `${mt("Hourly_None")}${h?.reason ? ` — ${h.reason}` : ""}`;
|
||||
@@ -99,6 +121,7 @@ export async function mountMyMachineForm(
|
||||
jobs: lists.jobs,
|
||||
power: lists.power,
|
||||
hour: lists.hour,
|
||||
readOnly: opt.readOnly,
|
||||
findSimilar: row
|
||||
? undefined
|
||||
: async (q): Promise<MachineSimilar[]> =>
|
||||
@@ -149,22 +172,13 @@ export async function mountMyMachineForm(
|
||||
|
||||
/* --- 저장 --- */
|
||||
const bodyOf = (): MyMachineBody | null => {
|
||||
const s = form.state();
|
||||
const errors = [...s.errors, ...(s.name ? [] : [mt("Name_Needed")])];
|
||||
const { body, errors } = bodyOfForm(form.state());
|
||||
notice.replaceChildren(...errors.map((e) => createText(e, "error")));
|
||||
if (errors.length) return null;
|
||||
if (s.hourly)
|
||||
return {
|
||||
이름: s.name,
|
||||
규격: s.spec,
|
||||
방식: "직접",
|
||||
시간당: (s.values[HOURLY_KEY] ?? {}) as MyMachineBody["시간당"],
|
||||
};
|
||||
const { [HOURLY_KEY]: _drop, ...vals } = s.values;
|
||||
return { 이름: s.name, 규격: s.spec, 방식: s.mode === "pumsem" ? "품셈" : "바로", 값: vals };
|
||||
return body;
|
||||
};
|
||||
const saveBtn = createButton({ label: mt("Save"), variant: "filled", size: 28 });
|
||||
saveBtn.dataset.mymachSave = "";
|
||||
saveBtn.hidden = !!opt.readOnly;
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
const body = bodyOf();
|
||||
if (!body) return;
|
||||
@@ -247,7 +261,7 @@ export async function mountMyMachineForm(
|
||||
delBtn,
|
||||
],
|
||||
}),
|
||||
createText(mt("Own_Note"), "muted"),
|
||||
createText(mt(opt.readOnly ? "View_Note" : "Own_Note"), "muted"),
|
||||
notice,
|
||||
server,
|
||||
form.root,
|
||||
@@ -263,34 +277,3 @@ export async function mountMyMachineForm(
|
||||
}
|
||||
return { root, dirty };
|
||||
}
|
||||
|
||||
/** 창으로 띄움 — 저장하면 닫고 `done(답)` · 저장 안 하고 닫으면 물음 */
|
||||
export function openMyMachineModal(
|
||||
owner: string | null,
|
||||
done: (got: MyMachineOne) => void,
|
||||
seed?: SimilarRow,
|
||||
): void {
|
||||
let handle: MyMachineFormHandle | null = null;
|
||||
const { close } = openModal({
|
||||
title: seed ? mt("Copy_Title") : mt("New"),
|
||||
closeLabel: mt("Close"),
|
||||
width: "1100px",
|
||||
wide: true,
|
||||
beforeClose: async () => !handle?.dirty() || showConfirmDialog(mt("Leave_Ask")),
|
||||
mount: (body) => {
|
||||
void mountMyMachineForm(body, {
|
||||
owner,
|
||||
seed,
|
||||
onSaved: (got) => {
|
||||
close();
|
||||
done(got);
|
||||
},
|
||||
})
|
||||
.then((h) => (handle = h))
|
||||
.catch((error) => {
|
||||
showToast(error instanceof Error ? error.message : mt("Failed"), "error");
|
||||
close();
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
GET 목록(줄마다 시간당 · 쓰는곳수) · GET `/{키}` 하나(+시간당 · 쓰는곳 · 같은이름) ·
|
||||
POST 만들기 몸 {줄?, 본뜬?: M01 · 내 기계 키(「비슷한 기계에서 시작」 · 「M01 기계 복사해 만들기」)} ·
|
||||
PUT `/{키}` 몸 {줄} · DELETE `/{키}`(답 = 쓰는곳 — 그 단가산출은 사본의 마지막 값 그대로).
|
||||
PUT `/{키}` 몸 {줄} · DELETE `/{키}`(답 = 쓰는곳 — 그 단가산출은 사본의 마지막 값 그대로) ·
|
||||
POST `/draft` 몸 {줄, 키?, 본뜬?} = 단가산출 기계 창 [확인](827-1 · 쓰기 없음 · 층 쓰기 권한) → {줄, 셈값, 시간당}.
|
||||
권한 = 층 권한 한 벌(`Router_Items._root` — 본인 개인 층 · 맡은 회사 관리자) · 개인 층만(그 밖 404).
|
||||
⚠ 등록은 `main.py`(로그인만).
|
||||
"""
|
||||
@@ -16,6 +17,7 @@ from pydantic import BaseModel
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_MyMachine as mym
|
||||
from M02_MasterTemplete import M02_MyMachine_Draft as draft
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Session, _call, _root
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 My Machine"])
|
||||
@@ -25,6 +27,8 @@ PATH = "/layers/{layer}/my-machines"
|
||||
class MachineBody(BaseModel):
|
||||
줄: dict[str, Any] | None = None
|
||||
본뜬: str | None = None
|
||||
키: str | None = None # 초안 확인(827-1) — 고칠 내 기계
|
||||
표지: str | None = None # 초안 확인(827-1) — 화면이 정한 표지(셈 기록 열쇠)
|
||||
|
||||
|
||||
def _folder(session: Session, layer: str, owner: str | None, write: bool = False):
|
||||
@@ -38,6 +42,16 @@ def my_machines(layer: str, owner: str | None = None, session: Session = Depends
|
||||
return _call(_folder(session, layer, owner), mym.listing)
|
||||
|
||||
|
||||
@router.post(PATH + "/draft")
|
||||
def my_machine_draft(
|
||||
layer: str,
|
||||
body: MachineBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
):
|
||||
return _call(_folder(session, layer, owner, True), draft.check, body.model_dump())
|
||||
|
||||
|
||||
@router.get(PATH + "/{key}")
|
||||
def my_machine(
|
||||
layer: str, key: str, owner: str | None = None, session: Session = Depends(verify_session)
|
||||
|
||||
@@ -10,7 +10,6 @@ const TEXT = {
|
||||
Crumb_Machine: "개인관리 › 기계",
|
||||
Crumb_Items: "개인관리 › 자재",
|
||||
New: "새 기계",
|
||||
Copy_Title: "복사해 내 기계로",
|
||||
Find: "이름 · 규격 · 키로 찾기",
|
||||
Name: "이름",
|
||||
Spec: "규격",
|
||||
@@ -19,7 +18,7 @@ const TEXT = {
|
||||
Uses_Count: "쓰는 곳",
|
||||
Made: "넣은 날",
|
||||
Edited: "고친 날",
|
||||
None: "내 기계 없음 — [새 기계] 로 만듦",
|
||||
None: "내 기계 없음 — 단가산출의 기계 줄에서 [고쳐 쓰기] 로 만듦",
|
||||
Save: "저장",
|
||||
Saved: "내 기계 저장 — 서버가 시간당을 다시 셈",
|
||||
Delete: "지우기",
|
||||
@@ -50,9 +49,29 @@ const TEXT = {
|
||||
직접: "시간당 값 직접",
|
||||
// 기계 고르기 창 · 줄 쌓기
|
||||
Pick_Group: "내 기계",
|
||||
Pick_New: "새 기계 만들기",
|
||||
Pick_Direct: "시간당 경비 식이 없는 기계 — 줄 쌓기에 못 넣음",
|
||||
To_Mine: "복사해 내 기계로",
|
||||
View_Note: "보기 · 쓰는 곳 · 지우기만 — 만들기 · 고치기는 단가산출의 기계 줄에서",
|
||||
// 단가산출 기계 창(827-1)
|
||||
Win_Title: "기계",
|
||||
Win_Kind: "구분",
|
||||
Win_Now: "지금 이 줄",
|
||||
Win_Draft: "초안 — 단가산출 [저장] 때 내 기계로",
|
||||
Win_M01: "M01 기계",
|
||||
Win_Copy: "이 단가산출에 복사된 값",
|
||||
Win_Live: "원본 지금 값",
|
||||
Win_Newer: "원본에 새 값 있음 — [단가 갱신] 으로 반영",
|
||||
Win_Swap: "이 기계로 바꾸기",
|
||||
Win_Edit: "고쳐 쓰기",
|
||||
Win_Blank: "빈 양식에서 새 기계",
|
||||
Win_Ok: "확인",
|
||||
Win_Cancel: "취소",
|
||||
Win_Edit_Note:
|
||||
"[확인] = 이 단가산출에 초안으로 끼움 · 단가산출 [저장] 때 내 기계로 저장 · 저장 안 하고 나가면 사라짐",
|
||||
Win_Uses: "이 기계를 쓰는 다른 단가산출 {value} 건 — 저장하면 그쪽에 「새 값」 배지",
|
||||
Win_Hint: "왼쪽에서 기계를 고르면 값이 보임",
|
||||
Win_View: "조회",
|
||||
Why_M01: "기계 값은 M01 기계 상세에서",
|
||||
Why_Company: "개인용으로 복사해서 씀",
|
||||
// 내 품목
|
||||
Items_Note:
|
||||
"단가산출 줄에 값을 바로 적은 재료(우리 자재에 안 이어진 것) — 고치기는 그 단가산출에서 · 줄 누르면 쓰는 단가산출",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/* =============================================================================
|
||||
* M02_MyMachine_View.ts
|
||||
* M02 「내 기계」 화면(PLAN 821-3 · 요구서 796 3 장) — 좌측 「개인관리」 › [기계] 단추로 가운데에 뜸(개인 층).
|
||||
* 목록 = 이름 · 규격 · 방식 · 시간당 경비(서버 셈) · 쓰는 곳 수 · 넣은 날 · 고친 날 · 찾기 · [새 기계]
|
||||
* 줄 누름 = 양식 한 장(`M02_MyMachine_Form`) · [목록] = 저장 안 한 고침이면 물음 · 저장 · 지우기 뒤 목록 다시
|
||||
* 목록 = 이름 · 규격 · 방식 · 시간당 경비(서버 셈) · 쓰는 곳 수 · 넣은 날 · 고친 날 · 찾기
|
||||
* 줄 누름 = 양식 한 장(`M02_MyMachine_Form` · 읽기 전용) · 쓰는 곳 · [지우기] → 목록 다시
|
||||
* 만들기 · 고치기 없음(827-1) — 단가산출 기계 줄의 기계 창 [고쳐 쓰기] → 단가산출 [저장] 때 내 기계로
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { createDataTable } from "@ui/ui_template_data_table";
|
||||
import { createInputField, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
@@ -68,18 +68,11 @@ export function buildMyMachineView(opt: MyMachineViewOptions): HTMLElement {
|
||||
};
|
||||
search.input.addEventListener("input", draw);
|
||||
|
||||
const newBtn = createButton({
|
||||
label: mt("New"),
|
||||
variant: "filled",
|
||||
size: 28,
|
||||
onClick: () => void openForm(),
|
||||
});
|
||||
newBtn.dataset.mymachNew = "";
|
||||
const list = el("div", {
|
||||
className: "m02-mymach-view__list",
|
||||
children: [
|
||||
el("div", { className: "ui-toolbar", children: [newBtn, search.root, hint] }),
|
||||
createText(mt("Own_Note"), "muted"),
|
||||
el("div", { className: "ui-toolbar", children: [search.root, hint] }),
|
||||
createText(mt("View_Note"), "muted"),
|
||||
fail,
|
||||
table.root,
|
||||
],
|
||||
@@ -101,13 +94,14 @@ export function buildMyMachineView(opt: MyMachineViewOptions): HTMLElement {
|
||||
draw();
|
||||
}
|
||||
|
||||
async function openForm(key?: string): Promise<void> {
|
||||
async function openForm(key: string): Promise<void> {
|
||||
const slot = el("div");
|
||||
host.replaceChildren(slot);
|
||||
try {
|
||||
const h = await mountMyMachineForm(slot, {
|
||||
owner: opt.owner,
|
||||
key,
|
||||
readOnly: true,
|
||||
onSaved: (got) => void openForm(got.줄.키),
|
||||
onDeleted: () => void showList(),
|
||||
onBack: async () => {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/* =============================================================================
|
||||
* common_util_line_stack_draft.ts
|
||||
* 줄 쌓기 기계 창 초안(PLAN 827-1) — 문서 칸 `내기계초안` 다루기(화면 · fetch 없음).
|
||||
* 초안 하나 = {표지 「EQP초안-N」, 키?(고칠 내 기계), 본뜬?(바탕 키), 줄(내 기계 몸), 시간당?(목록 금액 · 식 키)}
|
||||
* 줄 입력 기계='개인 EQP초안-N' 이 가리킴 · 단가산출 [저장] 때 서버가 만들기 · 고침 · 키 바꿈
|
||||
* (짝 = `M02_MasterTemplete/M02_MyMachine_Draft.py` — 칸 이름 · 표지 꼴)
|
||||
* ========================================================================== */
|
||||
|
||||
import { varId, type FormulaDoc } from "./common_util_formula_doc";
|
||||
import { repick, type Picked } from "./common_util_line_stack_pick";
|
||||
|
||||
export const DRAFT_FIELD = "내기계초안";
|
||||
const TAG = /EQP초안-\d+/g;
|
||||
|
||||
export interface DraftItem {
|
||||
표지: string;
|
||||
키?: string;
|
||||
본뜬?: string;
|
||||
줄: { 이름: string; 규격: string; 방식: string; 값?: Record<string, unknown>; 시간당?: object };
|
||||
시간당?: { ok: boolean; 계?: number; 로직?: string | null };
|
||||
}
|
||||
|
||||
type Holder = FormulaDoc & { [DRAFT_FIELD]?: DraftItem[] };
|
||||
|
||||
export const draftsOf = (doc: FormulaDoc | null | undefined): DraftItem[] =>
|
||||
((doc as Holder | null | undefined)?.[DRAFT_FIELD] ?? []).filter(Boolean);
|
||||
|
||||
/** 기계 입력 글(따옴표 뗌)이 초안인지 */
|
||||
export const isDraftNo = (no: string | null | undefined): boolean =>
|
||||
/^개인 EQP초안-\d+$/.test(no ?? "");
|
||||
|
||||
let last = Date.now() % 1_000_000_000;
|
||||
/** 새 표지 — 세션 안 겹치지 않게(캐시에 남은 옛 초안 값과 안 섞임) */
|
||||
export const newTag = (): string => `EQP초안-${++last}`;
|
||||
|
||||
/** 문서가 안 쓰는 초안은 뺌 · 다 빠지면 칸 지움 */
|
||||
export function pruneDrafts(doc: FormulaDoc): void {
|
||||
const box = doc as Holder;
|
||||
if (!box[DRAFT_FIELD]) return;
|
||||
const { [DRAFT_FIELD]: list, ...rest } = box;
|
||||
const used = new Set(JSON.stringify(rest).match(TAG) ?? []);
|
||||
const keep = (list ?? []).filter((d) => used.has(d.표지));
|
||||
if (keep.length) box[DRAFT_FIELD] = keep;
|
||||
else delete box[DRAFT_FIELD];
|
||||
}
|
||||
|
||||
/** 초안을 줄 `i`(단가 기호 `id`)에 끼움 — 내 기계 고침이면 이 문서에서 그 기계를 가리키던 줄 모두 초안으로 */
|
||||
export function putDraft(doc: FormulaDoc, i: number, id: string, item: DraftItem, p: Picked): void {
|
||||
const arg = p.입력?.기계 ?? "";
|
||||
if (item.키) {
|
||||
const was = `'개인 ${item.키}'`;
|
||||
for (const v of doc.변수 ?? []) {
|
||||
const l = v.연결;
|
||||
if (l?.갈래 === "단가산출" && l.입력?.기계 === was) {
|
||||
l.입력 = { ...l.입력, 기계: arg };
|
||||
l.키 = p.키;
|
||||
}
|
||||
}
|
||||
}
|
||||
repick(doc, i, id, "기계", p);
|
||||
const box = doc as Holder;
|
||||
box[DRAFT_FIELD] = [...draftsOf(doc).filter((d) => d.표지 !== item.표지), item];
|
||||
pruneDrafts(doc);
|
||||
}
|
||||
|
||||
/** 기계 줄 연결 → 지금 기계 입력 글(따옴표 뗌) · 옵션 기호(`S_굴착기`)면 그 옵션 값 · 없음 = null */
|
||||
export function machineNow(
|
||||
doc: FormulaDoc,
|
||||
link: { 갈래?: string; 입력?: Record<string, string> },
|
||||
): string | null {
|
||||
const arg = link.갈래 === "단가산출" ? String(link.입력?.기계 ?? "").trim() : "";
|
||||
if (!arg) return null;
|
||||
if (/^'.*'$/.test(arg)) return arg.slice(1, -1);
|
||||
const val = (doc.변수 ?? []).find((x) => varId(x) === arg)?.연결?.값;
|
||||
return val === undefined || val === null || String(val) === "" ? null : String(val);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// STMATE 재현 앱 calc.js 를 상태 JSON(stdin)으로 돌려 원가계산서 금액을 냄
|
||||
const C = require("../../tools/stmate-test/calc.js");
|
||||
let buf = "";
|
||||
process.stdin.on("data", (d) => (buf += d));
|
||||
process.stdin.on("end", () => {
|
||||
const k = C.calculateAll(JSON.parse(buf)).kanjubOrig;
|
||||
const out = { dM: k.dM, dL: k.dL, dS: k.dS, dT: k.dT };
|
||||
for (const it of k.items) out[it.code] = it.amount;
|
||||
Object.assign(out, { ILBAN: k.ilbanAmt, IYUN_M: k.pretaxProfitAdj, GOV: k.govFinal });
|
||||
out.GRAND = k.grandTotal;
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
|
||||
"""827-1 단가산출 기계 창 초안 — 임시 storage 만(M01 정본 · 공유 storage 안 씀).
|
||||
|
||||
① 저장 한 번 = 초안 내 기계 만들기 + 줄 연결(표지 → 진짜 키) + 사본 박기 · 미리 셈 = 저장 뒤 셈 · 새 SK(수식 저장 전)
|
||||
② 같은 초안 두 줄 = 내 기계 하나 ③ 저장 안 함(미리 셈만) = 내 기계 0
|
||||
④ 실패(판 다름 · 저장 중 오류) = 내 기계 · SK 그대로(만든 것 지움 · 고친 것 되돌림)
|
||||
⑤ 내 기계 고침 초안 → 그 SK 는 새 값 · 그 기계 쓰는 다른 SK 「단가새값」 → [단가 갱신]
|
||||
⑥ 회사 층 · 남의 층 거절 ⑦ 확인 길 = 쓰기 없음 · 셈 기록(화면 즉시 셈) · 틀린 몸 400 ⑧ M01 기계 목록 시간당 = 정본 시간당.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from M02_MasterTemplete import M02_Doc_Router as doc_route
|
||||
from M02_MasterTemplete import M02_Doc_Sk_Path as path
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_Machine_Hourly as mh
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_MyMachine as mym
|
||||
from M02_MasterTemplete import M02_MyMachine_Draft as draft
|
||||
from M02_MasterTemplete import M02_MyMachine_Router as route
|
||||
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
|
||||
|
||||
DIG = "EQ000022" # 굴착기(무한궤도) 1.0㎥ — 연료 · 조종원 있음
|
||||
NAME = "sub1임시 기계"
|
||||
USER = {"role": "USER", "company_id": 7, "user_id": 42}
|
||||
|
||||
|
||||
def _at(folder, fn, *args):
|
||||
with store.at(folder):
|
||||
return fn(*args)
|
||||
|
||||
|
||||
def _body(price=1500):
|
||||
vals = {"가격": price, "손료계수": 2500, "주연료": 2, "주연료종류": "경유", "잡재료율": 10,
|
||||
"조종원": 1, "조종원직종": "1048"} # fmt: skip
|
||||
return {"이름": NAME, "규격": "1㎾", "방식": "바로", "값": vals}
|
||||
|
||||
|
||||
def _var(n, no, logic):
|
||||
return {"기호": "P", "아래": f"기계{n}", "위": "", "이름": NAME, "단위": "",
|
||||
"연결": {"갈래": "단가산출", "키": logic, "입력": {"기계": f"'{no}'"}}} # fmt: skip
|
||||
|
||||
|
||||
def _doc(key, nos, logic, drafts=None):
|
||||
"""줄 쌓기 기계 줄(화면이 보내는 꼴) — 줄마다 기계='…' · 초안 칸."""
|
||||
lines = [{"이름": NAME, "단위": "hr", "비목": "묶음", "수량": "1", "단가": f"P_기계{n}"}
|
||||
for n in range(len(nos))] # fmt: skip
|
||||
out = {"꼴": "수식문서", "판": 1, "키": key, "이름": "sub1임시", "결과단위": "원",
|
||||
"변수": [_var(n, no, logic) for n, no in enumerate(nos)], "식": [], "금액": lines} # fmt: skip
|
||||
return out | ({draft.FIELD: drafts} if drafts is not None else {})
|
||||
|
||||
|
||||
def _sk(folder, name="sub1임시"):
|
||||
body = {"이름": name, "단위": "시간", "더한줄": []}
|
||||
return _at(folder, lambda: refs.create("sangeun", body)["이름"])
|
||||
|
||||
|
||||
def _save(folder, key, doc):
|
||||
판 = _at(folder, path.read, key)["판"]
|
||||
return _at(folder, path.save, key, {"문서": doc, "판": 판})
|
||||
|
||||
|
||||
def _file(folder, key):
|
||||
return _at(folder, lambda: store.read("sangeun", key)["문서"])
|
||||
|
||||
|
||||
def _new_draft(folder, tag="EQP초안-7", base=DIG, price=1500):
|
||||
got = _at(folder, draft.check, {"줄": _body(price), "본뜬": base})
|
||||
item = {"표지": tag, "본뜬": base, "줄": _body(price)}
|
||||
return item, got["시간당"]
|
||||
|
||||
|
||||
def test_save_creates_links_and_stamps(world):
|
||||
"""① 새 SK(수식 저장 전) — 미리 셈 = 확인 시간당 = 저장 뒤 셈 · 내 기계 1 · 표지 → 진짜 키 · 사본에 박힘."""
|
||||
me = world["personal"]
|
||||
item, hour = _new_draft(me)
|
||||
assert hour["ok"] and hour["로직"]
|
||||
sk = _sk(me)
|
||||
doc = _doc(sk, ["개인 EQP초안-7"], hour["로직"], [item])
|
||||
look = _at(me, path.preview, sk, {"문서": doc})
|
||||
assert look["셈"]["계"] == hour["계"]
|
||||
assert _at(me, mym.rows) == [] # 미리 셈 = 쓰기 없음
|
||||
after = _save(me, sk, doc)
|
||||
rows = _at(me, mym.rows)
|
||||
assert len(rows) == 1 and rows[0]["본뜬"] == DIG and rows[0]["값"]["가격"] == 1500
|
||||
key = rows[0]["키"]
|
||||
saved = _file(me, sk)["수식"]
|
||||
text = json.dumps(saved["문서"], ensure_ascii=False)
|
||||
assert "EQP초안" not in text and draft.FIELD not in saved["문서"]
|
||||
assert f"'개인 {key}'" in text
|
||||
assert key in [r["키"] for r in saved["단가"]] # 사본에 박힘
|
||||
assert after["셈"]["계"] == look["셈"]["계"] and after["사본"]["단가새값"] is False
|
||||
assert _at(me, mym.uses)[key] == [{"키": sk, "이름": "sub1임시"}]
|
||||
|
||||
|
||||
def test_same_draft_two_lines(world):
|
||||
"""② 같은 초안을 두 줄이 고름 = 내 기계 하나 · 두 줄 모두 그 키."""
|
||||
me = world["personal"]
|
||||
item, hour = _new_draft(me)
|
||||
sk = _sk(me)
|
||||
doc = _doc(sk, ["개인 EQP초안-7", "개인 EQP초안-7"], hour["로직"], [item])
|
||||
look = _at(me, path.preview, sk, {"문서": doc})
|
||||
_save(me, sk, doc)
|
||||
rows = _at(me, mym.rows)
|
||||
assert len(rows) == 1
|
||||
text = json.dumps(_file(me, sk)["수식"]["문서"], ensure_ascii=False)
|
||||
assert text.count(f"'개인 {rows[0]['키']}'") == 2
|
||||
assert _at(me, path.read, sk)["셈"]["계"] == look["셈"]["계"] == 2 * hour["계"]
|
||||
|
||||
|
||||
def test_unused_draft_not_made(world):
|
||||
"""③ 문서가 안 쓰는 초안 = 안 만듦 · 미리 셈만 = 0."""
|
||||
me = world["personal"]
|
||||
item, hour = _new_draft(me)
|
||||
sk = _sk(me)
|
||||
_at(me, path.preview, sk, {"문서": _doc(sk, ["개인 EQP초안-7"], hour["로직"], [item])})
|
||||
assert _at(me, mym.rows) == []
|
||||
_save(me, sk, _doc(sk, ["0101-0010"], "GC000269", [item])) # 줄을 다른 기계로 바꾼 뒤 저장
|
||||
assert _at(me, mym.rows) == []
|
||||
with pytest.raises(store.StoreError) as e: # 초안 없는 표지
|
||||
_save(me, sk, _doc(sk, ["개인 EQP초안-9"], hour["로직"], [item]))
|
||||
assert e.value.status == 400 and _at(me, mym.rows) == []
|
||||
|
||||
|
||||
def test_fail_leaves_nothing(world, monkeypatch):
|
||||
"""④ 판 다름 409 · 저장 중 오류 = 만든 내 기계 지움 · 고친 내 기계 되돌림 · SK 그대로."""
|
||||
me = world["personal"]
|
||||
item, hour = _new_draft(me)
|
||||
sk = _sk(me)
|
||||
doc = _doc(sk, ["개인 EQP초안-7"], hour["로직"], [item])
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
_at(me, path.save, sk, {"문서": doc, "판": "옛판"})
|
||||
assert e.value.status == 409 and _at(me, mym.rows) == []
|
||||
old = _at(me, mym.create, _body(1000))["줄"]
|
||||
edit = {"표지": "EQP초안-8", "키": old["키"], "줄": _body(4000)}
|
||||
both = _doc(sk, ["개인 EQP초안-7", "개인 EQP초안-8"], hour["로직"], [item, edit])
|
||||
before = _file(me, sk)
|
||||
|
||||
def boom(*_a, **_k):
|
||||
raise store.StoreError(500, "시험 실패")
|
||||
|
||||
monkeypatch.setattr(store, "write", boom)
|
||||
with pytest.raises(store.StoreError):
|
||||
_save(me, sk, both)
|
||||
assert _at(me, mym.rows) == [old] # 만든 것 없음 · 고친 것 되돌림
|
||||
assert _file(me, sk) == before
|
||||
|
||||
|
||||
def test_edit_mine_other_sk_new_value(world):
|
||||
"""⑤ 내 기계 고침 초안 — 저장한 SK = 새 값 그대로 · 그 기계 쓰는 다른 SK = 단가새값 → [단가 갱신]."""
|
||||
me = world["personal"]
|
||||
made = _at(me, mym.create, _body(1000))
|
||||
key, no, logic, was = (made["줄"]["키"], made["줄"]["원문번호"], made["시간당"]["로직"],
|
||||
made["시간당"]["계"]) # fmt: skip
|
||||
first = _sk(me, "sub1임시 첫째")
|
||||
_save(me, first, _doc(first, [no], logic))
|
||||
assert _at(me, path.read, first)["셈"]["계"] == was
|
||||
second = _sk(me, "sub1임시 둘째")
|
||||
edit = {"표지": "EQP초안-3", "키": key, "줄": _body(5000)}
|
||||
doc = _doc(second, ["개인 EQP초안-3"], logic, [edit])
|
||||
look = _at(me, path.preview, second, {"문서": doc})
|
||||
assert look["셈"]["계"] > was
|
||||
assert _at(me, mym.rows)[0]["값"]["가격"] == 1000 # 미리 셈 = 원본 그대로
|
||||
after = _save(me, second, doc)
|
||||
assert after["셈"]["계"] == look["셈"]["계"] and after["사본"]["단가새값"] is False
|
||||
assert _at(me, mym.rows)[0]["값"]["가격"] == 5000 and len(_at(me, mym.rows)) == 1
|
||||
now = _at(me, path.read, first)
|
||||
assert now["셈"]["계"] == was and now["사본"]["단가새값"] is True
|
||||
job = _at(me, path.preview, first, {"할일": "단가갱신"})
|
||||
assert [x["키"] for x in job["바뀐자원"]] == [key] and job["후"]["계"] == look["셈"]["계"]
|
||||
seen = _at(me, path.machine, first, f"'{no}'")
|
||||
assert (
|
||||
seen["사본"]["값"]["가격"] == 1000 and seen["지금"]["값"]["가격"] == 5000 and seen["새값"]
|
||||
)
|
||||
|
||||
|
||||
def test_company_and_other_refused(world):
|
||||
"""⑥ 회사 층 = 404(내 기계는 개인 층만) · 남의 개인 층 = 403(일반 사용자)."""
|
||||
item, hour = _new_draft(world["personal"])
|
||||
sk = _sk(world["company"])
|
||||
doc = _doc(sk, ["개인 EQP초안-7"], hour["로직"], [item])
|
||||
for fn in (path.preview, path.save):
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
_at(world["company"], fn, sk, {"문서": doc, "판": ""})
|
||||
assert e.value.status in (404, 409)
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
_at(world["company"], path.preview, sk, {"문서": doc})
|
||||
assert e.value.status == 404
|
||||
with pytest.raises(HTTPException) as e:
|
||||
route.my_machine_draft("personal", route.MachineBody(줄=_body()), "7/43", USER)
|
||||
assert e.value.status_code == 403
|
||||
with pytest.raises(HTTPException) as e:
|
||||
doc_route.save_doc("personal", sk, doc_route.DocBody(문서=doc, 판=""), "7/43", USER)
|
||||
assert e.value.status_code == 403
|
||||
|
||||
|
||||
def test_check_no_write(world):
|
||||
"""⑦ 확인 길 = 쓰기 없음 · 직접 = GC001199 · 빈 이름 400 · 없는 내 기계 404."""
|
||||
me = world["personal"]
|
||||
got = _at(me, draft.check, {"줄": {"이름": NAME, "방식": "직접",
|
||||
"시간당": {"노무비": 10, "재료비": 20, "경비": 30}}}) # fmt: skip
|
||||
assert got["시간당"]["로직"] == "GC001199" and got["시간당"]["계"] == 60
|
||||
assert got["셈값"] == {"노무비": 10, "재료비": 20, "경비": 30}
|
||||
rec = _at(me, draft.check, {"줄": _body(), "표지": "EQP초안-55"})["연결"] # 화면 계산기 캐시
|
||||
assert {"키": "EQ:개인 EQP초안-55", "칸": "가격"} in [e["p"] for e in rec["요소"]]
|
||||
assert rec["문서"] and "CC000336" in rec["표"]
|
||||
for raw, code in (({"줄": {"이름": ""}}, 400), ({"줄": _body(), "키": "EQP42-0099"}, 404)):
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
_at(me, draft.check, raw)
|
||||
assert e.value.status == code
|
||||
assert _at(me, mym.rows) == []
|
||||
|
||||
|
||||
def test_brief_same_as_hourly():
|
||||
"""⑧ 기계 창 M01 목록 시간당(`brief`) = 정본 시간당(`hourly`) · 원문번호로도 찾음."""
|
||||
keys = ["EQ000002", "EQ000010", DIG]
|
||||
rows = mh.brief(keys)
|
||||
assert [r["키"] for r in rows] == keys
|
||||
for r in rows:
|
||||
h = mh.hourly(r["키"])
|
||||
assert r["시간당"]["ok"] == h["ok"]
|
||||
if h["ok"]:
|
||||
assert r["시간당"]["계"] == h["계"] and r["시간당"]["로직"] == h["로직"]
|
||||
assert mh.brief([rows[0]["원문번호"]])[0]["키"] == "EQ000002"
|
||||
@@ -0,0 +1,57 @@
|
||||
"""STMATE 재현 앱 원가계산서 — 실무 STC 4건의 저장값(PRJLST.DBF · PRJLST.SMT)과 1원 대조.
|
||||
|
||||
정답은 STC 안 원본 저장값에서 읽음(코드에 박지 않음). 직접비는 내역 저장 단가 기준.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tools/stmate-test"))
|
||||
import server # noqa: E402
|
||||
|
||||
HELPER = Path(__file__).with_name("helper_stmate_calc.cjs")
|
||||
DOCS = ROOT / "resources/knowledge/original/실무문서"
|
||||
STCS = {
|
||||
"봉화2024": "04.산불 봉화/01. 내역서/13-2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.STC",
|
||||
"울진2025": "25년 산불진화임도(기번8)(울진소광)/2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).STC",
|
||||
"영월2024": "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.STC",
|
||||
"대흥2024": "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).STC",
|
||||
}
|
||||
SMT_KEYS = {
|
||||
"KANNO": "1_C_KANNO",
|
||||
"SANBO": "1_C_SANBO",
|
||||
"KOBO": "1_C_KOBO",
|
||||
"KUNBO": "1_C_KUNBO",
|
||||
"SLVBO": "1_C_SLVBO",
|
||||
"YUNBO": "1_C_YUNBO",
|
||||
"ANJUN": "1_C_ANJUN",
|
||||
"KITA": "1_C_T_KITA",
|
||||
"HWAN": "1_C_HWAN",
|
||||
"EQIP": "1_C_EQIPSU",
|
||||
"ILBAN": "1_C_ILBAN",
|
||||
"IYUN_M": "1_C_IYUN_M",
|
||||
"GOV": "1_C_UPDNKWN",
|
||||
}
|
||||
DBF_KEYS = {"dM": "MCOST1", "dL": "LCOST1", "dS": "SCOST1", "dT": "TCOST1", "GRAND": "COSTW1"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(STCS))
|
||||
def test_kanjub_matches_stc_to_the_won(name):
|
||||
loaded = server.api_load(server.LoadReq(source_path=str(DOCS / STCS[name])))
|
||||
out = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", str(HELPER)],
|
||||
input=json.dumps(loaded["sim_state"], ensure_ascii=False),
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
)
|
||||
expected = {k: loaded["prjlst_smt"][v] for k, v in SMT_KEYS.items()}
|
||||
expected |= {k: loaded["prjlst"][v] for k, v in DBF_KEYS.items()}
|
||||
diffs = {k: (out[k], float(v)) for k, v in expected.items() if abs(out[k] - float(v)) >= 0.5}
|
||||
assert not diffs, f"{name} 불일치 {diffs}"
|
||||
+286
-87
@@ -1,92 +1,291 @@
|
||||
# STMATE w 8.0 테스트 앱 계획서 및 검증 아카이브
|
||||
# STMATE w 8.0 재현 웹앱 계획서 · 진행률 장부 (`tools/stmate-test/`)
|
||||
|
||||
이 문서는 `tools/stmate-test/` 폴더 내 독립 웹앱의 개발 계획 및 검증 이력을 자체 보관·관리하는 문서입니다.
|
||||
- 목표 — 경쟁사 STMATE w 8.0 을 기능 그대로 재현한 독립 웹앱 · 인증·동글·라이선스·업데이트는 범위 밖
|
||||
- 구동 — `./venv/Scripts/python.exe -m uvicorn server:app --host 127.0.0.1 --port 8003 --app-dir tools/stmate-test` (안티그래비티 칸 8003)
|
||||
- 원본 근거 — `resources/knowledge/original/경쟁사 프로그램/STmate/`(old/20_분석 번호 문서 · old/30_원자료 · REA 분석)
|
||||
- 판정 — 구현 = 불러온 실제 프로젝트에서 원본 규칙대로 동작 · 부분 = 일부 갈래만·저장값 의존·고정값 · 없음 = 코드 없음 또는 껍데기
|
||||
- 박힌 정답값·자기 대조·저장값 복사는 구현으로 치지 않음
|
||||
- 이전 기록의 「95% 이상 · 100% 일치」는 봉화 한 건에 맞춘 값 · 저장소에 없는 검증 스크립트 기준 — 폐기
|
||||
|
||||
## 1. 개요 및 요구사항
|
||||
- **목적**: 경쟁사 데스크톱 프로그램 STMATE(w 8.0, 경영정보(주))의 95% 이상 복제 수준 달성 및 독립 웹앱 구축
|
||||
- **작업 경로**: `tools/stmate-test/`
|
||||
- **구동 서버**: `http://127.0.0.1:8095` (FastAPI + DBF/SMT/STC/JB_R 직결 엔진)
|
||||
- **외부 연동**: 로그인 및 공용 DB 의존 없음, 실무 프로젝트 DBF/SMT/STC 100% 무손실 연동
|
||||
## 진행 방식
|
||||
|
||||
1. 기능 하나 = 장부 한 줄 · 고칠 때 원본 근거 문서부터
|
||||
2. 정답은 원본 저장값(STC · 원본 출력 엑셀)에서 읽음 · 코드에 박지 않음
|
||||
3. 구현 → 지킴 시험(`resources/tester/test_stmate_*.py`) → ORCA 내장 브라우저 확인 → 커밋·push(안티그래비티)
|
||||
|
||||
## 지킴 시험
|
||||
|
||||
- `test_stmate_kanjub_stc.py` — 실무 STC 4건(봉화 2024 · 울진 2025 · 영월 2024 · 울진 대흥 2024) 원가계산서 18항목(직접비 4 · 간접비 11 · 이윤감액 · 관급 · 총공사비) 1원 대조 · 직접비는 내역 저장 단가 기준
|
||||
|
||||
## 진행률
|
||||
|
||||
- 합계: 전체 245 · 구현 29 · 부분 92 · 없음 124 → 진행률 30.6%((29 + 92×0.5) / 245) · 구현만 세면 11.8%
|
||||
|
||||
### G1 화면·메뉴 (구현 5 · 부분 21 · 없음 28) — 28.7%
|
||||
|
||||
- [ ] F1-01 메인 메뉴·툴바·하단 금액 집계줄 — STmate.Cpm 메인 · 현재: 부분(메뉴 5·툴바 9, 원본 메뉴 체계 일부만)
|
||||
- [ ] F1-02 공사목록표 목록·선택·불러오기 — wPrjList · 현재: 부분(안 연 프로젝트 칸은 고정 글자 view_prj_boq 212)
|
||||
- [ ] F1-03 프로젝트 신규 등록·삭제 — wM_Add_Prj · ST_U_DelPrj · 현재: 없음
|
||||
- [ ] F1-04 프로젝트 등록정보 수정 — wPrjList · 현재: 부분(공사명 하나만 prompt app.js 331)
|
||||
- [ ] F1-05 복사등록(차수·단계 변환) — wPrjList · ST_PList · 현재: 없음(화면 이동만 app.js 318)
|
||||
- [ ] F1-06 프로젝트 DATA 검색(노무~내역서 8종) — wSrch_PL · 현재: 없음
|
||||
- [x] F1-07 공종목록표 계층 내역 그리드(T/E/C 짝 집계) — wBoq · 현재: 구현(view_prj_boq 37~145)
|
||||
- [ ] F1-08 내역 칸 편집 — wBoq · 현재: 부분(공종번호·명칭·수량만, 규격·단위·단가·코드 고칠 수 없음)
|
||||
- [ ] F1-09 내역 행 추가·끼워넣기 — wBoq · 현재: 부분(첫 일위대가·수량 10·5번째 줄 고정 app.js 400)
|
||||
- [ ] F1-10 내역 행 삭제·이동·복사·붙여넣기 — wBoq · 현재: 없음
|
||||
- [ ] F1-11 레벨 필터(= 이상 ~ ====== 이상) — wBoq cBox01 · 현재: 부분(전체·집계·말단·제외 4택뿐)
|
||||
- [ ] F1-12 계표시 레벨 상향·하향, 주석 → 계시작 변환 — wBoq · 현재: 없음
|
||||
- [x] F1-13 내역 찾기 — wM_FindBoq · 현재: 구현(app.js 344)
|
||||
- [x] F1-14 호표 코드 → 대가표·단가표 이동 — wBoq · 현재: 구현(app.js 369)
|
||||
- [ ] F1-15 공종 목록(코드·명칭·분류 정렬, 대공종 필터) — wKongJong · 현재: 없음
|
||||
- [ ] F1-16 주공종 집계표(레벨별 계표시 확인) — wMBoq · 현재: 없음
|
||||
- [ ] F1-17 공종번호 자동입력(머리·끝 글자·번호형식·자리수) — wITNum · 현재: 부분(형식 선택 무시, 1./1.1 고정 app.js 158)
|
||||
- [ ] F1-18 내역 할증율 적용(전체/비목별) — wBoqRate · 현재: 부분(동작 틀림 — 전 자원 단가에 누적 곱 app.js 176)
|
||||
- [ ] F1-19 비목별 일괄 보정(절사/반올림 × 소수 0~4 · 범위) — wM_Rate · 현재: 부분(절사/반올림 무시, ECD 하나만 app.js 185)
|
||||
- [ ] F1-20 일위대가표 풀기 — wExpnd · 현재: 부분(집계선·끝전 선택 무시, 단가 없이 5번째 줄 app.js 190)
|
||||
- [ ] F1-21 풀어넣기 비목별 끝전·가산비목 지정 — wM_Expnd · wM_Expnd10 · 현재: 없음
|
||||
- [ ] F1-22 내역 동일코드 처리 — wSameCode · 현재: 없음
|
||||
- [ ] F1-23 내역보관함 (1)(2)(3) — wBoq · 현재: 부분(메모리만, 넣을 때 규격 빈칸·단위 '식' 고정 app.js 375)
|
||||
- [ ] F1-24 단가목록표 코드종류 필터(10종) — wM_JabPum cBoxCode · 현재: 부분(7종, 견적·대가산근·그림코드 없음)
|
||||
- [x] F1-25 단가 찾기(명칭·규격 / 산근내용) — wM_JabPum cBox_IFORM · 현재: 구현
|
||||
- [ ] F1-26 기초단가 직접 수정(재·노·경) — wM_JabPum · 현재: 부분(가짜 자원·숨은 구성줄 생성 view_prj_boq 519~548)
|
||||
- [ ] F1-27 코드 신규 등록·삭제 — wM_JabPum · ST_U_DelCode · 현재: 없음
|
||||
- [ ] F1-28 코드 변경·치환(일괄·선택) — ST_U_ChgCode · 현재: 없음
|
||||
- [ ] F1-29 기준단가 비율 일괄수정(보호표시·0원 제외) — wM_Edt_Hal · 현재: 부분(현재 단가에 누적 곱, 기준단가 분리 없음 app.js 243)
|
||||
- [ ] F1-30 할증 금액·율 두 갈래 편집 — ST_Jabpum Edt_Hal_Amt/Rate · 현재: 없음
|
||||
- [ ] F1-31 가격지 후보 비교(1~5단가·최소단가) — wM_Boxa RG_MComp · 현재: 부분(재료만, 슬롯 선택이 n번째 0 아닌 값 app.js 236)
|
||||
- [ ] F1-32 자재·공종 분류 필터, 불러간 목록 — wM_Boxa rg_FLT · wM_Box_H · 현재: 없음
|
||||
- [x] F1-33 대가표 목록·선택(B/D/X) — wM_iLWi_San · 현재: 구현(view_dan_ilwi 236~)
|
||||
- [ ] F1-34 일위대가 구성행 표 편집·노임계수·저장비목 선택 — wM_Edit_iLWi · 현재: 없음(SMT 글 편집만)
|
||||
- [ ] F1-35 단가산출근거 편집 + 계산(F9) — wM_Edit_San · 현재: 부분(글 편집·즉시 계산, 함수·문법검사 없음)
|
||||
- [ ] F1-36 손료 계산(F6)·시간당 손료계수 수정 — wM_Edit_San · wM_Edit_XRate · 현재: 없음
|
||||
- [ ] F1-37 사용자 단가산출식 편집 — wM_Edit_USER · 현재: 없음
|
||||
- [ ] F1-38 실적단가 → 일식단가 변환등록 — wM_Conv_A2W · 현재: 없음
|
||||
- [ ] F1-39 호환규정코드 찾기·입력 — wM_JDCode · 현재: 없음
|
||||
- [ ] F1-40 사용처 찾기 — ST_FindUse wM_FindUse · 현재: 없음
|
||||
- [ ] F1-41 코드 병합(4택)·데이터 병합(비목별 복사처/복사원/신규) — wM_Stmate_Merge_Code · uM_Merge · 현재: 없음
|
||||
- [ ] F1-42 자료파일 업데이트(매칭 5단계·명칭/단가/조달코드) — wM_Mrg_BSC · 현재: 없음
|
||||
- [ ] F1-43 단가 일괄변경·물가변동 — ST_U_ChgAmt · 현재: 없음
|
||||
- [ ] F1-44 초기값 입력 및 수정 — wM_Set_Sys · 현재: 부분(8칸만, 제목 42종과 다름)
|
||||
- [ ] F1-45 프로젝트 계산 초기값 — wM_Set_Init · 현재: 없음
|
||||
- [ ] F1-46 직종·노임 설정 — wM_Set_JLab · 현재: 부분(조건 직종 코드 입력만)
|
||||
- [ ] F1-47 제잡비 계산 화면(4형식 탭) — wM_KanJub · 현재: 부분(차수·방식·공사종류·기간·고용등급·퇴직 6칸 처리 없음)
|
||||
- [ ] F1-48 제비율표 수정·취소·저장·출력 — wM_KAN_RATE · 현재: 부분(보기만, 고칠 칸 없음 view_kanjub_prt 152)
|
||||
- [ ] F1-49 요율표 불러오기·선택 — wM_KAN_RATE · 현재: 부분(RATE_R 탭만 바뀜, 원가 요율 미연결 app.js 510)
|
||||
- [ ] F1-50 잡비 금액 불러오기(타 프로젝트 금액을 밑수로) — wM_GetAMT · 현재: 없음
|
||||
- [ ] F1-51 인쇄 메뉴 옵션(글꼴·코드필드·수량위치·양식·방향) — PM_Prt · 현재: 없음(고정 문구 view_kanjub_prt 454)
|
||||
- [ ] F1-52 보고서 인쇄순서·인쇄 공종 집계수준 선택 — PM_Prn_Ord · PM_Sel_St · 현재: 없음
|
||||
- [ ] F1-53 인쇄영역 선택(당초/변경/분할·레벨·변경 1~8차) — wM_Sel_Prn · 현재: 없음
|
||||
- [ ] F1-54 특수문자·그림코드·메모 입력 — mMinMsg_ · wInDlgImg · wInDlgMemo · 현재: 없음
|
||||
|
||||
### G2 계산 엔진 (구현 12 · 부분 29 · 없음 20) — 43.4%
|
||||
|
||||
- [x] F2-01 노임 일→시간 환산식 좌→우 순차 — 18번 §2.1 · Start8x RXXNM · 현재: 구현(calc.js 84)
|
||||
- [ ] F2-02 노임 환산 갈래 H0~H5(터널·준설선) — wM_Set_Sys cmBx_Sel_RX · 30번 §4 · 현재: 없음
|
||||
- [ ] F2-03 환산 적용 직종(COND_) — 20번 §2.5 · 현재: 부분(코드 고정 calc.js 600, 연쇄는 이름 '운전사'만 smt_engine 328)
|
||||
- [ ] F2-04 환율 적용(USD 구간·EUR·YEN·적용 범위) — wM_Set_Sys cmBX_EXR · 현재: 부분(USD 한 값, 범위 선택 없음)
|
||||
- [ ] F2-05 소수 자리수 설정(KCD·ACD·ZCD·JCD·ECD·BPN) — 22번 §3.4 · 현재: 부분(ECD만)
|
||||
- [ ] F2-06 자재 후보 최저가 자동채택 — 17번 §5.5 · 현재: 부분(후보 5칸만 calc.js 114, 6번째 적용칸 미반영)
|
||||
- [ ] F2-07 수동 고정·보호표시 우선 — 22번 §3.1 · 13번 T10 · 현재: 부분(일괄수정에서 UC=T 제외만)
|
||||
- [ ] F2-08 중기 시간당 사용료 조립(손료·운전원·연료·잡품 성분 절사) — 17번 §3 · 현재: 부분(식 있으나 불러온 프로젝트는 저장값 server.py 188)
|
||||
- [ ] F2-09 손료 주간·야간·2교대·3교대(E0~E3) — 30번 §4 · 현재: 없음
|
||||
- [ ] F2-10 단가산출 D코드 전개 계산 — 18번 §2.2 · 23번 · 현재: 부분(smt_engine 69 계산, 참조는 저장 단가·실패 시 저장값 유지 355)
|
||||
- [ ] F2-11 운반비 경비 귀속 — 23번 §4 · 26번 §1 · 현재: 부분(원본 저장 금액으로 판정 smt_engine 82)
|
||||
- [ ] F2-12 일위대가 B코드 구성 조립·성분 절사 — 17번 §2·§5 · 현재: 부분(calc.js 205 엔진뿐, 불러온 프로젝트는 저장값 server.py 230)
|
||||
- [ ] F2-13 호표 참조 그래프·위상순서·순환 오류 — 19번 §6.5 · 09번 §3 · 현재: 없음
|
||||
- [ ] F2-14 공구손료·잡재료 % 가산 — 07번 §4 · 현재: 부분(레시피 경로에서만, 레시피 0건 smt_parser 15)
|
||||
- [ ] F2-15 수량 할증과 금액 가산 분리 — 07번 §4 · 현재: 부분(calc.js 215 일위 할증만, 박힌 기본 상태 전용)
|
||||
- [ ] F2-16 TAMT=M+L+S·코드종류별 성분 검사 — 10번 §3 · 현재: 부분(합산만, L/M/S 종류별 검사 없음)
|
||||
- [x] F2-17 내역 행 ROUNDDOWN(수량×단가) — 16번 · 06번 §3 · 현재: 구현(calc.js 249)
|
||||
- [ ] F2-18 비목우선/총액우선 차액 보정(OPTNM) — REA 15단계 · 현재: 부분(calc.js 283, 소수 비교 규칙 원본 미대조)
|
||||
- [x] F2-19 계층 합산(SUM_TOT·T/E 짝) — 16번 §4.1 · 현재: 구현(같은 코드 여러 줄이면 짝 오류 위험 view_prj_boq 66)
|
||||
- [x] F2-20 제외금액 C* 구역 — 16번 §4.2 · 현재: 구현
|
||||
- [ ] F2-21 관급자재·조달수수료 분리, 관급 천원 올림 — 16번 §4.3·§4.7 · 현재: 부분(SMT C_KWANGUP·C_T_JODAL 입력값 사용 · 내역 C* 구역 자동 분류 없음)
|
||||
- [ ] F2-22 요율표 규모·기간 구간 조회 — 12번 §8~10 · 06번 §2 · 현재: 없음(SMT 저장 요율·JB_R 고정 셀 server.py 516)
|
||||
- [x] F2-23 요율 기본값 처리 — calc.js · 현재: 구현(num 기본값 · 4건 시험)
|
||||
- [ ] F2-24 공사종류·적용기준(행자부 RateM 포함)·공사기간 축 — 12번 §10.1 · 현재: 없음(칸 처리 없음)
|
||||
- [x] F2-25 간접노무비(직노×율) — 12번 §3 · 현재: 구현(calc.js 385, 요율은 저장값)
|
||||
- [x] F2-26 산재·건강·연금보험료 — 12번 §3 · 현재: 구현(calc.js 387~391)
|
||||
- [ ] F2-27 고용보험료(등급 7) — 12번 §8 rGrd_Rate_Kobo · 현재: 부분(식만, 등급 선택 처리 없음)
|
||||
- [x] F2-28 노인장기요양보험료(건강 확정액 밑수) — 25번 §2.3 · 현재: 구현(calc.js 390)
|
||||
- [ ] F2-29 퇴직공제부금비(토목·준설·건축·기타) — 12번 §10.1 · 현재: 부분(직노×율, 공종 선택 없음·기본 0%)
|
||||
- [ ] F2-30 산업안전보건관리비(규모식·1.2배·상한 3모드) — 11번 §7 · 16번 §4.3 · 현재: 부분(기초액 SMT 저장값 · 1.2배 상한 · 규모 구간 조회 없음)
|
||||
- [ ] F2-31 안전·품질관리비 별도내역 — 12번 §3 · 현재: 없음
|
||||
- [x] F2-32 기타경비(일반형식 묶음) — 12번 §3 · 현재: 구현(calc.js 431)
|
||||
- [ ] F2-33 수공 세부경비 8종·RateB 밑수 5택 — 12번 §4 · 25번 §1.4 · 현재: 부분(밑수 고정, 기본 요율 calc/server 불일치)
|
||||
- [ ] F2-34 공사이행보증·하도급대금지급보증수수료 — 06번 §3 · 12번 §3 · 현재: 없음
|
||||
- [ ] F2-35 환경보전비(공종 13택) — 06번 §3 · 현재: 부분(TA 밑수를 직접공사비로 씀 calc.js 440, 공종 선택 없음)
|
||||
- [ ] F2-36 건설기계대여금 지급보증(7택) — 12번 §10.1 · 현재: 부분(요율 0.4 고정 기본, 선택 축 없음)
|
||||
- [ ] F2-37 석면피해구제분담금·임금채권부담금 — 12번 §3 · 현재: 없음
|
||||
- [ ] F2-38 고용개선지원비 4갈래(복합 절사식) — 25번 §1.2·§2.4 · 현재: 없음
|
||||
- [ ] F2-39 일반관리비 — 06번 §3 · 현재: 부분(관리품목자재 미포함·S 밑수 다름 calc.js 451)
|
||||
- [x] F2-40 이윤(형식별 밑수) — 06번 §3 · 35번 · 현재: 구현(calc.js 456)
|
||||
- [ ] F2-41 이윤/수수료 택일·이윤 직접입력 — 12번 §10.1-2 pn02 · 현재: 없음
|
||||
- [x] F2-42 끝전조정(차액 세전환산 이윤 차감)·1원 보정 — 16번 §4.4 · REA 17 §5 · 현재: 구현(총공사비 기준 절사 · 절사 없음 = 저장 이윤감액 · 4건 1원)
|
||||
- [ ] F2-43 공급가액 절사 9택 — 12번 §10.2 · 현재: 없음
|
||||
- [ ] F2-44 부가세 설계 5택(산림조합 형식·면세품 포함) — 12번 §10.1 · 현재: 부분(10%·재료 10%·없음만, 산림조합 2택 없음)
|
||||
- [ ] F2-45 공사손해보험·작업설 감액·비과세자재·매입세 — 12번 §3.1 · 현재: 없음
|
||||
- [ ] F2-46 관급/사급 내용·사급자재/1.1·사급비 위치 4택 — 12번 §10.1 · 25번 §2.6 · 현재: 없음
|
||||
- [ ] F2-47 도급공사비/도급예정액·관급 2종 절사·절상·반올림 — 12번 §10.2 · 현재: 부분(관급 한 갈래만)
|
||||
- [ ] F2-48 총공사비 절사 자리 — 12번 §10.2 · 현재: 부분(I_TOT_JEOL 읽음, 기본값 버그 calc.js 468)
|
||||
- [x] F2-49 간접비 일괄 절사 7택 — wM_KanJub cBx_K_JeolSa · 현재: 구현
|
||||
- [ ] F2-50 추가금액 1~9단계 삽입 — 11번 §9 · 25번 §1.3 · 현재: 부분(저장 단계 1~4 계산 반영 · 화면 입력·5칸·부가세 여부 없음)
|
||||
- [ ] F2-51 간접비 순서 코드표(KAN_ORD_1/2) — 20번 §2.1 · 현재: 없음(순서가 코드에 고정)
|
||||
- [ ] F2-52 원가 일반형식 T — 12번 §3 · 현재: 부분(이행·하도급·석면·임금채권·고용개선·품질 빠짐)
|
||||
- [ ] F2-53 원가 수공형식 S — 12번 §4 · 현재: 부분(세부경비·일반관리비 밑수 다름)
|
||||
- [ ] F2-54 원가 실적일반 TA(노무·재료 산정방식·조정계수) — 12번 §5·§10.3 · 현재: 부분(실적 축 4개 없음)
|
||||
- [ ] F2-55 원가 실적수공 SA — 12번 §6 · 현재: 부분(실적 축·안전관리비 별도 없음)
|
||||
- [ ] F2-56 형식별 설정 스냅샷 4벌(_T/_S/_TA/_SA) — 12번 §10.1-2 · 현재: 없음(1_ 키 한 벌만)
|
||||
- [ ] F2-57 간접비 식 템플릿 26 평가(IF·선택토큰) — 25번 §2 · 현재: 없음
|
||||
- [ ] F2-58 항목별 밑수(D_)·결과(S_)·직접입력(C_) 키 왕복 — 25번 §1.1 · 현재: 없음
|
||||
- [ ] F2-59 요율 순환(무한루프) 경고·수동 확정 — 06번 §2 · 현재: 없음
|
||||
- [ ] F2-60 연쇄 재계산(L/M/S→X→D→B→내역→원가) — 08번 §1 · 현재: 부분(X·B 단계 레시피 0건 무동작, 위상순서 없음)
|
||||
- [ ] F2-61 표준시장단가 적용(ACTTYP·ACTOPT 노무비 끝전 보정) — 22번 §3.3 · 현재: 없음
|
||||
|
||||
### G3 수량산출·식언어 (구현 4 · 부분 9 · 없음 16) — 29.3%
|
||||
|
||||
- [x] F3-01 COSTN.SMT 메모 블록 읽기(IFORM 위치) — 21번 §1 · 23번 · 현재: 구현(smt_parser 150)
|
||||
- [x] F3-02 헤더 변수 정의(;; 줄)·변수 대입 — 23번 · 현재: 구현(smt_engine 112)
|
||||
- [x] F3-03 자원 참조 토큰 &코드[LMS]& 성분 지정 — 07번 §2 · 현재: 구현(참조값은 저장 단가)
|
||||
- [x] F3-04 라벨 :=A01·&PA01:M&·&PS_M&·&RC_S& 소계 참조 — 23번 · 현재: 구현(smt_engine 186~)
|
||||
- [ ] F3-05 구간 합산 (=)(==)(===)·소계 제외 (-=) — 23번 · 현재: 부분((==)(===)를 (=)와 같게, (-=) 뜻 추정)
|
||||
- [ ] F3-06 사칙·괄호 평가와 좌→우 순차 — 18번 §2.1 · 현재: 부분(JS 우선순위 계산, 순차 평가 아님)
|
||||
- [ ] F3-07 % 표기('%') 처리 — 23번 §5 · 현재: 부분(1 이상이면 ×0.01 짐작 smt_engine 46)
|
||||
- [ ] F3-08 분수 인자(f=1/1.3) 자리수 보존 — 26번 §2 · 현재: 부분(끝 '='이면 소수 2자리 반올림 짐작)
|
||||
- [ ] F3-09 함수 INT·ROUND·ROUNDDOWN·ROUNDUP·SQRT·√·SIN·COS·TAN·∏ — wM_Edit_San · 현재: 없음(이름을 0으로 바꿈 smt_engine 51)
|
||||
- [ ] F3-10 조건 함수 IF·IIF·IFI·IFO — 24번 §2.3 · 25번 §2.2 · 현재: 없음
|
||||
- [ ] F3-11 금액변수·소계변수(대상·저장·%) 선언 — 28번 §1 · 현재: 없음
|
||||
- [ ] F3-12 계산후 제외처리·항목집계 제외 코드 — 28번 §1·§4 · 현재: 없음
|
||||
- [ ] F3-13 강제금액(직접 지정 금액) — 28번 §4 · 현재: 부분('재료비: 숫자' 꼴만 smt_engine 131)
|
||||
- [ ] F3-14 수량기호 Q·A·T·N — wM_Edit_San · 현재: 없음
|
||||
- [ ] F3-15 식 문법 검사(줄 단위 오류 메시지) — 28번 §4 · 현재: 없음(오류 시 조용히 0)
|
||||
- [ ] F3-16 문법 강조·문법설명·함수/연산 입력 단추 — 26번 §3 · 현재: 없음
|
||||
- [ ] F3-17 결과적용 비목 4택·0원 비목 입력안함 — 26번 §3 · 현재: 없음
|
||||
- [ ] F3-18 결과값 낙찰율 적용·결과금액조정·계약단가 — 26번 §3 · 현재: 없음
|
||||
- [ ] F3-19 Q식(3600qKfE/Cm 등 10꼴) 평가 — 23번 §3 · 현재: 부분(calc.js 171 두 꼴, 불러온 프로젝트는 저장값 server.py 223)
|
||||
- [ ] F3-20 Cm 사이클식(t1+…·L/V1+L/V2+0.25·선회각 초) — 26번 §1 · 현재: 부분(사칙 대입만, 선회각 표기 미해석)
|
||||
- [ ] F3-21 표준중기공식 등록·수정·삭제 — wM_Edit_EQ · 28번 §1.1 · 현재: 없음
|
||||
- [ ] F3-22 수량산출서 화면 — wQtyList · 현재: 부분(작은 창 25줄, QCODE 안 읽음 app.js 207)
|
||||
- [ ] F3-23 수량산출서 파일 저장·불러오기 — STmateQty8 · 현재: 없음
|
||||
- [ ] F3-24 코드 종류별 수량 조회(9종 필터) — wQtyList cBoxCode · 현재: 없음
|
||||
- [ ] F3-25 기본수량변수·확장수량변수(셀별명) — 28번 §2 · 현재: 없음
|
||||
- [ ] F3-26 수량산출서 → 내역 수량·공종 가져오기 — wBoq · 현재: 부분(식 계산해 수량 반영, 변수 불가)
|
||||
- [ ] F3-27 수량변수 → 단가산출 전송 — 28번 §2 · 현재: 없음
|
||||
- [ ] F3-28 내역↔BDQTY 수량 연결(CON_QTY) — ST_U_ConBD · 현재: 없음
|
||||
- [ ] F3-29 수량코드(QCODE) 등록·검사 — 28번 §4 · 현재: 없음
|
||||
|
||||
### G4 파일 입출력 (구현 6 · 부분 9 · 없음 14) — 36.2%
|
||||
|
||||
- [x] F4-01 STC(ZIP) 열기 — ST_U_Archive · 09번 §5 · 현재: 구현(server.py 92)
|
||||
- [ ] F4-02 STC 저장(압축) — ST_U_Archive ComP_Data · 현재: 부분(work_dbf 사본, 같은 이름 덮어씀 server.py 644)
|
||||
- [ ] F4-03 STC 버전 검사 — Chk_STC_Ver · 현재: 없음
|
||||
- [x] F4-04 DBF 읽기(03/E5/06 변형) — 09번 §5 · 현재: 구현(smt_parser 27)
|
||||
- [ ] F4-05 DBF 다시 쓰기 무손실 — 09번 §5 · 현재: 부분(한글 '?'·앞 공백·삭제 레코드 빠짐·헤더 날짜 바뀜)
|
||||
- [x] F4-06 PRJLST.DBF 읽기 — 02번 · 현재: 구현
|
||||
- [ ] F4-07 PRJLST.SMT 저장키 241종 읽기 — 25번 §1 · 현재: 부분(PRJ_MEMO 가리키는 블록만 · 요율·절사·관급·추가금액 사용)
|
||||
- [ ] F4-08 PRJLST.SMT 쓰기 — 25번 · 현재: 부분(일부 키만 갱신)
|
||||
- [x] F4-09 COSTN.DBF 읽기(후보 6·차수 4벌) — 07번 §5 · 현재: 구현
|
||||
- [ ] F4-10 COSTN.DBF 쓰기 — 07번 · 현재: 부분(고친 코드 칸만, 차수 칸 의미 무시)
|
||||
- [ ] F4-11 COSTN.SMT 쓰기 — 21번 · 현재: 부분(덧붙이기만, SIxMemo 빈 블록 헤더 미갱신 smt_parser 315)
|
||||
- [ ] F4-12 COSTA.DBF 읽기 — 02번 · 현재: 없음
|
||||
- [ ] F4-13 BOQ1n 읽기·쓰기 — 02번 · 16번 · 현재: 부분(읽기 됨, 쓰기는 MAMT2·3 덮어씀·소계·TCOST 미갱신)
|
||||
- [x] F4-14 RATE*.DBF 읽기 — 06번 §1 · 현재: 구현(server.py 474)
|
||||
- [ ] F4-15 JB_* 기준요율표 43종 읽기(구형·2020 확장) — 10번 · 12번 §9 · 현재: 부분(JB_R 고정 셀만)
|
||||
- [ ] F4-16 KONGJONG.DBF 공종 분류 — 02번 · 현재: 없음
|
||||
- [x] F4-17 SET_REP.DBF 서식 카탈로그 읽기 — 20번 §3 · 현재: 구현(표시)
|
||||
- [ ] F4-18 QCODE.DBF·QCODE.SMT 읽기 — 02번 · 28번 · 현재: 없음
|
||||
- [ ] F4-19 Start8x.Ini 설치 설정 읽기 — 20번 §2 · 현재: 없음
|
||||
- [ ] F4-20 조달청 호환규정 XML 내보내기 — 30번 · uM_XML_Info · 현재: 부분(영문 임의 요소명, XSD 불일치 smt_engine 630)
|
||||
- [ ] F4-21 조달청 XML·조달 형식 가져오기(Conv_XML_ST8·Conv_Jodal_ST8) — 28번 §3 · 현재: 없음
|
||||
- [ ] F4-22 XML 변환 설정(공사유형 8·지역·건축 사업내용) — uM_XML_Info · uM_ConstType · 현재: 없음
|
||||
- [ ] F4-23 JDL 내보내기·검사 — frm_JDLAnno · JDLCHK.*.dll · 현재: 없음(근거 없는 '|' 줄 형식 app.js 569)
|
||||
- [ ] F4-24 엑셀 → 내역 불러오기 — wM_From_XLS · 현재: 없음
|
||||
- [ ] F4-25 엑셀 역등록(CONV_XLS_2_ST·INITIAL)·엑셀 → BDQTY — 24번 §3 · 현재: 없음
|
||||
- [ ] F4-26 자재 기준자료 XLS 입출력 — wM_JajeXLS · 현재: 없음
|
||||
- [ ] F4-27 기준 BOQ·코드 변경 엑셀 왕복 — 24번 §3 · ST_U_ChgCode · 현재: 없음
|
||||
- [ ] F4-28 구버전 → Win8 자료 변환 — ST_U_Convert ST_ConvToWin8 · 현재: 없음
|
||||
- [ ] F4-29 자동백업·자료 복원 — wM_D_Restore · wM_ST_Restore · 현재: 없음
|
||||
|
||||
### G5 보고서·내보내기 (구현 0 · 부분 15 · 없음 21) — 20.8%
|
||||
|
||||
- [ ] F5-01 재료비·노무비·경비 목록표(0101~0103) — Prn_SanMok · 현재: 부분(엑셀 시트 저장값 복사, 후보 칸 이름 짐작)
|
||||
- [ ] F5-02 일식·견적(W코드) 목록표(0104) — Prn_SanMok · 현재: 없음
|
||||
- [ ] F5-03 자재단가대비표(0105) — Prn_Col_Dan · 현재: 없음(화면 0101에 대비 열 없음)
|
||||
- [ ] F5-04 표준시장·실적단가 목록표(0106·0206) — Prn_SanMok · 현재: 없음
|
||||
- [ ] F5-05 환율및기초자료(0201) — 18번 §2.3 · 현재: 부분(환율 없이 노무·중기 목록만)
|
||||
- [ ] F5-06 중기목록표·시간당중기사용료(0202·0203) — Prn_Joong · 현재: 부분(저장 단가 표시, 손료·운전원 세부 없음)
|
||||
- [ ] F5-07 일위대가목록표(0301) — Prn_SanMok · 현재: 부분(엑셀 시트, 저장값)
|
||||
- [ ] F5-08 일위대가표(0302) — Prn_iLWi · 현재: 없음(구성 없이 수량 1·자기 단가 excel_builder 443)
|
||||
- [ ] F5-09 일위대가표 수공양식(0303) — Prn_iLWi_S · 현재: 없음
|
||||
- [ ] F5-10 단가산출근거목록표(0401) — Prn_SanMok · 현재: 부분(엑셀 시트, 저장값)
|
||||
- [ ] F5-11 단가산출근거(0402) — Calq_San · Edit_SanKun · 현재: 부분(식 원문만, 줄별 계산 펼침 없음)
|
||||
- [ ] F5-12 재료·노무·경비 수량금액집계표(0501~0503) — Prn_Col_Dan · 현재: 없음(화면 0501 집계 없음)
|
||||
- [ ] F5-13 일식·표준시장 수량금액집계표(0504·0508) — Prn_Col_Dan · 현재: 없음
|
||||
- [ ] F5-14 중기시간금액집계표(0505) — Prn_Col_Dan · 현재: 없음
|
||||
- [ ] F5-15 일위대가수량금액집계표(0506) — Prn_Col_Dan · 현재: 없음
|
||||
- [ ] F5-16 단가산출근거수량금액집계표(0507) — Calq_San · 현재: 없음
|
||||
- [ ] F5-17 총괄내역서(0601·종양식 0602) — Prn_Nae · 현재: 부분(집계행만 표시, 종양식 없음)
|
||||
- [ ] F5-18 설계내역서(0603·종양식 0604·0693·0694) — Prn_Nae · 현재: 부분(화면 전 행, 엑셀 소계 행 없음·#VALUE 위험)
|
||||
- [ ] F5-19 분할내역서(0605) — Prn_Nae · 현재: 없음
|
||||
- [ ] F5-20 사·관급내역서(0607) — Prn_Nae · 현재: 부분(제외 구역 행만 걸러 표시)
|
||||
- [ ] F5-21 공사원가계산서(0608) — Prn_Won_A · 현재: 부분(화면 됨, 엑셀 1% 미만 요율 80% 표기 excel_builder 108)
|
||||
- [ ] F5-22 총괄집계표(0609) — Prn_Col_Dan · 현재: 없음
|
||||
- [ ] F5-23 견적내역서(0613·0614)·견적단가대비표(0615) — PM_Nae_Est · 현재: 없음
|
||||
- [ ] F5-24 도공·굴착·시도·구도·비교 양식 내역서(0606·0651~0656·0672) — ST_P_Excav · Prn_Nae · 현재: 없음
|
||||
- [ ] F5-25 재료비·노무비·중장비 집계표(0675~0677) — Prn_Col_Dan · 현재: 없음
|
||||
- [ ] F5-26 장비·인력·자재 투입계획(0671·0678·0679)·간이명세서(0695) — Rep_Print · 현재: 없음
|
||||
- [ ] F5-27 목차 시트(〓 목 차 〓) — 19번 §3 · 현재: 부분(엑셀 하이퍼링크, 없는 시트 3개 링크 깨짐)
|
||||
- [ ] F5-28 안내 시트(※※안내※※) — 19번 §3 · 현재: 없음
|
||||
- [ ] F5-29 〓 INITIAL 〓 설정 스냅샷 시트 — 18번 §2.3 · 현재: 없음
|
||||
- [ ] F5-30 제잡비 요율표 인쇄 — PM_KAN_RATE · 현재: 없음
|
||||
- [ ] F5-31 엑셀 다중 시트 내보내기·수식 보존 — ST_P_XLS Prn_FMLA_INFO · 현재: 부분(11시트, 대부분 값 복사)
|
||||
- [ ] F5-32 보고서 인쇄·미리보기 — PM_* · 현재: 부분(브라우저 화면 인쇄, 보고서 양식 없음)
|
||||
- [ ] F5-33 가로형·토개공·수자원·산근양식 변형 — PM_*_L · PM_Prt · 현재: 없음
|
||||
- [ ] F5-34 출력별 반올림(내역 절사·집계 사사오입) — 06번 §3 · 18번 §2.4 · 현재: 부분(집계용 반올림 값만, 집계표 없음)
|
||||
- [ ] F5-35 금액 한글 표기(AMTTOHAN) — 24번 §2.4 · 현재: 없음
|
||||
- [ ] F5-36 SET_REP 데이터 기반 열·폭·제목 — 20번 §3 · 현재: 부분(양식표 표시만, 출력에 미적용)
|
||||
|
||||
### G6 설계변경·계약·실행·기성 (구현 2 · 부분 9 · 없음 25) — 18.1%
|
||||
|
||||
- [ ] F6-01 차수 4벌(원설계+3차변경) 칸 관리 — 08번 §2 · 24번 §7 · 현재: 부분(2·3 칸을 변경·계약 값으로 덮어씀 view_prj_boq 560)
|
||||
- [ ] F6-02 통합형 BOQ11+BOQ12에서 BOQ12 현재안 — 16번 §4.5 · 현재: 없음(늘 BOQ11 server.py 465)
|
||||
- [ ] F6-03 분리형 변경 STC 생성·차수 인계 — 16번 §4.6 · 현재: 없음
|
||||
- [ ] F6-04 변경 수량 입력 — 설계변경 · 현재: 부분(공종별 입력, 1 단위 칸 app.js 536)
|
||||
- [ ] F6-05 변경 단가·신규 공종·삭제 항목(0 유지) — 08번 §2 · 현재: 없음
|
||||
- [x] F6-06 변경 직접비 당초·변경·증감 — 19번 §6.5 · 현재: 구현(calc.js 661~670)
|
||||
- [x] F6-07 간접비 변경 모드: 재계산 — 08번 §2 · 현재: 구현(calc.js 357)
|
||||
- [ ] F6-08 간접비 변경 모드: 당초액 유지(건강·장기요양·연금·퇴직공제) — 19번 §6 · 35번 §4 · 현재: 부분(세 항목 고정, 퇴직공제·선택 없음)
|
||||
- [ ] F6-09 간접비 변경 방법 A(비율)·방법 B(증감×율) — 08번 §2 · 현재: 부분(식만, 고를 화면 없음)
|
||||
- [ ] F6-10 보험료 변경 계산 3모드 — wM_KanJub cBx_CH_BO · 현재: 없음
|
||||
- [ ] F6-11 2차·3차 변경 계산 — wM_KanJub cBx_Chasu · 현재: 없음(차수 칸 처리 없음)
|
||||
- [ ] F6-12 원가계산서 당초/변경 대비·변경분만 인쇄 — PM_Won_A_C · Cmbx_PORD1_H · 현재: 부분(화면 증감 표, 인쇄 양식 없음)
|
||||
- [ ] F6-13 설계변경내역서(0643·0644) — CHANGE_STR · 현재: 없음(0603과 같은 표 view_kanjub_prt 531)
|
||||
- [ ] F6-14 변경내역서(변경율)(0645) — CHANGE_AMT · 현재: 없음
|
||||
- [ ] F6-15 설계변경증감대비표(0648)·금액대비표 — CHANGE_AMT · 현재: 부분(화면 표만, 보고서·엑셀 없음)
|
||||
- [ ] F6-16 변경총괄·굴착·시도 변경내역서(0661~0666) — CHANGE_* · 현재: 없음
|
||||
- [ ] F6-17 계약 변환 비목별 단가적용율(노·재·경) — wM_Mk_Cont · 현재: 부분(행별 버림, 머리줄은 재료율 하나 view_kanjub_prt 274)
|
||||
- [ ] F6-18 계약 변환 옵션(기초단가·일위/산근 생성·동일코드·노무비율·비과세) — 27번 §2.2 · 현재: 없음
|
||||
- [ ] F6-19 적용율 0% → W코드 공내역 — 27번 §2.2 · 현재: 없음
|
||||
- [ ] F6-20 적용제외 공정 선택(레벨 9단계) — 27번 §2.3 · 현재: 없음(문구만)
|
||||
- [ ] F6-21 예상적용율 찾기(목표금액 역산)·미리계산 — 27번 §2.3 · 현재: 없음(문구만)
|
||||
- [ ] F6-22 설계 → 계약자료 변환등록(당초/변경·레벨) — wM_Conv_Mode · 현재: 없음
|
||||
- [ ] F6-23 낙찰율 열(NAGCD)·계약단가 저장 — 26번 §4 · 현재: 부분(MAMT3를 87.745% 일괄값으로 덮어씀)
|
||||
- [ ] F6-24 계약 원가계산(계약 제잡비) — 27번 · 현재: 없음(COSTW3=총공사비×재료율 view_prj_boq 633)
|
||||
- [ ] F6-25 계약·실행내역서(0623·0624) — PM_Nae_Cont · 현재: 없음(0603 복사)
|
||||
- [ ] F6-26 실행예산 작업(실행단가·실행수량) — wBoq 실행예산작업 · 현재: 없음(92% 고정 view_kanjub_prt 317)
|
||||
- [ ] F6-27 중기 실행단가 계산(절사 6택·차액 보정 순서) — wM_Boq_ExecX · 현재: 없음
|
||||
- [ ] F6-28 실행예산서 Ⅰ·Ⅱ(0673·0674) — SET_REP · 현재: 없음
|
||||
- [ ] F6-29 기성 회차 전회·금회·누계·비율 — wM_KanJub_K rGrd_Jab · 현재: 부분(전 공종 같은 비율 30/65% app.js 44)
|
||||
- [ ] F6-30 공종별 기성수량·잔량 입력 — 27번 §3.2 · 현재: 없음
|
||||
- [ ] F6-31 기성 제잡비(금회직접×계약제잡비율) — 27번 §3.1 · 현재: 없음(도급액×누계율 표시만)
|
||||
- [ ] F6-32 기성 부가세 4택·절사·이윤 직접입력 — 27번 §3.3 · 현재: 없음
|
||||
- [ ] F6-33 기성 집계수준 필터(소계~총합계) — wM_KanJub_K rd_Filter · 현재: 없음
|
||||
- [ ] F6-34 기성부분총괄·기성내역서·사정·기성율(0632~0635) — Prn_Jab · 현재: 없음(0603 복사)
|
||||
- [ ] F6-35 기성검사조서(을·병)·기성부분수량조서(0636~0638) — Prn_Jab · 현재: 없음
|
||||
- [ ] F6-36 준공조서(을·병)(0646·0647) — SET_REP · 현재: 없음
|
||||
|
||||
---
|
||||
|
||||
## 2. 세부 개발 및 검증 항목
|
||||
|
||||
- [x] **화면 및 메뉴 체계 복제 (TWM_STMATE_START_8X)**
|
||||
- 상단 5대 메뉴(`Act_PrjList`, `Act_Init`, `Act_BoqEdit`, `Act_KanJub`, `Act_KanRate`, `Act_StageEdit`, `Act_Print`)
|
||||
- 8버튼 메인 툴바 및 하단 순공사비·간접비·도급액·총공사비 집계바
|
||||
- 9개 메인 작업 화면:
|
||||
- `wPrjList`: 공사목록표 및 하단 `BOQ11.DBF` 미리보기
|
||||
- `wBoq`: 계층 내역서(`T1..T6`/`E1..E6`/`C*`), 공종번호 자동입력(`wITNum`), 내역할증(`wBoqRate`), 단수보정(`wM_Rate`), 일위풀기(`wExpnd`), 수량산출 연결(`wQtyList`), 내역보관함 (1)(2)(3)
|
||||
- `wM_Set_Sys`: 환경설정 (환율, ECD 소수위, 계산식, 환산식 `1/8*16/12*25/20`)
|
||||
- `wM_JabPum`: 단가목록표 (5대 가격지 비교 `wM_Boxa`, 기준단가 비율조정 `wM_Edt_Hal`, 표준 카탈로그 브라우저)
|
||||
- `wM_Edit_iLWi`: 일위대가·단가산출 수식 편집기 (`COSTN.SMT` 64B 블록 전개식, 표준 카탈로그 894코드·350레시피·21특수산식)
|
||||
- `wM_KanJub`: 제잡비 계산서 (4탭 `T/S/TA/SA`, 16대 콤보박스 및 이윤 끝전조정)
|
||||
- `wM_KAN_RATE`: 원가계산기준 제비율표 (`RATE_R/RA/M/B.DBF` 50×35 및 `ST_BSC/*.JB_R` 파일 직결)
|
||||
- `wStage`: 설계 후속 단계 (`wM_Mk_Cont` 계약내역, `wM_Boq_ExecX` 실행내역, `wM_KanJub_K` 기성내역, `REVISION` 설계변경 증감대비표)
|
||||
- `PM_Prt`: 30종 보고서 인쇄 미리보기 및 6시트 엑셀(`.xls`), 조달청 `<T3_C3B>` XML, JDL 내보내기
|
||||
|
||||
- [x] **100% 범용 `COSTN.SMT` 수식 인터프리터 (`smt_engine.js`)**
|
||||
- 봉화군 2024 `COSTN.SMT` 전수 65/65건(100%) 1원 오차 없이 일치 검증:
|
||||
- `;;` 헤더 변수 분리 파싱
|
||||
- `[A]`, `[D]'%'`, `N'%'` ($N \ge 1 \Rightarrow N \times 0.01$, $N < 1 \Rightarrow N$)
|
||||
- `&PA01:M&`, `&RA01:M&`, `&PS_M&`, `&RC_S:S&` 라벨·구간·누적 소계 처리
|
||||
- `&L00020&` 기구손료 인건비 누적소계 자동참조
|
||||
- `(-=)` 소계제외 블록, `(=)`/`(==)`/`(===)` 구간합산
|
||||
- 운반비(`운반`) 경비(`S`) 일괄 귀속 규칙
|
||||
|
||||
- [x] **5단 연쇄 재계산 엔진 (`cascadeRecalculateAll` / `[계산완료]`)**
|
||||
- 기초단가(`L*`/`M*`/`S*`) 변경 시 실시간 연쇄 전파:
|
||||
1. 중기사용료(`X*` 25/25건 100% 일치, 운전사 노임 좌→우 `1/8*16/12*25/20` 시간당 환산)
|
||||
2. 단가산출(`D*` 65/65건 100% 일치)
|
||||
3. 일위대가(`B*` 45/45건 100% 일치, 공구손료·잡품 `재료비의 %`/`노무비의 %` 구분)
|
||||
4. 내역서(`BOQ11.DBF` 141행)
|
||||
5. 원가계산서(`PRJLST.DBF/.SMT`)
|
||||
|
||||
- [x] **다중 슬롯 DBF 양방향 저장 (`/api/save`)**
|
||||
- 당초설계(`*1`), 설계변경(`*2`), 계약내역(`*3`) 멀티 슬롯 컬럼을 `COSTN.DBF`, `BOQ11.DBF`, `PRJLST.DBF`, `PRJLST.SMT`, `COSTN.SMT` 및 `.STC` 아카이브에 무손실 저장
|
||||
|
||||
- [x] **보고서 엑셀 내보내기 원본 STMATE 1:1 복제 (`excel_builder.py` + `/api/export-xlsx`)**
|
||||
- 원본 STMATE 엑셀 산출물 분석 기반 11개 시트 구성 및 네이티브 하이퍼링크/수식 구현:
|
||||
- `〓 목 차 〓`: 전 시트 바로가기 `=HYPERLINK("#'시트명'!A1", "시트명 →")` 수식 적용
|
||||
- 전 시트 상단 우측: 목차 복귀 `=HYPERLINK("#'〓 목 차 〓'!B2", "목차 →")` 수식 적용
|
||||
- `설계내역서` ↔ `단가산출근거목록표` / `일위대가목록표` 간 셀 참조 수식 및 `=ROUNDDOWN(수량*단가, 0)` 수식화
|
||||
- 원가계산서 비목별 연동 수식 및 11개 시트 스타일링 (헤더 폰트, 테두리, 열 너비 자동 조정)
|
||||
- 백엔드 생성기(`excel_builder.py`, 452줄) + 서버 엔드포인트(`server.py`, 630줄) + UI 연동(`app.js`, 689줄)
|
||||
- 전 파일 700줄 제한 엄격 준수 및 ruff/prettier 포맷팅 적용
|
||||
|
||||
---
|
||||
|
||||
## 3. 검증 결과 기록
|
||||
|
||||
### 자체 검증 (2026-11-05)
|
||||
- **700줄 제한 및 포맷터**: 소스코드 10개 파일 전체 700줄 이하 준수 및 ruff/prettier 포맷 완료.
|
||||
- **브라우저 결함 수정 5건**:
|
||||
1. `wBoq` 상단 헤더 관급자재 합계(`govMatTotal: govRawTotal`) 누락 수정 (`143,554,309원` 표시)
|
||||
2. `wM_KanJub` 및 `PM_Prt` 산정기초 금액 열(`base: baseAmt`) 표시
|
||||
3. `wStage` 설계변경 증감대비표 런타임 에러 수정
|
||||
4. `wM_Edit_iLWi` `D00016` 굴삭기 $Q$ 산식 중간 변수 반올림($f=0.85 \rightarrow Q=22.85$) 적용으로 DB 단가(`3,825원`) 일치
|
||||
5. `wExpnd` 모달 내역서 전개 삽입 연동
|
||||
|
||||
### 자체 검증 (2026-11-07 · 95%+ 완전 복제 고도화)
|
||||
- **Playwright 브라우저 엔진 전수 검증 통과 (`verify_95_fidelity.py`)**:
|
||||
- `COSTN.SMT` 65/65건 100% 일치
|
||||
- 중기사용료 `X*` 25/25건 100% 일치
|
||||
- 단가산출 `D*` 65/65건 100% 일치
|
||||
- 일위대가 `B*` 45/45건 100% 일치
|
||||
- 봉화군 2024 산림유역관리사업 기준:
|
||||
- 직접공사비(`TCOST1`): `781,283,415원`
|
||||
- 도급액(`FINAL_SUPPLY`): `1,270,728,000원`
|
||||
- 관급자재대: `143,555,000원`
|
||||
- 총공사비(`COSTW1`): `1,414,283,000원` (1원 오차 없이 100% 일치)
|
||||
- 동적 연쇄 전파 확인: 경유 `M00018` +100원 인상 시 `X00006` `65,015`→`65,627`원, `D00016` `3,825`→`3,878`원, 총공사비 `1,414,283,000`→`1,423,700,000`원 정상 반영
|
||||
- 6시트 엑셀 워크북(`237,840`바이트) 및 조달청 XML(`52,252`바이트) 정상 생성
|
||||
- 9개 화면 전 과정 콘솔/페이지 런타임 에러 `0건`
|
||||
|
||||
### 자체 검증 (2026-11-07 · 엑셀 원본 STMATE 1:1 하이퍼링크/수식 고도화)
|
||||
- **11개 시트 엑셀 내보내기 검증 완료**:
|
||||
- `〓 목 차 〓`, `공사원가계산서`, `단가산출근거목록표`, `일위대가목록표`, `설계내역서`, `중기사용료`, `단가산출근거`, `일위대가표`, `재료비목록표`, `노무비목록표`, `경비목록표` 총 11개 시트 정상 생성.
|
||||
- 목차의 각 항목별 바로가기 `=HYPERLINK("#'시트명'!A1", "시트명 →")` 및 세부 시트의 목차 복귀 `=HYPERLINK("#'〓 목 차 〓'!B2", "목차 →")` 네이티브 엑셀 하이퍼링크 수식 동작 확인.
|
||||
- 내역서 단가 참조 수식(`=단가산출근거목록표!F{row}`, `=일위대가목록표!F{row}`) 및 산출금액 내림 함수(`=ROUNDDOWN(...)`) 정상 반영.
|
||||
- 서버 상시 구동 상태에서 `POST /api/export-xlsx` 엔드포인트 200 OK 응답 확인.
|
||||
- 전체 소스코드 파일 700줄 이하 준수 (excel_builder.py: 452줄, server.py: 630줄, app.js: 689줄 등) 및 포맷터 완료.
|
||||
- 범위 밖(합계 제외): 하드웨어 동글(SX32W Sentinel) · 시리얼 인증(ST_BdSN) · 라이선스 서버 접속(wM_Host) · 유지보수 연장·A/S 신청(wM_Req_ON·wM_Req_AS) · 온라인 업데이트·패치·다운로드 센터(ST_U_ADL·STmexe·wM_Stmate_APatch·wM_Down_Center)
|
||||
- 재현 불가(합계 제외): 원본 암호화 자료 — BDQTY.DBF 수량 할증 세부 · SYSINFO.DBF · EQSYNC.DBF · 기준단가 BSC/CSTA(자재 5.7만·노임·중기·표준시장)
|
||||
- 기능 밖 결함(합계 제외): app.js 718줄 · 서버 0.0.0.0·CORS 전부 허용·임의 경로 읽기(/api/load·/work_dbf 정적 노출)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# STMATE w 8.0 Full Engine — 독립 테스트 웹앱 (`tools/stmate-test`)
|
||||
|
||||
아이슬로(Aislo) 메인 프로그램과 완전히 분리되어 동작하며, REA 정적분석(`Start8x.exe`, `ST_Sub1.dll`, `ST_Prt.CPM`, 77개 DFM 폼)과 `resources/knowledge/original/경쟁사 프로그램/STmate 분석/` 결과를 100% 반영한 **STMATE w 8.0 (경영정보(주)) 전체 화면·데이터·엔진 재현 웹앱**입니다.
|
||||
아이슬로(Aislo) 메인 프로그램과 완전히 분리되어 동작하며, REA 정적분석(`Start8x.exe`, `ST_Sub1.dll`, `ST_Prt.CPM`, 77개 DFM 폼)과 `resources/knowledge/original/경쟁사 프로그램/STmate 분석/` 결과를 근거로 만드는 **STMATE w 8.0 (경영정보(주)) 재현 웹앱** — 진행률·남은 기능은 `PLAN.md` 장부.
|
||||
|
||||
---
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
| [`style.css`](file:///D:/aislo-wt/안티그래비티/tools/stmate-test/style.css) | 596 | Delphi 7 + Raize Components(`TRzBitBtn`, `TRzPageControl`) + `TRDbGrid` 클래식 윈도우 데스크톱 UI |
|
||||
| [`smt_parser.py`](file:///D:/aislo-wt/안티그래비티/tools/stmate-test/smt_parser.py) | 375 | dBASE III/IV(`DBF`) 파서·빌더, `COSTN.DBF` 10바이트 바이너리 `IFORM`(`<HII`: `tag=0x0008`, `byte_len`, `block_no * 64`) 기반 `COSTN.SMT` 산출근거 본문 추출·재기록기, `30_원자료` 카탈로그(894 표준코드·350 일위대가 레시피·21 특수산식) 로더 |
|
||||
| [`server.py`](file:///D:/aislo-wt/안티그래비티/tools/stmate-test/server.py) | 649 | FastAPI 서버 (`127.0.0.1:8095`) — `/api/sources`, `/api/catalog`, `/api/load`(COSTN·COSTN.SMT·BOQ11~14·PRJLST·RATE_R/RA/M·SET_REP 전수), `/api/load-jbr`, `/api/save` |
|
||||
| [`calc.js`](file:///D:/aislo-wt/안티그래비티/tools/stmate-test/calc.js) | 674 | 5단계 적산 파이프라인(`M/L/S → X 중기 → D 단가산출 → B 일위대가 → BOQ 내역서 → 원가계산서 19행`), 봉화군 2024년 산림유역관리사업 1원 오차 없는 골든셋 내장 |
|
||||
| [`calc.js`](file:///D:/aislo-wt/안티그래비티/tools/stmate-test/calc.js) | 674 | 5단계 적산 파이프라인(`M/L/S → X 중기 → D 단가산출 → B 일위대가 → BOQ 내역서 → 원가계산서`) — 원가계산서는 실무 STC 4건 1원 대조 시험(`resources/tester/test_stmate_kanjub_stc.py`) |
|
||||
| [`view_prj_boq.js`](file:///D:/aislo-wt/안티그래비티/tools/stmate-test/view_prj_boq.js) | 677 | `wPrjList`(`TWPRJLIST` 공사목록표 + 하단 내역 미리보기), `wBoq`(`BOQ11~14.DBF` `T1~T6` 공종헤더·`E1~E6` 소계·`C*` 관급자재 제외구간·말단 내역 전수 편집기 + 보관함 1·2·3), 내역 도구 모달(`wITNum`, `wBoqRate`, `wM_Rate`, `wExpnd`, `wQtyList`) |
|
||||
| [`view_dan_ilwi.js`](file:///D:/aislo-wt/안티그래비티/tools/stmate-test/view_dan_ilwi.js) | 594 | `wM_Set_Sys`(`초기값 입력및수정` + `K_ORD` 판정기), `wM_JabPum`(`COSTN.DBF` 전수 단가목록표 + `wM_Boxa` 5개 가격지 비교 + `wM_Edt_Hal` 기준단가 비율보정), `wM_Edit_iLWi` / `wM_Edit_San`(`COSTN.SMT` 64B 블록 원문 전개 편집기 + `Q`/`Cm`/`&X00026L/M/S&` 실시간 계산기 + 카탈로그 브라우저) |
|
||||
| [`view_kanjub_prt.js`](file:///D:/aislo-wt/안티그래비티/tools/stmate-test/view_kanjub_prt.js) | 628 | `wM_KanJub`(`TWM_KANJUB` 16개 절사·단수·관급·이윤보정 콤보 + 4탭 `T/S/TA/SA`), `wM_KAN_RATE`(`TWM_KAN_RATE` 4탭 요율표 `RATE_R/RA/M/RATE` + `JB_R` 로더), `wStage`(`wM_Mk_Cont` 계약·`wM_Boq_ExecX` 실행·`wM_KanJub_K` 기성·설계변경), `PM_Prt`(`ST_Prt.CPM` 30종 보고서 + `SET_REP.DBF` 75종 양식 + CSV/조달청 XML/JDL 변환) |
|
||||
|
||||
@@ -56,9 +56,9 @@
|
||||
bt = ctx.computed.boqComputed.totals;
|
||||
const dbfInfo = ctx.dbfData
|
||||
? `COSTN ${ctx.dbfData.costn_count}건 · SMT산식 ${Object.keys(ctx.dbfData.costn_memos || {}).length}건 · ${ctx.dbfData.boq_table_name} ${ctx.dbfData.boq_count}행`
|
||||
: "내장 봉화군 2024 골든셋";
|
||||
: "새 공사 (불러온 파일 없음)";
|
||||
if (prjBar) {
|
||||
prjBar.innerHTML = `<div class="prj-context-left"><span class="prj-tag">열린 공사</span><strong>${ctx.state.projectMeta.pname}</strong><span>(${ctx.state.projectMeta.pnum || "2024년 단가"} · 코드 BOQ${ctx.state.projectMeta.pcode || "11"})</span></div><div class="prj-context-right"><span>원본 DBF/SMT: <strong>${dbfInfo}</strong></span><span>원가양식: <strong>${ctx.state.kanjubConfig.scheme}형식</strong></span></div>`;
|
||||
prjBar.innerHTML = `<div class="prj-context-left"><span class="prj-tag">열린 공사</span><strong>${ctx.state.projectMeta.pname}</strong><span>(${ctx.state.projectMeta.pnum || "-"} · 코드 BOQ${ctx.state.projectMeta.pcode || "11"})</span></div><div class="prj-context-right"><span>원본 DBF/SMT: <strong>${dbfInfo}</strong></span><span>원가양식: <strong>${ctx.state.kanjubConfig.scheme}형식</strong></span></div>`;
|
||||
}
|
||||
if (stBar) {
|
||||
stBar.innerHTML = `<div class="st-metrics"><span class="st-kv">순공사원가 재료비:<strong>${fmt(bt.directM)}</strong></span><span class="st-kv">노무비:<strong>${fmt(bt.directL)}</strong></span><span class="st-kv">경비:<strong>${fmt(bt.directS)}</strong></span><span class="st-kv">직접공사비 계:<strong>${fmt(bt.directT)}원</strong></span><span class="st-kv">간접공사비+일반+이윤:<strong>${fmt(kj.TOTAL_COST - kj.DIRECT_COST)}원</strong></span><span class="st-kv">도급액(공급가+부가세):<strong>${fmt(kj.FINAL_SUPPLY)}원</strong></span><span class="st-kv">관급자재대:<strong>${fmt(kj.GOV_MAT)}원</strong></span><span class="st-kv total">총공사비(COSTW1):<strong>${fmt(kj.GRAND_TOTAL)}원</strong></span></div><div>STMATE w 8.0 Full Engine</div>`;
|
||||
|
||||
+56
-86
@@ -1,7 +1,7 @@
|
||||
/* -*- coding: utf-8 -*- */
|
||||
/**
|
||||
* STMATE 계산 사슬 및 제어 옵션 시뮬레이터 엔진 (calc.js)
|
||||
* 근거: resources/knowledge/original/경쟁사 프로그램/STmate 분석/ (REA 정적분석 및 골든셋 회귀)
|
||||
* 근거: resources/knowledge/original/경쟁사 프로그램/STmate/ (old/20_분석 · REA 분석)
|
||||
*/
|
||||
const StmateCalc = (() => {
|
||||
const KINDS = ["재료비", "노무비", "경비"];
|
||||
@@ -344,7 +344,12 @@ const StmateCalc = (() => {
|
||||
dL = boqRes.directL,
|
||||
dS = boqRes.directS,
|
||||
dT = boqRes.directTotal,
|
||||
govPureMat = boqRes.govRawTotal;
|
||||
govMat = cfg.govMat ?? boqRes.govCombinedRaw,
|
||||
govFee = cfg.govFee ?? boqRes.govFeeTotal,
|
||||
govPureMat = govMat - govFee;
|
||||
const adds = cfg.addAmounts || [];
|
||||
const addSum = (stage) =>
|
||||
adds.filter((a) => a.stage === stage).reduce((acc, a) => acc + num(a.amount), 0);
|
||||
const truncInd = (v) => roundDown(v, indTruncDigits);
|
||||
|
||||
function resolveRow(code, label, baseDesc, baseAmt, ratePct, rawCalcAmt) {
|
||||
@@ -378,7 +383,7 @@ const StmateCalc = (() => {
|
||||
itemMap = {};
|
||||
const pushItem = (row) => (items.push(row), (itemMap[row.code] = row), row.amount);
|
||||
const pushRate = (code, label, desc, base, defR) => {
|
||||
const r = Number(rates[code]) ?? defR;
|
||||
const r = num(rates[code], defR);
|
||||
return pushItem(resolveRow(code, label, desc, base, r, (base * r) / 100));
|
||||
};
|
||||
|
||||
@@ -391,8 +396,8 @@ const StmateCalc = (() => {
|
||||
pushRate("YUNBO", "연금보험료", "직접노무비", dL, 4.5);
|
||||
pushRate("TBUGUM", "퇴직공제부금비", "직접노무비", dL, 0);
|
||||
|
||||
const rAnjun = Number(rates.ANJUN) ?? 1.86,
|
||||
anjunBaseAdd = Number(cfg.anjunBaseAdd) || 5349000;
|
||||
const rAnjun = num(rates.ANJUN, 1.86),
|
||||
anjunBaseAdd = num(cfg.anjunBaseAdd, 0);
|
||||
const anjunGovDiv = cfg.anjunGovVatExcluded !== false ? govPureMat / 1.1 : govPureMat;
|
||||
const anjunBaseNoGov = scheme === "TA" || scheme === "SA" ? dT : dM + dL,
|
||||
anjunBaseWithGov = anjunBaseNoGov + anjunGovDiv;
|
||||
@@ -445,15 +450,16 @@ const StmateCalc = (() => {
|
||||
const indirectExpenseSum = items
|
||||
.filter((x) => x.code !== "KANNO")
|
||||
.reduce((acc, x) => acc + x.amount, 0);
|
||||
const totalExpense = dS + indirectExpenseSum,
|
||||
const add1 = addSum(1),
|
||||
totalExpense = dS + indirectExpenseSum + add1,
|
||||
pureConstructionCost = dM + totalLabor + totalExpense;
|
||||
|
||||
const rIlban = Number(rates.ILBAN) ?? 6.0,
|
||||
const rIlban = num(rates.ILBAN, 6.0),
|
||||
ilbanBase = scheme === "SA" ? dT + kanno : pureConstructionCost;
|
||||
const ilbanDesc = scheme === "SA" ? "직접공사비+간접노무비(실적수공)" : "순공사원가(재+노+경)",
|
||||
ilbanAmt = truncInd((ilbanBase * rIlban) / 100);
|
||||
|
||||
const rIyun = Number(rates.IYUN) ?? 15.0;
|
||||
const rIyun = num(rates.IYUN, 15.0);
|
||||
const iyunBase =
|
||||
scheme === "TA" || scheme === "SA"
|
||||
? pureConstructionCost + ilbanAmt
|
||||
@@ -465,7 +471,10 @@ const StmateCalc = (() => {
|
||||
const iyunRaw = truncInd((iyunBase * rIyun) / 100);
|
||||
|
||||
const vatMode = cfg.vatMode || "supply_10",
|
||||
totJeolDigits = Number(cfg.totJeolDigits) ?? -3;
|
||||
totJeolDigits = num(cfg.totJeolDigits, -3),
|
||||
add2 = addSum(2),
|
||||
add3 = addSum(3),
|
||||
add4 = addSum(4);
|
||||
const calcVat = (sVal) =>
|
||||
vatMode === "zero"
|
||||
? 0
|
||||
@@ -475,35 +484,35 @@ const StmateCalc = (() => {
|
||||
? roundDown((dM + dS) * 0.1, 0)
|
||||
: roundDown(sVal * 0.1, 0);
|
||||
|
||||
const supplyBeforeAdj = pureConstructionCost + ilbanAmt + iyunRaw,
|
||||
const govFinal =
|
||||
govMat > 0
|
||||
? applyRoundMode(govMat, cfg.govRoundMode || "up", num(cfg.govRoundDigits, -3))
|
||||
: 0;
|
||||
const iyunManual = num(cfg.iyunManualAdj, 0),
|
||||
supplyBeforeAdj = pureConstructionCost + ilbanAmt + iyunRaw - iyunManual + add2,
|
||||
vatBeforeAdj = calcVat(supplyBeforeAdj),
|
||||
contractBeforeAdj = supplyBeforeAdj + vatBeforeAdj;
|
||||
contractBeforeAdj = supplyBeforeAdj + vatBeforeAdj + add3;
|
||||
let contractTarget = contractBeforeAdj,
|
||||
diffTotal = 0,
|
||||
pretaxProfitAdj = 0,
|
||||
iyunFinal = iyunRaw,
|
||||
pretaxProfitAdj = iyunManual,
|
||||
iyunFinal = iyunRaw - iyunManual,
|
||||
supplyFinal = supplyBeforeAdj,
|
||||
vatFinal = vatBeforeAdj;
|
||||
|
||||
if (totJeolDigits < 0 && cfg.enableProfitEndAdj !== false) {
|
||||
contractTarget = roundDown(contractBeforeAdj, totJeolDigits);
|
||||
// 총액 절사 = 총공사비(도급 + 관급 + 단계6 추가) 기준 · 차액은 도급에서 뺌
|
||||
contractTarget =
|
||||
roundDown(contractBeforeAdj + govFinal + add4, totJeolDigits) - govFinal - add4;
|
||||
diffTotal = contractBeforeAdj - contractTarget;
|
||||
pretaxProfitAdj = vatMode === "supply_10" ? roundHalfUp(diffTotal / 1.1, 0) : diffTotal;
|
||||
iyunFinal = iyunRaw - pretaxProfitAdj;
|
||||
supplyFinal = pureConstructionCost + ilbanAmt + iyunFinal;
|
||||
supplyFinal = pureConstructionCost + ilbanAmt + iyunFinal + add2;
|
||||
vatFinal = calcVat(supplyFinal);
|
||||
if (supplyFinal + vatFinal !== contractTarget) vatFinal = contractTarget - supplyFinal;
|
||||
if (supplyFinal + vatFinal + add3 !== contractTarget)
|
||||
vatFinal = contractTarget - supplyFinal - add3;
|
||||
}
|
||||
const contractFinal = supplyFinal + vatFinal;
|
||||
const govFinal =
|
||||
boqRes.govCombinedRaw > 0
|
||||
? applyRoundMode(
|
||||
boqRes.govCombinedRaw,
|
||||
cfg.govRoundMode || "up",
|
||||
Number(cfg.govRoundDigits) ?? -3,
|
||||
)
|
||||
: 0;
|
||||
const grandTotal = contractFinal + govFinal;
|
||||
const contractFinal = supplyFinal + vatFinal + add3;
|
||||
const grandTotal = contractFinal + govFinal + add4;
|
||||
const mkLine = (key, label, formula, baseAmt, rate, amount) =>
|
||||
Object.fromEntries([
|
||||
["key", key],
|
||||
@@ -518,7 +527,7 @@ const StmateCalc = (() => {
|
||||
iyunStr = `${iyunDesc} (보정 -${fmt(pretaxProfitAdj)})`,
|
||||
supStr = "순공사원가 + 일반관리비 + 이윤",
|
||||
finStr = "공급가액 + 부가가치세 (천원절사)",
|
||||
govStr = `관급원자재(${fmt(boqRes.govRawTotal)}) + 수수료(${fmt(boqRes.govFeeTotal)})`;
|
||||
govStr = `관급원자재(${fmt(govPureMat)}) + 수수료(${fmt(govFee)})`;
|
||||
const pCost = pureConstructionCost;
|
||||
const lines = [
|
||||
mkLine("DIRECT_M", "직접재료비", "내역서 재료비 합계", dM, null, dM),
|
||||
@@ -532,7 +541,7 @@ const StmateCalc = (() => {
|
||||
mkLine("SUPPLY_VAL", "총원가 (공급가액)", supStr, supplyFinal, null, supplyFinal),
|
||||
mkLine("VAT", "부가가치세", "공급가액 × 10%", supplyFinal, 10, vatFinal),
|
||||
mkLine("FINAL_SUPPLY", "도급공사비", finStr, contractBeforeAdj, null, contractFinal),
|
||||
mkLine("GOV_MAT", "관급자재대", govStr, boqRes.govCombinedRaw, null, govFinal),
|
||||
mkLine("GOV_MAT", "관급자재대", govStr, govMat, null, govFinal),
|
||||
mkLine("GRAND_TOTAL", "총공사비 (COSTW1)", "도급 + 관급", grandTotal, null, grandTotal),
|
||||
];
|
||||
|
||||
@@ -541,7 +550,7 @@ const StmateCalc = (() => {
|
||||
Object.assign(out, { ilbanBase, ilbanDesc, ilbanAmt, iyunBase, iyunDesc, iyunRaw });
|
||||
Object.assign(out, { pretaxProfitAdj, iyunFinal, supplyBeforeAdj, vatBeforeAdj });
|
||||
Object.assign(out, { contractBeforeAdj, diffTotal, supplyFinal, vatFinal, contractFinal });
|
||||
Object.assign(out, { govRawTotal: boqRes.govRawTotal, govFeeTotal: boqRes.govFeeTotal });
|
||||
Object.assign(out, { govRawTotal: govPureMat, govFeeTotal: govFee, adds });
|
||||
Object.assign(out, { govFinal, grandTotal, items, itemMap, lines });
|
||||
Object.assign(out, { DIRECT_COST: dT, KANNO: kanno, IND_EXP: kanno + indirectExpenseSum });
|
||||
Object.assign(out, { TOTAL_COST: pCost, ILBAN: ilbanAmt, IYUN_INIT: iyunRaw });
|
||||
@@ -567,18 +576,10 @@ const StmateCalc = (() => {
|
||||
return { nextK, downK: K - (K > 5 ? K - 4 : 1) };
|
||||
}
|
||||
|
||||
// 새 공사 = 빈 상태 · 요율만 초기값(지식DB 06 · 2024 기준표) — 특정 공사 금액을 담지 않음
|
||||
function createDefaultState() {
|
||||
const mkRes = (id, name, spec, unit, kind, val) => {
|
||||
const r = { id, code: id, name, spec, unit, kind };
|
||||
Object.assign(r, { candidates: [val, 0, 0, 0, 0], adoptMode: "manual", manualPrice: val });
|
||||
return Object.assign(r, { convertHourly: false, isUsd: false });
|
||||
};
|
||||
const mkBoq = (id, code, name, spec, unit, refId, rowType, _recno) => {
|
||||
const b = { id, code, name, spec, unit, refId };
|
||||
return Object.assign(b, { rowType, qty: 1, changedQty: 1, _recno });
|
||||
};
|
||||
const ratePairs =
|
||||
"KANNO:14.5,SANBO:3.56,KOBO:1.01,KUNBO:3.545,SLVBO:12.95,YUNBO:4.5,TBUGUM:0,ANJUN:1.86,KITA:6,HWAN:0.8,EQIP:0.4,ILBAN:6,IYUN:15,S_SUDO:0.45,S_BOKRI:1.15,S_SOMO:0.65,S_TONGSIN:0.85,S_SEGUM:0.55,S_PEGI:0.35,S_DOSEO:0.25,S_JIGUP:0.45";
|
||||
"KANNO:14.5,SANBO:3.56,KOBO:1.01,KUNBO:3.545,SLVBO:12.95,YUNBO:4.5,TBUGUM:0,ANJUN:1.86,KITA:6,HWAN:0.8,EQIP:0.4,ILBAN:6,IYUN:15,S_SUDO:0,S_BOKRI:0,S_SOMO:0,S_TONGSIN:0,S_SEGUM:0,S_PEGI:0,S_DOSEO:0,S_JIGUP:0";
|
||||
const rates = Object.fromEntries(
|
||||
ratePairs.split(",").map((p) => [p.split(":")[0], Number(p.split(":")[1])]),
|
||||
);
|
||||
@@ -589,68 +590,37 @@ const StmateCalc = (() => {
|
||||
["KUNBO", "SLVBO", "YUNBO"].includes(k) ? "carry_original" : "recalculated",
|
||||
]),
|
||||
);
|
||||
const projectMeta = {
|
||||
pname: "2024년 산림유역관리사업(기번41, 국비)",
|
||||
pnum: "2024년 단가",
|
||||
pcode: "11",
|
||||
};
|
||||
Object.assign(projectMeta, { pclass: 0, kOrd: 0, tcost1: 781283415, costw1: 1414283000 });
|
||||
const projectMeta = { pname: "새 공사", pnum: "", pcode: "11", pclass: 0, kOrd: 0 };
|
||||
const sysConfig = {
|
||||
rxFormula: "1/8*16/12*25/20",
|
||||
condCodes: "L00008, L00009, L00010",
|
||||
usdRate1: 1350,
|
||||
usdRate2: 1400,
|
||||
};
|
||||
Object.assign(sysConfig, {
|
||||
condCodes: "",
|
||||
usdRate1: 0,
|
||||
usdRate2: 0,
|
||||
useChangedUsd: false,
|
||||
ecdDigits: 0,
|
||||
chnMode: "bimok",
|
||||
optOrder: "SML",
|
||||
});
|
||||
sysConfig.kOrdSim = { pclass: 7, regMode: 7, flag: 0, currentK: 2 };
|
||||
};
|
||||
sysConfig.kOrdSim = { pclass: 0, regMode: 7, flag: 0, currentK: 1 };
|
||||
const kanjubConfig = {
|
||||
scheme: "T",
|
||||
indirectTruncDigits: 0,
|
||||
anjunBaseAdd: 5349000,
|
||||
anjunBaseAdd: 0,
|
||||
anjunGovVatExcluded: true,
|
||||
};
|
||||
Object.assign(kanjubConfig, {
|
||||
anjunCap12: true,
|
||||
vatMode: "supply_10",
|
||||
totJeolDigits: -3,
|
||||
enableProfitEndAdj: true,
|
||||
});
|
||||
Object.assign(kanjubConfig, { govRoundMode: "up", govRoundDigits: -3, rates, revModes });
|
||||
const dName = "2024년 산림유역관리 본공사 종합";
|
||||
const dLines = ["B_MAT", "B_LAB", "B_EXP"].map((refId) => ({
|
||||
refId,
|
||||
qty: 1,
|
||||
halPercent: 0,
|
||||
devd: false,
|
||||
}));
|
||||
const d00014 = { id: "D00014", code: "D00014", name: dName, rawName: dName };
|
||||
Object.assign(d00014, { prefix: "D", spec: "산림토목", unit: "식", lines: dLines });
|
||||
const b1Name = "2024년 산림유역관리 본공사 일체";
|
||||
return {
|
||||
projectMeta,
|
||||
sysConfig,
|
||||
resources: [
|
||||
mkRes("B_MAT", "봉화군 산림유역 직접재료비", "식", "식", "재료비", 191867113),
|
||||
mkRes("B_LAB", "봉화군 산림유역 직접노무비", "식", "식", "노무비", 426802539),
|
||||
mkRes("B_EXP", "봉화군 산림유역 산출경비", "식", "식", "경비", 162613763),
|
||||
mkRes("G_MAT", "봉화군 관급자재 원자재 합계", "C4 제외구역", "식", "재료비", 142783280),
|
||||
mkRes("G_FEE", "봉화군 관급 조달수수료", "C4 제외구역", "식", "경비", 771029),
|
||||
],
|
||||
machines: [],
|
||||
sanItems: [],
|
||||
ilwiItems: [d00014],
|
||||
boqItems: [
|
||||
mkBoq("BOQ_1", "D00014", b1Name, "산림토목", "식", "D00014", "normal", 1),
|
||||
mkBoq("BOQ_2", "G_MAT", "[제외 C4] 관급자재 원자재", "관급", "식", "G_MAT", "gov_mat", 2),
|
||||
mkBoq("BOQ_3", "G_FEE", "[제외 C4] 관급 조달수수료", "관급", "식", "G_FEE", "gov_fee", 3),
|
||||
],
|
||||
kanjubConfig,
|
||||
iyunManualAdj: 0,
|
||||
govMat: 0,
|
||||
govFee: 0,
|
||||
addAmounts: [],
|
||||
};
|
||||
Object.assign(kanjubConfig, { govRoundMode: "up", govRoundDigits: -3, rates, revModes });
|
||||
return Object.assign(
|
||||
{ projectMeta, sysConfig, kanjubConfig },
|
||||
{ resources: [], machines: [], sanItems: [], ilwiItems: [], boqItems: [] },
|
||||
);
|
||||
}
|
||||
|
||||
function calculateAll(data) {
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
<div class="menu-item" data-menu="m5">
|
||||
<span><u>5</u>. 기타</span>
|
||||
<div class="menu-dropdown">
|
||||
<button data-act="Act_LoadGolden">봉화군 2024 산불진화임도 표준 상태 복원</button>
|
||||
<button data-act="Act_LoadGolden">새 공사 (빈 상태)</button>
|
||||
<button data-act="Act_ReaInfoModal">REA 정적분석 아키텍처 & DBF/SMT 명세 확인</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PRJLST.SMT [1_KANJUB]·[KANJUB_ADD] 키 → 원가계산서(제잡비) 계산 설정 (kanjub_cfg.py).
|
||||
|
||||
근거: 지식DB STmate/old/20_분석 06(간접비·끝전) · 11 §9(추가금액 단계) · 25 §1(저장키).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
RATE_KEYS = {
|
||||
"KANNO": "1_R_KANNO",
|
||||
"SANBO": "1_R_SANBO",
|
||||
"KOBO": "1_R_KOBO",
|
||||
"KUNBO": "1_R_KUNBO",
|
||||
"SLVBO": "1_R_SLVBO",
|
||||
"YUNBO": "1_R_YUNBO",
|
||||
"TBUGUM": "1_R_TBUGUM",
|
||||
"ANJUN": "1_R_ANJUN",
|
||||
"KITA": "1_R_T_KITA",
|
||||
"HWAN": "1_R_HWAN",
|
||||
"EQIP": "1_R_EQIPSU",
|
||||
"ILBAN": "1_R_ILBAN",
|
||||
"IYUN": "1_R_IYUN",
|
||||
"S_SUDO": "1_R_S_SUDO",
|
||||
"S_BOKRI": "1_R_S_BOKRI",
|
||||
"S_SOMO": "1_R_S_SOMO",
|
||||
"S_TONGSIN": "1_R_S_TONGSIN",
|
||||
"S_SEGUM": "1_R_S_SEGUM",
|
||||
"S_PEGI": "1_R_S_PEGI",
|
||||
"S_DOSEO": "1_R_S_DOSEO",
|
||||
"S_JIGUP": "1_R_S_JIGUP",
|
||||
}
|
||||
REV_KEYS = (
|
||||
"KANNO",
|
||||
"SANBO",
|
||||
"KOBO",
|
||||
"KUNBO",
|
||||
"SLVBO",
|
||||
"YUNBO",
|
||||
"TBUGUM",
|
||||
"ANJUN",
|
||||
"KITA",
|
||||
"HWAN",
|
||||
"EQIP",
|
||||
)
|
||||
|
||||
|
||||
def _num(kv: dict, key: str, default: float = 0.0) -> float:
|
||||
try:
|
||||
s = str(kv.get(key, "")).strip().replace(",", "")
|
||||
return float(s) if s else default
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def add_amounts(kv: dict) -> list[dict]:
|
||||
"""ADD_AMT_<단계>_<행>_<칸> — 저장 단계 1~4 = 화면 단계 3~6(일반관리비 앞 · 이윤 뒤 · 도급 앞 · 총공사비 앞)."""
|
||||
out = []
|
||||
for stage in range(1, 5):
|
||||
for i in range(1, int(_num(kv, f"ADD_CNT_{stage}")) + 1):
|
||||
row = f"{stage}_{i:02d}"
|
||||
out.append(
|
||||
{
|
||||
"stage": stage,
|
||||
"name": kv.get(f"ADD_NAME_{row}", ""),
|
||||
"amount": _num(kv, f"ADD_AMT_{row}_1"),
|
||||
"vat": _num(kv, f"ADD_BUGA_{row}") == 1,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def build_kanjub_config(kv: dict) -> dict:
|
||||
jab_type = int(_num(kv, "JAB_TYPE"))
|
||||
cal_actl = int(_num(kv, "CAL_ACTL"))
|
||||
scheme = ("SA" if jab_type == 1 else "TA") if cal_actl == 1 else ("S" if jab_type == 1 else "T")
|
||||
tot_jeol = int(_num(kv, "1_I_TOT_JEOL"))
|
||||
return {
|
||||
"scheme": scheme,
|
||||
"indirectTruncDigits": 0,
|
||||
"anjunBaseAdd": _num(kv, "1_C_ANJUN_G"),
|
||||
"anjunGovVatExcluded": True,
|
||||
"anjunCap12": True,
|
||||
"vatMode": "supply_10",
|
||||
"totJeolDigits": -tot_jeol,
|
||||
"enableProfitEndAdj": tot_jeol > 0,
|
||||
"iyunManualAdj": 0 if tot_jeol > 0 else _num(kv, "1_C_IYUN_M"),
|
||||
"govMat": _num(kv, "1_C_KWANGUP") if "1_C_KWANGUP" in kv else None,
|
||||
"govFee": _num(kv, "1_C_T_JODAL") if "1_C_T_JODAL" in kv else None,
|
||||
"govRoundMode": "up",
|
||||
"govRoundDigits": -int(_num(kv, "1_I_KWN_JEOLSA", 3)),
|
||||
"addAmounts": add_amounts(kv),
|
||||
"rates": {k: _num(kv, v) for k, v in RATE_KEYS.items()},
|
||||
"revModes": {
|
||||
k: ("carry_original" if k in ("KUNBO", "SLVBO", "YUNBO") else "recalculated")
|
||||
for k in REV_KEYS
|
||||
},
|
||||
}
|
||||
@@ -14,6 +14,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from excel_builder import build_stmate_full_xlsx
|
||||
from kanjub_cfg import build_kanjub_config
|
||||
from smt_parser import (
|
||||
build_dbf_bytes,
|
||||
extract_costn_memos,
|
||||
@@ -330,33 +331,6 @@ def convert_dbf_to_sim_state(
|
||||
}
|
||||
)
|
||||
|
||||
rates = {
|
||||
"KANNO": fnum(smt_kv.get("1_R_KANNO"), 14.5),
|
||||
"SANBO": fnum(smt_kv.get("1_R_SANBO"), 3.56),
|
||||
"KOBO": fnum(smt_kv.get("1_R_KOBO"), 1.01),
|
||||
"KUNBO": fnum(smt_kv.get("1_R_KUNBO"), 3.545),
|
||||
"SLVBO": fnum(smt_kv.get("1_R_SLVBO"), 12.95),
|
||||
"YUNBO": fnum(smt_kv.get("1_R_YUNBO"), 4.5),
|
||||
"TBUGUM": fnum(smt_kv.get("1_R_TBUGUM"), 0.0),
|
||||
"ANJUN": fnum(smt_kv.get("1_R_ANJUN"), 1.86),
|
||||
"KITA": fnum(smt_kv.get("1_R_T_KITA"), 6.0),
|
||||
"HWAN": fnum(smt_kv.get("1_R_HWAN"), 0.8),
|
||||
"EQIP": fnum(smt_kv.get("1_R_EQIPSU"), 0.4),
|
||||
"ILBAN": fnum(smt_kv.get("1_R_ILBAN"), 6.0),
|
||||
"IYUN": fnum(smt_kv.get("1_R_IYUN"), 15.0),
|
||||
"S_SUDO": fnum(smt_kv.get("1_R_S_SUDO"), 0.0),
|
||||
"S_BOKRI": fnum(smt_kv.get("1_R_S_BOKRI"), 0.0),
|
||||
"S_SOMO": fnum(smt_kv.get("1_R_S_SOMO"), 0.0),
|
||||
"S_TONGSIN": fnum(smt_kv.get("1_R_S_TONGSIN"), 0.0),
|
||||
"S_SEGUM": fnum(smt_kv.get("1_R_S_SEGUM"), 0.0),
|
||||
"S_PEGI": fnum(smt_kv.get("1_R_S_PEGI"), 0.0),
|
||||
"S_DOSEO": fnum(smt_kv.get("1_R_S_DOSEO"), 0.0),
|
||||
"S_JIGUP": fnum(smt_kv.get("1_R_S_JIGUP"), 0.0),
|
||||
}
|
||||
jab_type = int(fnum(smt_kv.get("JAB_TYPE"), 0))
|
||||
cal_actl = int(fnum(smt_kv.get("CAL_ACTL"), 0))
|
||||
scheme = ("SA" if jab_type == 1 else "TA") if cal_actl == 1 else ("S" if jab_type == 1 else "T")
|
||||
|
||||
return {
|
||||
"projectMeta": {
|
||||
"pname": prj_row.get("PNAME", ""),
|
||||
@@ -388,35 +362,7 @@ def convert_dbf_to_sim_state(
|
||||
"sanItems": san_items,
|
||||
"ilwiItems": ilwi_items,
|
||||
"boqItems": boq_items,
|
||||
"kanjubConfig": {
|
||||
"scheme": scheme,
|
||||
"indirectTruncDigits": 0,
|
||||
"anjunBaseAdd": fnum(smt_kv.get("1_C_ANJUN_G"), 5349000),
|
||||
"anjunGovVatExcluded": True,
|
||||
"anjunCap12": True,
|
||||
"vatMode": "supply_10",
|
||||
"totJeolDigits": -int(fnum(smt_kv.get("1_I_TOT_JEOL"), 3)),
|
||||
"enableProfitEndAdj": True,
|
||||
"govRoundMode": "up",
|
||||
"govRoundDigits": -int(fnum(smt_kv.get("1_I_KWN_JEOLSA"), 3)),
|
||||
"rates": rates,
|
||||
"revModes": {
|
||||
k: ("carry_original" if k in ("KUNBO", "SLVBO", "YUNBO") else "recalculated")
|
||||
for k in (
|
||||
"KANNO",
|
||||
"SANBO",
|
||||
"KOBO",
|
||||
"KUNBO",
|
||||
"SLVBO",
|
||||
"YUNBO",
|
||||
"TBUGUM",
|
||||
"ANJUN",
|
||||
"KITA",
|
||||
"HWAN",
|
||||
"EQIP",
|
||||
)
|
||||
},
|
||||
},
|
||||
"kanjubConfig": build_kanjub_config(smt_kv),
|
||||
}
|
||||
|
||||
|
||||
@@ -467,7 +413,9 @@ def api_load(req: LoadReq):
|
||||
_, prj_rows, _ = (
|
||||
parse_dbf_bytes(files["PRJLST.DBF"]) if "PRJLST.DBF" in files else ([], [{}], {})
|
||||
)
|
||||
smt_kv = parse_prjlst_smt(files.get("PRJLST.SMT", b""))
|
||||
smt_kv = parse_prjlst_smt(
|
||||
files.get("PRJLST.SMT", b""), (prj_rows[0] if prj_rows else {}).get("PRJ_MEMO_HEX", "")
|
||||
)
|
||||
|
||||
rate_tables: dict[str, list[dict]] = {}
|
||||
for rname in ("RATE_R.DBF", "RATE_RA.DBF", "RATE_M.DBF", "RATE_B.DBF", "RATE.DBF"):
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. COSTN.SMT 범용 수식 인터프리터 (봉화 2024 전수 65/65 1원 오차 없이 100% 일치 검증)
|
||||
* 1. COSTN.SMT 범용 수식 인터프리터
|
||||
*/
|
||||
function evaluateSmtFormulaText(smtText, ctx, codeHint) {
|
||||
const byCode = buildCostnMap(ctx);
|
||||
|
||||
@@ -57,8 +57,8 @@ def parse_dbf_bytes(b: bytes) -> tuple[list[dict], list[dict], dict]:
|
||||
for f in fields:
|
||||
val_bytes = rec[cur : cur + f["len"]]
|
||||
cur += f["len"]
|
||||
if f["name"] == "IFORM":
|
||||
row["IFORM_HEX"] = val_bytes.hex()
|
||||
if f["name"] == "IFORM" or f["type"] == "M":
|
||||
row[f"{f['name']}_HEX"] = val_bytes.hex()
|
||||
row[f["name"]] = val_bytes.decode("cp949", "replace").strip()
|
||||
if not deleted:
|
||||
rows.append(row)
|
||||
@@ -105,9 +105,10 @@ def build_dbf_bytes(fields: list[dict], rows: list[dict], meta: dict | None = No
|
||||
out.write(b"*" if row.get("_deleted") else b" ")
|
||||
for f in fields:
|
||||
flen, fdec, ftype = int(f["len"]), int(f.get("dec", 0)), f.get("type", "C")
|
||||
if f["name"] == "IFORM" and row.get("IFORM_HEX"):
|
||||
raw_hex = row.get(f"{f['name']}_HEX")
|
||||
if (f["name"] == "IFORM" or ftype == "M") and raw_hex:
|
||||
try:
|
||||
out.write(bytes.fromhex(row["IFORM_HEX"])[:flen].ljust(flen, b"\x00"))
|
||||
out.write(bytes.fromhex(raw_hex)[:flen].ljust(flen, b"\x00"))
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
@@ -131,10 +132,17 @@ def build_dbf_bytes(fields: list[dict], rows: list[dict], meta: dict | None = No
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def parse_prjlst_smt(b: bytes) -> dict[str, str]:
|
||||
def parse_prjlst_smt(b: bytes, memo_hex: str = "") -> dict[str, str]:
|
||||
"""PRJ_MEMO 포인터(<HII: tag, 길이, 블록번호)가 가리키는 블록만 읽음 — 남은 옛 블록 무시."""
|
||||
if len(b) <= 0x200:
|
||||
return {}
|
||||
return {k: v.strip() for k, v in SMT_KV_RE.findall(b[0x200:].decode("cp949", "replace"))}
|
||||
body = b[0x200:]
|
||||
if len(memo_hex) >= 20:
|
||||
_tag, ln, blk = struct.unpack("<HII", bytes.fromhex(memo_hex)[:10])
|
||||
bs = struct.unpack("<I", b[4:8])[0] or 64
|
||||
if blk and ln and blk * bs + ln <= len(b):
|
||||
body = b[blk * bs : blk * bs + ln]
|
||||
return {k: v.strip() for k, v in SMT_KV_RE.findall(body.decode("cp949", "replace"))}
|
||||
|
||||
|
||||
def update_prjlst_smt_bytes(orig_b: bytes, updates: dict[str, str | int | float]) -> bytes:
|
||||
|
||||
Reference in New Issue
Block a user