Переглянути джерело

庫存查詢 / 盤點調整權限 / 批次現況報表

fix負數倉
CANCERYS\kw093 1 тиждень тому
джерело
коміт
1ebfc7df95
3 змінених файлів з 116 додано та 290 видалено
  1. +1
    -1
      src/app/api/inventory/actions.ts
  2. +4
    -4
      src/components/InventorySearch/InventoryLotLineTable.tsx
  3. +111
    -285
      src/components/InventorySearch/InventorySearch.tsx

+ 1
- 1
src/app/api/inventory/actions.ts Переглянути файл

@@ -172,7 +172,7 @@ async function fetchInventoriesLatestImpl(data: SearchInventory) {
export const fetchInventories = cache(fetchInventoriesImpl);

/**
* FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.0 | 2026-07-17
* FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.3 | 2026-09-07
* Inventory search page: latest inventory row per item (no baseUnit/uomId filter).
*/
export const fetchInventoriesLatest = cache(fetchInventoriesLatestImpl);


+ 4
- 4
src/components/InventorySearch/InventoryLotLineTable.tsx Переглянути файл

@@ -34,7 +34,7 @@ import dayjs from "dayjs";
import CheckIcon from "@mui/icons-material/Check";
import { submitStockAdjustment, StockAdjustmentLineRequest } from "@/app/api/stockAdjustment/actions";
import { useSession } from "next-auth/react";
import { AUTH } from "@/authorities";
import { AUTH, hasAbility } from "@/authorities";

type AdjustmentEntry = InventoryLotLineResult & {
adjustedQty: number;
@@ -59,7 +59,7 @@ interface Props {
onStockAdjustmentSuccess?: () => void | Promise<void>;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.5 | 2026-08-05 */
/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.6 | 2026-09-07 */
const InventoryLotLineTable: React.FC<Props> = ({
inventoryLotLines, pagingController, setPagingController, totalCount, inventory,
filterLotNo,
@@ -68,8 +68,8 @@ const InventoryLotLineTable: React.FC<Props> = ({
}) => {
const { t } = useTranslation(["inventory"]);
const { data: session } = useSession();
const abilities = session?.user?.abilities ?? [];
const canStockAdjust = abilities.includes(AUTH.INVENTORY_ADJUST);
const abilities = session?.abilities ?? session?.user?.abilities ?? [];
const canStockAdjust = hasAbility(abilities, AUTH.INVENTORY_ADJUST);
const PRINT_PRINTER_ID_KEY = 'inventoryLotLinePrintPrinterId';
const { setIsUploading } = useUploadContext();
const [stockTransferModalOpen, setStockTransferModalOpen] = useState(false);


+ 111
- 285
src/components/InventorySearch/InventorySearch.tsx Переглянути файл

@@ -2,7 +2,7 @@
import { InventoryLotLineResult, InventoryResult } from '@/app/api/inventory';
import { useTranslation } from 'react-i18next';
import SearchBox, { Criterion } from '../SearchBox';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { uniq, uniqBy } from 'lodash';
import InventoryTable from './InventoryTable';
import { defaultPagingController } from '../SearchResults/SearchResults';
@@ -16,23 +16,10 @@ import {
fetchInventoryLotLines,
} from '@/app/api/inventory/actions';
import { PrinterCombo } from '@/app/api/settings/printer';
import { ItemCombo, fetchItemsWithDetails, ItemWithDetails } from '@/app/api/settings/item/actions';
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
Box,
CircularProgress,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Radio,
} from '@mui/material';
import { fetchItemsByPage } from '@/app/api/settings/item/actions';
import { useSession } from 'next-auth/react';
import { AUTH, hasAbility } from '@/authorities';
import { Button, Box } from '@mui/material';

interface Props {
inventories: InventoryResult[];
@@ -56,42 +43,68 @@ type SearchQuery = Partial<
>;
type SearchParamNames = keyof SearchQuery;

/** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.0 | 2026-07-17 */
type ItemLookupRow = {
id: number;
code: string;
name: string;
type?: string;
uom?: string;
uomDesc?: string;
purchaseUnit?: string;
};

const extractItemRecords = (res: unknown): ItemLookupRow[] => {
if (!res) return [];
if (Array.isArray(res)) return res as ItemLookupRow[];
if (typeof res === 'object' && Array.isArray((res as { records?: unknown }).records)) {
return (res as { records: ItemLookupRow[] }).records;
}
return [];
};

/** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.3 | 2026-09-07 */
const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
const { t } = useTranslation(['inventory', 'common', 'item']);

const buildSyntheticInventory = useCallback(
(item: ItemWithDetails): InventoryResult => ({
const { data: session } = useSession();
const abilities = session?.abilities ?? session?.user?.abilities ?? [];
const canStockAdjust = hasAbility(abilities, AUTH.INVENTORY_ADJUST);
const searchInFlightRef = useRef(false);

const buildSyntheticInventory = useCallback((item: ItemLookupRow): InventoryResult => {
const uom = item.uomDesc || item.uom || item.purchaseUnit || '';
return {
id: 0,
itemId: item.id,
itemId: Number(item.id),
itemCode: item.code,
itemName: item.name,
itemType: 'Material',
itemType: item.type || 'mat',
onHandQty: 0,
onHoldQty: 0,
unavailableQty: 0,
availableQty: 0,
uomCode: item.uom,
uomUdfudesc: item.uomDesc,
uomShortDesc: item.uom,
uomCode: item.uom || uom,
uomUdfudesc: uom,
uomShortDesc: item.uom || uom,
qtyPerSmallestUnit: 1,
baseUom: item.uom,
baseUom: uom,
price: 0,
currencyName: '',
status: 'active',
latestMarketUnitPrice: undefined,
latestMupUpdatedDate: undefined,
}),
[],
);

const getFirstItemRecord = useCallback((res: any): ItemWithDetails | null => {
if (!res) return null;
if (Array.isArray(res)) return (res[0] as ItemWithDetails) ?? null;
if (Array.isArray(res?.records)) return (res.records[0] as ItemWithDetails) ?? null;
return null;
};
}, []);

const lookupItemsByCodeOrName = useCallback(async (code?: string, name?: string) => {
const trimmedCode = code?.trim();
const trimmedName = name?.trim();
if (!trimmedCode && !trimmedName) return [];
const params: Record<string, string | number> = { pageSize: 50, pageNum: 1 };
if (trimmedCode) params.code = trimmedCode;
else params.name = trimmedName as string;
const itemRes = await fetchItemsByPage(params);
return extractItemRecords(itemRes);
}, []);

// Inventory
const [filteredInventories, setFilteredInventories] = useState<InventoryResult[]>([]);
@@ -104,6 +117,20 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
const [inventoryLotLinesPagingController, setInventoryLotLinesPagingController] = useState(defaultPagingController)
const [inventoryLotLinesTotalCount, setInventoryLotLinesTotalCount] = useState(0)

const applyItemsAsSyntheticInventories = useCallback(
(items: ItemLookupRow[]) => {
if (!items.length) return false;
const synthetics = items.map(buildSyntheticInventory);
setFilteredInventories(synthetics);
setInventoriesTotalCount(synthetics.length);
setSelectedInventory(synthetics[0]);
setFilteredInventoryLotLines([]);
setInventoryLotLinesPagingController(() => defaultPagingController);
return true;
},
[buildSyntheticInventory],
);

// Scan-mode UI (hardware QR scanner via QrCodeScannerProvider)
const qrScanner = useQrCodeScannerContext();
const [scanUiMode, setScanUiMode] = useState<'idle' | 'scanning'>('idle');
@@ -113,13 +140,6 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
const [lotNoFilter, setLotNoFilter] = useState('');
const [scannedItemId, setScannedItemId] = useState<number | null>(null);

// Opening inventory (pure opening stock for items without existing inventory)
const [openingItems, setOpeningItems] = useState<ItemCombo[]>([]);
const [openingModalOpen, setOpeningModalOpen] = useState(false);
const [openingSelectedItem, setOpeningSelectedItem] = useState<ItemCombo | null>(null);
const [openingLoading, setOpeningLoading] = useState(false);
const [openingSearchText, setOpeningSearchText] = useState('');

const defaultInputs = useMemo(
() => ({
itemId: '',
@@ -297,46 +317,55 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
// On Search
const onSearch = useCallback(
async (query: Record<SearchParamNames, string>) => {
setLotNoFilter('');
setScannedItemId(null);
setScanUiMode('idle');
setScanHoverCancel(false);
qrScanner.stopScan();
qrScanner.resetScan();
const invRes = await refetchInventoryData(query, 'search', defaultPagingController, '');
await refetchInventoryLotLineData(null, 'search', defaultPagingController);

setInputs(() => query);
setInventoriesPagingController(() => defaultPagingController);
setInventoryLotLinesPagingController(() => defaultPagingController);
if (searchInFlightRef.current) return;
searchInFlightRef.current = true;
try {
setLotNoFilter('');
setScannedItemId(null);
setScanUiMode('idle');
setScanHoverCancel(false);
qrScanner.stopScan();
qrScanner.resetScan();
const invRes = await refetchInventoryData(query, 'search', defaultPagingController, '');
await refetchInventoryLotLineData(null, 'search', defaultPagingController);

setInputs(() => query);
setInventoriesPagingController(() => defaultPagingController);
setInventoryLotLinesPagingController(() => defaultPagingController);

// If there are no inventory rows, render a synthetic inventory so the "Stock Adjustment" chip can be used.
if (invRes?.records?.length === 0) {
try {
const code = query.itemCode?.trim?.();
const name = query.itemName?.trim?.();
const lookupParams = code ? { code } : name ? { name } : null;

if (lookupParams) {
const itemRes = await fetchItemsWithDetails(lookupParams);
const firstItem = getFirstItemRecord(itemRes);
if (firstItem) {
setSelectedInventory(buildSyntheticInventory(firstItem));
setFilteredInventoryLotLines([]);
setInventoryLotLinesPagingController(() => defaultPagingController);
// No inventory rows: look up item master so 0-qty rows can be selected for stock adjustment.
if (canStockAdjust && invRes?.records?.length === 0) {
try {
const items = await lookupItemsByCodeOrName(query.itemCode, query.itemName);
const typeFilter = query.itemType?.trim();
let filtered =
typeFilter && typeFilter.toLowerCase() !== 'all'
? items.filter((it) => (it.type ?? '').toLowerCase() === typeFilter.toLowerCase())
: items;
const exactCode = query.itemCode?.trim().toLowerCase();
if (exactCode) {
filtered = [...filtered].sort((a, b) => {
const aExact = a.code?.toLowerCase() === exactCode ? 0 : 1;
const bExact = b.code?.toLowerCase() === exactCode ? 0 : 1;
return aExact - bExact;
});
}
applyItemsAsSyntheticInventories(filtered);
} catch (e) {
console.error('Failed to build synthetic inventory:', e);
}
} catch (e) {
console.error('Failed to build synthetic inventory:', e);
}
} finally {
searchInFlightRef.current = false;
}
},
[
qrScanner,
refetchInventoryData,
refetchInventoryLotLineData,
buildSyntheticInventory,
getFirstItemRecord,
lookupItemsByCodeOrName,
applyItemsAsSyntheticInventories,
canStockAdjust,
],
);

@@ -382,13 +411,11 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
onInventoryRowClick(target);
} else {
refetchInventoryLotLineData(null, 'search', defaultPagingController);
// No inventory rows for this scanned item => show synthetic inventory with the existing chip workflow.
const itemRes = await fetchItemsWithDetails({ code: res?.itemCode });
const firstItem = getFirstItemRecord(itemRes);
if (firstItem) {
setSelectedInventory(buildSyntheticInventory(firstItem));
setFilteredInventoryLotLines([]);
setInventoryLotLinesPagingController(() => defaultPagingController);
if (canStockAdjust) {
const items = await lookupItemsByCodeOrName(res?.itemCode);
if (!applyItemsAsSyntheticInventories(items)) {
setSelectedInventory(null);
}
} else {
setSelectedInventory(null);
}
@@ -410,112 +437,12 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
qrScanner.result,
refetchInventoryData,
refetchInventoryLotLineData,
buildSyntheticInventory,
getFirstItemRecord,
lookupItemsByCodeOrName,
applyItemsAsSyntheticInventories,
canStockAdjust,
scanUiMode,
]);

//console.log('', 'color: #666', inventoriesPagingController);

const handleOpenOpeningInventoryModal = useCallback(() => {
setOpeningSelectedItem(null);
setOpeningItems([]);
setOpeningSearchText('');
setOpeningModalOpen(true);
}, []);

const handleOpeningSearch = useCallback(async () => {
const trimmed = openingSearchText.trim();
if (!trimmed) {
setOpeningItems([]);
return;
}

setOpeningLoading(true);
try {
const searchParams: Record<string, any> = {
pageSize: 50,
pageNum: 1,
};

// Heuristic: if input contains space, treat as name; otherwise treat as code.
if (trimmed.includes(' ')) {
searchParams.name = trimmed;
} else {
searchParams.code = trimmed;
}

const response = await fetchItemsWithDetails(searchParams);

let records: any[] = [];
if (response && typeof response === 'object') {
const anyRes = response as any;
if (Array.isArray(anyRes.records)) {
records = anyRes.records;
} else if (Array.isArray(anyRes)) {
records = anyRes;
}
}

const combos: ItemCombo[] = records.map((item: any) => ({
id: item.id,
label: `${item.code} - ${item.name}`,
uomId: item.uomId,
uom: item.uom,
uomDesc: item.uomDesc,
group: item.group,
currentStockBalance: item.currentStockBalance,
}));

setOpeningItems(combos);
} catch (e) {
console.error('Failed to search items for opening inventory:', e);
setOpeningItems([]);
} finally {
setOpeningLoading(false);
}
}, [openingSearchText]);

const handleConfirmOpeningInventory = useCallback(() => {
if (!openingSelectedItem) {
setOpeningModalOpen(false);
return;
}

const rawLabel = openingSelectedItem.label ?? '';
const [codePart, ...nameParts] = rawLabel.split(' - ');
const itemCode = codePart?.trim() || rawLabel;
const itemName = nameParts.join(' - ').trim() || itemCode;

const syntheticInventory: InventoryResult = {
id: 0,
itemId: Number(openingSelectedItem.id),
itemCode,
itemName,
itemType: 'Material',
onHandQty: 0,
onHoldQty: 0,
unavailableQty: 0,
availableQty: 0,
uomCode: openingSelectedItem.uom,
uomUdfudesc: openingSelectedItem.uomDesc,
uomShortDesc: openingSelectedItem.uom,
qtyPerSmallestUnit: 1,
baseUom: openingSelectedItem.uom,
price: 0,
currencyName: '',
status: 'active',
latestMarketUnitPrice: undefined,
latestMupUpdatedDate: undefined,
};

// Use this synthetic inventory to drive the stock adjustment UI
setSelectedInventory(syntheticInventory);
setFilteredInventoryLotLines([]);
setInventoryLotLinesPagingController(() => defaultPagingController);
setOpeningModalOpen(false);
}, [openingSelectedItem]);

return (
<>
<SearchBox
@@ -540,15 +467,6 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
</Button>
</>
)}

<Button
variant="outlined"
color="secondary"
onClick={handleOpenOpeningInventoryModal}
sx={{ display: 'none' }}
>
{t('Add entry for items without inventory')}
</Button>
</Box>
}
/>
@@ -600,98 +518,6 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
}
}}
/>

<Dialog
open={openingModalOpen}
onClose={() => setOpeningModalOpen(false)}
fullWidth
maxWidth="md"
>
<DialogTitle>{t('Add entry for items without inventory')}</DialogTitle>
<DialogContent sx={{ pt: 2 }}>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<TextField
label={t('Item')}
fullWidth
value={openingSearchText}
onChange={(e) => setOpeningSearchText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleOpeningSearch();
}
}}
sx={{ flex: 2 }}
/>
<Button
variant="contained"
onClick={handleOpeningSearch}
disabled={openingLoading}
sx={{ flex: 1 }}
>
{openingLoading ? <CircularProgress size={20} /> : t('common:Search')}
</Button>
</Box>

{openingItems.length === 0 && !openingLoading ? (
<Box sx={{ py: 1, color: 'text.secondary', fontSize: 14 }}>
{openingSearchText
? t('No data')
: t('Enter item code or name to search')}
</Box>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell />
<TableCell>{t('Code')}</TableCell>
<TableCell>{t('Name')}</TableCell>
<TableCell>{t('UoM')}</TableCell>
<TableCell align="right">{t('Current Stock')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{openingItems.map((it) => {
const [code, ...nameParts] = (it.label ?? '').split(' - ');
const name = nameParts.join(' - ');
const selected = openingSelectedItem?.id === it.id;
return (
<TableRow
key={it.id}
hover
selected={selected}
onClick={() => setOpeningSelectedItem(it)}
sx={{ cursor: 'pointer' }}
>
<TableCell padding="checkbox">
<Radio checked={selected} />
</TableCell>
<TableCell>{code}</TableCell>
<TableCell>{name}</TableCell>
<TableCell>{it.uomDesc || it.uom}</TableCell>
<TableCell align="right">
{it.currentStockBalance != null ? it.currentStockBalance : '-'}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setOpeningModalOpen(false)}>
{t('common:Cancel')}
</Button>
<Button
variant="contained"
onClick={handleConfirmOpeningInventory}
disabled={!openingSelectedItem}
>
{t('common:Confirm')}
</Button>
</DialogActions>
</Dialog>
</>
);
};


Завантаження…
Відмінити
Зберегти