feat(supply-chain): 新增商品库存管理(库存/云仓/筛选/导出/快捷查询)
- 页面:成品库存 × 实际仓库,按条码匹配产品档案与仓库配置 - 服务:聚水潭成品库存全量读取与缓存、筛选/排序/分页、小计、Excel 导出 - 云仓:链接服务器 OPENQUERY 汇总 order_able_qty,支持云仓明细下钻 - 接入:菜单、标签页、权限节点 productInventory
This commit is contained in:
parent
d8b5006e44
commit
1b849b0209
@ -0,0 +1,45 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { productInventoryService } from '@/server/services/product-inventory.service'
|
||||
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
|
||||
|
||||
// 运营云仓库存明细:按条码 + 实际仓库编号下钻。
|
||||
|
||||
const querySchema = z.object({
|
||||
barcode: z.string().trim().min(1).max(80),
|
||||
wmsCoId: z.coerce.number().int().positive(),
|
||||
})
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const parsed = querySchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams.entries()),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '条码或仓库编号不正确', 400, ctx.requestId)
|
||||
}
|
||||
const access = await supplyChainPermissionSettingsService.getUserAccess(
|
||||
ctx.user,
|
||||
'productInventory',
|
||||
)
|
||||
if (!access.view) {
|
||||
return jsonError(
|
||||
ErrorCode.AUTH_PERMISSION_DENIED,
|
||||
'当前账号没有商品库存管理的查看权限',
|
||||
403,
|
||||
ctx.requestId,
|
||||
)
|
||||
}
|
||||
return jsonSuccess(
|
||||
await productInventoryService.getCloudStockDetail(parsed.data),
|
||||
200,
|
||||
ctx.requestId,
|
||||
)
|
||||
},
|
||||
{ requireAuth: true },
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,96 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError } from '@/server/core/response'
|
||||
import {
|
||||
PRODUCT_INVENTORY_COLUMNS,
|
||||
isProductInventoryColumnKey,
|
||||
sanitizeProductInventoryFilters,
|
||||
sanitizeProductInventorySort,
|
||||
type ProductInventoryColumnKey,
|
||||
} from '@/lib/product-inventory'
|
||||
import { productInventoryService } from '@/server/services/product-inventory.service'
|
||||
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
|
||||
|
||||
// 商品库存导出:支持勾选字段,按当前筛选/排序导出全部匹配行。
|
||||
|
||||
const exportQuerySchema = z.object({
|
||||
columns: z.string().max(4000).optional(),
|
||||
filters: z.string().max(50_000).optional(),
|
||||
sortKey: z.string().trim().max(64).optional(),
|
||||
sortDirection: z.enum(['asc', 'desc']).optional(),
|
||||
})
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const parsed = exportQuerySchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams.entries()),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '导出条件不正确', 400, ctx.requestId)
|
||||
}
|
||||
|
||||
const access = await supplyChainPermissionSettingsService.getUserAccess(
|
||||
ctx.user,
|
||||
'productInventory',
|
||||
)
|
||||
if (!access.download) {
|
||||
return jsonError(
|
||||
ErrorCode.AUTH_PERMISSION_DENIED,
|
||||
'当前账号没有商品库存管理的导出权限',
|
||||
403,
|
||||
ctx.requestId,
|
||||
)
|
||||
}
|
||||
|
||||
let filters = {}
|
||||
if (parsed.data.filters) {
|
||||
try {
|
||||
filters = sanitizeProductInventoryFilters(JSON.parse(parsed.data.filters))
|
||||
} catch {
|
||||
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件不正确', 400, ctx.requestId)
|
||||
}
|
||||
}
|
||||
|
||||
const requestedColumns = (parsed.data.columns ?? '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
const columns = (requestedColumns.length
|
||||
? requestedColumns
|
||||
: PRODUCT_INVENTORY_COLUMNS.map((column) => column.key)
|
||||
).filter(isProductInventoryColumnKey) as ProductInventoryColumnKey[]
|
||||
if (!columns.length) {
|
||||
return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请至少选择一个导出字段', 400, ctx.requestId)
|
||||
}
|
||||
|
||||
const sort = parsed.data.sortKey
|
||||
? sanitizeProductInventorySort({
|
||||
key: parsed.data.sortKey,
|
||||
direction: parsed.data.sortDirection ?? 'asc',
|
||||
})
|
||||
: null
|
||||
if (parsed.data.sortKey && !sort) {
|
||||
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '排序条件不正确', 400, ctx.requestId)
|
||||
}
|
||||
|
||||
const payload = await productInventoryService.createExportWorkbook({
|
||||
columns,
|
||||
filters,
|
||||
sort,
|
||||
})
|
||||
return new NextResponse(new Uint8Array(payload.content), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(payload.fileName)}`,
|
||||
'Cache-Control': 'private, no-store, max-age=0',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
})
|
||||
},
|
||||
{ requireAuth: true },
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import {
|
||||
PRODUCT_INVENTORY_MAX_PAGE_SIZE,
|
||||
sanitizeProductInventoryFilters,
|
||||
sanitizeProductInventorySort,
|
||||
} from '@/lib/product-inventory'
|
||||
import { productInventoryService } from '@/server/services/product-inventory.service'
|
||||
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
|
||||
|
||||
// 商品库存管理列表:字段筛选(文本模糊/点选、数值大小)、排序、分页均在服务端完成。
|
||||
|
||||
const listQuerySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(PRODUCT_INVENTORY_MAX_PAGE_SIZE).default(50),
|
||||
filters: z.string().max(50_000).optional(),
|
||||
sortKey: z.string().trim().max(64).optional(),
|
||||
sortDirection: z.enum(['asc', 'desc']).optional(),
|
||||
refresh: z.enum(['1']).optional(),
|
||||
})
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const parsed = listQuerySchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams.entries()),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return jsonError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
'筛选条件不正确',
|
||||
400,
|
||||
ctx.requestId,
|
||||
)
|
||||
}
|
||||
|
||||
const access = await supplyChainPermissionSettingsService.getUserAccess(
|
||||
ctx.user,
|
||||
'productInventory',
|
||||
)
|
||||
if (!access.view) {
|
||||
return jsonError(
|
||||
ErrorCode.AUTH_PERMISSION_DENIED,
|
||||
'当前账号没有商品库存管理的查看权限',
|
||||
403,
|
||||
ctx.requestId,
|
||||
)
|
||||
}
|
||||
|
||||
let filters = {}
|
||||
if (parsed.data.filters) {
|
||||
try {
|
||||
filters = sanitizeProductInventoryFilters(JSON.parse(parsed.data.filters))
|
||||
} catch {
|
||||
return jsonError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
'筛选条件不正确',
|
||||
400,
|
||||
ctx.requestId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const sort = parsed.data.sortKey
|
||||
? sanitizeProductInventorySort({
|
||||
key: parsed.data.sortKey,
|
||||
direction: parsed.data.sortDirection ?? 'asc',
|
||||
})
|
||||
: null
|
||||
if (parsed.data.sortKey && !sort) {
|
||||
return jsonError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
'排序条件不正确',
|
||||
400,
|
||||
ctx.requestId,
|
||||
)
|
||||
}
|
||||
|
||||
const page = await productInventoryService.getPage({
|
||||
page: parsed.data.page,
|
||||
pageSize: parsed.data.pageSize,
|
||||
filters,
|
||||
sort,
|
||||
refresh: parsed.data.refresh === '1',
|
||||
})
|
||||
return jsonSuccess(page, 200, ctx.requestId)
|
||||
},
|
||||
{ requireAuth: true },
|
||||
)
|
||||
}
|
||||
@ -98,6 +98,7 @@ export const SUPPLY_CHAIN_ROUTE_TITLES: Record<string, string> = {
|
||||
'/supply-chain/database/product-archive': '产品全量档案',
|
||||
'/supply-chain/database/category-config': '类目配置',
|
||||
'/supply-chain/database/warehouse-config': '仓库管理',
|
||||
'/supply-chain/database/product-inventory': '商品库存管理',
|
||||
'/supply-chain/database/store-account-management': '店铺账号管理',
|
||||
'/supply-chain/new-products/unified-table': '新品开发进度总表',
|
||||
'/supply-chain/new-products/1688-dropship-data': '1688代发数据明细',
|
||||
|
||||
@ -59,6 +59,7 @@ describe('supply chain menu', () => {
|
||||
'新品开发进度总表',
|
||||
'类目配置',
|
||||
'仓库管理',
|
||||
'商品库存管理',
|
||||
'店铺账号管理',
|
||||
])
|
||||
})
|
||||
|
||||
@ -51,6 +51,7 @@ export const SUPPLY_CHAIN_MENU: SupplyChainMenuModule[] = [
|
||||
{ label: '新品开发进度总表', route: '/supply-chain/new-products/unified-table', targetKey: 'newProductUnifiedTable' },
|
||||
{ label: '类目配置', route: '/supply-chain/database/category-config', targetKey: 'categoryConfig' },
|
||||
{ label: '仓库管理', route: '/supply-chain/database/warehouse-config', targetKey: 'warehouseConfig' },
|
||||
{ label: '商品库存管理', route: '/supply-chain/database/product-inventory', targetKey: 'productInventory' },
|
||||
{ label: '店铺账号管理', route: '/supply-chain/database/store-account-management', targetKey: 'storeAccountManagement' },
|
||||
],
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,17 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
|
||||
import { ProductInventoryView } from './ProductInventoryView'
|
||||
|
||||
export const metadata = { title: '商品库存管理 · 供应链中心' }
|
||||
|
||||
export default async function ProductInventoryPage() {
|
||||
const { authUser } = await requireSession('/login')
|
||||
const access = await supplyChainPermissionSettingsService.getUserAccess(
|
||||
authUser,
|
||||
'productInventory',
|
||||
)
|
||||
if (!access.view) redirect('/supply-chain')
|
||||
|
||||
return <ProductInventoryView canExport={access.download} />
|
||||
}
|
||||
@ -0,0 +1,984 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
background: var(--sc-bg);
|
||||
color: var(--sc-ink);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
min-height: 52px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
border-bottom: 1px solid var(--sc-border);
|
||||
background: var(--sc-surface);
|
||||
padding: 10px 24px;
|
||||
}
|
||||
|
||||
.headerText p {
|
||||
margin: 0;
|
||||
color: var(--sc-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.headerText strong {
|
||||
color: var(--sc-ink);
|
||||
}
|
||||
|
||||
.builtAt {
|
||||
margin-left: 10px;
|
||||
color: var(--sc-subtle);
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filterBadge {
|
||||
color: var(--sc-muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quickBar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--sc-border);
|
||||
background: var(--sc-surface);
|
||||
padding: 8px 24px;
|
||||
}
|
||||
|
||||
.quickEmpty {
|
||||
color: var(--sc-subtle);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.quickInput {
|
||||
height: 30px;
|
||||
width: 180px;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--sc-border);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 0 10px;
|
||||
color: var(--sc-ink);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.quickInput:focus {
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.quickRange {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.quickSelectWrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.quickSelect {
|
||||
display: inline-flex;
|
||||
height: 30px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--sc-border);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 0 10px;
|
||||
color: var(--sc-ink);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quickSelect:hover {
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.quickSelectActive {
|
||||
border-color: #bfdbfe;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.quickRangeLabel {
|
||||
color: var(--sc-muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quickNumber {
|
||||
height: 30px;
|
||||
width: 72px;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--sc-border);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 0 8px;
|
||||
color: var(--sc-ink);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.quickNumber:focus {
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.primarySmall {
|
||||
display: inline-flex;
|
||||
height: 30px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid #2563eb;
|
||||
border-radius: 6px;
|
||||
background: #2563eb;
|
||||
padding: 0 14px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primarySmall:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.savedQuerySelect {
|
||||
height: 30px;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--sc-border);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 0 8px;
|
||||
color: var(--sc-ink);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.savedQueryBar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-bottom: 1px solid var(--sc-border);
|
||||
background: var(--sc-surface);
|
||||
padding: 6px 24px;
|
||||
}
|
||||
|
||||
.builtinQueryButton {
|
||||
display: inline-flex;
|
||||
height: 30px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
align-items: center;
|
||||
border: 1px solid var(--sc-border);
|
||||
border-radius: 6px;
|
||||
background: var(--sc-surface-muted);
|
||||
padding: 0 12px;
|
||||
color: var(--sc-ink);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.builtinQueryButton:hover {
|
||||
border-color: #bfdbfe;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.savedQueryBarLabel {
|
||||
color: var(--sc-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.savedQueryChip {
|
||||
display: inline-flex;
|
||||
height: 24px;
|
||||
min-height: 0;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
border: 1px solid var(--sc-border);
|
||||
border-radius: 12px;
|
||||
background: var(--sc-surface-muted);
|
||||
padding: 0 4px 0 10px;
|
||||
}
|
||||
|
||||
.savedQueryChipActive {
|
||||
border-color: #bfdbfe;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.savedQueryChipName {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: var(--sc-ink);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.savedQueryChipName:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.savedQueryChipRemove {
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: var(--sc-subtle);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.savedQueryChipRemove:hover {
|
||||
background: rgba(39, 39, 42, 0.08);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
display: inline-flex;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--sc-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.iconButton:hover:not(:disabled) {
|
||||
background: rgba(39, 39, 42, 0.08);
|
||||
color: var(--sc-ink);
|
||||
}
|
||||
|
||||
.iconButton:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.pageSizeSelect {
|
||||
height: 30px;
|
||||
border: 1px solid var(--sc-border);
|
||||
border-radius: 6px;
|
||||
background: var(--sc-surface);
|
||||
padding: 0 8px;
|
||||
color: var(--sc-ink);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ghostButton {
|
||||
display: inline-flex;
|
||||
height: 30px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--sc-border);
|
||||
border-radius: 6px;
|
||||
background: var(--sc-surface);
|
||||
padding: 0 12px;
|
||||
color: var(--sc-ink);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ghostButton:hover:not(:disabled) {
|
||||
border-color: #bfdbfe;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.ghostButton:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.refreshButton {
|
||||
display: inline-flex;
|
||||
height: 30px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 6px;
|
||||
background: #27272a;
|
||||
padding: 0 12px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.refreshButton:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: productInventorySpin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes productInventorySpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.sourceWarnings {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 14px;
|
||||
border-bottom: 1px solid #fde68a;
|
||||
background: #fffbeb;
|
||||
padding: 6px 24px;
|
||||
color: #92400e;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.errorBanner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
padding: 8px 24px;
|
||||
color: #b91c1c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.errorBanner button {
|
||||
height: 26px;
|
||||
border: 1px solid #f87171;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
padding: 0 10px;
|
||||
color: #b91c1c;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tableWrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: var(--sc-surface);
|
||||
}
|
||||
|
||||
.table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
height: 36px;
|
||||
border-right: 1px solid var(--sc-border);
|
||||
border-bottom: 1px solid var(--sc-border);
|
||||
background: var(--sc-surface-muted);
|
||||
padding: 0 8px;
|
||||
color: var(--sc-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.thContent {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sortButton {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sortButton span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sortIdleIcon {
|
||||
color: #c4c4c8;
|
||||
}
|
||||
|
||||
.thFilterWrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.filterTrigger {
|
||||
display: inline-flex;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #a1a1aa;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filterTrigger:hover {
|
||||
background: rgba(39, 39, 42, 0.08);
|
||||
color: #27272a;
|
||||
}
|
||||
|
||||
.filterTriggerActive {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.filterPopover {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
width: 260px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border: 1px solid #d4d4d8;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 10px;
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
|
||||
text-align: left;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.filterPopoverRight {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.filterInput {
|
||||
height: 28px;
|
||||
border: 1px solid #d4d4d8;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
padding: 0 8px;
|
||||
color: #27272a;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.filterInput:focus {
|
||||
border-color: #52525b;
|
||||
}
|
||||
|
||||
.filterValues {
|
||||
display: flex;
|
||||
max-height: 220px;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.filterValueItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
color: #27272a;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filterValueItem:hover {
|
||||
background: #f4f4f5;
|
||||
}
|
||||
|
||||
/* 与 12px 文字同高的小方框勾选,避免原生复选框视觉过重。 */
|
||||
.filterValueItem input[type='checkbox'] {
|
||||
position: relative;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
/* 覆盖全局窄屏 44px 最小触摸目标:密集表格里按文字大小显示。 */
|
||||
min-width: 12px;
|
||||
min-height: 12px;
|
||||
margin: 0;
|
||||
flex: 0 0 auto;
|
||||
appearance: none;
|
||||
border: 1px solid #b4b4bb;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filterValueItem input[type='checkbox']:hover {
|
||||
border-color: #71717a;
|
||||
}
|
||||
|
||||
.filterValueItem input[type='checkbox']:checked {
|
||||
border-color: #27272a;
|
||||
background: #27272a;
|
||||
}
|
||||
|
||||
.filterValueItem input[type='checkbox']:checked::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 1px;
|
||||
width: 3.5px;
|
||||
height: 6.5px;
|
||||
border: solid #fff;
|
||||
border-width: 0 1.3px 1.3px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.filterValueItem span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filterValueBlank {
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
.filterEmpty {
|
||||
padding: 4px;
|
||||
color: #a1a1aa;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.filterNumbers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filterNumbers label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #52525b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.filterNumbers label span {
|
||||
flex: 0 0 28px;
|
||||
}
|
||||
|
||||
.filterNumbers label .filterInput {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.filterActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 7px;
|
||||
border-top: 1px solid #e4e4e7;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.filterActions button {
|
||||
height: 26px;
|
||||
min-width: 0;
|
||||
min-height: 26px;
|
||||
border-radius: 4px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 本页是密集数据表格:窄窗口下保持紧凑,覆盖移动端 44px 最小触摸目标规则。 */
|
||||
.sortButton,
|
||||
.filterTrigger,
|
||||
.ghostButton,
|
||||
.refreshButton,
|
||||
.pageSizeSelect {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.filterClear {
|
||||
border: 1px solid #d4d4d8;
|
||||
background: #fff;
|
||||
color: #52525b;
|
||||
}
|
||||
|
||||
.filterApply {
|
||||
border: 1px solid #27272a;
|
||||
background: #27272a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.columnResizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
width: 7px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.columnResizer:hover,
|
||||
.columnResizer:focus-visible {
|
||||
background: rgba(37, 99, 235, 0.25);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.table tbody td {
|
||||
height: 34px;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--sc-border);
|
||||
border-bottom: 1px solid var(--sc-border);
|
||||
padding: 0 8px;
|
||||
color: var(--sc-ink);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table tbody tr:hover td {
|
||||
background: var(--sc-hover);
|
||||
}
|
||||
|
||||
.numberCell {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 锁定表头行:纵向滚动时表头保持在顶部。 */
|
||||
.table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* 锁定条码列:横向滚动时固定在左侧,方便对照商品与库存。 */
|
||||
.table thead th.frozenHeaderCell {
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
box-shadow: inset -1px 0 0 var(--sc-border);
|
||||
}
|
||||
|
||||
.table tbody td.frozenCell {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
background: var(--sc-surface);
|
||||
box-shadow: inset -1px 0 0 var(--sc-border);
|
||||
}
|
||||
|
||||
.table tbody tr:hover td.frozenCell {
|
||||
background: var(--sc-hover);
|
||||
}
|
||||
|
||||
/* 底部小计行:吸底显示,随筛选条件变化。 */
|
||||
.table tfoot td {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
height: 34px;
|
||||
border-top: 1px solid var(--sc-border);
|
||||
border-right: 1px solid var(--sc-border);
|
||||
background: var(--sc-surface-muted);
|
||||
padding: 0 8px;
|
||||
color: var(--sc-ink);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table tfoot td.frozenCell {
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.subtotalLabel {
|
||||
color: var(--sc-ink);
|
||||
}
|
||||
|
||||
.subtotalLabel span {
|
||||
margin-left: 4px;
|
||||
color: var(--sc-subtle);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
height: 200px;
|
||||
color: var(--sc-muted);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loadingOverlay {
|
||||
position: absolute;
|
||||
top: 46px;
|
||||
right: 16px;
|
||||
z-index: 3;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--sc-border);
|
||||
border-radius: 6px;
|
||||
background: var(--sc-surface);
|
||||
padding: 6px 10px;
|
||||
color: var(--sc-muted);
|
||||
font-size: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
height: 44px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid var(--sc-border);
|
||||
background: var(--sc-surface);
|
||||
padding: 0 24px;
|
||||
color: var(--sc-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 设置 / 导出 / 快捷查询弹窗 */
|
||||
.modalMask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(15, 23, 42, 0.28);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: flex;
|
||||
width: min(760px, 100%);
|
||||
max-height: min(80vh, 720px);
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--sc-border);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.25);
|
||||
}
|
||||
|
||||
.modalSmall {
|
||||
width: min(440px, 100%);
|
||||
}
|
||||
|
||||
.modalMedium {
|
||||
width: min(560px, 100%);
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #e4e4e7;
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.modalBody {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow: auto;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.modalFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
border-top: 1px solid #e4e4e7;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.modalHint {
|
||||
margin: 0;
|
||||
color: var(--sc-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.settingsSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.settingsSection h3 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--sc-ink);
|
||||
}
|
||||
|
||||
.settingsList {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 2px 12px;
|
||||
}
|
||||
|
||||
.settingsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border-radius: 5px;
|
||||
padding: 3px 4px;
|
||||
color: var(--sc-ink);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settingsRow label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settingsOrder {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.settingsList input[type='checkbox'],
|
||||
.exportColumns input[type='checkbox'] {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
min-width: 12px;
|
||||
min-height: 12px;
|
||||
margin: 0;
|
||||
accent-color: #27272a;
|
||||
}
|
||||
|
||||
.exportTools {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.exportColumns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 4px 12px;
|
||||
}
|
||||
|
||||
.exportColumns label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--sc-ink);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cloudDetailLink {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #2563eb;
|
||||
font: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cloudDetailLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.detailTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detailTable th,
|
||||
.detailTable td {
|
||||
border-bottom: 1px solid #e4e4e7;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.detailTable th {
|
||||
background: var(--sc-surface-muted);
|
||||
color: var(--sc-muted);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detailTable tfoot td {
|
||||
border-top: 1px solid var(--sc-border);
|
||||
border-bottom: none;
|
||||
color: var(--sc-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detailTable .numberCell {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.detailState {
|
||||
display: flex;
|
||||
min-height: 120px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: var(--sc-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detailStateError {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.modalFooter button,
|
||||
.exportTools button,
|
||||
.quickBar button,
|
||||
.quickBar select,
|
||||
.quickBar input {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
249
furniture-3d-system/src/lib/product-inventory.test.ts
Normal file
249
furniture-3d-system/src/lib/product-inventory.test.ts
Normal file
@ -0,0 +1,249 @@
|
||||
import {
|
||||
buildProductInventoryRow,
|
||||
compareProductInventoryRows,
|
||||
computeProductInventoryFacets,
|
||||
isProductInventoryZeroNumericRow,
|
||||
parseProductInventoryBatchInput,
|
||||
matchesProductInventoryFilters,
|
||||
PRODUCT_INVENTORY_COLUMNS,
|
||||
PRODUCT_INVENTORY_DEFAULT_FILTERS,
|
||||
sanitizeProductInventoryFilters,
|
||||
sanitizeProductInventoryLayout,
|
||||
sanitizeProductInventorySort,
|
||||
type ProductInventoryRow,
|
||||
} from './product-inventory'
|
||||
|
||||
function row(overrides: Partial<ProductInventoryRow>): ProductInventoryRow {
|
||||
return {
|
||||
category: '置物架',
|
||||
sku: 'SKU-1',
|
||||
barcode: 'BAR-1',
|
||||
productName: '款式一 白色',
|
||||
productionStatus: '销售',
|
||||
supplier: '供应商甲',
|
||||
purchaser: '土元',
|
||||
stockQty: 10,
|
||||
orderLockQty: 3,
|
||||
availableQty: 7,
|
||||
publicAvailableQty: 7,
|
||||
cloudAvailableQty: 0,
|
||||
warehouseName: '简尚-新苏北仓',
|
||||
warehouseAttribute: '成品发货仓-真实库存',
|
||||
warehouseOwnership: '简尚',
|
||||
warehouseHead: '徐祯卿',
|
||||
wmsCoId: 101,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('商品库存管理字段与逻辑', () => {
|
||||
it('明细列覆盖需求表字段并按需求顺序排列', () => {
|
||||
expect(PRODUCT_INVENTORY_COLUMNS.map((column) => column.label)).toEqual([
|
||||
'分类',
|
||||
'SKU',
|
||||
'条码',
|
||||
'商品名称',
|
||||
'生产状态',
|
||||
'供应商',
|
||||
'采购',
|
||||
'实际库存数',
|
||||
'订单占有数',
|
||||
'可用数',
|
||||
'公有可用数',
|
||||
'运营云仓可用数',
|
||||
'仓库名称',
|
||||
'仓库属性',
|
||||
'仓库归属',
|
||||
'仓库负责人',
|
||||
])
|
||||
expect(PRODUCT_INVENTORY_COLUMNS.filter((column) => column.kind === 'number').map((column) => column.key))
|
||||
.toEqual(['stockQty', 'orderLockQty', 'availableQty', 'publicAvailableQty', 'cloudAvailableQty'])
|
||||
})
|
||||
|
||||
it('组装时计算可用数、公有可用数,并对缺失来源留空', () => {
|
||||
const assembled = buildProductInventoryRow(
|
||||
{ barcode: 'BAR-1', name: '商品A', qty: 10, orderLockQty: 4, wmsCoId: 100 },
|
||||
{
|
||||
archive: {
|
||||
barcode: 'BAR-1',
|
||||
category: '置物架',
|
||||
sku: 'SKU-1',
|
||||
productionStatus: '销售',
|
||||
supplier: '供应商甲',
|
||||
purchaser: '土元',
|
||||
},
|
||||
warehouse: {
|
||||
wmsCoId: 100,
|
||||
name: '简尚-新苏北仓',
|
||||
attribute: '成品发货仓-真实库存',
|
||||
ownershipLabel: '简尚',
|
||||
head: '徐祯卿',
|
||||
},
|
||||
cloudAvailableQty: 2,
|
||||
},
|
||||
)
|
||||
|
||||
expect(assembled).toMatchObject({
|
||||
category: '置物架',
|
||||
sku: 'SKU-1',
|
||||
barcode: 'BAR-1',
|
||||
productName: '商品A',
|
||||
availableQty: 6,
|
||||
publicAvailableQty: 4,
|
||||
cloudAvailableQty: 2,
|
||||
warehouseName: '简尚-新苏北仓',
|
||||
})
|
||||
|
||||
const withoutSources = buildProductInventoryRow(
|
||||
{ barcode: 'BAR-2', name: '商品B', qty: 5, orderLockQty: 1, wmsCoId: 999 },
|
||||
{ archive: undefined, warehouse: undefined, cloudAvailableQty: null },
|
||||
)
|
||||
expect(withoutSources).toMatchObject({
|
||||
category: '',
|
||||
sku: '',
|
||||
availableQty: 4,
|
||||
cloudAvailableQty: null,
|
||||
publicAvailableQty: null,
|
||||
warehouseName: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('文本筛选支持模糊包含与精确点选叠加', () => {
|
||||
const target = row({})
|
||||
expect(matchesProductInventoryFilters(target, { productName: { contains: '款式' } })).toBe(true)
|
||||
expect(matchesProductInventoryFilters(target, { productName: { contains: '款式二' } })).toBe(false)
|
||||
expect(matchesProductInventoryFilters(target, { warehouseName: { values: ['简尚-新苏北仓'] } })).toBe(true)
|
||||
expect(matchesProductInventoryFilters(target, { warehouseName: { values: ['简尚-睢宁仓'] } })).toBe(false)
|
||||
expect(matchesProductInventoryFilters(target, {
|
||||
productName: { contains: '款式' },
|
||||
warehouseName: { values: ['简尚-新苏北仓'] },
|
||||
})).toBe(true)
|
||||
// 命中多个点选值之一即可(OR 关系)。
|
||||
expect(matchesProductInventoryFilters(target, {
|
||||
warehouseName: { values: ['简尚-睢宁仓', '简尚-新苏北仓'] },
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('数值筛选支持大于、小于与区间', () => {
|
||||
const target = row({ availableQty: 7 })
|
||||
expect(matchesProductInventoryFilters(target, { availableQty: { gt: 5 } })).toBe(true)
|
||||
expect(matchesProductInventoryFilters(target, { availableQty: { gt: 7 } })).toBe(false)
|
||||
expect(matchesProductInventoryFilters(target, { availableQty: { lt: 8 } })).toBe(true)
|
||||
expect(matchesProductInventoryFilters(target, { availableQty: { lt: 7 } })).toBe(false)
|
||||
expect(matchesProductInventoryFilters(target, { availableQty: { gt: 1, lt: 10 } })).toBe(true)
|
||||
// 数据缺失的数值列不参与比较筛选。
|
||||
expect(matchesProductInventoryFilters(row({ publicAvailableQty: null }), { publicAvailableQty: { gt: -1 } })).toBe(false)
|
||||
})
|
||||
|
||||
it('文本点选支持「空白」值筛选', () => {
|
||||
const blankHead = row({ warehouseHead: '' })
|
||||
const filledHead = row({ warehouseHead: '安康' })
|
||||
|
||||
expect(matchesProductInventoryFilters(blankHead, { warehouseHead: { values: [''] } })).toBe(true)
|
||||
expect(matchesProductInventoryFilters(filledHead, { warehouseHead: { values: [''] } })).toBe(false)
|
||||
expect(matchesProductInventoryFilters(filledHead, { warehouseHead: { values: ['', '安康'] } })).toBe(true)
|
||||
})
|
||||
|
||||
it('判定同行数值字段是否全为 0(空值按 0 处理)', () => {
|
||||
expect(isProductInventoryZeroNumericRow(row({
|
||||
stockQty: 0,
|
||||
orderLockQty: 0,
|
||||
availableQty: 0,
|
||||
publicAvailableQty: 0,
|
||||
cloudAvailableQty: 0,
|
||||
}))).toBe(true)
|
||||
expect(isProductInventoryZeroNumericRow(row({
|
||||
stockQty: 0,
|
||||
orderLockQty: 0,
|
||||
availableQty: 0,
|
||||
publicAvailableQty: null,
|
||||
cloudAvailableQty: null,
|
||||
}))).toBe(true)
|
||||
// 任意一个数值字段非 0 就保留。
|
||||
expect(isProductInventoryZeroNumericRow(row({ cloudAvailableQty: 3 }))).toBe(false)
|
||||
expect(isProductInventoryZeroNumericRow(row({ orderLockQty: -2 }))).toBe(false)
|
||||
})
|
||||
|
||||
it('排序支持升序降序,空值恒排在最后', () => {
|
||||
const low = row({ barcode: 'A', availableQty: 2, warehouseHead: '' })
|
||||
const high = row({ barcode: 'B', availableQty: 10, warehouseHead: '土元' })
|
||||
|
||||
expect([high, low].sort((a, b) => compareProductInventoryRows(a, b, { key: 'availableQty', direction: 'asc' })).map((item) => item.barcode))
|
||||
.toEqual(['A', 'B'])
|
||||
expect([low, high].sort((a, b) => compareProductInventoryRows(a, b, { key: 'availableQty', direction: 'desc' })).map((item) => item.barcode))
|
||||
.toEqual(['B', 'A'])
|
||||
expect([low, high].sort((a, b) => compareProductInventoryRows(a, b, { key: 'warehouseHead', direction: 'asc' })).map((item) => item.barcode))
|
||||
.toEqual(['B', 'A'])
|
||||
})
|
||||
|
||||
it('筛选参数收口:丢弃非法列/非法形态并截断超长值', () => {
|
||||
const long = 'x'.repeat(500)
|
||||
expect(sanitizeProductInventoryFilters({
|
||||
unknown: { contains: 'a' },
|
||||
productName: { contains: long, values: ['a', 'a', '', 'b'] },
|
||||
stockQty: { gt: '3', lt: 'abc' },
|
||||
supplier: { gt: 1 },
|
||||
availableQty: 'not-an-object',
|
||||
})).toEqual({
|
||||
// 空字符串保留为「空白」点选值。
|
||||
productName: { contains: 'x'.repeat(120), values: ['a', '', 'b'] },
|
||||
stockQty: { gt: 3 },
|
||||
})
|
||||
})
|
||||
|
||||
it('排序参数收口:非法字段或方向返回 null', () => {
|
||||
expect(sanitizeProductInventorySort({ key: 'availableQty', direction: 'desc' }))
|
||||
.toEqual({ key: 'availableQty', direction: 'desc' })
|
||||
expect(sanitizeProductInventorySort({ key: 'unknown', direction: 'asc' })).toBeNull()
|
||||
expect(sanitizeProductInventorySort({ key: 'availableQty', direction: 'up' })).toBeNull()
|
||||
})
|
||||
|
||||
it('筛选候选项按中文排序去重去空', () => {
|
||||
const facets = computeProductInventoryFacets([
|
||||
row({ warehouseName: 'B仓' }),
|
||||
row({ warehouseName: 'A仓' }),
|
||||
row({ warehouseName: 'A仓' }),
|
||||
row({ warehouseName: '' }),
|
||||
])
|
||||
// 存在空白内容时,空字符串作为「空白」候选值排在首位。
|
||||
expect(facets.warehouseName).toEqual(['', 'A仓', 'B仓'])
|
||||
// 没有空白内容时不追加空字符串。
|
||||
expect(facets.warehouseOwnership).toEqual(['简尚'])
|
||||
expect(facets.availableQty).toBeUndefined()
|
||||
})
|
||||
|
||||
it('列/筛选布局:无本地设置时使用默认值,非法设置回退默认', () => {
|
||||
const defaults = sanitizeProductInventoryLayout(null)
|
||||
expect(defaults.visibleColumns).toHaveLength(PRODUCT_INVENTORY_COLUMNS.length)
|
||||
expect(defaults.quickFilterColumns).toEqual([
|
||||
'sku',
|
||||
'barcode',
|
||||
'warehouseName',
|
||||
'warehouseAttribute',
|
||||
'warehouseOwnership',
|
||||
])
|
||||
|
||||
const custom = sanitizeProductInventoryLayout({
|
||||
visibleColumns: ['barcode', 'sku', 'unknown', 'barcode'],
|
||||
quickFilterColumns: ['warehouseHead', 'nope'],
|
||||
})
|
||||
expect(custom.visibleColumns).toEqual(['barcode', 'sku'])
|
||||
expect(custom.quickFilterColumns).toEqual(['warehouseHead'])
|
||||
expect(PRODUCT_INVENTORY_DEFAULT_FILTERS).toEqual({
|
||||
warehouseAttribute: { values: ['成品发货仓-真实库存', '配件仓&成品缺货仓'] },
|
||||
warehouseOwnership: { values: ['简尚'] },
|
||||
})
|
||||
})
|
||||
|
||||
it('快速筛选批量输入:逗号/空格/换行分割,去空去重', () => {
|
||||
expect(parseProductInventoryBatchInput('SKU-1, SKU-2,SKU-1\nSKU-3;sku-4 SKU-2'))
|
||||
.toEqual(['SKU-1', 'SKU-2', 'SKU-3', 'sku-4'])
|
||||
})
|
||||
|
||||
it('点选值匹配忽略大小写(批量粘贴条码更友好)', () => {
|
||||
expect(matchesProductInventoryFilters(
|
||||
row({ barcode: 'CFZW003701N401V01' }),
|
||||
{ barcode: { values: ['cfzw003701n401v01'] } },
|
||||
)).toBe(true)
|
||||
})
|
||||
})
|
||||
475
furniture-3d-system/src/lib/product-inventory.ts
Normal file
475
furniture-3d-system/src/lib/product-inventory.ts
Normal file
@ -0,0 +1,475 @@
|
||||
// 商品库存管理:字段定义、筛选/排序/组装纯逻辑(前后端共用)
|
||||
// 需求来源:商品库存管理需求.xlsx
|
||||
// - 条码/商品名称/实际库存数/订单占有数/仓库编号来自聚水潭 rpt.jst_inventoryInfo(只保留商品属性为「成品」的条码)
|
||||
// - 分类/SKU/生产状态/供应商/采购来自 SupplyChainProductArchive(按条码匹配)
|
||||
// - 仓库名称/属性/归属/负责人来自仓库配置(按 wms_co_id / 仓库名称匹配)
|
||||
// - 可用数 = 实际库存数 - 订单占有数;公有可用数 = 可用数 - 运营云仓可用数
|
||||
|
||||
export type ProductInventoryColumnKind = 'text' | 'number'
|
||||
|
||||
export type ProductInventoryColumnKey =
|
||||
| 'category'
|
||||
| 'sku'
|
||||
| 'barcode'
|
||||
| 'productName'
|
||||
| 'productionStatus'
|
||||
| 'supplier'
|
||||
| 'purchaser'
|
||||
| 'stockQty'
|
||||
| 'orderLockQty'
|
||||
| 'availableQty'
|
||||
| 'publicAvailableQty'
|
||||
| 'cloudAvailableQty'
|
||||
| 'warehouseName'
|
||||
| 'warehouseAttribute'
|
||||
| 'warehouseOwnership'
|
||||
| 'warehouseHead'
|
||||
|
||||
export type ProductInventoryColumn = {
|
||||
key: ProductInventoryColumnKey
|
||||
label: string
|
||||
kind: ProductInventoryColumnKind
|
||||
width: number
|
||||
}
|
||||
|
||||
export const PRODUCT_INVENTORY_COLUMNS: readonly ProductInventoryColumn[] = [
|
||||
{ key: 'category', label: '分类', kind: 'text', width: 120 },
|
||||
{ key: 'sku', label: 'SKU', kind: 'text', width: 150 },
|
||||
{ key: 'barcode', label: '条码', kind: 'text', width: 170 },
|
||||
{ key: 'productName', label: '商品名称', kind: 'text', width: 260 },
|
||||
{ key: 'productionStatus', label: '生产状态', kind: 'text', width: 110 },
|
||||
{ key: 'supplier', label: '供应商', kind: 'text', width: 160 },
|
||||
{ key: 'purchaser', label: '采购', kind: 'text', width: 90 },
|
||||
{ key: 'stockQty', label: '实际库存数', kind: 'number', width: 120 },
|
||||
{ key: 'orderLockQty', label: '订单占有数', kind: 'number', width: 120 },
|
||||
{ key: 'availableQty', label: '可用数', kind: 'number', width: 96 },
|
||||
{ key: 'publicAvailableQty', label: '公有可用数', kind: 'number', width: 120 },
|
||||
{ key: 'cloudAvailableQty', label: '运营云仓可用数', kind: 'number', width: 150 },
|
||||
{ key: 'warehouseName', label: '仓库名称', kind: 'text', width: 180 },
|
||||
{ key: 'warehouseAttribute', label: '仓库属性', kind: 'text', width: 170 },
|
||||
{ key: 'warehouseOwnership', label: '仓库归属', kind: 'text', width: 110 },
|
||||
{ key: 'warehouseHead', label: '仓库负责人', kind: 'text', width: 120 },
|
||||
]
|
||||
|
||||
const PRODUCT_INVENTORY_COLUMN_KEY_SET: ReadonlySet<string> = new Set(
|
||||
PRODUCT_INVENTORY_COLUMNS.map((column) => column.key),
|
||||
)
|
||||
|
||||
export function isProductInventoryColumnKey(value: unknown): value is ProductInventoryColumnKey {
|
||||
return typeof value === 'string' && PRODUCT_INVENTORY_COLUMN_KEY_SET.has(value)
|
||||
}
|
||||
|
||||
export function getProductInventoryColumn(key: ProductInventoryColumnKey) {
|
||||
return PRODUCT_INVENTORY_COLUMNS.find((column) => column.key === key)
|
||||
}
|
||||
|
||||
export type ProductInventoryRow = {
|
||||
category: string
|
||||
sku: string
|
||||
barcode: string
|
||||
productName: string
|
||||
productionStatus: string
|
||||
supplier: string
|
||||
purchaser: string
|
||||
stockQty: number
|
||||
orderLockQty: number
|
||||
availableQty: number
|
||||
/** 可用数 - 运营云仓可用数;云仓数据源未接通时为 null(显示「—」)。 */
|
||||
publicAvailableQty: number | null
|
||||
/** 云仓数据源未接通时为 null(显示「—」)。 */
|
||||
cloudAvailableQty: number | null
|
||||
warehouseName: string
|
||||
warehouseAttribute: string
|
||||
warehouseOwnership: string
|
||||
warehouseHead: string
|
||||
/** 实际仓库编号(jst_inventoryInfo.wms_co_id),用于云仓明细下钻,不展示为列。 */
|
||||
wmsCoId: number
|
||||
}
|
||||
|
||||
export type ProductInventoryTextFilter = {
|
||||
/** 模糊筛选:忽略大小写的包含匹配。 */
|
||||
contains?: string
|
||||
/** 精确点选:与单元格文本完全一致的可选值(OR 关系)。 */
|
||||
values?: string[]
|
||||
}
|
||||
|
||||
export type ProductInventoryNumberFilter = {
|
||||
/** 只保留大于该值的行。 */
|
||||
gt?: number
|
||||
/** 只保留小于该值的行。 */
|
||||
lt?: number
|
||||
}
|
||||
|
||||
export type ProductInventoryFilters = Partial<
|
||||
Record<ProductInventoryColumnKey, ProductInventoryTextFilter | ProductInventoryNumberFilter>
|
||||
>
|
||||
|
||||
export type ProductInventorySort = {
|
||||
key: ProductInventoryColumnKey
|
||||
direction: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export const PRODUCT_INVENTORY_TEXT_FILTER_MAX_LENGTH = 120
|
||||
export const PRODUCT_INVENTORY_FILTER_VALUE_LIMIT = 200
|
||||
export const PRODUCT_INVENTORY_FACET_LIMIT = 200
|
||||
export const PRODUCT_INVENTORY_DEFAULT_PAGE_SIZE = 50
|
||||
export const PRODUCT_INVENTORY_PAGE_SIZES = [50, 100, 200] as const
|
||||
export const PRODUCT_INVENTORY_MAX_PAGE_SIZE = 200
|
||||
|
||||
export const PRODUCT_INVENTORY_NUMERIC_COLUMN_KEYS = [
|
||||
'stockQty',
|
||||
'orderLockQty',
|
||||
'availableQty',
|
||||
'publicAvailableQty',
|
||||
'cloudAvailableQty',
|
||||
] as const
|
||||
|
||||
export type ProductInventoryNumericColumnKey =
|
||||
(typeof PRODUCT_INVENTORY_NUMERIC_COLUMN_KEYS)[number]
|
||||
|
||||
export type ProductInventorySubtotals = Record<ProductInventoryNumericColumnKey, number>
|
||||
|
||||
/** 小计:按当前行集合对数值字段求和(空值按 0 计)。 */
|
||||
export function computeProductInventorySubtotals(
|
||||
rows: readonly ProductInventoryRow[],
|
||||
): ProductInventorySubtotals {
|
||||
const subtotals: ProductInventorySubtotals = {
|
||||
stockQty: 0,
|
||||
orderLockQty: 0,
|
||||
availableQty: 0,
|
||||
publicAvailableQty: 0,
|
||||
cloudAvailableQty: 0,
|
||||
}
|
||||
for (const row of rows) {
|
||||
for (const key of PRODUCT_INVENTORY_NUMERIC_COLUMN_KEYS) {
|
||||
subtotals[key] += row[key] ?? 0
|
||||
}
|
||||
}
|
||||
return subtotals
|
||||
}
|
||||
|
||||
export type ProductInventoryLayout = {
|
||||
/** 可见列,顺序即表头/表体展示顺序。 */
|
||||
visibleColumns: ProductInventoryColumnKey[]
|
||||
/** 快速查询栏使用的字段。 */
|
||||
quickFilterColumns: ProductInventoryColumnKey[]
|
||||
}
|
||||
|
||||
export const PRODUCT_INVENTORY_DEFAULT_LAYOUT: ProductInventoryLayout = {
|
||||
visibleColumns: PRODUCT_INVENTORY_COLUMNS.map((column) => column.key),
|
||||
quickFilterColumns: [
|
||||
'sku',
|
||||
'barcode',
|
||||
'warehouseName',
|
||||
'warehouseAttribute',
|
||||
'warehouseOwnership',
|
||||
],
|
||||
}
|
||||
|
||||
/** 默认筛选:只看成品发货/配件缺货仓中的简尚仓。 */
|
||||
export const PRODUCT_INVENTORY_DEFAULT_FILTERS: ProductInventoryFilters = {
|
||||
warehouseAttribute: {
|
||||
values: ['成品发货仓-真实库存', '配件仓&成品缺货仓'],
|
||||
},
|
||||
warehouseOwnership: {
|
||||
values: ['简尚'],
|
||||
},
|
||||
}
|
||||
|
||||
/** 默认筛选对应的内置快捷查询名称(一键恢复默认视图)。 */
|
||||
export const PRODUCT_INVENTORY_DEFAULT_QUICK_QUERY_NAME = '快捷查询-简尚公用库存'
|
||||
|
||||
/** 快速筛选栏里用「搜索 + 点选」下拉的字段(支持单选/多选)。 */
|
||||
export const PRODUCT_INVENTORY_QUICK_SELECT_COLUMNS: readonly ProductInventoryColumnKey[] = [
|
||||
'warehouseName',
|
||||
'warehouseAttribute',
|
||||
'warehouseOwnership',
|
||||
]
|
||||
|
||||
/** 读取并校验本地保存的列/筛选设置;非法值回退默认。 */
|
||||
export function sanitizeProductInventoryLayout(input: unknown): ProductInventoryLayout {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
return {
|
||||
visibleColumns: [...PRODUCT_INVENTORY_DEFAULT_LAYOUT.visibleColumns],
|
||||
quickFilterColumns: [...PRODUCT_INVENTORY_DEFAULT_LAYOUT.quickFilterColumns],
|
||||
}
|
||||
}
|
||||
const source = input as Record<string, unknown>
|
||||
const uniqueKeys = (value: unknown) => {
|
||||
if (!Array.isArray(value)) return []
|
||||
const seen = new Set<ProductInventoryColumnKey>()
|
||||
for (const candidate of value) {
|
||||
if (isProductInventoryColumnKey(candidate) && !seen.has(candidate)) seen.add(candidate)
|
||||
}
|
||||
return [...seen]
|
||||
}
|
||||
const visibleColumns = uniqueKeys(source.visibleColumns)
|
||||
const quickFilterColumns = uniqueKeys(source.quickFilterColumns)
|
||||
return {
|
||||
visibleColumns: visibleColumns.length
|
||||
? visibleColumns
|
||||
: [...PRODUCT_INVENTORY_DEFAULT_LAYOUT.visibleColumns],
|
||||
quickFilterColumns,
|
||||
}
|
||||
}
|
||||
|
||||
function cleanTextValue(value: unknown, maxLength = PRODUCT_INVENTORY_TEXT_FILTER_MAX_LENGTH) {
|
||||
return typeof value === 'string' ? value.trim().slice(0, maxLength) : ''
|
||||
}
|
||||
|
||||
/** 兼容请求参数:只保留合法列、合法形态与合法取值,并做长度/数量上限收口。 */
|
||||
export function sanitizeProductInventoryFilters(input: unknown): ProductInventoryFilters {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) return {}
|
||||
const result: ProductInventoryFilters = {}
|
||||
|
||||
for (const column of PRODUCT_INVENTORY_COLUMNS) {
|
||||
const raw = (input as Record<string, unknown>)[column.key]
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
|
||||
const candidate = raw as Record<string, unknown>
|
||||
|
||||
if (column.kind === 'text') {
|
||||
const filter: ProductInventoryTextFilter = {}
|
||||
const contains = cleanTextValue(candidate.contains)
|
||||
if (contains) filter.contains = contains
|
||||
if (Array.isArray(candidate.values)) {
|
||||
// 允许保留空字符串作为「空白」筛选值;非字符串一律丢弃。
|
||||
const values = Array.from(new Set(
|
||||
candidate.values
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
.map((value) => cleanTextValue(value)),
|
||||
)).slice(0, PRODUCT_INVENTORY_FILTER_VALUE_LIMIT)
|
||||
if (values.length) filter.values = values
|
||||
}
|
||||
if (Object.keys(filter).length) result[column.key] = filter
|
||||
} else {
|
||||
const filter: ProductInventoryNumberFilter = {}
|
||||
for (const operator of ['gt', 'lt'] as const) {
|
||||
const value = candidate[operator]
|
||||
const numeric = typeof value === 'number' ? value : Number(String(value ?? '').trim())
|
||||
if (String(value ?? '').trim() !== '' && Number.isFinite(numeric)) filter[operator] = numeric
|
||||
}
|
||||
if (Object.keys(filter).length) result[column.key] = filter
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function sanitizeProductInventorySort(input: unknown): ProductInventorySort | null {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) return null
|
||||
const candidate = input as Record<string, unknown>
|
||||
if (!isProductInventoryColumnKey(candidate.key)) return null
|
||||
if (candidate.direction !== 'asc' && candidate.direction !== 'desc') return null
|
||||
return { key: candidate.key, direction: candidate.direction }
|
||||
}
|
||||
|
||||
/** 快速筛选的批量输入:支持逗号/分号/顿号/空格/换行分隔,去空去重。 */
|
||||
export function parseProductInventoryBatchInput(value: string) {
|
||||
return Array.from(new Set(
|
||||
String(value ?? '')
|
||||
.split(/[\s,,;;、]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
)).slice(0, PRODUCT_INVENTORY_FILTER_VALUE_LIMIT)
|
||||
}
|
||||
|
||||
function matchesTextFilter(value: string, filter: ProductInventoryTextFilter) {
|
||||
const normalized = value.trim()
|
||||
if (filter.contains) {
|
||||
if (!normalized.toLocaleLowerCase('zh-CN').includes(filter.contains.toLocaleLowerCase('zh-CN'))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (filter.values?.length) {
|
||||
const normalizedLower = normalized.toLocaleLowerCase('zh-CN')
|
||||
if (!filter.values.some((value) => value.toLocaleLowerCase('zh-CN') === normalizedLower)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function matchesNumberFilter(value: number | null, filter: ProductInventoryNumberFilter) {
|
||||
// 数据缺失(如云仓数据源未接通)不参与数值比较筛选。
|
||||
if (value === null) return false
|
||||
if (filter.gt !== undefined && !(value > filter.gt)) return false
|
||||
if (filter.lt !== undefined && !(value < filter.lt)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function matchesProductInventoryFilters(
|
||||
row: ProductInventoryRow,
|
||||
filters: ProductInventoryFilters,
|
||||
) {
|
||||
for (const column of PRODUCT_INVENTORY_COLUMNS) {
|
||||
const filter = filters[column.key]
|
||||
if (!filter) continue
|
||||
if (column.kind === 'text') {
|
||||
if (!matchesTextFilter(String(row[column.key] ?? ''), filter as ProductInventoryTextFilter)) return false
|
||||
} else if (!matchesNumberFilter(row[column.key] as number | null, filter as ProductInventoryNumberFilter)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function isEmptySortValue(value: string | number | null | undefined) {
|
||||
return value === null || value === undefined || value === ''
|
||||
}
|
||||
|
||||
/** 排序:数值按数值比、文本按中文文案比;空值恒排在最后。 */
|
||||
export function compareProductInventoryRows(
|
||||
left: ProductInventoryRow,
|
||||
right: ProductInventoryRow,
|
||||
sort: ProductInventorySort,
|
||||
) {
|
||||
const leftValue = left[sort.key]
|
||||
const rightValue = right[sort.key]
|
||||
const leftEmpty = isEmptySortValue(leftValue)
|
||||
const rightEmpty = isEmptySortValue(rightValue)
|
||||
if (leftEmpty || rightEmpty) {
|
||||
if (leftEmpty && rightEmpty) return 0
|
||||
return leftEmpty ? 1 : -1
|
||||
}
|
||||
const base = typeof leftValue === 'number' && typeof rightValue === 'number'
|
||||
? leftValue - rightValue
|
||||
: String(leftValue).localeCompare(String(rightValue), 'zh-CN')
|
||||
return sort.direction === 'asc' ? base : -base
|
||||
}
|
||||
|
||||
export type ProductInventoryInventoryRecord = {
|
||||
barcode: string
|
||||
name: string
|
||||
qty: number
|
||||
orderLockQty: number
|
||||
wmsCoId: number
|
||||
}
|
||||
|
||||
export type ProductInventoryArchiveProfile = {
|
||||
barcode: string
|
||||
category: string
|
||||
sku: string
|
||||
productionStatus: string
|
||||
supplier: string
|
||||
purchaser: string
|
||||
}
|
||||
|
||||
export type ProductInventoryWarehouseProfile = {
|
||||
wmsCoId: number
|
||||
name: string
|
||||
attribute: string
|
||||
ownershipLabel: string
|
||||
head: string
|
||||
}
|
||||
|
||||
/** 组装一行展示数据;缺档案/仓库/云仓数据时对应列为空,不阻断整行。 */
|
||||
export function buildProductInventoryRow(
|
||||
record: ProductInventoryInventoryRecord,
|
||||
sources: {
|
||||
archive: ProductInventoryArchiveProfile | undefined
|
||||
warehouse: ProductInventoryWarehouseProfile | undefined
|
||||
cloudAvailableQty: number | null
|
||||
},
|
||||
): ProductInventoryRow {
|
||||
const availableQty = record.qty - record.orderLockQty
|
||||
const cloudAvailableQty = sources.cloudAvailableQty
|
||||
return {
|
||||
category: sources.archive?.category ?? '',
|
||||
sku: sources.archive?.sku ?? '',
|
||||
barcode: record.barcode,
|
||||
productName: record.name,
|
||||
productionStatus: sources.archive?.productionStatus ?? '',
|
||||
supplier: sources.archive?.supplier ?? '',
|
||||
purchaser: sources.archive?.purchaser ?? '',
|
||||
stockQty: record.qty,
|
||||
orderLockQty: record.orderLockQty,
|
||||
availableQty,
|
||||
cloudAvailableQty,
|
||||
publicAvailableQty: cloudAvailableQty === null ? null : availableQty - cloudAvailableQty,
|
||||
warehouseName: sources.warehouse?.name ?? '',
|
||||
warehouseAttribute: sources.warehouse?.attribute ?? '',
|
||||
warehouseOwnership: sources.warehouse?.ownershipLabel ?? '',
|
||||
warehouseHead: sources.warehouse?.head ?? '',
|
||||
wmsCoId: record.wmsCoId,
|
||||
}
|
||||
}
|
||||
|
||||
export type ProductInventoryCloudStockRow = {
|
||||
cloudWarehouseName: string
|
||||
qty: number
|
||||
orderAbleQty: number
|
||||
orderLock: number
|
||||
}
|
||||
|
||||
export type ProductInventoryCloudStockDetail = {
|
||||
barcode: string
|
||||
wmsCoId: number
|
||||
rows: ProductInventoryCloudStockRow[]
|
||||
totals: { qty: number; orderAbleQty: number; orderLock: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* 同行所有数值字段是否都为 0(空值按 0 处理)。
|
||||
* 用于剔除没有任何库存意义的明细行:实际库存数/订单占有数/可用数/公有可用数/运营云仓可用数全为 0。
|
||||
*/
|
||||
export function isProductInventoryZeroNumericRow(row: ProductInventoryRow) {
|
||||
return [
|
||||
row.stockQty,
|
||||
row.orderLockQty,
|
||||
row.availableQty,
|
||||
row.publicAvailableQty,
|
||||
row.cloudAvailableQty,
|
||||
].every((value) => (value ?? 0) === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本列筛选候选项(点选列表):按中文排序去重;存在空白内容时把空字符串放在首位,
|
||||
* 前端显示为「空白」选项,便于快速筛出该字段为空的行。
|
||||
*/
|
||||
export function computeProductInventoryFacets(
|
||||
rows: readonly ProductInventoryRow[],
|
||||
): Partial<Record<ProductInventoryColumnKey, string[]>> {
|
||||
const facets: Partial<Record<ProductInventoryColumnKey, string[]>> = {}
|
||||
for (const column of PRODUCT_INVENTORY_COLUMNS) {
|
||||
if (column.kind !== 'text') continue
|
||||
const values = new Set<string>()
|
||||
let hasBlank = false
|
||||
for (const row of rows) {
|
||||
const value = String(row[column.key] ?? '').trim()
|
||||
if (value) values.add(value)
|
||||
else hasBlank = true
|
||||
}
|
||||
const sorted = [...values]
|
||||
.sort((left, right) => left.localeCompare(right, 'zh-CN'))
|
||||
.slice(0, PRODUCT_INVENTORY_FACET_LIMIT)
|
||||
facets[column.key] = hasBlank ? ['', ...sorted] : sorted
|
||||
}
|
||||
return facets
|
||||
}
|
||||
|
||||
export type ProductInventorySourceStatus = {
|
||||
/** 数据组装时间(ISO 字符串)。 */
|
||||
builtAt: string
|
||||
/** 聚水潭库存明细最后修改时间(MAX(jst_inventoryInfo.modified)),用于确认库存截止时间。 */
|
||||
sourceUpdatedAt: string | null
|
||||
inventoryReady: boolean
|
||||
archiveReady: boolean
|
||||
warehouseReady: boolean
|
||||
cloudReady: boolean
|
||||
cloudMessage: string
|
||||
inventoryRowCount: number
|
||||
archiveMatchedCount: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export type ProductInventoryPage = {
|
||||
rows: ProductInventoryRow[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
totalPages: number
|
||||
/** 当前筛选条件下的全量小计(未筛选时前端改用当页小计)。 */
|
||||
subtotals: ProductInventorySubtotals
|
||||
facets: Partial<Record<ProductInventoryColumnKey, string[]>>
|
||||
sourceStatus: ProductInventorySourceStatus
|
||||
}
|
||||
@ -19,6 +19,7 @@ export function getSupplyChainPermissionTargetForPath(pathname: string): SupplyC
|
||||
if (pathname === '/supply-chain/database/product-archive') return 'productArchive' as const
|
||||
if (pathname === '/supply-chain/database/category-config') return 'categoryConfig' as const
|
||||
if (pathname === '/supply-chain/database/warehouse-config') return 'warehouseConfig' as const
|
||||
if (pathname === '/supply-chain/database/product-inventory') return 'productInventory' as const
|
||||
if (pathname === '/supply-chain/database/store-account-management') return 'storeAccountManagement' as const
|
||||
if (pathname === '/supply-chain/new-products/overview') return 'newProductOverview' as const
|
||||
if (pathname === '/supply-chain/new-products/1688-dropship-data') return 'newProduct1688DropshipData' as const
|
||||
@ -143,6 +144,12 @@ export function getSupplyChainApiPermissionRequirement(pathname: string, method:
|
||||
if (pathname.startsWith('/api/supply-chain/warehouse-config')) {
|
||||
return { targetKey: 'warehouseConfig' as const, action }
|
||||
}
|
||||
if (pathname.startsWith('/api/supply-chain/product-inventory/export')) {
|
||||
return { targetKey: 'productInventory' as const, action: 'download' as const }
|
||||
}
|
||||
if (pathname.startsWith('/api/supply-chain/product-inventory')) {
|
||||
return { targetKey: 'productInventory' as const, action }
|
||||
}
|
||||
if (pathname === '/api/supply-chain/management/owner-config/export') {
|
||||
return { targetKey: 'ownerConfig' as const, action: 'download' as const }
|
||||
}
|
||||
|
||||
@ -29,6 +29,18 @@ describe('supply chain permission settings', () => {
|
||||
)).toEqual({ targetKey: 'ownerConfig', action: 'download' })
|
||||
})
|
||||
|
||||
it('maps the product inventory page and APIs to its own permission node', () => {
|
||||
expect(
|
||||
getSupplyChainPermissionTargetForPath('/supply-chain/database/product-inventory'),
|
||||
).toBe('productInventory')
|
||||
expect(
|
||||
getSupplyChainApiPermissionRequirement('/api/supply-chain/product-inventory', 'GET'),
|
||||
).toEqual({ targetKey: 'productInventory', action: 'view' })
|
||||
expect(
|
||||
getSupplyChainApiPermissionRequirement('/api/supply-chain/product-inventory/export', 'GET'),
|
||||
).toEqual({ targetKey: 'productInventory', action: 'download' })
|
||||
})
|
||||
|
||||
it('requires business management view for dingtalk workflow monitor queries', () => {
|
||||
expect(getSupplyChainApiPermissionRequirement(
|
||||
'/api/supply-chain/dingtalk-workflow-monitor',
|
||||
|
||||
@ -14,6 +14,7 @@ export type SupplyChainPermissionTargetKey =
|
||||
| 'productArchive'
|
||||
| 'categoryConfig'
|
||||
| 'warehouseConfig'
|
||||
| 'productInventory'
|
||||
| 'storeAccountManagement'
|
||||
| 'newProductOverview'
|
||||
| 'newProduct1688DropshipData'
|
||||
@ -59,6 +60,7 @@ export const SUPPLY_CHAIN_PERMISSION_TARGETS: SupplyChainPermissionTarget[] = [
|
||||
{ key: 'productArchive', label: '产品全量档案', description: '商品、供应链和运营档案' },
|
||||
{ key: 'categoryConfig', label: '类目配置', description: '产品全量档案类目维护' },
|
||||
{ key: 'warehouseConfig', label: '仓库配置', description: '出入库仓库维护' },
|
||||
{ key: 'productInventory', label: '商品库存管理', description: '成品库存、运营云仓占用与仓库配置汇总' },
|
||||
{ key: 'storeAccountManagement', label: '店铺账号管理', description: '店铺账号人工维护与权限控制' },
|
||||
{ key: 'newProductOverview', label: '新品经营总览', description: '新品经营指标和阶段时效分析' },
|
||||
{ key: 'newProduct1688DropshipData', label: '1688代发数据明细', description: '1688 导单店铺销售、履约和退款明细' },
|
||||
|
||||
@ -0,0 +1,94 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import type sql from 'mssql'
|
||||
import { ProductInventoryRepositoryClass } from './product-inventory.repository'
|
||||
|
||||
const ENV_KEYS = [
|
||||
'JST_REPORT_DB_HOST',
|
||||
'JST_REPORT_DB_NAME',
|
||||
'JST_REPORT_DB_USER',
|
||||
'JST_REPORT_DB_PASSWORD',
|
||||
] as const
|
||||
|
||||
describe('商品库存管理:运营云仓数据源', () => {
|
||||
let previous: Record<string, string | undefined>
|
||||
|
||||
beforeEach(() => {
|
||||
previous = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]))
|
||||
Object.assign(process.env, {
|
||||
JST_REPORT_DB_HOST: 'localhost',
|
||||
JST_REPORT_DB_NAME: 'report_test',
|
||||
JST_REPORT_DB_USER: 'reader',
|
||||
JST_REPORT_DB_PASSWORD: 'test-only',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
if (previous[key] === undefined) delete process.env[key]
|
||||
else process.env[key] = previous[key]
|
||||
}
|
||||
})
|
||||
|
||||
function poolWith(query: jest.Mock) {
|
||||
return {
|
||||
connect: jest.fn().mockResolvedValue({ request: () => ({ query }) }),
|
||||
} as unknown as sql.ConnectionPool
|
||||
}
|
||||
|
||||
it('按条码+仓库编号汇总 order_able_qty,并对条码做去空格大写归一', async () => {
|
||||
const query = jest.fn().mockResolvedValue({
|
||||
recordset: [
|
||||
{ barcode: 'ctg008204N382', wmsCoId: 15032603, orderAbleQty: 6 },
|
||||
{ barcode: 'CTG008204N382', wmsCoId: 15032603, orderAbleQty: 1 },
|
||||
{ barcode: 'BAR-2', wmsCoId: 200, orderAbleQty: 0 },
|
||||
{ barcode: null, wmsCoId: 200, orderAbleQty: 3 },
|
||||
],
|
||||
})
|
||||
const repository = new ProductInventoryRepositoryClass(() => poolWith(query))
|
||||
|
||||
const result = await repository.loadCloudAvailableMap()
|
||||
|
||||
expect(result.available).toBe(true)
|
||||
expect(result.map.get('CTG008204N382|15032603')).toBe(7)
|
||||
expect(result.map.get('BAR-2|200')).toBe(0)
|
||||
expect(result.map.size).toBe(2)
|
||||
const statement = query.mock.calls[0][0] as string
|
||||
expect(statement).toContain('OPENQUERY([JianSun_Old]')
|
||||
expect(statement).toContain('JianSun.dbo.jst_virtualstockStocksInfo')
|
||||
expect(statement).toContain('WHERE v.qty > 0')
|
||||
})
|
||||
|
||||
it('链接服务器读取失败时降级为「未接通」,不阻断页面', async () => {
|
||||
const query = jest.fn().mockRejectedValue(new Error('linked server unavailable'))
|
||||
const repository = new ProductInventoryRepositoryClass(() => poolWith(query))
|
||||
|
||||
const result = await repository.loadCloudAvailableMap()
|
||||
|
||||
expect(result.available).toBe(false)
|
||||
expect(result.message).toContain('运营云仓数据源读取失败')
|
||||
expect(result.map.size).toBe(0)
|
||||
})
|
||||
|
||||
it('按条码+仓库编号读取云仓明细(只取 qty > 0)', async () => {
|
||||
const query = jest.fn().mockResolvedValue({
|
||||
recordset: [
|
||||
{ cloudWarehouseName: '渠道-分销部线下云仓', qty: 12, orderAbleQty: 7, orderLock: 3 },
|
||||
{ cloudWarehouseName: null, qty: null, orderAbleQty: null, orderLock: null },
|
||||
],
|
||||
})
|
||||
const repository = new ProductInventoryRepositoryClass(() => poolWith(query))
|
||||
|
||||
const rows = await repository.listCloudStockDetail("bar-1'x", 15038347)
|
||||
|
||||
expect(rows).toEqual([
|
||||
{ cloudWarehouseName: '渠道-分销部线下云仓', qty: 12, orderAbleQty: 7, orderLock: 3 },
|
||||
{ cloudWarehouseName: '', qty: 0, orderAbleQty: 0, orderLock: 0 },
|
||||
])
|
||||
const statement = query.mock.calls[0][0] as string
|
||||
expect(statement).toContain('OPENQUERY([JianSun_Old]')
|
||||
expect(statement).toContain('WHERE v.qty > 0')
|
||||
expect(statement).toContain("AND v.sku_id = N''BAR-1''''X''")
|
||||
expect(statement).toContain('AND v.wms_co_id = 15038347')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,334 @@
|
||||
import sql from 'mssql'
|
||||
import type { Pool } from 'pg'
|
||||
import { getSupplyChainArchiveReadPool } from '@/server/repositories/supply-chain-archive-read-pool'
|
||||
import type {
|
||||
ProductInventoryArchiveProfile,
|
||||
ProductInventoryCloudStockRow,
|
||||
} from '@/lib/product-inventory'
|
||||
|
||||
// 商品库存管理数据访问层:
|
||||
// - 聚水潭报表副本 rpt.jst_inventoryInfo(只保留商品属性为「成品」的条码)
|
||||
// - 产品全量档案只读库 public."SupplyChainProductArchive"(按条码匹配分类/SKU/生产状态/供应商/采购)
|
||||
// - 运营云仓数据源 JianSun.dbo.jst_virtualstockStocksInfo
|
||||
// (经报表副本的链接服务器 JianSun_Old 用 OPENQUERY 读取,见 loadCloudAvailableMap)
|
||||
|
||||
export type JstFinishedInventoryRecord = {
|
||||
barcode: string
|
||||
name: string
|
||||
qty: number
|
||||
orderLockQty: number
|
||||
wmsCoId: number
|
||||
}
|
||||
|
||||
export type ProductInventoryCloudAvailable = {
|
||||
available: boolean
|
||||
message: string
|
||||
/** key = 条码(大写)| wms_co_id,value = 该条码在该实际仓库的云仓可下单量(order_able_qty)合计。 */
|
||||
map: Map<string, number>
|
||||
}
|
||||
|
||||
type InventoryPoolGlobal = typeof globalThis & {
|
||||
productInventoryJstPool?: sql.ConnectionPool
|
||||
}
|
||||
|
||||
const SCHEMA_FALLBACK = 'rpt'
|
||||
const INVENTORY_QUERY_TIMEOUT_MS = 90_000
|
||||
const CLOUD_QUERY_TIMEOUT_MS = 30_000
|
||||
const ARCHIVE_CHUNK_SIZE = 20_000
|
||||
const ARCHIVE_TABLE = 'public."SupplyChainProductArchive"'
|
||||
/** 聚水潭业务库链接服务器名(读取说明:JSReportReplica → JianSun_Old → JianSun)。 */
|
||||
const CLOUD_LINKED_SERVER = 'JianSun_Old'
|
||||
|
||||
function requiredConfig() {
|
||||
const server = process.env.JST_REPORT_DB_HOST?.trim()
|
||||
const database = process.env.JST_REPORT_DB_NAME?.trim()
|
||||
const user = process.env.JST_REPORT_DB_USER?.trim()
|
||||
const password = process.env.JST_REPORT_DB_PASSWORD?.trim()
|
||||
if (!server || !database || !user || !password) {
|
||||
throw new Error('聚水潭报表库连接未配置,请补充 JST_REPORT_DB_* 后读取商品库存。')
|
||||
}
|
||||
return {
|
||||
server,
|
||||
database,
|
||||
user,
|
||||
password,
|
||||
port: Number(process.env.JST_REPORT_DB_PORT?.trim() || '1433'),
|
||||
schema: process.env.JST_REPORT_DB_SCHEMA?.trim() || SCHEMA_FALLBACK,
|
||||
encrypt: process.env.JST_REPORT_DB_ENCRYPT?.trim().toLowerCase() !== 'false',
|
||||
trustServerCertificate:
|
||||
process.env.JST_REPORT_DB_TRUST_SERVER_CERTIFICATE?.trim().toLowerCase() !== 'false',
|
||||
}
|
||||
}
|
||||
|
||||
function quoteIdentifier(value: string) {
|
||||
return `[${value.replaceAll(']', ']]')}]`
|
||||
}
|
||||
|
||||
function configuredPool() {
|
||||
const globalForPool = globalThis as InventoryPoolGlobal
|
||||
if (globalForPool.productInventoryJstPool) return globalForPool.productInventoryJstPool
|
||||
const config = requiredConfig()
|
||||
const pool = new sql.ConnectionPool({
|
||||
server: config.server,
|
||||
port: config.port,
|
||||
database: config.database,
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
options: {
|
||||
encrypt: config.encrypt,
|
||||
trustServerCertificate: config.trustServerCertificate,
|
||||
readOnlyIntent: true,
|
||||
appName: 'furniture-product-inventory-readonly',
|
||||
},
|
||||
pool: { min: 0, max: 2, idleTimeoutMillis: 30_000 },
|
||||
connectionTimeout: 15_000,
|
||||
requestTimeout: 60_000,
|
||||
})
|
||||
globalForPool.productInventoryJstPool = pool
|
||||
return pool
|
||||
}
|
||||
|
||||
function toInt(value: unknown) {
|
||||
const numeric = Number(value)
|
||||
return Number.isFinite(numeric) ? Math.trunc(numeric) : 0
|
||||
}
|
||||
|
||||
export function normalizeProductInventoryBarcode(value: unknown) {
|
||||
return String(value ?? '').trim().toUpperCase()
|
||||
}
|
||||
|
||||
function cleanText(value: unknown) {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string) {
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => reject(new Error(message)), timeoutMs)
|
||||
}),
|
||||
])
|
||||
} finally {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle)
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProductInventoryRepository {
|
||||
listFinishedInventory(): Promise<JstFinishedInventoryRecord[]>
|
||||
/** 聚水潭库存明细最后修改时间(只统计成品条码),用于展示库存截止时间。 */
|
||||
getLatestInventoryModifiedAt(): Promise<string | null>
|
||||
loadArchiveProfiles(): Promise<Map<string, ProductInventoryArchiveProfile>>
|
||||
loadCloudAvailableMap(): Promise<ProductInventoryCloudAvailable>
|
||||
/** 单个条码 + 实际仓库下的各云仓库存明细(只取 qty > 0)。 */
|
||||
listCloudStockDetail(barcode: string, wmsCoId: number): Promise<ProductInventoryCloudStockRow[]>
|
||||
}
|
||||
|
||||
export class ProductInventoryRepositoryClass implements ProductInventoryRepository {
|
||||
constructor(
|
||||
private readonly poolFactory: () => sql.ConnectionPool = configuredPool,
|
||||
private readonly archivePoolFactory: () => Pool = getSupplyChainArchiveReadPool,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 读取成品库存明细:只保留 jst_sku.item_type = '成品' 的条码。
|
||||
* 结果一次性拉全(约 8 万行),由服务层缓存 + 定期刷新,页面按筛选分页展示。
|
||||
*/
|
||||
async listFinishedInventory(): Promise<JstFinishedInventoryRecord[]> {
|
||||
const config = requiredConfig()
|
||||
const schema = quoteIdentifier(config.schema)
|
||||
const pool = await this.poolFactory().connect()
|
||||
type FinishedInventoryRow = {
|
||||
barcode: string | null
|
||||
name: string | null
|
||||
qty: number | null
|
||||
orderLockQty: number | null
|
||||
wmsCoId: number | null
|
||||
}
|
||||
const result: sql.IResult<FinishedInventoryRow> = await withTimeout(
|
||||
pool.request().query<FinishedInventoryRow>(`
|
||||
SELECT
|
||||
i.sku_id AS barcode,
|
||||
i.name AS name,
|
||||
i.qty AS qty,
|
||||
i.order_lock AS orderLockQty,
|
||||
i.wms_co_id AS wmsCoId
|
||||
FROM ${schema}.[jst_inventoryInfo] AS i
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM ${schema}.[jst_sku] AS s
|
||||
WHERE s.sku_id = i.sku_id AND s.item_type = N'成品'
|
||||
)`),
|
||||
INVENTORY_QUERY_TIMEOUT_MS,
|
||||
'聚水潭库存数据源响应超时,请稍后重试。',
|
||||
)
|
||||
|
||||
return result.recordset
|
||||
.map((record) => ({
|
||||
barcode: normalizeProductInventoryBarcode(record.barcode),
|
||||
name: cleanText(record.name),
|
||||
qty: toInt(record.qty),
|
||||
orderLockQty: toInt(record.orderLockQty),
|
||||
wmsCoId: toInt(record.wmsCoId),
|
||||
}))
|
||||
.filter((record) => record.barcode)
|
||||
}
|
||||
|
||||
/** 产品全量档案按条码的最小投影;档案变动慢,由服务层用更长 TTL 缓存。 */
|
||||
async loadArchiveProfiles(): Promise<Map<string, ProductInventoryArchiveProfile>> {
|
||||
const pool = this.archivePoolFactory()
|
||||
const profiles = new Map<string, ProductInventoryArchiveProfile>()
|
||||
let lastBarcode = ''
|
||||
|
||||
for (;;) {
|
||||
const result = await pool.query<{
|
||||
barcode: string | null
|
||||
category: string | null
|
||||
sku: string | null
|
||||
productionStatus: string | null
|
||||
supplier: string | null
|
||||
purchaser: string | null
|
||||
}>(
|
||||
`SELECT
|
||||
barcode,
|
||||
"categoryL2" AS category,
|
||||
sku,
|
||||
"productStatus" AS "productionStatus",
|
||||
"supplierName" AS supplier,
|
||||
"purchaseOwner" AS purchaser
|
||||
FROM ${ARCHIVE_TABLE}
|
||||
WHERE "barcode" > $1
|
||||
ORDER BY "barcode"
|
||||
LIMIT ${ARCHIVE_CHUNK_SIZE}`,
|
||||
[lastBarcode],
|
||||
)
|
||||
if (!result.rows.length) break
|
||||
for (const row of result.rows) {
|
||||
const barcode = normalizeProductInventoryBarcode(row.barcode)
|
||||
if (!barcode) continue
|
||||
profiles.set(barcode, {
|
||||
barcode,
|
||||
category: cleanText(row.category),
|
||||
sku: cleanText(row.sku),
|
||||
productionStatus: cleanText(row.productionStatus),
|
||||
supplier: cleanText(row.supplier),
|
||||
purchaser: cleanText(row.purchaser),
|
||||
})
|
||||
}
|
||||
const tail = result.rows[result.rows.length - 1]
|
||||
lastBarcode = String(tail.barcode ?? '')
|
||||
if (result.rows.length < ARCHIVE_CHUNK_SIZE) break
|
||||
}
|
||||
|
||||
return profiles
|
||||
}
|
||||
|
||||
async getLatestInventoryModifiedAt(): Promise<string | null> {
|
||||
const config = requiredConfig()
|
||||
const schema = quoteIdentifier(config.schema)
|
||||
const pool = await this.poolFactory().connect()
|
||||
type LatestModifiedRow = { latest: Date | null }
|
||||
const result: sql.IResult<LatestModifiedRow> = await withTimeout(
|
||||
pool.request().query<LatestModifiedRow>(`
|
||||
SELECT MAX(i.modified) AS latest
|
||||
FROM ${schema}.[jst_inventoryInfo] AS i
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM ${schema}.[jst_sku] AS s
|
||||
WHERE s.sku_id = i.sku_id AND s.item_type = N'成品'
|
||||
)`),
|
||||
60_000,
|
||||
'聚水潭库存更新时间读取超时',
|
||||
)
|
||||
const latest = result.recordset[0]?.latest
|
||||
return latest ? new Date(latest).toISOString() : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 运营云仓可用数:JianSun.dbo.jst_virtualstockStocksInfo.order_able_qty 按
|
||||
* 商品编码(sku_id)+ 实际仓库(wms_co_id)求和;只取 qty > 0 的云仓库存行。
|
||||
*
|
||||
* 读取链路:JSReportReplica → 链接服务器 JianSun_Old → JianSun 业务库(OPENQUERY)。
|
||||
* 快照参考:2026-10-16 共 564 行(大约 5 个实际仓库)。
|
||||
*/
|
||||
async loadCloudAvailableMap(): Promise<ProductInventoryCloudAvailable> {
|
||||
const pool = await this.poolFactory().connect()
|
||||
try {
|
||||
type CloudStockRow = {
|
||||
barcode: string | null
|
||||
wmsCoId: number | null
|
||||
orderAbleQty: number | null
|
||||
}
|
||||
const result: sql.IResult<CloudStockRow> = await withTimeout(
|
||||
pool.request().query<CloudStockRow>(`
|
||||
SELECT sku_id AS barcode, wms_co_id AS wmsCoId, order_able_qty AS orderAbleQty
|
||||
FROM OPENQUERY([${CLOUD_LINKED_SERVER}], N'
|
||||
SELECT v.sku_id, v.wms_co_id, v.order_able_qty
|
||||
FROM JianSun.dbo.jst_virtualstockStocksInfo v
|
||||
WHERE v.qty > 0')`),
|
||||
CLOUD_QUERY_TIMEOUT_MS,
|
||||
'运营云仓数据源响应超时',
|
||||
)
|
||||
|
||||
const map = new Map<string, number>()
|
||||
for (const row of result.recordset) {
|
||||
const barcode = normalizeProductInventoryBarcode(row.barcode)
|
||||
if (!barcode) continue
|
||||
const key = `${barcode}|${toInt(row.wmsCoId)}`
|
||||
map.set(key, (map.get(key) ?? 0) + toInt(row.orderAbleQty))
|
||||
}
|
||||
return { available: true, message: '', map }
|
||||
} catch (cause) {
|
||||
console.error('[product-inventory] cloud stock read failed', cause)
|
||||
return {
|
||||
available: false,
|
||||
message: '运营云仓数据源读取失败,请稍后重试',
|
||||
map: new Map(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async listCloudStockDetail(
|
||||
barcode: string,
|
||||
wmsCoId: number,
|
||||
): Promise<ProductInventoryCloudStockRow[]> {
|
||||
const pool = await this.poolFactory().connect()
|
||||
// OPENQUERY 内层 SQL 不支持参数化,先做严格校验,再对单引号做转义。
|
||||
// OPENQUERY 外层字符串需要再处理一次单引号:输入 ' 在内层是 '',外层要写成 ''''。
|
||||
const safeBarcode = String(barcode).trim().toUpperCase().slice(0, 80).replaceAll("'", "''''")
|
||||
const safeWmsCoId = Math.trunc(Number(wmsCoId))
|
||||
if (!safeBarcode || !Number.isFinite(safeWmsCoId) || safeWmsCoId <= 0) return []
|
||||
|
||||
type CloudDetailRow = {
|
||||
cloudWarehouseName: string | null
|
||||
qty: number | null
|
||||
orderAbleQty: number | null
|
||||
orderLock: number | null
|
||||
}
|
||||
const result: sql.IResult<CloudDetailRow> = await withTimeout(
|
||||
pool.request().query<CloudDetailRow>(`
|
||||
SELECT
|
||||
name AS cloudWarehouseName,
|
||||
qty,
|
||||
order_able_qty AS orderAbleQty,
|
||||
order_lock AS orderLock
|
||||
FROM OPENQUERY([${CLOUD_LINKED_SERVER}], N'
|
||||
SELECT v.name, v.qty, v.order_able_qty, v.order_lock
|
||||
FROM JianSun.dbo.jst_virtualstockStocksInfo v
|
||||
WHERE v.qty > 0
|
||||
AND v.sku_id = N''${safeBarcode}''
|
||||
AND v.wms_co_id = ${safeWmsCoId}')`),
|
||||
CLOUD_QUERY_TIMEOUT_MS,
|
||||
'运营云仓明细响应超时',
|
||||
)
|
||||
return result.recordset.map((row) => ({
|
||||
cloudWarehouseName: cleanText(row.cloudWarehouseName),
|
||||
qty: toInt(row.qty),
|
||||
orderAbleQty: toInt(row.orderAbleQty),
|
||||
orderLock: toInt(row.orderLock),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export const productInventoryRepository = new ProductInventoryRepositoryClass()
|
||||
@ -0,0 +1,275 @@
|
||||
import type { ProductInventoryArchiveProfile } from '@/lib/product-inventory'
|
||||
import {
|
||||
productInventoryRepository,
|
||||
type JstFinishedInventoryRecord,
|
||||
} from '@/server/repositories/product-inventory.repository'
|
||||
import { supplyChainProductArchiveWarehouseConfigService } from '@/server/services/supply-chain-product-archive-warehouse-config.service'
|
||||
import {
|
||||
clearProductInventoryDatasetCache,
|
||||
ProductInventoryService,
|
||||
} from './product-inventory.service'
|
||||
|
||||
jest.mock('@/server/repositories/product-inventory.repository', () => ({
|
||||
productInventoryRepository: {
|
||||
listFinishedInventory: jest.fn(),
|
||||
getLatestInventoryModifiedAt: jest.fn(),
|
||||
loadArchiveProfiles: jest.fn(),
|
||||
loadCloudAvailableMap: jest.fn(),
|
||||
listCloudStockDetail: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/server/services/supply-chain-product-archive-warehouse-config.service', () => ({
|
||||
supplyChainProductArchiveWarehouseConfigService: {
|
||||
list: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockedRepository = productInventoryRepository as unknown as {
|
||||
listFinishedInventory: jest.Mock
|
||||
getLatestInventoryModifiedAt: jest.Mock
|
||||
loadArchiveProfiles: jest.Mock
|
||||
loadCloudAvailableMap: jest.Mock
|
||||
listCloudStockDetail: jest.Mock
|
||||
}
|
||||
const mockedWarehouseConfig = supplyChainProductArchiveWarehouseConfigService as unknown as {
|
||||
list: jest.Mock
|
||||
}
|
||||
|
||||
function inventoryRecord(overrides: Partial<JstFinishedInventoryRecord>): JstFinishedInventoryRecord {
|
||||
return {
|
||||
barcode: 'BAR-1',
|
||||
name: '商品一',
|
||||
qty: 10,
|
||||
orderLockQty: 2,
|
||||
wmsCoId: 101,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function archiveProfile(overrides: Partial<ProductInventoryArchiveProfile>): ProductInventoryArchiveProfile {
|
||||
return {
|
||||
barcode: 'BAR-1',
|
||||
category: '置物架',
|
||||
sku: 'SKU-1',
|
||||
productionStatus: '销售',
|
||||
supplier: '供应商甲',
|
||||
purchaser: '土元',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function warehouseListResult(items: Array<Record<string, unknown>>) {
|
||||
return {
|
||||
items,
|
||||
pageInfo: { page: 1, pageSize: 200, total: items.length, totalPages: 1 },
|
||||
facets: { heads: [], wmsCoIds: [], names: [], attributes: [], createdDates: [], updatedDates: [], statuses: [] },
|
||||
}
|
||||
}
|
||||
|
||||
function defaultWarehouseItems() {
|
||||
return [
|
||||
{ wmsCoId: 101, warehouseName: '仓一', warehouseAttribute: '成品发货仓-真实库存', warehouseOwnership: 'JIANSHANG', warehouseHead: '土元' },
|
||||
{ wmsCoId: 102, warehouseName: '仓二', warehouseAttribute: '工厂仓', warehouseOwnership: 'FACTORY', warehouseHead: '千里眼' },
|
||||
]
|
||||
}
|
||||
|
||||
describe('商品库存管理服务', () => {
|
||||
beforeEach(() => {
|
||||
clearProductInventoryDatasetCache()
|
||||
jest.clearAllMocks()
|
||||
mockedRepository.listFinishedInventory.mockResolvedValue([
|
||||
inventoryRecord({}),
|
||||
inventoryRecord({ barcode: 'BAR-2', name: '商品二', qty: 5, orderLockQty: 0, wmsCoId: 102 }),
|
||||
])
|
||||
mockedRepository.loadArchiveProfiles.mockResolvedValue(
|
||||
new Map([['BAR-1', archiveProfile({})]]),
|
||||
)
|
||||
mockedRepository.getLatestInventoryModifiedAt.mockResolvedValue(
|
||||
'2026-10-17T07:00:00.000Z',
|
||||
)
|
||||
mockedRepository.loadCloudAvailableMap.mockResolvedValue({
|
||||
available: false,
|
||||
message: '运营云仓数据源(jst_virtualstockStocksInfo)尚未接通',
|
||||
map: new Map(),
|
||||
})
|
||||
mockedWarehouseConfig.list.mockResolvedValue(warehouseListResult(defaultWarehouseItems()))
|
||||
mockedRepository.listCloudStockDetail.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('组装页面数据:档案/仓库按条码与仓库编号匹配,云仓未接通时留空', async () => {
|
||||
const service = new ProductInventoryService()
|
||||
const page = await service.getPage({ page: 1, pageSize: 50, filters: {}, sort: null })
|
||||
|
||||
expect(page.total).toBe(2)
|
||||
expect(page.rows[0]).toMatchObject({
|
||||
barcode: 'BAR-1',
|
||||
category: '置物架',
|
||||
sku: 'SKU-1',
|
||||
productName: '商品一',
|
||||
stockQty: 10,
|
||||
orderLockQty: 2,
|
||||
availableQty: 8,
|
||||
cloudAvailableQty: null,
|
||||
publicAvailableQty: null,
|
||||
warehouseName: '仓一',
|
||||
warehouseAttribute: '成品发货仓-真实库存',
|
||||
warehouseOwnership: '简尚',
|
||||
warehouseHead: '土元',
|
||||
})
|
||||
// 档案里没有 BAR-2:对应列为空,但不影响整行。
|
||||
expect(page.rows[1]).toMatchObject({
|
||||
barcode: 'BAR-2',
|
||||
category: '',
|
||||
sku: '',
|
||||
availableQty: 5,
|
||||
warehouseName: '仓二',
|
||||
warehouseOwnership: '工厂',
|
||||
})
|
||||
expect(page.sourceStatus).toMatchObject({
|
||||
inventoryReady: true,
|
||||
archiveReady: true,
|
||||
warehouseReady: true,
|
||||
cloudReady: false,
|
||||
inventoryRowCount: 2,
|
||||
archiveMatchedCount: 1,
|
||||
})
|
||||
// 中文候选值按拼音排序:仓二(er) 在 仓一(yi) 之前。
|
||||
expect(page.facets.warehouseName).toEqual(['仓二', '仓一'])
|
||||
expect(page.subtotals).toEqual({
|
||||
stockQty: 15,
|
||||
orderLockQty: 2,
|
||||
availableQty: 13,
|
||||
publicAvailableQty: 0,
|
||||
cloudAvailableQty: 0,
|
||||
})
|
||||
expect(page.sourceStatus.sourceUpdatedAt).toBe('2026-10-17T07:00:00.000Z')
|
||||
})
|
||||
|
||||
it('云仓数据源接通时:按条码+仓库编号汇总云仓可用数并计算公有可用数', async () => {
|
||||
mockedRepository.loadCloudAvailableMap.mockResolvedValue({
|
||||
available: true,
|
||||
message: '',
|
||||
map: new Map([
|
||||
['BAR-1|101', 5],
|
||||
['BAR-2|102', 1],
|
||||
]),
|
||||
})
|
||||
const service = new ProductInventoryService()
|
||||
const page = await service.getPage({ page: 1, pageSize: 50, filters: {}, sort: null })
|
||||
|
||||
expect(page.rows[0]).toMatchObject({ cloudAvailableQty: 5, publicAvailableQty: 3 })
|
||||
expect(page.rows[1]).toMatchObject({ cloudAvailableQty: 1, publicAvailableQty: 4 })
|
||||
expect(page.sourceStatus.cloudReady).toBe(true)
|
||||
expect(page.sourceStatus.cloudMessage).toBe('')
|
||||
})
|
||||
|
||||
it('剔除同行各数值字段都为 0 的明细,保留仅有云仓库存的行', async () => {
|
||||
mockedRepository.listFinishedInventory.mockResolvedValue([
|
||||
inventoryRecord({}),
|
||||
inventoryRecord({ barcode: 'BAR-ZERO', name: '全零行', qty: 0, orderLockQty: 0, wmsCoId: 102 }),
|
||||
inventoryRecord({ barcode: 'BAR-CLOUD', name: '仅云仓库存', qty: 0, orderLockQty: 0, wmsCoId: 102 }),
|
||||
])
|
||||
mockedRepository.loadCloudAvailableMap.mockResolvedValue({
|
||||
available: true,
|
||||
message: '',
|
||||
map: new Map([['BAR-CLOUD|102', 3]]),
|
||||
})
|
||||
const service = new ProductInventoryService()
|
||||
const page = await service.getPage({ page: 1, pageSize: 50, filters: {}, sort: null })
|
||||
|
||||
expect(page.total).toBe(2)
|
||||
expect(page.rows.map((row) => row.barcode)).toEqual(['BAR-1', 'BAR-CLOUD'])
|
||||
expect(page.rows[1]).toMatchObject({ cloudAvailableQty: 3, publicAvailableQty: -3 })
|
||||
})
|
||||
|
||||
it('云仓明细下钻:按条码+仓库编号返回各云仓库存与合计', async () => {
|
||||
mockedRepository.listCloudStockDetail.mockResolvedValue([
|
||||
{ cloudWarehouseName: '渠道-分销部线下云仓', qty: 12, orderAbleQty: 7, orderLock: 3 },
|
||||
{ cloudWarehouseName: '渠道-外贸部线下云仓', qty: 5, orderAbleQty: 2, orderLock: 1 },
|
||||
])
|
||||
const service = new ProductInventoryService()
|
||||
const detail = await service.getCloudStockDetail({ barcode: 'bar-1', wmsCoId: 101 })
|
||||
|
||||
expect(detail.barcode).toBe('BAR-1')
|
||||
expect(detail.rows).toHaveLength(2)
|
||||
expect(detail.totals).toEqual({ qty: 17, orderAbleQty: 9, orderLock: 4 })
|
||||
expect(mockedRepository.listCloudStockDetail).toHaveBeenCalledWith('BAR-1', 101)
|
||||
await expect(
|
||||
service.getCloudStockDetail({ barcode: "bad'code", wmsCoId: 101 }),
|
||||
).rejects.toThrow('条码格式不正确')
|
||||
})
|
||||
|
||||
it('支持文本点选筛选、数值筛选、排序与分页', async () => {
|
||||
const service = new ProductInventoryService()
|
||||
|
||||
const filtered = await service.getPage({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
filters: { warehouseName: { values: ['仓二'] } },
|
||||
sort: null,
|
||||
})
|
||||
expect(filtered.total).toBe(1)
|
||||
expect(filtered.rows[0].barcode).toBe('BAR-2')
|
||||
|
||||
const numeric = await service.getPage({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
filters: { availableQty: { gt: 6 } },
|
||||
sort: null,
|
||||
})
|
||||
expect(numeric.total).toBe(1)
|
||||
expect(numeric.rows[0].barcode).toBe('BAR-1')
|
||||
|
||||
const sorted = await service.getPage({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
filters: {},
|
||||
sort: { key: 'availableQty', direction: 'desc' },
|
||||
})
|
||||
expect(sorted.total).toBe(2)
|
||||
expect(sorted.totalPages).toBe(2)
|
||||
expect(sorted.rows[0].barcode).toBe('BAR-1')
|
||||
|
||||
const secondPage = await service.getPage({
|
||||
page: 2,
|
||||
pageSize: 1,
|
||||
filters: {},
|
||||
sort: { key: 'availableQty', direction: 'desc' },
|
||||
})
|
||||
expect(secondPage.page).toBe(2)
|
||||
expect(secondPage.rows[0].barcode).toBe('BAR-2')
|
||||
})
|
||||
|
||||
it('数据集带缓存,refresh 时重新拉取数据源', async () => {
|
||||
const service = new ProductInventoryService()
|
||||
await service.getPage({ page: 1, pageSize: 50, filters: {}, sort: null })
|
||||
await service.getPage({ page: 1, pageSize: 50, filters: {}, sort: null })
|
||||
expect(mockedRepository.listFinishedInventory).toHaveBeenCalledTimes(1)
|
||||
|
||||
await service.getPage({ page: 1, pageSize: 50, filters: {}, sort: null, refresh: true })
|
||||
expect(mockedRepository.listFinishedInventory).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('聚水潭数据源失败时抛出中文错误,下次调用会重新尝试', async () => {
|
||||
mockedRepository.listFinishedInventory.mockRejectedValue(new Error('ECONNREFUSED'))
|
||||
const service = new ProductInventoryService()
|
||||
|
||||
await expect(service.getPage({ page: 1, pageSize: 50, filters: {}, sort: null }))
|
||||
.rejects.toThrow('聚水潭库存数据源读取失败')
|
||||
await expect(service.getPage({ page: 1, pageSize: 50, filters: {}, sort: null }))
|
||||
.rejects.toThrow('聚水潭库存数据源读取失败')
|
||||
expect(mockedRepository.listFinishedInventory).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('档案或仓库配置失败时降级为空列并给出提示,不阻断页面', async () => {
|
||||
mockedRepository.loadArchiveProfiles.mockRejectedValue(new Error('pg down'))
|
||||
const service = new ProductInventoryService()
|
||||
const page = await service.getPage({ page: 1, pageSize: 50, filters: {}, sort: null })
|
||||
|
||||
expect(page.total).toBe(2)
|
||||
expect(page.rows[0].category).toBe('')
|
||||
expect(page.sourceStatus.archiveReady).toBe(false)
|
||||
expect(page.sourceStatus.warnings.join('')).toContain('产品全量档案数据源不可用')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,310 @@
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import {
|
||||
buildProductInventoryRow,
|
||||
compareProductInventoryRows,
|
||||
computeProductInventoryFacets,
|
||||
computeProductInventorySubtotals,
|
||||
isProductInventoryZeroNumericRow,
|
||||
matchesProductInventoryFilters,
|
||||
PRODUCT_INVENTORY_COLUMNS,
|
||||
PRODUCT_INVENTORY_MAX_PAGE_SIZE,
|
||||
type ProductInventoryArchiveProfile,
|
||||
type ProductInventoryColumnKey,
|
||||
type ProductInventoryCloudStockDetail,
|
||||
type ProductInventoryFilters,
|
||||
type ProductInventoryPage,
|
||||
type ProductInventoryRow,
|
||||
type ProductInventorySort,
|
||||
type ProductInventoryWarehouseProfile,
|
||||
} from '@/lib/product-inventory'
|
||||
import { productArchiveWarehouseOwnershipLabel } from '@/lib/supply-chain-product-archive-warehouse-config'
|
||||
import {
|
||||
productInventoryRepository,
|
||||
type ProductInventoryRepository,
|
||||
} from '@/server/repositories/product-inventory.repository'
|
||||
import { supplyChainProductArchiveWarehouseConfigService } from '@/server/services/supply-chain-product-archive-warehouse-config.service'
|
||||
|
||||
// 商品库存管理:数据组装 + 缓存(过期后台刷新,页面始终快速返回)+ 筛选/排序/分页。
|
||||
|
||||
/** 库存与档案的全量组装结果;聚水潭一次性拉全约需 15~25 秒,缓存由「更新」按钮强制刷新。 */
|
||||
const DATASET_TTL_MS = 3 * 60 * 1000
|
||||
|
||||
type ProductInventoryDataset = {
|
||||
rows: ProductInventoryRow[]
|
||||
facets: ProductInventoryPage['facets']
|
||||
sourceStatus: ProductInventoryPage['sourceStatus']
|
||||
}
|
||||
|
||||
type ServiceCacheGlobal = typeof globalThis & {
|
||||
productInventoryDataset?: { builtAtMs: number; dataset: ProductInventoryDataset }
|
||||
productInventoryBuilding?: Promise<ProductInventoryDataset>
|
||||
}
|
||||
|
||||
export function clearProductInventoryDatasetCache() {
|
||||
const globalForCache = globalThis as ServiceCacheGlobal
|
||||
delete globalForCache.productInventoryDataset
|
||||
delete globalForCache.productInventoryBuilding
|
||||
}
|
||||
|
||||
export type ProductInventoryPageInput = {
|
||||
page: number
|
||||
pageSize: number
|
||||
filters: ProductInventoryFilters
|
||||
sort: ProductInventorySort | null
|
||||
/** 「更新」按钮:等待重新拉取数据源后再返回。 */
|
||||
refresh?: boolean
|
||||
}
|
||||
|
||||
function exportFileStamp(date: Date) {
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
export class ProductInventoryService {
|
||||
constructor(
|
||||
private readonly repository: ProductInventoryRepository = productInventoryRepository,
|
||||
private readonly warehouseConfigService: typeof supplyChainProductArchiveWarehouseConfigService =
|
||||
supplyChainProductArchiveWarehouseConfigService,
|
||||
) {}
|
||||
|
||||
/** 仓库配置(含聚水潭仓库编号/属性与人工配置的归属/负责人),按 wms_co_id 索引。 */
|
||||
private async loadWarehouseIndex() {
|
||||
const byWmsCoId = new Map<number, ProductInventoryWarehouseProfile>()
|
||||
let page = 1
|
||||
for (;;) {
|
||||
const result = await this.warehouseConfigService.list({ page, pageSize: PRODUCT_INVENTORY_MAX_PAGE_SIZE })
|
||||
for (const item of result.items) {
|
||||
if (item.wmsCoId === null) continue
|
||||
byWmsCoId.set(item.wmsCoId, {
|
||||
wmsCoId: item.wmsCoId,
|
||||
name: item.warehouseName,
|
||||
attribute: item.warehouseAttribute ?? '',
|
||||
ownershipLabel: item.warehouseOwnership
|
||||
? productArchiveWarehouseOwnershipLabel(item.warehouseOwnership)
|
||||
: '',
|
||||
head: item.warehouseHead || '',
|
||||
})
|
||||
}
|
||||
const { page: currentPage, totalPages } = result.pageInfo
|
||||
if (currentPage >= totalPages || page > 20) break
|
||||
page += 1
|
||||
}
|
||||
return byWmsCoId
|
||||
}
|
||||
|
||||
private async buildDataset(): Promise<ProductInventoryDataset> {
|
||||
const warnings: string[] = []
|
||||
|
||||
const [inventory, archive, cloud, warehouseIndex, latestModifiedAt] = await Promise.all([
|
||||
this.repository.listFinishedInventory().catch((cause) => {
|
||||
throw new AppError(
|
||||
ErrorCode.SYSTEM_INTERNAL,
|
||||
`聚水潭库存数据源读取失败:${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
)
|
||||
}),
|
||||
this.repository.loadArchiveProfiles().catch((cause) => {
|
||||
console.error('[product-inventory] archive read failed', cause)
|
||||
warnings.push('产品全量档案数据源不可用:分类/SKU/生产状态/供应商/采购暂为空。')
|
||||
return new Map<string, ProductInventoryArchiveProfile>()
|
||||
}),
|
||||
this.repository.loadCloudAvailableMap().catch((cause) => {
|
||||
console.error('[product-inventory] cloud stock read failed', cause)
|
||||
return {
|
||||
available: false,
|
||||
message: '运营云仓数据源读取失败',
|
||||
map: new Map<string, number>(),
|
||||
}
|
||||
}),
|
||||
this.loadWarehouseIndex().catch((cause) => {
|
||||
console.error('[product-inventory] warehouse config read failed', cause)
|
||||
warnings.push('仓库配置读取失败:仓库名称/属性/归属/负责人暂为空。')
|
||||
return new Map<number, ProductInventoryWarehouseProfile>()
|
||||
}),
|
||||
this.repository.getLatestInventoryModifiedAt().catch((cause) => {
|
||||
console.error('[product-inventory] latest modified time read failed', cause)
|
||||
return null
|
||||
}),
|
||||
])
|
||||
|
||||
const assembled = inventory.map((record) => {
|
||||
const archiveProfile = archive.get(record.barcode)
|
||||
const warehouseProfile = warehouseIndex.get(record.wmsCoId)
|
||||
// 云仓可用数按「条码 + 实际仓库编号」归集,和库存行的仓库编号直接对齐。
|
||||
const cloudAvailableQty = cloud.available
|
||||
? cloud.map.get(`${record.barcode}|${record.wmsCoId}`) ?? 0
|
||||
: null
|
||||
return {
|
||||
archiveMatched: Boolean(archiveProfile),
|
||||
row: buildProductInventoryRow(record, {
|
||||
archive: archiveProfile,
|
||||
warehouse: warehouseProfile,
|
||||
cloudAvailableQty,
|
||||
}),
|
||||
}
|
||||
})
|
||||
// 剔除同行各数值字段全为 0 的明细(没有任何库存意义的行)。
|
||||
const kept = assembled.filter(({ row }) => !isProductInventoryZeroNumericRow(row))
|
||||
const rows = kept.map(({ row }) => row)
|
||||
const archiveMatchedCount = kept.filter(({ archiveMatched }) => archiveMatched).length
|
||||
|
||||
return {
|
||||
rows,
|
||||
facets: computeProductInventoryFacets(rows),
|
||||
sourceStatus: {
|
||||
builtAt: new Date().toISOString(),
|
||||
sourceUpdatedAt: latestModifiedAt,
|
||||
inventoryReady: true,
|
||||
archiveReady: warnings.every((warning) => !warning.startsWith('产品全量档案')),
|
||||
warehouseReady: warnings.every((warning) => !warning.startsWith('仓库配置')),
|
||||
cloudReady: cloud.available,
|
||||
cloudMessage: cloud.message,
|
||||
inventoryRowCount: rows.length,
|
||||
archiveMatchedCount,
|
||||
warnings,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private rebuild() {
|
||||
const globalForCache = globalThis as ServiceCacheGlobal
|
||||
if (globalForCache.productInventoryBuilding) return globalForCache.productInventoryBuilding
|
||||
const building = this.buildDataset()
|
||||
.then((dataset) => {
|
||||
globalForCache.productInventoryDataset = { builtAtMs: Date.now(), dataset }
|
||||
globalForCache.productInventoryBuilding = undefined
|
||||
return dataset
|
||||
})
|
||||
.catch((error) => {
|
||||
globalForCache.productInventoryBuilding = undefined
|
||||
throw error
|
||||
})
|
||||
globalForCache.productInventoryBuilding = building
|
||||
return building
|
||||
}
|
||||
|
||||
/** 取缓存数据集:过期时先用旧数据响应并后台刷新;refresh 或首次加载时等待重建。 */
|
||||
private async ensureDataset(refresh: boolean): Promise<ProductInventoryDataset> {
|
||||
const globalForCache = globalThis as ServiceCacheGlobal
|
||||
const cached = globalForCache.productInventoryDataset
|
||||
|
||||
if (refresh || !cached) {
|
||||
await this.rebuild()
|
||||
} else if (Date.now() - cached.builtAtMs > DATASET_TTL_MS) {
|
||||
// 过期先用旧数据兜底,后台刷新;失败时保留旧结果并在下次访问重试。
|
||||
void this.rebuild().catch((error) => {
|
||||
console.error('[product-inventory] background refresh failed', error)
|
||||
})
|
||||
}
|
||||
|
||||
const dataset = (globalThis as ServiceCacheGlobal).productInventoryDataset?.dataset
|
||||
if (!dataset) {
|
||||
throw new AppError(
|
||||
ErrorCode.SYSTEM_INTERNAL,
|
||||
'商品库存数据尚未就绪,请点击「更新」重试。',
|
||||
)
|
||||
}
|
||||
return dataset
|
||||
}
|
||||
|
||||
async getPage(input: ProductInventoryPageInput): Promise<ProductInventoryPage> {
|
||||
const dataset = await this.ensureDataset(Boolean(input.refresh))
|
||||
|
||||
const pageSize = Math.min(PRODUCT_INVENTORY_MAX_PAGE_SIZE, Math.max(1, Math.floor(input.pageSize)))
|
||||
const filtered = dataset.rows.filter((row) => matchesProductInventoryFilters(row, input.filters))
|
||||
const sorted = input.sort
|
||||
? [...filtered].sort((left, right) => compareProductInventoryRows(left, right, input.sort!))
|
||||
: filtered
|
||||
const total = sorted.length
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const page = Math.min(Math.max(1, Math.floor(input.page)), totalPages)
|
||||
const rows = sorted.slice((page - 1) * pageSize, page * pageSize)
|
||||
|
||||
return {
|
||||
rows,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages,
|
||||
subtotals: computeProductInventorySubtotals(filtered),
|
||||
facets: dataset.facets,
|
||||
sourceStatus: dataset.sourceStatus,
|
||||
}
|
||||
}
|
||||
|
||||
/** 导出用全量明细:当前筛选 + 排序下的所有行(不分页)。 */
|
||||
async listForExport(input: {
|
||||
filters: ProductInventoryFilters
|
||||
sort: ProductInventorySort | null
|
||||
}): Promise<ProductInventoryRow[]> {
|
||||
const dataset = await this.ensureDataset(false)
|
||||
const filtered = dataset.rows.filter((row) => matchesProductInventoryFilters(row, input.filters))
|
||||
return input.sort
|
||||
? [...filtered].sort((left, right) => compareProductInventoryRows(left, right, input.sort!))
|
||||
: filtered
|
||||
}
|
||||
|
||||
/** 生成导出工作簿(仅包含用户勾选的字段,列顺序与选择顺序一致)。 */
|
||||
async createExportWorkbook(input: {
|
||||
columns: ProductInventoryColumnKey[]
|
||||
filters: ProductInventoryFilters
|
||||
sort: ProductInventorySort | null
|
||||
}): Promise<{ content: Buffer; fileName: string }> {
|
||||
const columns = input.columns
|
||||
.map((key) => PRODUCT_INVENTORY_COLUMNS.find((column) => column.key === key))
|
||||
.filter((column): column is NonNullable<typeof column> => Boolean(column))
|
||||
if (!columns.length) {
|
||||
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请至少选择一个导出字段')
|
||||
}
|
||||
|
||||
const rows = await this.listForExport({ filters: input.filters, sort: input.sort })
|
||||
// 动态引入 exceljs:避免页面/接口冷启动时加载导出依赖,也便于测试环境隔离。
|
||||
const ExcelJS = (await import('exceljs')).default
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
const worksheet = workbook.addWorksheet('商品库存')
|
||||
worksheet.columns = columns.map((column) => ({
|
||||
header: column.label,
|
||||
key: column.key,
|
||||
width: Math.min(40, Math.max(10, Math.round(column.width / 8))),
|
||||
}))
|
||||
worksheet.views = [{ state: 'frozen', ySplit: 1 }]
|
||||
worksheet.getRow(1).font = { bold: true }
|
||||
for (const row of rows) {
|
||||
worksheet.addRow(Object.fromEntries(
|
||||
columns.map((column) => [column.key, row[column.key] ?? '']),
|
||||
))
|
||||
}
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer()
|
||||
return {
|
||||
content: Buffer.from(buffer as ArrayBuffer),
|
||||
fileName: `商品库存_${exportFileStamp(new Date())}.xlsx`,
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击「运营云仓可用数」下钻:该条码在该实际仓库下的各云仓库存明细。 */
|
||||
async getCloudStockDetail(input: {
|
||||
barcode: string
|
||||
wmsCoId: number
|
||||
}): Promise<ProductInventoryCloudStockDetail> {
|
||||
const barcode = String(input.barcode ?? '').trim().toUpperCase()
|
||||
if (!/^[A-Z0-9._-]{1,80}$/.test(barcode)) {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '条码格式不正确')
|
||||
}
|
||||
const wmsCoId = Math.trunc(Number(input.wmsCoId))
|
||||
if (!Number.isFinite(wmsCoId) || wmsCoId <= 0) {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '仓库编号不正确')
|
||||
}
|
||||
const rows = await this.repository.listCloudStockDetail(barcode, wmsCoId)
|
||||
const totals = rows.reduce(
|
||||
(accumulator, row) => ({
|
||||
qty: accumulator.qty + row.qty,
|
||||
orderAbleQty: accumulator.orderAbleQty + row.orderAbleQty,
|
||||
orderLock: accumulator.orderLock + row.orderLock,
|
||||
}),
|
||||
{ qty: 0, orderAbleQty: 0, orderLock: 0 },
|
||||
)
|
||||
return { barcode, wmsCoId, rows, totals }
|
||||
}
|
||||
}
|
||||
|
||||
export const productInventoryService = new ProductInventoryService()
|
||||
Loading…
x
Reference in New Issue
Block a user