Merge branch 'sub_laptop_1' of C:/Aislo_wt/sub_laptop_1 into main_laptop_1
This commit is contained in:
@@ -5,6 +5,8 @@
|
||||
* [재생성](지금 M01 문서로 다시 찍음 · 버리는 것 알림) — 누를 때만(자동 갱신 없음)
|
||||
* 흐름 = 미리 셈(`{길}/preview` {할일}) → 확인 창(계 전 → 후 · 바뀐 자원 · 버리는 것) → 저장(`{길}/save` {할일, 판})
|
||||
* 층 쓰기 권한 없으면 「읽기만」 배지
|
||||
* (821-3) 값 줄만 바뀜(M01 단가 · 내 기계 고침) = 「새 값 있음」(같은 자리 · 같은 꼴) · 지운 키(M01 · 내 기계) = 「지워짐 n」 ·
|
||||
* [단가 갱신] 확인 창 = 바뀐 자원 줄(이름 · 바뀐 칸 전 → 후 · 지워짐 = 마지막 값 유지)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
@@ -50,9 +52,16 @@ export function isStale(m: CopyMark | undefined): boolean {
|
||||
|
||||
export function copyBadges(m: CopyMark | undefined, edit: boolean): HTMLElement[] {
|
||||
const out: HTMLElement[] = [];
|
||||
if (isStale(m)) {
|
||||
const b = createBadge(ft("New_M01"), "danger");
|
||||
b.dataset.copyStale = "";
|
||||
const doc = !!m?.M01새판 || !!m?.문서새판?.length;
|
||||
if (doc || m?.단가새값) {
|
||||
const b = createBadge(ft(doc ? "New_M01" : "New_Value"), "danger");
|
||||
b.dataset.copyStale = doc ? "doc" : "value";
|
||||
out.push(b);
|
||||
}
|
||||
if (m?.지운키?.length) {
|
||||
const b = createBadge(`${ft("Gone")} ${m.지운키.length}`, "warning");
|
||||
b.title = `${m.지운키.join(" · ")} — ${ft("Gone_Note")}`;
|
||||
b.dataset.copyGone = m.지운키.join(",");
|
||||
out.push(b);
|
||||
}
|
||||
if (!edit) out.push(createBadge(ft("Read_Only"), "neutral"));
|
||||
@@ -76,17 +85,40 @@ export function copyTools(edit: boolean, run: (job: CopyJob) => void): HTMLEleme
|
||||
const sum = (m: Money | undefined): string =>
|
||||
typeof m?.계 === "number" ? `${eqText(m.계)} 원` : "—";
|
||||
|
||||
/** 바뀐 자원 한 줄 — 「이름 칸 전 → 후」(값 묶음에서 다른 칸만) · 지워짐 = 마지막 값 유지 */
|
||||
export function changedLine(x: unknown): string {
|
||||
const r = (x ?? {}) as {
|
||||
키?: string;
|
||||
이름?: string;
|
||||
전?: unknown;
|
||||
후?: unknown;
|
||||
지워짐?: boolean;
|
||||
};
|
||||
const name = r.이름 || r.키 || "";
|
||||
if (r.지워짐) return `${name} ${ft("Gone")}(${ft("Gone_Note")})`;
|
||||
const show = (v: unknown): string => (typeof v === "number" ? eqText(v) : String(v ?? "—"));
|
||||
const a = r.전 && typeof r.전 === "object" ? (r.전 as Record<string, unknown>) : null;
|
||||
const b = r.후 && typeof r.후 === "object" ? (r.후 as Record<string, unknown>) : null;
|
||||
if (!a || !b) return `${name} ${show(r.전)} → ${show(r.후)}`;
|
||||
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].filter(
|
||||
(k) => JSON.stringify(a[k]) !== JSON.stringify(b[k]),
|
||||
);
|
||||
return `${name} ${keys.map((k) => `${k} ${show(a[k])} → ${show(b[k])}`).join(", ")}`;
|
||||
}
|
||||
|
||||
/** 할일 알림 — 계 전 → 후 · 바뀐 자원 수 · 재생성이 버리는 것(이름) → 확인이면 true */
|
||||
export function askJob(job: CopyJob, got: JobReply, names: Map<string, string>): Promise<boolean> {
|
||||
const bits = [
|
||||
job === "단가갱신" ? ft("Price_Ask") : ft("Regen_Ask"),
|
||||
`${ft("Before_After")} ${sum(got.전)} → ${sum(got.후)}`,
|
||||
];
|
||||
const lines = job === "단가갱신" ? (got.바뀐자원 ?? []).slice(0, 8).map(changedLine) : []; // 821-3
|
||||
if (job === "단가갱신") bits.push(`${ft("Changed")} ${got.바뀐자원?.length ?? 0}`);
|
||||
const lost = [
|
||||
...(got.버림?.문서고침 ? [ft("Regen_Edit")] : []),
|
||||
...(got.버림?.끊음 ?? []).map((k) => `${ft("Cut")} ${names.get(k) || k}`),
|
||||
];
|
||||
if (job === "재생성" && lost.length) bits.push(`${ft("Regen_Lost")} — ${lost.join(" · ")}`);
|
||||
return showConfirmDialog(bits.join(" · "), job === "단가갱신" ? ft("Price") : ft("Regen"));
|
||||
const text = [bits.join(" · "), ...lines].join("\n");
|
||||
return showConfirmDialog(text, job === "단가갱신" ? ft("Price") : ft("Regen"));
|
||||
}
|
||||
|
||||
@@ -63,6 +63,9 @@ const TEXT = {
|
||||
"Discard edits to this basis and follow the copy again",
|
||||
],
|
||||
New_M01: ["M01 새 판 있음", "New M01 version"],
|
||||
New_Value: ["새 값 있음", "New values"],
|
||||
Gone: ["지워짐", "Deleted"],
|
||||
Gone_Note: ["마지막 값 유지", "last value kept"],
|
||||
Price: ["단가 갱신", "Refresh prices"],
|
||||
Regen: ["재생성", "Regenerate"],
|
||||
Price_Ask: ["자원 단가만 지금 M01 값으로 — 식 · 연결 그대로", "Prices only — equation unchanged"],
|
||||
|
||||
@@ -46,6 +46,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 { openStackPick, type StackTake } from "./line_stack_pick";
|
||||
import { buildBlock, sourceTag } from "./line_stack_rows";
|
||||
import { lt, type LsKey } from "./line_stack_text";
|
||||
@@ -165,7 +166,7 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
|
||||
openStackPick(
|
||||
kind,
|
||||
(p) => h.change((doc) => repick(doc, i, own, kind, p)),
|
||||
undefined,
|
||||
kind === "기계" ? h.take : undefined, // 821-3 개인 층 = 내 기계 묶음도
|
||||
found?.찾기어 ?? String(mat?.검색어 ?? line.이름 ?? ""),
|
||||
),
|
||||
});
|
||||
@@ -175,6 +176,14 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
|
||||
head.push(pick);
|
||||
const mark = machineMark(found, link);
|
||||
if (mark) head.push(mark);
|
||||
// 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,61 @@
|
||||
/* =============================================================================
|
||||
* 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;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
* 줄 쌓기 고르기 창(PLAN 804-3 · 804-4 · 요구 678-1 · 678-3 · 680-1 · 681)
|
||||
* 찾아 넣기 = M02 [항목 추가] 창 그대로(`openFindPicker` · 탭 M01 전체 · 찾기 칸 · 표) — 줄 갈래마다 그 종류만
|
||||
* 인력(노임 LB) · 재료(자재 MT) · 기계(EQ → 시간당 경비 로직 + 기계='원문번호') · 다른 단가산출(M01 단가산출 로직)
|
||||
* 기계 + M02 개인 층(821-3) = 품셈 기계 위 「내 기계」 묶음 · 머리 [새 기계 만들기] — 고름 = 로직 + 기계='개인 EQP…'
|
||||
* M01 단가산출 기초 줄 쌓기 = 「M01 전체」 탭만 — M01 은 원천 기준값이라 M02 단가산출 · 일위대가를 부르지 않음(요구 680-1)
|
||||
* M02 단가산출 줄 쌓기(`take` 있음) · 다른 단가산출 = 탭 [M01 전체 | M02 단가산출 | 일위대가](위 층 · 자기 SK 뺌) —
|
||||
* 고름 = 복사해 펼침(`take(src)` · src = M01 키 | `참조|층|주인|키`) · 참조 줄 없음(요구 681)
|
||||
@@ -56,6 +57,7 @@ export function openStackPick(
|
||||
owner: copy?.owner ?? null,
|
||||
title: `${lt("Find_Title")} — ${lt(copy ? "K_복사" : (`K_${kind}` as never))}`,
|
||||
kinds: copy ? ["logic", "sk", "pre"] : [want], // M01 = M01 전체만(680-1)
|
||||
mine: kind === "기계" && m02?.layer === "personal" ? { owner: m02.owner } : undefined,
|
||||
query: query.trim() || undefined,
|
||||
ref: copy
|
||||
? (r) =>
|
||||
|
||||
@@ -21,6 +21,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 as mym
|
||||
from M02_MasterTemplete import M02_Sangeun_Copy as skc
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
|
||||
@@ -128,11 +129,22 @@ def edited(copy: dict, body: dict, key: str, sk: dict | None = None) -> dict:
|
||||
*(c for d in out.get("끊음", {}).values() for c in dc.calls(d)),
|
||||
]
|
||||
if not dim.is_imported(sk):
|
||||
out |= _mine(out)
|
||||
out["부름"] = dc.closure(dc.over(out), calls, copy.get("부름"))
|
||||
out["따로"] = True
|
||||
return out
|
||||
|
||||
|
||||
def _mine(copy: dict) -> dict:
|
||||
"""줄 쌓기에서 고른 내 기계(기계='개인 EQP…') 가운데 값 사본에 없는 것만 지금 값으로 더함
|
||||
(821-3) — 그 뒤 내 기계를 고쳐도 금액 그대로 + 「단가새값」 · [단가 갱신] 으로 반영 ·
|
||||
지워도 마지막 값."""
|
||||
keys = mym.keys_in(copy.get("문서"))
|
||||
if not keys or "로직" not in copy:
|
||||
return {}
|
||||
return skc.grow(dc.values_of(copy), skc.snap([], keys))
|
||||
|
||||
|
||||
def preview(key: str, body: dict) -> dict[str, Any]:
|
||||
"""미리 셈(저장 없음) — {셈, 전, 후, 바뀐자원?, 버림?}."""
|
||||
doc = _file(key)["문서"]
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* · (728-2) 돈 아닌 기초(결과단위가 「원」 아님 · ㎥/시간 등 21 개) = 목록에서 뺌
|
||||
* · (785-3) `oil` = M01 유가전력(MO · `/logic-lib/elements?group=재료` 의 MO 줄) 고름 · 고정 줄 꼴 = 이음 유가 MO ·
|
||||
* 재료비 = 지금 값(셈은 M01 값) · 「M01 전체」 탭 재료 아래
|
||||
* · (821-3) `mine` = 개인 층 내 기계 묶음(품셈 기계 위 · 구분 「내 기계」 · 출처 「개인 키」 · 금액 = 서버 시간당) ·
|
||||
* 머리 [새 기계 만들기](양식 창 → 저장하면 그 기계로 고름) · 「시간당 값 직접」 내 기계 = 줄 쌓기 식 없음 → 회색
|
||||
* ========================================================================== */
|
||||
|
||||
import { createDataTable } from "@ui/ui_template_data_table";
|
||||
@@ -24,6 +26,9 @@ import { createButton, createInputField, el, showToast } from "@ui/ui_template_e
|
||||
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";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
@@ -83,6 +88,8 @@ export interface FindPickOptions {
|
||||
machine?: (e: M01Element) => void;
|
||||
/** 785-3 M01 유가전력(MO) 고름 — 있을 때만 유가 줄이 보임 */
|
||||
oil?: (e: M01Element) => void;
|
||||
/** 821-3 개인 층 내 기계 묶음(`machine` 과 함께) — 주인(null = 자기 층) */
|
||||
mine?: { owner: string | null };
|
||||
}
|
||||
|
||||
/** 727-1 기초마다 한 줄(옛 창 `Labor_Pick` 도 씀) — 규격 칸 = 「옵션 N」 */
|
||||
@@ -182,6 +189,7 @@ export function openFindPicker(opt: FindPickOptions): void {
|
||||
let goods: PickItem[] = [];
|
||||
let machines: PickItem[] = [];
|
||||
let oils: PickItem[] = []; // 785-3 유가전력 MO
|
||||
let mines: MyMachineRow[] = []; // 821-3 내 기계
|
||||
const lists: Record<"sk" | "pre", SangeunRow[] | null> = { sk: null, pre: null };
|
||||
let shown: Line[] = [];
|
||||
let seq = 0;
|
||||
@@ -280,6 +288,26 @@ export function openFindPicker(opt: FindPickOptions): void {
|
||||
: take(() => put(e)),
|
||||
};
|
||||
});
|
||||
/** 821-3 내 기계 줄 — 금액 = 서버 시간당 · 직접 = 회색(줄 쌓기 식 없음) */
|
||||
const mineLines = (q: string): Line[] => {
|
||||
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
return mines
|
||||
.filter((r) => words.every((w) => `${r.이름} ${r.규격} ${r.키}`.toLowerCase().includes(w)))
|
||||
.map((r) => {
|
||||
const e = mineElement(r, r.시간당);
|
||||
const off = !e.로직 || !opt.machine;
|
||||
return {
|
||||
품명: r.이름,
|
||||
규격: r.규격,
|
||||
단위: "시간",
|
||||
금액: e.값,
|
||||
구분: join(mt("Pick_Group"), mt(r.방식), off ? mt("Pick_Direct") : ""),
|
||||
출처: r.원문번호,
|
||||
dim: off,
|
||||
go: off ? () => showToast(mt("Pick_Direct"), "warning") : take(() => opt.machine?.(e)),
|
||||
};
|
||||
});
|
||||
};
|
||||
const docLines = (rows: SangeunRow[], kind: string): Line[] =>
|
||||
rows.map((x) => ({
|
||||
품명: x.제목 || x.이름,
|
||||
@@ -299,6 +327,7 @@ export function openFindPicker(opt: FindPickOptions): void {
|
||||
...(ok("labor") ? elementLines("인력", people) : []),
|
||||
...(ok("material") ? elementLines("재료", goods) : []),
|
||||
...(opt.oil && ok("oil") ? elementLines("유가", oils) : []),
|
||||
...(opt.machine && opt.mine && ok("machine") ? mineLines(q) : []),
|
||||
...(opt.machine && ok("machine") ? elementLines("기계", machines) : []),
|
||||
];
|
||||
} else {
|
||||
@@ -387,9 +416,25 @@ 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] : []),
|
||||
];
|
||||
({ close } = openModal({
|
||||
title: opt.title ?? L("M02_FindTitle"),
|
||||
...(canNew ? { actions: [newMat] } : {}),
|
||||
...(headBtns.length ? { actions: headBtns } : {}),
|
||||
closeLabel: tc("Pick_Close"),
|
||||
dialogClass: "m01-logic__pick m01-combo",
|
||||
width: "960px", // 표 여섯 칸
|
||||
@@ -415,6 +460,13 @@ export function openFindPicker(opt: FindPickOptions): void {
|
||||
})
|
||||
.catch(fail);
|
||||
void findElements();
|
||||
if (opt.mine && opt.machine && ok("machine"))
|
||||
void listMyMachines(opt.mine.owner)
|
||||
.then((rows) => {
|
||||
mines = rows;
|
||||
draw();
|
||||
})
|
||||
.catch(fail);
|
||||
void upper(opt.layer, opt.owner)
|
||||
.then((scopes) =>
|
||||
Promise.all(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side.ts
|
||||
* 마스터 템플릿 좌측 패널 차례(716-3 · 717-2a) — 「단가산출」(층 단추 따로 · 기본 개인 · `UI_Side_Sangeun`) › 「일위대가」(층 단추 따로 · 2단 폴더 나무 · `UI_Side_Precombo` ·
|
||||
* 50-3 · 134-3) › 「구조물 조합」(층 단추 · 나무) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 ·
|
||||
* 50-3 · 134-3) › 「내 기계」 · 「내 품목」(821-3 · 개인 층 · 펼치면 가운데 화면) › 「구조물 조합」(층 단추 · 나무) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 ·
|
||||
* `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기).
|
||||
* 구조물 옵션 정의는 M01 로 옮김(716-1).
|
||||
* 도면 양식 = 시스템 관리자면 늘(마스터 층 것 · 구조물 조합 층과 무관 · 720-4).
|
||||
@@ -37,6 +37,9 @@ import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";
|
||||
import type { Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
import { buildPrecomboSection, type PrecomboOptions } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
import { buildSangeunSection, type SangeunOptions } from "./M02_MasterTemplete_UI_Side_Sangeun";
|
||||
import { buildMyItemsView } from "./M02_MyItems_View";
|
||||
import { buildMyMachineView } from "./M02_MyMachine_View";
|
||||
import { mt } from "./M02_MyMachine_Text";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { createDivider } from "@ui/ui_template_divider";
|
||||
|
||||
@@ -154,6 +157,35 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
getOpen: opt.getOpen,
|
||||
});
|
||||
const sangeunSec = section(L("M02_SangeunTitle"), sangeun.body);
|
||||
// 내 기계 · 내 품목(821-3) — 자기 개인 층 · 펼치면 가운데 화면(목록 · 양식)
|
||||
const hint = (key: "Side_Hint" | "Items_Side_Hint"): HTMLElement =>
|
||||
createEmpty({ text: mt(key), small: true });
|
||||
const myMachSec = openOnExpand(section(mt("Title"), hint("Side_Hint")), async () => {
|
||||
if (!(await opt.confirmLeave())) return;
|
||||
let mark = (_d: boolean): void => {};
|
||||
const node = buildMyMachineView({ owner: null, setDirty: (d) => mark(d) });
|
||||
mark = opt.showView(mt("Title"), node);
|
||||
});
|
||||
myMachSec.dataset.myMachines = "";
|
||||
const myItemsSec = openOnExpand(section(mt("Items_Title"), hint("Items_Side_Hint")), async () => {
|
||||
if (!(await opt.confirmLeave())) return;
|
||||
const node = buildMyItemsView({
|
||||
owner: null,
|
||||
openSk: async (u) => {
|
||||
if (!(await opt.confirmLeave())) return;
|
||||
opt.onOpen({
|
||||
layer: "personal",
|
||||
owner: null,
|
||||
canWrite: true,
|
||||
projectId: null,
|
||||
kind: "sangeun",
|
||||
name: u.키,
|
||||
label: `${u.키} ${u.이름}`.trim(),
|
||||
});
|
||||
},
|
||||
});
|
||||
opt.showView(mt("Items_Title"), node);
|
||||
});
|
||||
// 실무자료로 일위대가를 만들었음(확인 창 · 엑셀 보기 창) → 목록 다시 · 이 패널이 내려갔으면 듣기 끝
|
||||
const onBuilt = (): void =>
|
||||
precombo.body.isConnected
|
||||
@@ -173,6 +205,8 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
children: [
|
||||
sangeunSec,
|
||||
precomboSec,
|
||||
myMachSec,
|
||||
myItemsSec,
|
||||
summary,
|
||||
createDivider(),
|
||||
section(hopyo.title, hopyo.body),
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/* =============================================================================
|
||||
* M02_MyItems_View.ts
|
||||
* M02 「내 품목」 모아 보기(PLAN 821-3 · 요구서 796 3 장 · 읽기만) — 좌측 「내 품목」 칸을 펼치면 가운데에 뜸(개인 층).
|
||||
* 줄 = 그 층 단가산출 줄에 값을 바로 적은 재료(우리 자재에 안 이어진 것 · 서버 `M02_MyMachine_Look.items`)
|
||||
* 칸 = 이름 · 규격 · 단위 · 값 · 쓰는 단가산출 수 · 같은 이름 수(같은 이름 묶음 = 이름 차례로 붙어 보임)
|
||||
* 줄 누름 = 아래 쓰는 단가산출 목록 → 누르면 그 단가산출로(고치기는 거기서 · 여기선 저장 없음)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createDataTable } from "@ui/ui_template_data_table";
|
||||
import { createInputField, el } from "@ui/ui_template_elements";
|
||||
import { createFindList } from "@ui/ui_template_find_list";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { listMyItems, type MyItem, type Use } from "./M02_MyMachine_Api";
|
||||
import { mt } from "./M02_MyMachine_Text";
|
||||
|
||||
export interface MyItemsViewOptions {
|
||||
owner: string | null;
|
||||
/** 그 단가산출 열기(떠남 확인은 부르는 쪽) */
|
||||
openSk: (use: Use) => void;
|
||||
}
|
||||
|
||||
export function buildMyItemsView(opt: MyItemsViewOptions): HTMLElement {
|
||||
let rows: MyItem[] = [];
|
||||
let shown: MyItem[] = [];
|
||||
const search = createInputField({ type: "search", placeholder: mt("Find") });
|
||||
const hint = createText("");
|
||||
const fail = el("div");
|
||||
const uses = createFindList({ placeholder: mt("Open_Sk") });
|
||||
uses.root.hidden = true;
|
||||
const table = createDataTable({
|
||||
caption: mt("Items_Title"),
|
||||
columns: [
|
||||
{ key: "이름", label: mt("Name"), width: 200 },
|
||||
{ key: "규격", label: mt("Spec"), width: 140 },
|
||||
{ key: "단위", label: mt("Unit"), width: 60 },
|
||||
{ key: "값", label: mt("Value"), kind: "number", decimals: 2, trim: true, width: 100 },
|
||||
{ key: "쓰는곳", label: mt("Uses_Count"), kind: "number", decimals: 0, width: 70 },
|
||||
{ key: "같은이름", label: mt("Same_Name"), kind: "number", decimals: 0, width: 70 },
|
||||
],
|
||||
rows: [],
|
||||
empty: mt("Items_None"),
|
||||
mergeSame: ["이름"],
|
||||
onRowClick: (i) => pick(shown[i]),
|
||||
});
|
||||
const pick = (item: MyItem | undefined): void => {
|
||||
if (!item) return;
|
||||
uses.root.hidden = false;
|
||||
uses.hint.textContent = `${item.이름} ${item.규격}`.trim();
|
||||
uses.setRows(
|
||||
item.쓰는곳.map((u) => ({
|
||||
key: u.키,
|
||||
name: u.이름 || u.키,
|
||||
sub: u.키,
|
||||
onPick: () => opt.openSk(u),
|
||||
})),
|
||||
);
|
||||
};
|
||||
const draw = (): void => {
|
||||
const words = search.input.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const count = new Map<string, number>();
|
||||
for (const r of rows) count.set(r.이름, (count.get(r.이름) ?? 0) + 1);
|
||||
shown = rows.filter((r) => words.every((w) => `${r.이름} ${r.규격}`.toLowerCase().includes(w)));
|
||||
hint.textContent = rows.length ? `${shown.length} / ${rows.length}` : "";
|
||||
table.setRows(
|
||||
shown.map((r) => ({
|
||||
이름: r.이름,
|
||||
규격: r.규격,
|
||||
단위: r.단위,
|
||||
값: typeof r.값 === "number" ? r.값 : Number(r.값) || null,
|
||||
쓰는곳: r.쓰는곳.length,
|
||||
같은이름: count.get(r.이름) ?? 1,
|
||||
})),
|
||||
);
|
||||
};
|
||||
search.input.addEventListener("input", draw);
|
||||
void listMyItems(opt.owner)
|
||||
.then((got) => (rows = got))
|
||||
.catch((error) =>
|
||||
fail.replaceChildren(
|
||||
createText(error instanceof Error ? error.message : mt("Personal_Only"), "error"),
|
||||
),
|
||||
)
|
||||
.finally(draw);
|
||||
return el("div", {
|
||||
className: "m02-mymach-view",
|
||||
attrs: { "data-my-items": "" },
|
||||
children: [
|
||||
el("div", { className: "ui-toolbar", children: [search.root, hint] }),
|
||||
createText(mt("Items_Note"), "muted"),
|
||||
fail,
|
||||
table.root,
|
||||
uses.root,
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* M02 내 기계 · 내 품목 화면(PLAN 821-3) — 차림만 · 색 · 크기는 공통 값 */
|
||||
.m02-mymach,
|
||||
.m02-mymach-view,
|
||||
.m02-mymach-view__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
.m02-mymach-view {
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
.m02-mymach__uses ul {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
padding-left: var(--spacing-16);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/* =============================================================================
|
||||
* M02_MyMachine_Api.ts
|
||||
* M02 내 기계 · 내 품목 서버 호출(PLAN 821-3) — 개인 층만.
|
||||
* 내 기계 = `/layers/personal/my-machines`(821-1 · `M02_MyMachine_Router`) — 목록 · 하나 · 만들기 · 고치기 · 지우기
|
||||
* 양식 목록 · 비슷한 기계 찾기 · 내 품목 = `M02_MyMachine_Look`(읽기만)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { MachinePick, MachineValues } from "@ui/ui_template_machine_form";
|
||||
import { call } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { M01Element } from "./M02_MasterTemplete_Find_Pick";
|
||||
|
||||
export type MachineHow = "바로" | "품셈" | "직접";
|
||||
type Costs = Record<"노무비" | "재료비" | "경비", number>;
|
||||
|
||||
/** 서버 시간당 셈 답 — ok 아님 = 까닭 */
|
||||
export interface MyHourly extends Partial<Costs> {
|
||||
ok: boolean;
|
||||
계?: number;
|
||||
로직?: string | null;
|
||||
직접?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** 내 기계 줄(저장 꼴) */
|
||||
export interface MyMachine {
|
||||
키: string;
|
||||
원문번호: string;
|
||||
이름: string;
|
||||
규격: string;
|
||||
방식: MachineHow;
|
||||
값: MachineValues;
|
||||
시간당?: Costs;
|
||||
비고?: string;
|
||||
넣은날: string;
|
||||
고친날: string;
|
||||
본뜬?: string;
|
||||
}
|
||||
|
||||
export interface MyMachineRow extends Omit<MyMachine, "시간당"> {
|
||||
시간당: MyHourly;
|
||||
쓰는곳수: number;
|
||||
}
|
||||
|
||||
export interface Use {
|
||||
키: string;
|
||||
이름: string;
|
||||
}
|
||||
|
||||
export interface MyMachineOne {
|
||||
줄: MyMachine;
|
||||
시간당: MyHourly;
|
||||
같은이름: { 키: string; 이름: string; 규격: string; 출처: string }[];
|
||||
쓰는곳?: Use[];
|
||||
}
|
||||
|
||||
/** 보내는 줄 — 방식 · 값 또는 시간당(직접) */
|
||||
export interface MyMachineBody {
|
||||
이름: string;
|
||||
규격: string;
|
||||
방식: MachineHow;
|
||||
값?: MachineValues;
|
||||
시간당?: Partial<Costs>;
|
||||
}
|
||||
|
||||
export interface MachineLists {
|
||||
fuels: MachinePick[];
|
||||
jobs: MachinePick[];
|
||||
power: number;
|
||||
hour: number;
|
||||
}
|
||||
|
||||
export interface SimilarRow {
|
||||
키: string;
|
||||
원문번호?: string;
|
||||
이름: string;
|
||||
규격: string;
|
||||
구분: string;
|
||||
값?: MachineValues;
|
||||
방식?: MachineHow;
|
||||
시간당?: Costs;
|
||||
출처: "개인" | "M01";
|
||||
}
|
||||
|
||||
export interface MyItem {
|
||||
이름: string;
|
||||
규격: string;
|
||||
단위: string;
|
||||
값: unknown;
|
||||
쓰는곳: Use[];
|
||||
}
|
||||
|
||||
const at = (owner: string | null, tail = "", q: Record<string, string> = {}): string => {
|
||||
const ask = new URLSearchParams({ ...q, ...(owner ? { owner } : {}) }).toString();
|
||||
return `/layers/personal/my-machines${tail}${ask ? `?${ask}` : ""}`;
|
||||
};
|
||||
const look = (owner: string | null, path: string, q: Record<string, string> = {}): string => {
|
||||
const ask = new URLSearchParams({ ...q, ...(owner ? { owner } : {}) }).toString();
|
||||
return `/layers/personal/${path}${ask ? `?${ask}` : ""}`;
|
||||
};
|
||||
const key = (k: string): string => `/${encodeURIComponent(k)}`;
|
||||
|
||||
export const listMyMachines = async (owner: string | null): Promise<MyMachineRow[]> =>
|
||||
(await call<{ 줄: MyMachineRow[] }>(at(owner))).줄;
|
||||
|
||||
export const readMyMachine = (owner: string | null, k: string): Promise<MyMachineOne> =>
|
||||
call(at(owner, key(k)));
|
||||
|
||||
export const createMyMachine = (
|
||||
owner: string | null,
|
||||
body: MyMachineBody,
|
||||
base?: string,
|
||||
): Promise<MyMachineOne> =>
|
||||
call(at(owner), { method: "POST", body: JSON.stringify({ 줄: body, 본뜬: base ?? null }) });
|
||||
|
||||
export const updateMyMachine = (
|
||||
owner: string | null,
|
||||
k: string,
|
||||
body: MyMachineBody,
|
||||
): Promise<MyMachineOne> =>
|
||||
call(at(owner, key(k)), { method: "PUT", body: JSON.stringify({ 줄: body }) });
|
||||
|
||||
export const deleteMyMachine = (owner: string | null, k: string): Promise<{ 쓰는곳: Use[] }> =>
|
||||
call(at(owner, key(k)), { method: "DELETE" });
|
||||
|
||||
let lists: Promise<MachineLists> | null = null;
|
||||
/** 양식 고르기 목록 — 한 번 받아 둠 */
|
||||
export const fetchMyMachineLists = (): Promise<MachineLists> =>
|
||||
(lists ??= call<MachineLists>("/my-machine-lists").catch((e) => {
|
||||
lists = null;
|
||||
return Promise.reject(e);
|
||||
}));
|
||||
|
||||
export const findSimilar = async (owner: string | null, q: string): Promise<SimilarRow[]> =>
|
||||
(await call<{ rows: SimilarRow[] }>(look(owner, "my-machine-find", { q }))).rows;
|
||||
|
||||
export const listMyItems = async (owner: string | null): Promise<MyItem[]> =>
|
||||
(await call<{ rows: MyItem[] }>(look(owner, "my-items"))).rows;
|
||||
|
||||
/** 단가산출 줄 기계 입력 글(줄 쌓기 `기계='개인 EQP…'`) */
|
||||
export const machineArg = (no: string): string => `'${no}'`;
|
||||
|
||||
/** 내 기계 → 기계 고르기 한 줄(`M01Element` 꼴 · 줄 쌓기 = 로직 + 기계='개인 EQP…') */
|
||||
export const mineElement = (
|
||||
row: Pick<MyMachine, "키" | "원문번호" | "이름" | "규격">,
|
||||
h: MyHourly,
|
||||
): M01Element => ({
|
||||
종류: "기계",
|
||||
코드: row.키,
|
||||
이름: row.이름,
|
||||
규격: row.규격,
|
||||
단위: "시간",
|
||||
값: h.ok ? (h.계 ?? null) : null,
|
||||
단가: { 노무비: h.노무비 ?? 0, 재료비: h.재료비 ?? 0, 경비: h.경비 ?? 0 },
|
||||
로직: h.로직 ?? undefined,
|
||||
원문번호: row.원문번호,
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
/* =============================================================================
|
||||
* M02_MyMachine_Form.ts
|
||||
* M02 내 기계 양식 한 장(PLAN 821-3 · 요구서 796 1 · 3 장) — 기계 양식 부품(`ui_template_machine_form` · 821-2) +
|
||||
* [저장] · [지우기] · 「개인 값」 표지 · 같은 이름 · 규격 알림 · 서버 정본 시간당 · 쓰는 곳.
|
||||
* 기본 = 손료계수 「바로 넣기」 · 「시간당 값 직접」 꼴 · 직종 · 연료 = M01 목록 고르기 · 옆 계산식 즉시(계수 짝 TS)
|
||||
* [저장] 때만 서버(자동저장 없음) — 서버가 정본으로 다시 셈한 시간당을 받아 보임
|
||||
* 새 기계 = 「비슷한 기계에서 시작」(M01 · 내 기계 · 고른 키 = 서버 `본뜬`) 또는 빈 양식 · `seed` = 품셈 기계 복사
|
||||
* 지우기 = 쓰는 단가산출 이름 보이고 확인 + 「이미 만든 단가산출은 마지막 값 유지」
|
||||
* 부르는 곳 = 내 기계 화면(`M02_MyMachine_View`) · 기계 고르기 창 「새 기계 만들기」 · 줄 쌓기 「복사해 내 기계로」(창 `openMyMachineModal`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
createMachineForm,
|
||||
HOURLY_KEY,
|
||||
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,
|
||||
deleteMyMachine,
|
||||
fetchMyMachineLists,
|
||||
findSimilar,
|
||||
readMyMachine,
|
||||
updateMyMachine,
|
||||
type MyHourly,
|
||||
type MyMachine,
|
||||
type MyMachineBody,
|
||||
type MyMachineOne,
|
||||
type SimilarRow,
|
||||
} from "./M02_MyMachine_Api";
|
||||
import { mt } from "./M02_MyMachine_Text";
|
||||
import "./M02_MyMachine.css";
|
||||
|
||||
export interface MyMachineFormOptions {
|
||||
owner: string | null;
|
||||
/** 고칠 내 기계 키 — 없으면 새 기계 */
|
||||
key?: string;
|
||||
/** 새 기계 바탕(복사해 내 기계로) — 값 · 이름 · 규격 · 본뜬 키 */
|
||||
seed?: SimilarRow;
|
||||
/** 새 기계면 「비슷한 기계에서 시작」 을 펼침 */
|
||||
openSimilar?: boolean;
|
||||
onSaved: (got: MyMachineOne) => void;
|
||||
onDeleted?: () => void;
|
||||
onBack?: () => void;
|
||||
onDirty?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
export interface MyMachineFormHandle {
|
||||
root: HTMLElement;
|
||||
dirty: () => boolean;
|
||||
}
|
||||
|
||||
const won = (v: number | undefined): string =>
|
||||
`${(v ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 2 })}${mt("Won")}`;
|
||||
|
||||
/** 줄(저장 꼴) → 양식 값 묶음 — 직접 = 시간당 칸 하나 */
|
||||
export function valuesOf(row: Pick<MyMachine, "방식" | "값" | "시간당">): MachineValues {
|
||||
return row.방식 === "직접" ? { [HOURLY_KEY]: { ...(row.시간당 ?? {}) } } : { ...row.값 };
|
||||
}
|
||||
|
||||
/** 서버 시간당 한 줄 글 */
|
||||
export function hourlyText(h: MyHourly | undefined): string {
|
||||
if (!h?.ok) return `${mt("Hourly_None")}${h?.reason ? ` — ${h.reason}` : ""}`;
|
||||
return `${won(h.계)} (${mt("Labor")} ${won(h.노무비)} · ${mt("Material")} ${won(h.재료비)} · ${mt("Expense")} ${won(h.경비)})`;
|
||||
}
|
||||
|
||||
export async function mountMyMachineForm(
|
||||
host: HTMLElement,
|
||||
opt: MyMachineFormOptions,
|
||||
): Promise<MyMachineFormHandle> {
|
||||
const [lists, one] = await Promise.all([
|
||||
fetchMyMachineLists(),
|
||||
opt.key ? readMyMachine(opt.owner, opt.key) : Promise.resolve(null),
|
||||
]);
|
||||
const row = one?.줄;
|
||||
const seed = opt.seed;
|
||||
let base = seed && seed.출처 === "M01" ? seed.키 : undefined; // 서버 `본뜬`
|
||||
const found = new Map<string, SimilarRow>();
|
||||
const notice = el("div", { className: "m02-mymach__notice" });
|
||||
const server = el("div", { className: "m02-mymach__server" });
|
||||
const usesBox = el("div", { className: "m02-mymach__uses" });
|
||||
|
||||
const form = createMachineForm({
|
||||
name: row?.이름 ?? seed?.이름 ?? "",
|
||||
spec: row?.규격 ?? seed?.규격 ?? "",
|
||||
values: row
|
||||
? valuesOf(row)
|
||||
: seed
|
||||
? valuesOf({ 방식: seed.방식 ?? "바로", 값: seed.값 ?? {}, 시간당: seed.시간당 })
|
||||
: {},
|
||||
coefMode: "direct",
|
||||
allowHourly: true,
|
||||
fuels: lists.fuels,
|
||||
jobs: lists.jobs,
|
||||
power: lists.power,
|
||||
hour: lists.hour,
|
||||
findSimilar: row
|
||||
? undefined
|
||||
: async (q): Promise<MachineSimilar[]> =>
|
||||
(await findSimilar(opt.owner, q)).map((r) => {
|
||||
found.set(r.키, r);
|
||||
return {
|
||||
key: r.키,
|
||||
name: r.이름,
|
||||
spec: r.규격,
|
||||
tag: r.출처 === "개인" ? mt("Mine") : r.구분,
|
||||
values: valuesOf({ 방식: r.방식 ?? "바로", 값: r.값 ?? {}, 시간당: r.시간당 }),
|
||||
};
|
||||
}),
|
||||
onSimilar: (item) => {
|
||||
base = item.key;
|
||||
checkSame();
|
||||
},
|
||||
onChange: () => {
|
||||
notice.replaceChildren(); // 지난 저장 막힘 글 지움(다시 [저장] 때 새로)
|
||||
opt.onDirty?.(dirty());
|
||||
checkSame();
|
||||
},
|
||||
});
|
||||
|
||||
let saved = !opt.key && seed ? false : true;
|
||||
const dirty = (): boolean => form.state().changed || !saved;
|
||||
|
||||
/* --- 같은 이름 · 규격 알림(M01 · 내 기계) --- */
|
||||
let timer: number | undefined;
|
||||
let seq = 0;
|
||||
const sameText = (hits: { 키: string; 출처: string }[]): string =>
|
||||
hits.length
|
||||
? mt("Same").replace("{value}", hits.map((h) => `${h.출처} ${h.키}`).join(" · "))
|
||||
: "";
|
||||
const checkSame = (): void => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(async () => {
|
||||
const mine = ++seq;
|
||||
const s = form.state();
|
||||
const rows = s.name ? await findSimilar(opt.owner, s.name).catch(() => []) : [];
|
||||
if (mine !== seq) return;
|
||||
const hits = rows.filter(
|
||||
(r) => r.키 !== opt.key && r.이름.trim() === s.name && (r.규격 ?? "").trim() === s.spec,
|
||||
);
|
||||
form.setNotice(sameText(hits));
|
||||
}, 300);
|
||||
};
|
||||
|
||||
/* --- 저장 --- */
|
||||
const bodyOf = (): MyMachineBody | null => {
|
||||
const s = form.state();
|
||||
const errors = [...s.errors, ...(s.name ? [] : [mt("Name_Needed")])];
|
||||
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 };
|
||||
};
|
||||
const saveBtn = createButton({ label: mt("Save"), variant: "filled", size: 28 });
|
||||
saveBtn.dataset.mymachSave = "";
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
const body = bodyOf();
|
||||
if (!body) return;
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
const got = opt.key
|
||||
? await updateMyMachine(opt.owner, opt.key, body)
|
||||
: await createMyMachine(opt.owner, body, base);
|
||||
saved = true;
|
||||
opt.onDirty?.(false);
|
||||
showToast(mt("Saved"), "success");
|
||||
opt.onSaved(got);
|
||||
} catch (error) {
|
||||
notice.replaceChildren(
|
||||
createText(error instanceof Error ? error.message : mt("Failed"), "error"),
|
||||
);
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
/* --- 지우기 --- */
|
||||
const delBtn = createButton({ label: mt("Delete"), variant: "danger-ghost", size: 28 });
|
||||
delBtn.hidden = !opt.key;
|
||||
delBtn.addEventListener("click", async () => {
|
||||
if (!opt.key) return;
|
||||
const uses = (await readMyMachine(opt.owner, opt.key).catch(() => one))?.쓰는곳 ?? [];
|
||||
const ask = [
|
||||
mt("Delete_Ask").replace("{value}", String(uses.length)),
|
||||
...uses.map((u) => `${u.키} ${u.이름}`),
|
||||
mt("Delete_Keep"),
|
||||
].join("\n");
|
||||
if (!(await showConfirmDialog(ask, mt("Delete")))) return;
|
||||
try {
|
||||
await deleteMyMachine(opt.owner, opt.key);
|
||||
saved = true;
|
||||
opt.onDirty?.(false);
|
||||
showToast(mt("Deleted"), "success");
|
||||
opt.onDeleted?.();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : mt("Failed"), "error");
|
||||
}
|
||||
});
|
||||
|
||||
const back = opt.onBack
|
||||
? [
|
||||
createButton({
|
||||
label: mt("Back"),
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
iconStart: "chevronLeft",
|
||||
onClick: opt.onBack,
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
const title = row ? `${row.이름} ${row.규격}`.trim() : mt("New");
|
||||
server.replaceChildren(
|
||||
...(one ? [createText(`${mt("Server_Hourly")} ${hourlyText(one.시간당)}`)] : []),
|
||||
);
|
||||
if (one?.같은이름.length) form.setNotice(sameText(one.같은이름));
|
||||
const uses = one?.쓰는곳 ?? [];
|
||||
usesBox.replaceChildren(
|
||||
el("strong", { text: `${mt("Uses")} ${uses.length}` }),
|
||||
...(uses.length
|
||||
? [el("ul", { children: uses.map((u) => el("li", { text: `${u.키} ${u.이름}` })) })]
|
||||
: [createText(mt("No_Uses"), "muted")]),
|
||||
);
|
||||
const root = el("div", {
|
||||
className: "m02-mymach",
|
||||
attrs: { "data-mymach-form": opt.key ?? "new" },
|
||||
children: [
|
||||
el("div", {
|
||||
className: "ui-toolbar m02-mymach__bar",
|
||||
children: [
|
||||
...back,
|
||||
el("strong", { text: title }),
|
||||
el("span", { className: "ui-text ui-text--muted", text: opt.key ?? "" }),
|
||||
createBadge(mt("Own_Value"), "info"),
|
||||
saveBtn,
|
||||
delBtn,
|
||||
],
|
||||
}),
|
||||
createText(mt("Own_Note"), "muted"),
|
||||
notice,
|
||||
server,
|
||||
form.root,
|
||||
...(opt.key ? [usesBox] : []),
|
||||
],
|
||||
});
|
||||
host.replaceChildren(root);
|
||||
if (!row && (opt.openSimilar ?? !seed))
|
||||
root.querySelector<HTMLDetailsElement>("details")?.setAttribute("open", "");
|
||||
if (seed) {
|
||||
form.setNotice(mt("Copied").replace("{value}", `${seed.이름} ${seed.규격}`.trim()));
|
||||
opt.onDirty?.(true);
|
||||
}
|
||||
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();
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"""M02 내 기계 화면 읽기 길(PLAN 821-3) — 양식 목록 · 비슷한 기계 찾기 · 내 품목 · 쓰기 없음.
|
||||
|
||||
GET `/api/m02/my-machine-lists` = 양식 고르기(연료 · 직종 · 전력 단가 · 시간환산) —
|
||||
M01 기계 화면과 같은 값(`M01_MasterData_Machine.lists` · 그 길은 시스템 관리자만).
|
||||
GET `/api/m02/layers/{층}/my-machine-find?q=&owner=` = 「비슷한 기계에서 시작」 ·
|
||||
「복사해 내 기계로」 — 그 층 내 기계(앞) + M01 기계(`M01_MasterData_Machine.find`) ·
|
||||
q 가 M01 원문번호와 같으면 그 기계 먼저.
|
||||
GET `/api/m02/layers/{층}/my-items?owner=` = 내 품목 — 그 층 단가산출 수식 문서 재료비 줄 가운데
|
||||
단가가 우리 자재에 안 이어진 것(단가 변수 연결 = 숫자 · 입력) ·
|
||||
(이름 · 규격 · 단위 · 값) 묶음마다 쓰는 단가산출.
|
||||
권한 = 내 기계 길과 같음(`M02_MyMachine_Router._folder` — 개인 층 · 본인 · 맡은 회사 관리자 읽기).
|
||||
⚠ 등록은 `main.py`(로그인만).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_MyMachine as mym
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Session, _call
|
||||
from M02_MasterTemplete.M02_MyMachine_Router import _folder
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 My Machine Look"])
|
||||
DIRECT = ("숫자", "입력") # 단가 변수 연결 — 값을 줄에 바로 적은 것
|
||||
|
||||
|
||||
def _m01m():
|
||||
from M01_MasterData import M01_MasterData_Machine as m01m # M01 store 가 scripts 를 길에 둔 뒤
|
||||
|
||||
return m01m
|
||||
|
||||
|
||||
def lists() -> dict[str, Any]:
|
||||
got = _m01m().lists()
|
||||
return {k: got[k] for k in ("fuels", "jobs", "power", "hour")}
|
||||
|
||||
|
||||
def find(q: str, limit: int = 30) -> list[dict]:
|
||||
"""내 기계(앞) + M01 기계 — 줄 {키, 원문번호, 이름, 규격, 구분, 값, 방식?, 시간당?, 출처}."""
|
||||
words = [w for w in q.lower().split() if w]
|
||||
keep = ("키", "원문번호", "이름", "규격", "값", "방식", "시간당")
|
||||
out = [
|
||||
{**{k: r[k] for k in keep if k in r}, "구분": "내 기계", "출처": "개인"}
|
||||
for r in mym.rows()
|
||||
if all(w in f"{r.get('이름')} {r.get('규격')} {r.get('키')}".lower() for w in words)
|
||||
]
|
||||
exact = (m01.loaded()[1].numbers.get("EQ") or {}).get(q.strip())
|
||||
head = [exact] if exact is not None and not mym.mine(exact.get("키")) else []
|
||||
m01_rows = [*head, *_m01m().find(q, limit)]
|
||||
seen = set()
|
||||
for r in m01_rows:
|
||||
if r["키"] in seen:
|
||||
continue
|
||||
seen.add(r["키"])
|
||||
brief = {k: r[k] for k in ("키", "원문번호", "이름", "규격", "값") if k in r}
|
||||
out.append({**brief, "구분": r.get("상세구분") or r.get("구분") or "", "출처": "M01"})
|
||||
return json.loads(json.dumps(out[:limit], default=_m01m()._plain)) # Decimal → 수(글 아님)
|
||||
|
||||
|
||||
def _items_of(doc: dict) -> list[dict]:
|
||||
"""수식 문서 재료비 줄 — 단가 = 바로 적은 값 변수(숫자 · 입력)."""
|
||||
import master_doc as md
|
||||
|
||||
body = ((doc.get("수식") or {}).get("문서")) or {}
|
||||
vars_ = {md.var_id(v): v for v in body.get("변수") or [] if isinstance(v, dict)}
|
||||
out = []
|
||||
for line in body.get("금액") or []:
|
||||
if not isinstance(line, dict) or line.get("비목") != "재료비":
|
||||
continue
|
||||
var = vars_.get(str(line.get("단가") or "").strip())
|
||||
link = (var or {}).get("연결") or {}
|
||||
if link.get("갈래") not in DIRECT:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"이름": str(line.get("이름") or var.get("이름") or ""),
|
||||
"규격": str(line.get("규격") or ""),
|
||||
"단위": str(line.get("단위") or ""),
|
||||
"값": link.get("값"),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def items() -> list[dict]:
|
||||
"""내 품목 — (이름 · 규격 · 단위 · 값) 묶음 · 쓰는곳 [{키, 이름}] · 이름 차례."""
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
|
||||
groups: dict[tuple, dict] = {}
|
||||
for path in sorted((store.root() / sd.KIND).glob("SK-*.json")):
|
||||
try:
|
||||
doc = json.loads(path.read_bytes())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
for it in _items_of(doc):
|
||||
got = groups.setdefault(
|
||||
(it["이름"], it["규격"], it["단위"], json.dumps(it["값"])), {**it, "쓰는곳": []}
|
||||
)
|
||||
use = {"키": doc.get("키") or path.stem, "이름": doc.get("이름") or ""}
|
||||
if use not in got["쓰는곳"]:
|
||||
got["쓰는곳"].append(use)
|
||||
return sorted(groups.values(), key=lambda g: (g["이름"], g["규격"], g["단위"]))
|
||||
|
||||
|
||||
@router.get("/my-machine-lists")
|
||||
def get_lists() -> dict:
|
||||
return lists()
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/my-machine-find")
|
||||
def get_find(
|
||||
layer: str, q: str = "", owner: str | None = None, session: Session = Depends(verify_session)
|
||||
) -> dict:
|
||||
return {"rows": _call(_folder(session, layer, owner), find, q)}
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/my-items")
|
||||
def get_items(
|
||||
layer: str, owner: str | None = None, session: Session = Depends(verify_session)
|
||||
) -> dict:
|
||||
return {"rows": _call(_folder(session, layer, owner), items)}
|
||||
@@ -0,0 +1,67 @@
|
||||
/* =============================================================================
|
||||
* M02_MyMachine_Text.ts
|
||||
* M02 내 기계 · 내 품목 화면 글(PLAN 821-3).
|
||||
* ========================================================================== */
|
||||
|
||||
const TEXT = {
|
||||
Title: "내 기계",
|
||||
Items_Title: "내 품목",
|
||||
New: "새 기계",
|
||||
Copy_Title: "복사해 내 기계로",
|
||||
Find: "이름 · 규격 · 키로 찾기",
|
||||
Name: "이름",
|
||||
Spec: "규격",
|
||||
How: "방식",
|
||||
Hourly: "시간당 경비(원)",
|
||||
Uses_Count: "쓰는 곳",
|
||||
Made: "넣은 날",
|
||||
Edited: "고친 날",
|
||||
None: "내 기계 없음 — [새 기계] 로 만듦",
|
||||
Save: "저장",
|
||||
Saved: "내 기계 저장 — 서버가 시간당을 다시 셈",
|
||||
Delete: "지우기",
|
||||
Deleted: "내 기계 지움",
|
||||
Delete_Ask: "이 내 기계를 지울까? 쓰는 단가산출 {value}",
|
||||
Delete_Keep: "지워도 이미 만든 단가산출은 마지막 값 유지(「지워짐」 표시)",
|
||||
Back: "목록",
|
||||
Close: "닫기",
|
||||
Failed: "못 함",
|
||||
Name_Needed: "이름 · 비어 있음",
|
||||
Same: "같은 이름 · 규격 있음 — {value}",
|
||||
Copied: "「{value}」 값을 복사 — 고친 뒤 [저장]",
|
||||
Own_Value: "개인 값",
|
||||
Own_Note:
|
||||
"개인 값 = 이 기계 고유 값(가격 · 손료계수 · 연료 ℓ · 잡품 % · 조종원 수) · 단가(노임 · 유가)는 M01 값 · 단가산출엔 만들 때 복사 → 고친 뒤 「새 값」 → [단가 갱신]",
|
||||
Server_Hourly: "서버 셈 시간당",
|
||||
Hourly_None: "시간당 못 셈",
|
||||
Uses: "쓰는 단가산출",
|
||||
No_Uses: "쓰는 단가산출 없음",
|
||||
Leave_Ask: "저장 안 한 고침을 버리고 닫을까?",
|
||||
Mine: "내 기계",
|
||||
Side_Hint: "펼치면 가운데에 목록 · 양식",
|
||||
Items_Side_Hint: "펼치면 가운데에 모아 보기(읽기)",
|
||||
Won: "원",
|
||||
Labor: "노",
|
||||
Material: "재",
|
||||
Expense: "경",
|
||||
바로: "바로 넣기",
|
||||
품셈: "품셈 방식",
|
||||
직접: "시간당 값 직접",
|
||||
// 기계 고르기 창 · 줄 쌓기
|
||||
Pick_Group: "내 기계",
|
||||
Pick_New: "새 기계 만들기",
|
||||
Pick_Direct: "시간당 값 직접 기계 — 줄 쌓기 식이 없어 못 넣음",
|
||||
To_Mine: "복사해 내 기계로",
|
||||
// 내 품목
|
||||
Items_Note:
|
||||
"단가산출 줄에 값을 바로 적은 재료(우리 자재에 안 이어진 것) — 고치기는 그 단가산출에서 · 줄 누르면 쓰는 단가산출",
|
||||
Items_None: "내 품목 없음",
|
||||
Value: "값(원)",
|
||||
Unit: "단위",
|
||||
Same_Name: "같은 이름",
|
||||
Open_Sk: "쓰는 단가산출 — 누르면 그 단가산출로",
|
||||
Personal_Only: "개인 층에서만 — 로그인 사용자 회사 · 개인 층이 없음",
|
||||
} as const;
|
||||
|
||||
export type MtKey = keyof typeof TEXT;
|
||||
export const mt = (key: MtKey): string => TEXT[key];
|
||||
@@ -0,0 +1,127 @@
|
||||
/* =============================================================================
|
||||
* M02_MyMachine_View.ts
|
||||
* M02 「내 기계」 화면(PLAN 821-3 · 요구서 796 3 장) — 좌측 「내 기계」 칸을 펼치면 가운데에 뜸(개인 층).
|
||||
* 목록 = 이름 · 규격 · 방식 · 시간당 경비(서버 셈) · 쓰는 곳 수 · 넣은 날 · 고친 날 · 찾기 · [새 기계]
|
||||
* 줄 누름 = 양식 한 장(`M02_MyMachine_Form`) · [목록] = 저장 안 한 고침이면 물음 · 저장 · 지우기 뒤 목록 다시
|
||||
* ========================================================================== */
|
||||
|
||||
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";
|
||||
import { listMyMachines, type MyMachineRow } from "./M02_MyMachine_Api";
|
||||
import { mountMyMachineForm } from "./M02_MyMachine_Form";
|
||||
import { mt, type MtKey } from "./M02_MyMachine_Text";
|
||||
import "./M02_MyMachine.css";
|
||||
|
||||
export interface MyMachineViewOptions {
|
||||
owner: string | null;
|
||||
/** 저장 안 한 고침 표시(페이지 떠남 물음) */
|
||||
setDirty: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
const day = (t: string | undefined): string => (t ?? "").slice(0, 10);
|
||||
|
||||
export function buildMyMachineView(opt: MyMachineViewOptions): HTMLElement {
|
||||
const host = el("div", { className: "m02-mymach-view" });
|
||||
let rows: MyMachineRow[] = [];
|
||||
let shown: MyMachineRow[] = [];
|
||||
let dirty = (): boolean => false;
|
||||
const mark = (d: boolean): void => opt.setDirty(d);
|
||||
|
||||
const search = createInputField({ type: "search", placeholder: mt("Find") });
|
||||
const hint = createText("");
|
||||
const fail = el("div");
|
||||
const table = createDataTable({
|
||||
caption: mt("Title"),
|
||||
columns: [
|
||||
{ key: "이름", label: mt("Name"), width: 180 },
|
||||
{ key: "규격", label: mt("Spec"), width: 120 },
|
||||
{ key: "방식", label: mt("How"), width: 100 },
|
||||
{ key: "시간당", label: mt("Hourly"), kind: "number", decimals: 0, width: 110 },
|
||||
{ key: "쓰는곳", label: mt("Uses_Count"), kind: "number", decimals: 0, width: 70 },
|
||||
{ key: "넣은날", label: mt("Made"), width: 96 },
|
||||
{ key: "고친날", label: mt("Edited"), width: 96 },
|
||||
],
|
||||
rows: [],
|
||||
empty: mt("None"),
|
||||
onRowClick: (i) => shown[i] && void openForm(shown[i].키),
|
||||
});
|
||||
const draw = (): void => {
|
||||
const words = search.input.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
shown = rows.filter((r) => {
|
||||
const text = `${r.이름} ${r.규격} ${r.키}`.toLowerCase();
|
||||
return words.every((w) => text.includes(w));
|
||||
});
|
||||
hint.textContent = rows.length ? `${shown.length} / ${rows.length}` : "";
|
||||
table.setRows(
|
||||
shown.map((r) => ({
|
||||
이름: r.이름,
|
||||
규격: r.규격,
|
||||
방식: mt(r.방식 as MtKey),
|
||||
시간당: r.시간당.ok ? (r.시간당.계 ?? null) : null,
|
||||
쓰는곳: r.쓰는곳수,
|
||||
넣은날: day(r.넣은날),
|
||||
고친날: day(r.고친날),
|
||||
})),
|
||||
);
|
||||
};
|
||||
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"),
|
||||
fail,
|
||||
table.root,
|
||||
],
|
||||
});
|
||||
|
||||
async function showList(): Promise<void> {
|
||||
dirty = () => false;
|
||||
mark(false);
|
||||
host.replaceChildren(list);
|
||||
fail.replaceChildren();
|
||||
try {
|
||||
rows = await listMyMachines(opt.owner);
|
||||
} catch (error) {
|
||||
rows = [];
|
||||
fail.replaceChildren(
|
||||
createText(error instanceof Error ? error.message : mt("Personal_Only"), "error"),
|
||||
);
|
||||
}
|
||||
draw();
|
||||
}
|
||||
|
||||
async function openForm(key?: string): Promise<void> {
|
||||
const slot = el("div");
|
||||
host.replaceChildren(slot);
|
||||
try {
|
||||
const h = await mountMyMachineForm(slot, {
|
||||
owner: opt.owner,
|
||||
key,
|
||||
onSaved: (got) => void openForm(got.줄.키),
|
||||
onDeleted: () => void showList(),
|
||||
onBack: async () => {
|
||||
if (!dirty() || (await showConfirmDialog(mt("Leave_Ask")))) await showList();
|
||||
},
|
||||
onDirty: mark,
|
||||
});
|
||||
dirty = h.dirty;
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : mt("Failed"), "error");
|
||||
await showList();
|
||||
}
|
||||
}
|
||||
|
||||
void showList();
|
||||
return host;
|
||||
}
|
||||
@@ -583,6 +583,11 @@ app.include_router(
|
||||
__import__("M02_MasterTemplete.M02_MyMachine_Router", fromlist=["router"]).router,
|
||||
dependencies=protected,
|
||||
)
|
||||
# 내 기계 화면 읽기(PLAN 821-3) — 양식 목록 · 비슷한 기계 찾기 · 내 품목 · 로그인 · 층 권한은 길 안에서
|
||||
app.include_router(
|
||||
__import__("M02_MasterTemplete.M02_MyMachine_Look", fromlist=["router"]).router,
|
||||
dependencies=protected,
|
||||
)
|
||||
# 단가산출(SK) 수식 문서 길(PLAN 802-4a) — 로그인 · 층 권한은 길 안에서
|
||||
app.include_router(
|
||||
__import__("M02_MasterTemplete.M02_Doc_Router", fromlist=["router"]).router,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
|
||||
"""821-3 내 기계 화면 읽기 길(`M02_MyMachine_Look`) — 임시 storage · 쓰기 없음.
|
||||
|
||||
① 양식 목록 = M01 기계 화면 목록과 같음(연료 · 직종 · 전력 · 시간환산)
|
||||
② 비슷한 기계 찾기 = 내 기계(앞) + M01 · 원문번호 = 그 M01 기계 먼저 · 남의 층 내 기계 안 보임
|
||||
③ 내 품목 = 값을 바로 적은 재료비 줄만 · 같은 품목 묶음 · 쓰는 단가산출 ④ 개인 층 밖 = 404.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_MyMachine as mym
|
||||
from M02_MasterTemplete import M02_MyMachine_Look as look
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
|
||||
|
||||
DIG = "EQ000022" # 굴착기(무한궤도) 1.0㎥
|
||||
NAME = "서브원임시기계"
|
||||
|
||||
|
||||
def _at(folder, fn, *args):
|
||||
with store.at(folder):
|
||||
return fn(*args)
|
||||
|
||||
|
||||
def _mine(folder):
|
||||
row = {"이름": NAME, "규격": "1㎾", "방식": "바로", "값": {"가격": 1000, "손료계수": 2000}}
|
||||
return _at(folder, mym.create, row)["줄"]
|
||||
|
||||
|
||||
def test_lists_same_as_m01():
|
||||
"""① 양식 목록 = M01 기계 화면 값."""
|
||||
from M01_MasterData import M01_MasterData_Machine as m01m
|
||||
|
||||
got, want = look.lists(), m01m.lists()
|
||||
assert set(got) == {"fuels", "jobs", "power", "hour"}
|
||||
for k in got:
|
||||
assert got[k] == want[k], k
|
||||
assert got["fuels"] and got["jobs"] and got["hour"] == 0.125
|
||||
|
||||
|
||||
def test_find_mine_first_and_number(world):
|
||||
"""② 내 기계 앞 · 원문번호 = 그 M01 기계 먼저 · 남의 층 = 내 기계 안 보임."""
|
||||
me, other = world["personal"], layers.personal_dir(7, 43)
|
||||
key = _mine(me)["키"]
|
||||
rows = _at(me, look.find, NAME)
|
||||
assert rows[0]["키"] == key and rows[0]["출처"] == "개인" and rows[0]["값"]["손료계수"] == 2000
|
||||
assert all(r["출처"] == "M01" for r in _at(other, look.find, NAME))
|
||||
no = str(m01.loaded()[1].get(DIG)["원문번호"])
|
||||
first = _at(me, look.find, no)[0]
|
||||
assert first["키"] == DIG and first["원문번호"] == no and first["출처"] == "M01"
|
||||
assert isinstance(first["값"]["가격"], int | float) and first["구분"] # 수(글이면 미리보기 0)
|
||||
dig = [r for r in _at(me, look.find, "굴착기") if r["출처"] == "M01"]
|
||||
assert dig and len({r["키"] for r in dig}) == len(dig) # 겹침 없음
|
||||
|
||||
|
||||
def _sk(folder, key, name, lines):
|
||||
"""수식 문서 재료비 줄 — (이름, 연결) · 숫자 = 바로 적은 값 · 요소 = 우리 자재."""
|
||||
vars_, money = [], []
|
||||
for i, (item, link) in enumerate(lines):
|
||||
vars_.append({"열쇠": f"P_x{i}", "기호": "P", "아래": f"x{i}", "위": "", "연결": link})
|
||||
money.append(
|
||||
{"이름": item, "규격": "10㎜", "단위": "개", "비목": "재료비", "단가": f"P_x{i}"}
|
||||
)
|
||||
money.append({"이름": "인부", "단위": "인", "비목": "노무비", "단가": "P_x0"})
|
||||
doc = {"키": key, "이름": name, "수식": {"문서": {"변수": vars_, "금액": money}}}
|
||||
path = folder / "sangeun" / f"{key}.json"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
def test_items_direct_only(world):
|
||||
"""③ 바로 적은 값 재료만 · 같은 품목 = 한 묶음 · 쓰는 단가산출 · 값 다르면 따로."""
|
||||
me = world["personal"]
|
||||
_sk(me, "SK-000001", "가", [("볼트", {"갈래": "숫자", "값": 500}),
|
||||
("시멘트", {"갈래": "요소", "키": "MT004176"})]) # fmt: skip
|
||||
_sk(me, "SK-000002", "나", [("볼트", {"갈래": "숫자", "값": 500}),
|
||||
("볼트", {"갈래": "입력", "값": 700})]) # fmt: skip
|
||||
got = _at(me, look.items)
|
||||
assert [(r["이름"], r["값"]) for r in got] == [("볼트", 500), ("볼트", 700)]
|
||||
assert [u["키"] for u in got[0]["쓰는곳"]] == ["SK-000001", "SK-000002"]
|
||||
assert got[1]["쓰는곳"] == [{"키": "SK-000002", "이름": "나"}]
|
||||
assert got[0]["규격"] == "10㎜" and got[0]["단위"] == "개"
|
||||
assert _at(layers.personal_dir(7, 43), look.items) == []
|
||||
|
||||
|
||||
def test_personal_only(world):
|
||||
"""④ 개인 층 밖 = 404 · 남의 개인 층 = 403(일반)."""
|
||||
user = {"role": "USER", "company_id": 7, "user_id": 42}
|
||||
for layer, owner, code in (("company", None, 404), ("personal", "7/43", 403)):
|
||||
with pytest.raises(HTTPException) as e:
|
||||
look.get_items(layer, owner, user)
|
||||
assert e.value.status_code == code
|
||||
assert look.get_items("personal", None, user) == {"rows": []}
|
||||
|
||||
|
||||
def _stack_doc(key, no):
|
||||
"""줄 쌓기 기계 줄 하나(화면이 저장하는 꼴) — 기계='개인 …'."""
|
||||
var = {"기호": "P", "아래": "새", "위": "", "이름": NAME, "단위": "",
|
||||
"연결": {"갈래": "단가산출", "키": "GC000268", "입력": {"기계": f"'{no}'"}}} # fmt: skip
|
||||
line = {"이름": NAME, "단위": "hr", "비목": "묶음", "수량": "1", "단가": "P_새"}
|
||||
return {"꼴": "수식문서", "판": 1, "키": key, "이름": "sub1임시", "결과단위": "원",
|
||||
"변수": [var], "식": [], "금액": [line]} # fmt: skip
|
||||
|
||||
|
||||
def test_stack_line_copy(world):
|
||||
"""⑤ 줄 쌓기로 고른 내 기계 — 미리 셈 됨 · 저장 때 값 사본에 박힘 →
|
||||
고쳐도 금액 그대로 + 단가새값 → [단가 갱신] 바뀐 자원 1 → 지워도 마지막 값 + 지운키."""
|
||||
from M02_MasterTemplete import M02_Doc_Sk_Path as path
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
me = world["personal"]
|
||||
made = _at(me, mym.create, {**_mine_row(), "값": {**_mine_row()["값"], "주연료": 2,
|
||||
"주연료종류": "경유", "조종원": 1, "조종원직종": "1048"}}) # fmt: skip
|
||||
key, no, was = made["줄"]["키"], made["줄"]["원문번호"], made["시간당"]["계"]
|
||||
sk = _at(
|
||||
me,
|
||||
lambda: refs.create("sangeun", {"이름": "sub1임시", "단위": "시간", "더한줄": []})["이름"],
|
||||
)
|
||||
doc = _stack_doc(sk, no)
|
||||
look_ = _at(me, path.preview, sk, {"문서": doc})
|
||||
assert look_["셈"]["계"] == was # 사본에 아직 없는 내 기계도 셈
|
||||
판 = _at(me, path.read, sk)["판"]
|
||||
_at(me, path.save, sk, {"문서": doc, "판": 판})
|
||||
copy = _at(me, lambda: store.read("sangeun", sk)["문서"]["수식"])
|
||||
assert key in [r["키"] for r in copy["단가"]] # 값 사본에 박힘
|
||||
_at(me, mym.update, key, {**made["줄"], "값": {**made["줄"]["값"], "가격": 3000}})
|
||||
now = _at(me, path.read, sk)
|
||||
assert now["셈"]["계"] == was and now["사본"]["단가새값"] is True
|
||||
job = _at(me, path.preview, sk, {"할일": "단가갱신"})
|
||||
assert [x["키"] for x in job["바뀐자원"]] == [key] and job["후"]["계"] > was
|
||||
_at(me, mym.delete, key)
|
||||
gone = _at(me, path.read, sk)
|
||||
assert gone["셈"]["계"] == was and gone["사본"]["지운키"] == [key]
|
||||
|
||||
|
||||
def _mine_row():
|
||||
return {"이름": NAME, "규격": "1㎾", "방식": "바로", "값": {"가격": 1000, "손료계수": 2000}}
|
||||
@@ -69,6 +69,9 @@ export interface MachineFormState {
|
||||
/** 숫자 아닌 칸 등 — 있으면 저장 막음 */
|
||||
errors: string[];
|
||||
changed: boolean;
|
||||
/** 손료계수 방식 · 「시간당 값 직접」 꼴인지(내 기계 저장 방식) */
|
||||
mode: "direct" | "pumsem";
|
||||
hourly: boolean;
|
||||
}
|
||||
|
||||
export interface MachineFormHandle {
|
||||
@@ -507,6 +510,8 @@ export function createMachineForm(opts: MachineFormOptions): MachineFormHandle {
|
||||
),
|
||||
errors: [...errors.values()],
|
||||
changed: changed(),
|
||||
mode,
|
||||
hourly: hourlyMode,
|
||||
}),
|
||||
setNotice,
|
||||
};
|
||||
|
||||
@@ -94,6 +94,11 @@
|
||||
width: min(1100px, 94vw);
|
||||
height: var(--modal-max);
|
||||
}
|
||||
/* 확인창 글 — 줄바꿈(
|
||||
) 그대로(쓰는 곳 목록 · 바뀐 자원 줄) */
|
||||
.ui-confirm__message {
|
||||
white-space: pre-line;
|
||||
}
|
||||
.ui-confirm__title {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
font-size: var(--text-body);
|
||||
|
||||
Reference in New Issue
Block a user