Add preview functionality for outsource purchase order numbers
- Introduced a new endpoint `/preview-order-no` to generate order numbers based on registered numbering rules without incrementing the sequence. - Implemented the `previewOrderNo` function in the `outsourcePurchaseController` to handle the logic for generating the preview order number. - Updated the `outsourcePurchaseRoutes` to include the new route for previewing order numbers. - Enhanced the `RegistrationModal` component to automatically fill the order number input with the preview value when the modal opens. - Added a new API function `previewOrderNo` in the frontend to call the backend endpoint. (TASK:ERP-019)
This commit is contained in:
@@ -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) 자동 채움
|
||||
// — 사급자재 체크 시 해당 공정의 자재 목록을 외주발주 자재 형식으로 반환
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<string> {
|
||||
|
||||
/** 등록된 채번 규칙 조회 — table_type_columns.detail_settings.numberingRuleId */
|
||||
async function findOrderNoRuleId(
|
||||
client: { query: (q: string, p?: any[]) => Promise<any> },
|
||||
companyCode: string,
|
||||
): Promise<string | null> {
|
||||
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<string | null> {
|
||||
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<string> {
|
||||
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(
|
||||
|
||||
@@ -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<string>("");
|
||||
const [manager, setManager] = useState<string>("");
|
||||
const [memo, setMemo] = useState<string>("");
|
||||
const [orderNoPreview, setOrderNoPreview] = useState<string>("(자동채번)");
|
||||
// 외주발주번호 — 등록된 채번 규칙 미리보기 + 사용자 수정 가능
|
||||
// - orderNo: input의 현재 값 (사용자 입력 또는 미리보기)
|
||||
// - originalPreview: 모달 진입 시 받은 미리보기 값 (사용자 수정 여부 판단)
|
||||
const [orderNo, setOrderNo] = useState<string>("");
|
||||
const [originalPreview, setOriginalPreview] = useState<string>("");
|
||||
|
||||
// 탭
|
||||
const [activeTab, setActiveTab] = useState<SourceTab>("수주");
|
||||
@@ -181,6 +204,10 @@ export function RegistrationModal({ open, onOpenChange, editId, onSaved }: Regis
|
||||
// 외주사 옵션
|
||||
const [vendors, setVendors] = useState<SubcontractorOption[]>([]);
|
||||
|
||||
// 회사 사용자 옵션 (담당자 셀렉트용)
|
||||
const [users, setUsers] = useState<UserOption[]>([]);
|
||||
const [managerComboOpen, setManagerComboOpen] = useState(false);
|
||||
|
||||
// 카테고리 옵션 (item_info: material/division/type/inventory_unit)
|
||||
const [categoryOptions, setCategoryOptions] = useState<Record<string, CategoryOption[]>>({});
|
||||
// 거래처 코드 → 이름 맵 (수주 탭 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 ? "외주발주 수정" : "외주발주 등록"}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{orderNoPreview}
|
||||
{orderNo || "(자동채번)"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
@@ -1068,7 +1153,14 @@ export function RegistrationModal({ open, onOpenChange, editId, onSaved }: Regis
|
||||
<div className="grid grid-cols-4 gap-3 border-b bg-slate-50 px-4 py-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-600">외주발주번호</Label>
|
||||
<Input value={orderNoPreview} readOnly className="h-8 bg-white text-xs" />
|
||||
<Input
|
||||
value={orderNo}
|
||||
onChange={(e) => setOrderNo(e.target.value)}
|
||||
readOnly={!!editId}
|
||||
placeholder="(자동채번)"
|
||||
className="h-8 bg-white text-xs"
|
||||
title={editId ? "기존 외주발주번호는 수정할 수 없습니다" : "기본값은 채번 규칙으로 자동 생성됩니다. 필요 시 직접 입력 가능."}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-600">발주일</Label>
|
||||
@@ -1080,7 +1172,67 @@ export function RegistrationModal({ open, onOpenChange, editId, onSaved }: Regis
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-600">담당자</Label>
|
||||
<Input value={manager} onChange={(e) => setManager(e.target.value)} className="h-8 text-xs" placeholder="담당자" />
|
||||
<Popover open={managerComboOpen} onOpenChange={setManagerComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={managerComboOpen}
|
||||
className="h-8 w-full justify-between bg-white text-xs font-normal"
|
||||
>
|
||||
{manager
|
||||
? users.find((u) => u.user_id === manager)?.user_name || manager
|
||||
: <span className="text-slate-400">담당자 선택</span>}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="이름·ID 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-3 text-center text-xs text-slate-500">사용자를 찾을 수 없어요</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{/* 선택 해제 옵션 */}
|
||||
{manager ? (
|
||||
<CommandItem
|
||||
value="__clear__"
|
||||
onSelect={() => {
|
||||
setManager("");
|
||||
setManagerComboOpen(false);
|
||||
}}
|
||||
className="text-xs text-slate-500"
|
||||
>
|
||||
<X className="mr-2 h-3 w-3" />
|
||||
선택 해제
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{users.map((u) => (
|
||||
<CommandItem
|
||||
key={u.user_id}
|
||||
value={`${u.user_name} ${u.user_id} ${u.dept_code || ""}`}
|
||||
onSelect={() => {
|
||||
setManager(u.user_id);
|
||||
setManagerComboOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-3 w-3", manager === u.user_id ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<UserCircle className="mr-2 h-3 w-3 text-slate-400" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{u.user_name}</span>
|
||||
{u.dept_code ? (
|
||||
<span className="text-[10px] text-slate-500">{u.dept_code}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -249,6 +249,21 @@ export async function getAutoProcesses(
|
||||
return res.data?.data || { source: "none", routing: [], candidates: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 외주발주번호 미리보기 — 등록된 채번 규칙 기반. 시퀀스 증가 없음.
|
||||
* 응답이 빈 문자열이면 채번 규칙 미설정 — 백엔드 폴백(OPO-YYYY-NNN) 사용됨.
|
||||
*/
|
||||
export async function previewOrderNo(): Promise<string> {
|
||||
try {
|
||||
const res = await apiClient.get<ApiEnvelope<{ generatedCode: string }>>(
|
||||
"/outsource-purchase/preview-order-no",
|
||||
);
|
||||
return res.data?.data?.generatedCode || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 자재투입(material_input) 자동 채움 — 사급자재 체크 시 호출
|
||||
* 응답: 외주발주 자재 입력 형식 배열 [{item_code, item_name, qty, unit}]
|
||||
|
||||
Reference in New Issue
Block a user