fix: [자재투입] 재고 검색 필드 매핑 수정 + 재공품 원자재 필터
- searchStockByCode: API 응답 필드 매핑 수정 (Item 모델 code/name → itemCode/itemName) - 재공품(WIP) 자재 투입 시 원자재(RM)만 검색되도록 item_type 필터 추가 - handleStockSearch query null 안전 처리 - 재고생산 품목코드 동적 반영 (expectedItemCode 상태 추가) - 재고생산 목록 검색에 품목코드 포함
This commit is contained in:
@@ -89,6 +89,12 @@ export function MaterialInputModal({
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const materialsLoadedRef = useRef(false);
|
||||
|
||||
// 재공품 여부 판별 — 재공품이면 검색 시 원자재(RM)만 조회
|
||||
const isWipOrder = useMemo(() => {
|
||||
if (!order) return false;
|
||||
return order.projectName === '재고생산' || order.salesOrderNo?.startsWith('STK');
|
||||
}, [order]);
|
||||
|
||||
// 재고 검색 상태
|
||||
const [searchOpenGroup, setSearchOpenGroup] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -98,12 +104,14 @@ export function MaterialInputModal({
|
||||
// 품목 검색 결과 (재고 없는 품목 포함)
|
||||
const [itemSearchResults, setItemSearchResults] = useState<ItemSearchResult[]>([]);
|
||||
|
||||
const handleStockSearch = useCallback(async (query: string) => {
|
||||
if (!query.trim()) { setSearchResults([]); setItemSearchResults([]); return; }
|
||||
const handleStockSearch = useCallback(async (query: string | undefined) => {
|
||||
if (!query?.trim()) { setSearchResults([]); setItemSearchResults([]); return; }
|
||||
setIsSearching(true);
|
||||
// 재공품: 원자재(RM)만 검색, 일반: 전체 검색
|
||||
const typeFilter = isWipOrder ? 'RM' : undefined;
|
||||
const [stockResult, itemResult] = await Promise.all([
|
||||
searchStockByCode(query.trim()),
|
||||
searchItems(query.trim()),
|
||||
searchStockByCode(query.trim(), typeFilter),
|
||||
searchItems(query.trim(), typeFilter),
|
||||
]);
|
||||
if (stockResult.success) setSearchResults(stockResult.data);
|
||||
if (itemResult.success) {
|
||||
@@ -112,7 +120,7 @@ export function MaterialInputModal({
|
||||
setItemSearchResults(itemResult.data.filter(i => !stockItemIds.has(i.itemId)));
|
||||
}
|
||||
setIsSearching(false);
|
||||
}, []);
|
||||
}, [isWipOrder]);
|
||||
|
||||
const [isForceCreating, setIsForceCreating] = useState(false);
|
||||
|
||||
@@ -570,7 +578,7 @@ export function MaterialInputModal({
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleStockSearch(searchQuery)}
|
||||
placeholder="품목코드 또는 자재명으로 재고 검색"
|
||||
placeholder={isWipOrder ? "원자재 코드 또는 자재명으로 검색" : "품목코드 또는 자재명으로 재고 검색"}
|
||||
className="flex-1 text-xs px-2 py-1.5 border rounded bg-white"
|
||||
/>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => handleStockSearch(searchQuery)}>
|
||||
@@ -904,7 +912,7 @@ export function MaterialInputModal({
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleStockSearch(searchQuery)}
|
||||
placeholder="품목코드 또는 자재명 검색"
|
||||
placeholder={isWipOrder ? "원자재 코드 또는 자재명 검색" : "품목코드 또는 자재명 검색"}
|
||||
className="flex-1 text-xs px-2 py-1.5 border rounded bg-white"
|
||||
/>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => handleStockSearch(searchQuery)}>
|
||||
|
||||
@@ -904,40 +904,64 @@ export async function forceCreateReceiving(
|
||||
}
|
||||
|
||||
export async function searchStockByCode(
|
||||
search: string
|
||||
search: string,
|
||||
itemType?: string
|
||||
): Promise<{ success: boolean; data: StockSearchResult[]; error?: string }> {
|
||||
const result = await executeServerAction<{
|
||||
data: Array<{
|
||||
// Item 모델 필드 (stocks API는 Item 기준 조회)
|
||||
id: number;
|
||||
item_id: number;
|
||||
item_code: string;
|
||||
item_name: string;
|
||||
stock_qty: number;
|
||||
available_qty: number;
|
||||
lot_count: number;
|
||||
code: string;
|
||||
name: string;
|
||||
item_type: string;
|
||||
stock?: {
|
||||
id: number;
|
||||
stock_qty: number;
|
||||
available_qty: number;
|
||||
lot_count?: number;
|
||||
lots?: Array<{ lot_no: string; available_qty: number; fifo_order: number }>;
|
||||
};
|
||||
// 레거시 호환 (직접 필드)
|
||||
item_id?: number;
|
||||
item_code?: string;
|
||||
item_name?: string;
|
||||
stock_qty?: number;
|
||||
available_qty?: number;
|
||||
lot_count?: number;
|
||||
lots?: Array<{ lot_no: string; available_qty: number; fifo_order: number }>;
|
||||
}>;
|
||||
}>({
|
||||
url: buildApiUrl('/api/v1/stocks', { search, per_page: 10, with_lots: 'true' }),
|
||||
url: buildApiUrl('/api/v1/stocks', { search, per_page: 10, with_lots: 'true', item_type: itemType }),
|
||||
errorMessage: '재고 검색에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, data: [], error: result.error };
|
||||
const items = Array.isArray(result.data) ? result.data : (result.data.data || []);
|
||||
return {
|
||||
success: true,
|
||||
data: items.map((s) => ({
|
||||
itemId: s.item_id,
|
||||
itemCode: s.item_code,
|
||||
itemName: s.item_name,
|
||||
stockQty: s.stock_qty || 0,
|
||||
availableQty: s.available_qty || 0,
|
||||
lotCount: s.lot_count || 0,
|
||||
lots: (s.lots || []).map((l: { lot_no: string; available_qty: number; fifo_order: number }) => ({
|
||||
lotNo: l.lot_no,
|
||||
availableQty: l.available_qty,
|
||||
fifoOrder: l.fifo_order,
|
||||
})),
|
||||
})),
|
||||
data: items.map((s) => {
|
||||
// Item 모델 구조: { id, code, name, stock: { stock_qty, ... } }
|
||||
const stock = s.stock;
|
||||
const itemId = s.item_id ?? s.id;
|
||||
const itemCode = s.item_code ?? s.code;
|
||||
const itemName = s.item_name ?? s.name;
|
||||
const stockQty = s.stock_qty ?? stock?.stock_qty ?? 0;
|
||||
const availableQty = s.available_qty ?? stock?.available_qty ?? 0;
|
||||
const lotCount = s.lot_count ?? stock?.lot_count ?? 0;
|
||||
const lots = s.lots ?? stock?.lots ?? [];
|
||||
return {
|
||||
itemId,
|
||||
itemCode,
|
||||
itemName,
|
||||
stockQty,
|
||||
availableQty,
|
||||
lotCount,
|
||||
lots: lots.map((l: { lot_no: string; available_qty: number; fifo_order: number }) => ({
|
||||
lotNo: l.lot_no,
|
||||
availableQty: l.available_qty,
|
||||
fifoOrder: l.fifo_order,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -951,12 +975,13 @@ export interface ItemSearchResult {
|
||||
}
|
||||
|
||||
export async function searchItems(
|
||||
search: string
|
||||
search: string,
|
||||
itemType?: string
|
||||
): Promise<{ success: boolean; data: ItemSearchResult[]; error?: string }> {
|
||||
const result = await executeServerAction<{
|
||||
data: Array<{ id: number; code: string; name: string; item_type: string; unit: string }>;
|
||||
}>({
|
||||
url: buildApiUrl('/api/v1/items', { search, per_page: 10 }),
|
||||
url: buildApiUrl('/api/v1/items', { search, per_page: 10, item_type: itemType }),
|
||||
errorMessage: '품목 검색에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, data: [], error: result.error };
|
||||
|
||||
@@ -261,6 +261,9 @@ export function BendingLotForm({ initialData, isEditMode = false }: BendingLotFo
|
||||
const [codeMap, setCodeMap] = useState<BendingCodeMap | null>(null);
|
||||
const [resolvedItem, setResolvedItem] = useState<BendingResolvedItem | null>(null);
|
||||
const [resolveError, setResolveError] = useState<string>('');
|
||||
const [expectedItemCode, setExpectedItemCode] = useState<string>(
|
||||
initialData?.items?.[0]?.itemCode || ''
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [rawLotModalOpen, setRawLotModalOpen] = useState(false);
|
||||
@@ -290,6 +293,11 @@ export function BendingLotForm({ initialData, isEditMode = false }: BendingLotFo
|
||||
const itemResult = await resolveBendingItem(bl.prodCode, bl.specCode, bl.lengthCode);
|
||||
if (itemResult.success && itemResult.data) {
|
||||
setResolvedItem(itemResult.data);
|
||||
if (itemResult.data.item_code) {
|
||||
setExpectedItemCode(itemResult.data.item_code);
|
||||
}
|
||||
} else if (itemResult.data?.expected_code) {
|
||||
setExpectedItemCode(itemResult.data.expected_code);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,6 +351,7 @@ export function BendingLotForm({ initialData, isEditMode = false }: BendingLotFo
|
||||
}));
|
||||
setResolvedItem(null);
|
||||
setResolveError('');
|
||||
setExpectedItemCode('');
|
||||
}, []);
|
||||
|
||||
// 종류 변경
|
||||
@@ -354,6 +363,7 @@ export function BendingLotForm({ initialData, isEditMode = false }: BendingLotFo
|
||||
}));
|
||||
setResolvedItem(null);
|
||||
setResolveError('');
|
||||
setExpectedItemCode('');
|
||||
}, []);
|
||||
|
||||
// 모양&길이 변경 → 품목 매핑 조회
|
||||
@@ -362,6 +372,7 @@ export function BendingLotForm({ initialData, isEditMode = false }: BendingLotFo
|
||||
setForm((prev) => ({ ...prev, lengthCode: value }));
|
||||
setResolvedItem(null);
|
||||
setResolveError('');
|
||||
setExpectedItemCode('');
|
||||
|
||||
if (form.prodCode && form.specCode && value) {
|
||||
const result = await resolveBendingItem(form.prodCode, form.specCode, value);
|
||||
@@ -371,8 +382,12 @@ export function BendingLotForm({ initialData, isEditMode = false }: BendingLotFo
|
||||
}
|
||||
if (result.success && result.data) {
|
||||
setResolvedItem(result.data);
|
||||
if (result.data.item_code) {
|
||||
setExpectedItemCode(result.data.item_code);
|
||||
}
|
||||
} else {
|
||||
const code = result.data?.expected_code;
|
||||
if (code) setExpectedItemCode(code);
|
||||
setResolveError(`해당 조합에 매핑된 품목이 없습니다.${code ? ` (${code})` : ''}`);
|
||||
}
|
||||
}
|
||||
@@ -439,7 +454,7 @@ export function BendingLotForm({ initialData, isEditMode = false }: BendingLotFo
|
||||
},
|
||||
item: {
|
||||
itemId: resolvedItem?.item_id,
|
||||
itemCode: resolvedItem?.item_code,
|
||||
itemCode: resolvedItem?.item_code || expectedItemCode || undefined,
|
||||
itemName,
|
||||
specification: resolvedItem?.specification || material,
|
||||
quantity: form.quantity,
|
||||
@@ -469,7 +484,7 @@ export function BendingLotForm({ initialData, isEditMode = false }: BendingLotFo
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [form, resolvedItem, codeMap, material, isSmokeBarrier, router, basePath]);
|
||||
}, [form, resolvedItem, expectedItemCode, codeMap, material, isSmokeBarrier, router, basePath]);
|
||||
|
||||
// 취소
|
||||
const handleCancel = useCallback(() => {
|
||||
@@ -497,9 +512,10 @@ export function BendingLotForm({ initialData, isEditMode = false }: BendingLotFo
|
||||
<div className="space-y-2">
|
||||
<Label>품목코드</Label>
|
||||
<Input
|
||||
value={resolvedItem?.item_code || initialData?.items?.[0]?.itemCode || '품목 선택 시 자동'}
|
||||
value={resolvedItem?.item_code || expectedItemCode || initialData?.items?.[0]?.itemCode || ''}
|
||||
disabled
|
||||
className={resolvedItem?.item_code || initialData?.items?.[0]?.itemCode ? 'font-mono font-semibold' : ''}
|
||||
placeholder="품목 선택 시 자동 지정"
|
||||
className={resolvedItem?.item_code || expectedItemCode || initialData?.items?.[0]?.itemCode ? 'font-mono font-semibold' : ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -746,6 +762,7 @@ export function BendingLotForm({ initialData, isEditMode = false }: BendingLotFo
|
||||
material,
|
||||
lotPreview,
|
||||
resolvedItem,
|
||||
expectedItemCode,
|
||||
resolveError,
|
||||
isSmokeBarrier,
|
||||
isEditMode,
|
||||
|
||||
@@ -127,6 +127,7 @@ export function StockProductionList() {
|
||||
!searchTerm ||
|
||||
order.orderNo.toLowerCase().includes(searchLower) ||
|
||||
order.itemSummary.toLowerCase().includes(searchLower) ||
|
||||
(order.items?.[0]?.itemCode || '').toLowerCase().includes(searchLower) ||
|
||||
(order.bendingLot?.lotNumber || '').toLowerCase().includes(searchLower);
|
||||
|
||||
const statusFilter = filterValues.status as string;
|
||||
|
||||
Reference in New Issue
Block a user