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:
kjs
2026-05-06 18:33:23 +09:00
parent 970a8f708a
commit fa0a28df42
5 changed files with 251 additions and 8 deletions

View File

@@ -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) 자동 채움
// — 사급자재 체크 시 해당 공정의 자재 목록을 외주발주 자재 형식으로 반환

View File

@@ -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);

View File

@@ -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(

View File

@@ -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>

View File

@@ -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}]