diff --git a/backend-node/src/controllers/outsourcePurchaseController.ts b/backend-node/src/controllers/outsourcePurchaseController.ts index a43d5310..927cfa4c 100644 --- a/backend-node/src/controllers/outsourcePurchaseController.ts +++ b/backend-node/src/controllers/outsourcePurchaseController.ts @@ -182,6 +182,22 @@ export async function autoProcesses( } } +// ───────────────────────────────────────────────────────────────────────────── +// 외주발주번호 미리보기 — 모달 진입 시 input 자동 채움용 (시퀀스 증가 없음) +// ───────────────────────────────────────────────────────────────────────────── +export async function previewOrderNo( + req: AuthenticatedRequest, + res: Response, +) { + try { + const companyCode = req.user!.companyCode; + const code = await svc.previewOrderNo(companyCode); + return ok(res, { generatedCode: code || "" }); + } catch (e: any) { + return fail(res, 500, e?.message || "외주발주번호 미리보기 실패", e); + } +} + // ───────────────────────────────────────────────────────────────────────────── // 공정 자재투입(material_input) 자동 채움 // — 사급자재 체크 시 해당 공정의 자재 목록을 외주발주 자재 형식으로 반환 diff --git a/backend-node/src/routes/outsourcePurchaseRoutes.ts b/backend-node/src/routes/outsourcePurchaseRoutes.ts index 72486d00..1f99eb4d 100644 --- a/backend-node/src/routes/outsourcePurchaseRoutes.ts +++ b/backend-node/src/routes/outsourcePurchaseRoutes.ts @@ -14,6 +14,9 @@ router.use(authenticateToken); // 헬퍼 (정적 경로 — :id 라우트보다 위에) router.get("/auto-processes", ctrl.autoProcesses); +// 외주발주번호 미리보기 (시퀀스 증가 없음) +router.get("/preview-order-no", ctrl.previewOrderNo); + // 공정 자재투입 자동 채움 (사급자재 체크 시) router.get("/process-materials", ctrl.getProcessMaterials); diff --git a/backend-node/src/services/outsourcePurchaseService.ts b/backend-node/src/services/outsourcePurchaseService.ts index b331601b..2f4bbe83 100644 --- a/backend-node/src/services/outsourcePurchaseService.ts +++ b/backend-node/src/services/outsourcePurchaseService.ts @@ -11,6 +11,7 @@ import { PoolClient } from "pg"; import { getPool, transaction } from "../database/db"; import { logger } from "../utils/logger"; +import { numberingRuleService } from "./numberingRuleService"; // ───────────────────────────────────────────────────────────────────────────── // 타입 정의 @@ -67,9 +68,51 @@ export interface ListFilter { } // ───────────────────────────────────────────────────────────────────────────── -// 채번 (회사+년도 단위) — OPO-YYYY-NNN +// 채번 — 우선순위: +// 1. table_type_columns 매핑된 numbering_rules.allocateCode (관리자 옵션설정 화면에서 등록) +// 2. 폴백: nextOrderNoFallback (OPO-YYYY-NNN, 회사+년도 단위) // ───────────────────────────────────────────────────────────────────────────── -async function nextOrderNo(client: PoolClient, companyCode: string): Promise { + +/** 등록된 채번 규칙 조회 — table_type_columns.detail_settings.numberingRuleId */ +async function findOrderNoRuleId( + client: { query: (q: string, p?: any[]) => Promise }, + companyCode: string, +): Promise { + const r = await client.query( + `SELECT detail_settings + FROM table_type_columns + WHERE company_code = $1 + AND table_name = 'outsource_purchase_order' + AND column_name = 'order_no' + AND input_type = 'numbering' + LIMIT 1`, + [companyCode], + ); + const raw = r.rows[0]?.detail_settings; + if (!raw) return null; + try { + const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; + return parsed?.numberingRuleId || null; + } catch { + return null; + } +} + +/** 미리보기 — 시퀀스 증가 없음. 모달 진입 시 input 자동 채움용 */ +export async function previewOrderNo(companyCode: string): Promise { + const pool = getPool(); + const ruleId = await findOrderNoRuleId(pool, companyCode); + if (!ruleId) return null; + try { + return await numberingRuleService.previewCode(ruleId, companyCode); + } catch (e: any) { + logger.warn("외주발주 채번 미리보기 실패 — 폴백 사용", { error: e?.message, companyCode }); + return null; + } +} + +/** 폴백 채번 (회사+년도 단위) — OPO-YYYY-NNN */ +async function nextOrderNoFallback(client: PoolClient, companyCode: string): Promise { const yyyy = new Date().getFullYear(); const prefix = `OPO-${yyyy}-`; @@ -233,7 +276,21 @@ export async function createOrder( payload: OPOInput ) { return transaction(async (client) => { - const orderNo = payload.order_no || (await nextOrderNo(client, companyCode)); + // 채번: 사용자 입력값 우선 → 등록된 채번 규칙 allocate → 폴백 nextOrderNoFallback + let orderNo = (payload.order_no || "").trim(); + if (!orderNo) { + const ruleId = await findOrderNoRuleId(client, companyCode); + if (ruleId) { + try { + orderNo = await numberingRuleService.allocateCode(ruleId, companyCode); + } catch (e: any) { + logger.warn("외주발주 채번 allocate 실패 — 폴백 사용", { error: e?.message, ruleId, companyCode }); + } + } + if (!orderNo) { + orderNo = await nextOrderNoFallback(client, companyCode); + } + } // 마스터 INSERT const m = await client.query( diff --git a/frontend/app/(main)/COMPANY_7/outsourcing/purchase-order/RegistrationModal.tsx b/frontend/app/(main)/COMPANY_7/outsourcing/purchase-order/RegistrationModal.tsx index ccb78e62..414fe32b 100644 --- a/frontend/app/(main)/COMPANY_7/outsourcing/purchase-order/RegistrationModal.tsx +++ b/frontend/app/(main)/COMPANY_7/outsourcing/purchase-order/RegistrationModal.tsx @@ -19,17 +19,29 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Check, ChevronDown, ChevronLeft, ChevronRight as ChevronRightIcon, ChevronsLeft, ChevronsRight, + ChevronsUpDown, Loader2, RefreshCw, Save, Search as SearchIcon, Trash2, + UserCircle, X, Package, } from "lucide-react"; @@ -48,6 +60,7 @@ import { getProcessMaterials, listSubcontractors, listOutsourceableWorkOrders, + previewOrderNo, type OPOInputPayload, type OPOProcess, } from "@/lib/api/outsourcePurchase"; @@ -104,6 +117,12 @@ interface SubcontractorOption { name: string; } +interface UserOption { + user_id: string; // 저장 키 (불변) + user_name: string; // 화면 표시 + dept_code?: string; +} + /** * 탭별 페이징 캐시 — 페이지 번호 방식 (TASK:ERP-019 재구현) * - rows: 현재 페이지의 데이터만 (누적 X) @@ -138,7 +157,11 @@ export function RegistrationModal({ open, onOpenChange, editId, onSaved }: Regis const [dueDate, setDueDate] = useState(""); const [manager, setManager] = useState(""); const [memo, setMemo] = useState(""); - const [orderNoPreview, setOrderNoPreview] = useState("(자동채번)"); + // 외주발주번호 — 등록된 채번 규칙 미리보기 + 사용자 수정 가능 + // - orderNo: input의 현재 값 (사용자 입력 또는 미리보기) + // - originalPreview: 모달 진입 시 받은 미리보기 값 (사용자 수정 여부 판단) + const [orderNo, setOrderNo] = useState(""); + const [originalPreview, setOriginalPreview] = useState(""); // 탭 const [activeTab, setActiveTab] = useState("수주"); @@ -181,6 +204,10 @@ export function RegistrationModal({ open, onOpenChange, editId, onSaved }: Regis // 외주사 옵션 const [vendors, setVendors] = useState([]); + // 회사 사용자 옵션 (담당자 셀렉트용) + const [users, setUsers] = useState([]); + const [managerComboOpen, setManagerComboOpen] = useState(false); + // 카테고리 옵션 (item_info: material/division/type/inventory_unit) const [categoryOptions, setCategoryOptions] = useState>({}); // 거래처 코드 → 이름 맵 (수주 탭 partner_id 변환) @@ -224,6 +251,37 @@ export function RegistrationModal({ open, onOpenChange, editId, onSaved }: Regis }; }, []); + // ───────────────────────────────────────────────────────────────────────── + // 회사 사용자 옵션 로드 (담당자 셀렉트용) + // - autoFilter: 현재 회사 필터 자동 적용 + // - size: 0 → 전체 (CLAUDE.md 규칙: 마스터 참조 데이터는 size:0) + // ───────────────────────────────────────────────────────────────────────── + useEffect(() => { + let alive = true; + (async () => { + try { + const res = await apiClient.post(`/table-management/tables/user_info/data`, { + page: 1, size: 0, autoFilter: true, + }); + if (!alive) return; + const rows = res.data?.data?.data || res.data?.data?.rows || []; + const opts: UserOption[] = rows + .map((u: any) => ({ + user_id: u.user_id || u.id || "", + user_name: u.user_name || u.name || u.user_id || "", + dept_code: u.dept_code || "", + })) + .filter((o: UserOption) => o.user_id && o.user_name); + setUsers(opts); + } catch { + // 사용자 마스터 조회 실패 시 무시 (input 자유 입력으로 폴백) + } + })(); + return () => { + alive = false; + }; + }, []); + // ───────────────────────────────────────────────────────────────────────── // 카테고리 옵션 + 거래처 마스터 + 품목정보 마스터 로드 // (좌측 리스트의 코드 → 라벨 변환에 사용) @@ -326,7 +384,9 @@ export function RegistrationModal({ open, onOpenChange, editId, onSaved }: Regis try { const detail = await getOutsourcePurchaseOrder(editId); if (!alive || !detail) return; - setOrderNoPreview(detail.order_no || "(자동채번)"); + // 수정 모드: 기존 발주번호 그대로 사용 (수정 불가, 재할당 금지) + setOrderNo(detail.order_no || ""); + setOriginalPreview(detail.order_no || ""); setOrderDate(detail.order_date || ""); setDueDate(detail.due_date || ""); setManager(detail.manager || ""); @@ -676,8 +736,25 @@ export function RegistrationModal({ open, onOpenChange, editId, onSaved }: Regis setMngByOrderNo(null); setSourceSearch(""); setDebouncedSearch(""); + setOrderNo(""); + setOriginalPreview(""); }, [open]); + // 신규 등록 — 모달 open 시 외주발주번호 미리보기 채움 (시퀀스 증가 없음) + useEffect(() => { + if (!open || editId) return; + let alive = true; + (async () => { + const code = await previewOrderNo(); + if (!alive) return; + setOrderNo(code || ""); + setOriginalPreview(code || ""); + })(); + return () => { + alive = false; + }; + }, [open, editId]); + // ───────────────────────────────────────────────────────────────────────── // 좌측 → 우측 추가 // (TASK:ERP-019 재구현 — 작업지시 행이면 즉시 공정 자동표기 호출) @@ -983,7 +1060,13 @@ export function RegistrationModal({ open, onOpenChange, editId, onSaved }: Regis await updateOutsourcePurchaseOrder(editId, payload); toast.success("외주발주가 수정되었어요"); } else { + // 신규 등록 — 외주발주번호: + // - 사용자가 미리보기 값을 그대로 둠 → payload.order_no 비움 (백엔드 allocate) + // - 사용자가 직접 수정 → 그 값 그대로 사용 (UNIQUE 충돌 시 백엔드에서 에러) + // - 단, 첫 1건에만 사용자 입력 적용 (2건 이상은 모두 자동채번) + const userTyped = orderNo.trim() !== "" && orderNo.trim() !== originalPreview.trim(); let success = 0; + let firstUserOrderNo: string | undefined = userTyped ? orderNo.trim() : undefined; for (const entry of selected) { const payload: OPOInputPayload = { source_type: entry.source_type, @@ -998,8 +1081,10 @@ export function RegistrationModal({ open, onOpenChange, editId, onSaved }: Regis manager, memo, processes: entry.processes, + ...(firstUserOrderNo ? { order_no: firstUserOrderNo } : {}), }; await createOutsourcePurchaseOrder(payload); + firstUserOrderNo = undefined; // 첫 건 사용 후 비움 — 이후는 자동채번 success++; } toast.success(`${success}건의 외주발주가 등록되었어요`); @@ -1022,7 +1107,7 @@ export function RegistrationModal({ open, onOpenChange, editId, onSaved }: Regis {editId ? "외주발주 수정" : "외주발주 등록"} - {orderNoPreview} + {orderNo || "(자동채번)"} ); @@ -1068,7 +1153,14 @@ export function RegistrationModal({ open, onOpenChange, editId, onSaved }: Regis
- + setOrderNo(e.target.value)} + readOnly={!!editId} + placeholder="(자동채번)" + className="h-8 bg-white text-xs" + title={editId ? "기존 외주발주번호는 수정할 수 없습니다" : "기본값은 채번 규칙으로 자동 생성됩니다. 필요 시 직접 입력 가능."} + />
@@ -1080,7 +1172,67 @@ export function RegistrationModal({ open, onOpenChange, editId, onSaved }: Regis
- setManager(e.target.value)} className="h-8 text-xs" placeholder="담당자" /> + + + + + + + + + 사용자를 찾을 수 없어요 + + {/* 선택 해제 옵션 */} + {manager ? ( + { + setManager(""); + setManagerComboOpen(false); + }} + className="text-xs text-slate-500" + > + + 선택 해제 + + ) : null} + {users.map((u) => ( + { + setManager(u.user_id); + setManagerComboOpen(false); + }} + className="text-xs" + > + + +
+ {u.user_name} + {u.dept_code ? ( + {u.dept_code} + ) : null} +
+
+ ))} +
+
+
+
+
diff --git a/frontend/lib/api/outsourcePurchase.ts b/frontend/lib/api/outsourcePurchase.ts index ed5d96fe..d168a3f6 100644 --- a/frontend/lib/api/outsourcePurchase.ts +++ b/frontend/lib/api/outsourcePurchase.ts @@ -249,6 +249,21 @@ export async function getAutoProcesses( return res.data?.data || { source: "none", routing: [], candidates: [] }; } +/** + * 외주발주번호 미리보기 — 등록된 채번 규칙 기반. 시퀀스 증가 없음. + * 응답이 빈 문자열이면 채번 규칙 미설정 — 백엔드 폴백(OPO-YYYY-NNN) 사용됨. + */ +export async function previewOrderNo(): Promise { + try { + const res = await apiClient.get>( + "/outsource-purchase/preview-order-no", + ); + return res.data?.data?.generatedCode || ""; + } catch { + return ""; + } +} + /** * 공정 자재투입(material_input) 자동 채움 — 사급자재 체크 시 호출 * 응답: 외주발주 자재 입력 형식 배열 [{item_code, item_name, qty, unit}]