feat(supply-chain): 新增商品库存管理(库存/云仓/筛选/导出/快捷查询)

- 页面:成品库存 × 实际仓库,按条码匹配产品档案与仓库配置
- 服务:聚水潭成品库存全量读取与缓存、筛选/排序/分页、小计、Excel 导出
- 云仓:链接服务器 OPENQUERY 汇总 order_able_qty,支持云仓明细下钻
- 接入:菜单、标签页、权限节点 productInventory
This commit is contained in:
WuYongJie 2026-10-17 22:35:29 +08:00 committed by WuYongJie
parent d8b5006e44
commit 1b849b0209
18 changed files with 4370 additions and 0 deletions

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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代发数据明细',

View File

@ -59,6 +59,7 @@ describe('supply chain menu', () => {
'新品开发进度总表',
'类目配置',
'仓库管理',
'商品库存管理',
'店铺账号管理',
])
})

View File

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

View File

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

View File

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

View 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)
})
})

View 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
}

View File

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

View File

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

View File

@ -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 导单店铺销售、履约和退款明细' },

View File

@ -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')
})
})

View File

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

View File

@ -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('产品全量档案数据源不可用')
})
})

View File

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