Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wuyongjie-assisted-assembly-next
This commit is contained in:
commit
66491cf49e
@ -75,7 +75,6 @@ dist
|
||||
|
||||
# Gatsby文件
|
||||
.cache/
|
||||
public
|
||||
|
||||
# Vuepress构建输出
|
||||
.vuepress/dist
|
||||
|
||||
@ -7,10 +7,9 @@
|
||||
# ============================================
|
||||
# 阶段 1: 基础镜像
|
||||
# ============================================
|
||||
FROM node:18-alpine AS base
|
||||
FROM node:24-alpine AS base
|
||||
|
||||
# 使用阿里云镜像源加速(中国网络优化)
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
# 保留官方 Alpine 源,避免镜像站同步滞后导致 apk 包 404。
|
||||
|
||||
# ============================================
|
||||
# 阶段 2: 依赖安装 (deps)
|
||||
@ -71,6 +70,11 @@ FROM base AS builder
|
||||
ARG DATABASE_URL="postgresql://user:password@localhost:5432/placeholder_db?schema=public"
|
||||
ARG NEXTAUTH_URL=""
|
||||
ARG NEXT_PUBLIC_APP_URL=""
|
||||
ARG S3_ENDPOINT="http://localhost:9000"
|
||||
ARG S3_PUBLIC_DOMAIN="http://localhost:9000/furniture-3d-models"
|
||||
ARG S3_BUCKET="furniture-3d-models"
|
||||
ARG S3_ACCESS_KEY="build-only"
|
||||
ARG S3_SECRET_KEY="build-only"
|
||||
|
||||
# Prisma 引擎缓存配置
|
||||
ARG PRISMA_ENGINE_CACHE_FILE_NAME="libquery_engine-linux-musl-openssl-3.0.x.so.node"
|
||||
@ -82,6 +86,12 @@ ENV PRISMA_SCHEMA_ENGINE_CACHE_FILE_NAME=${PRISMA_SCHEMA_ENGINE_CACHE_FILE_NAME}
|
||||
ENV DATABASE_URL=${DATABASE_URL}
|
||||
ENV NEXTAUTH_URL=${NEXTAUTH_URL}
|
||||
ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
|
||||
ENV S3_ENDPOINT=${S3_ENDPOINT}
|
||||
ENV S3_PUBLIC_DOMAIN=${S3_PUBLIC_DOMAIN}
|
||||
ENV S3_BUCKET=${S3_BUCKET}
|
||||
ENV S3_ACCESS_KEY=${S3_ACCESS_KEY}
|
||||
ENV S3_SECRET_KEY=${S3_SECRET_KEY}
|
||||
ENV PUPPETEER_SKIP_DOWNLOAD=true
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
ENV PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1
|
||||
|
||||
1
furniture-3d-system/local-packages/npm-cache/node_modules/.gitkeep
generated
vendored
Normal file
1
furniture-3d-system/local-packages/npm-cache/node_modules/.gitkeep
generated
vendored
Normal file
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1 @@
|
||||
|
||||
16
furniture-3d-system/package-lock.json
generated
16
furniture-3d-system/package-lock.json
generated
@ -34,6 +34,7 @@
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"mssql": "^12.5.0",
|
||||
"mysql2": "^3.15.3",
|
||||
"nanoid": "^5.1.6",
|
||||
"next": "^16.1.6",
|
||||
"obj2gltf": "^3.2.0",
|
||||
@ -3016,7 +3017,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/@img/colour/-/colour-1.0.0.tgz",
|
||||
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@ -7521,7 +7521,6 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
@ -9031,7 +9030,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@ -10698,7 +10696,6 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-property": "^1.0.2"
|
||||
@ -11710,7 +11707,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-regex": {
|
||||
@ -13844,7 +13840,6 @@
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz",
|
||||
"integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"bun": ">=1.0.0",
|
||||
@ -14170,7 +14165,6 @@
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
|
||||
"integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.1",
|
||||
@ -14191,7 +14185,6 @@
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@ -14208,7 +14201,6 @@
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
|
||||
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lru.min": "^1.1.0"
|
||||
@ -16156,8 +16148,7 @@
|
||||
"node_modules/seq-queue": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
|
||||
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==",
|
||||
"dev": true
|
||||
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
@ -16220,7 +16211,6 @@
|
||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.0.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
@ -16264,7 +16254,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
@ -16486,7 +16475,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
|
||||
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
|
||||
@ -25,7 +25,8 @@
|
||||
"demo:refresh": "npm run prisma:push && npm run demo:update",
|
||||
"thumbnails:backfill": "tsx scripts/backfill-asset-variant-thumbnails.ts",
|
||||
"materials:sync-textures": "tsx scripts/sync-material-textures.ts",
|
||||
"materials:sync-standard": "tsx scripts/sync-standard-finishes.ts"
|
||||
"materials:sync-standard": "tsx scripts/sync-standard-finishes.ts",
|
||||
"sync:jst-sku-stock": "tsx scripts/sync-jst-sku-stock-products.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.948.0",
|
||||
@ -54,6 +55,7 @@
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"mssql": "^12.5.0",
|
||||
"mysql2": "^3.15.3",
|
||||
"nanoid": "^5.1.6",
|
||||
"next": "^16.1.6",
|
||||
"obj2gltf": "^3.2.0",
|
||||
|
||||
107
furniture-3d-system/scripts/sync-jst-sku-stock-products.ts
Normal file
107
furniture-3d-system/scripts/sync-jst-sku-stock-products.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import 'dotenv/config'
|
||||
import { prisma } from '../src/lib/prisma'
|
||||
import { syncJstSkuStockProducts } from '../src/server/services/jst-sku-stock-sync.service'
|
||||
|
||||
type CliOptions = {
|
||||
apply: boolean
|
||||
batchSize?: number
|
||||
limit?: number
|
||||
help: boolean
|
||||
}
|
||||
|
||||
function readNumberArg(value: string | undefined, name: string) {
|
||||
if (!value) return undefined
|
||||
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error(`${name} must be a positive number`)
|
||||
}
|
||||
|
||||
return Math.floor(parsed)
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
const options: CliOptions = {
|
||||
apply: false,
|
||||
help: false,
|
||||
}
|
||||
|
||||
argv.forEach((arg) => {
|
||||
if (arg === '--apply') {
|
||||
options.apply = true
|
||||
return
|
||||
}
|
||||
|
||||
if (arg === '--dry-run') {
|
||||
options.apply = false
|
||||
return
|
||||
}
|
||||
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
options.help = true
|
||||
return
|
||||
}
|
||||
|
||||
if (arg.startsWith('--batch-size=')) {
|
||||
options.batchSize = readNumberArg(arg.split('=')[1], '--batch-size')
|
||||
return
|
||||
}
|
||||
|
||||
if (arg.startsWith('--limit=')) {
|
||||
options.limit = readNumberArg(arg.split('=')[1], '--limit')
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(`Unknown argument: ${arg}`)
|
||||
})
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
Usage:
|
||||
npm run sync:jst-sku-stock -- [--dry-run] [--apply] [--limit=100] [--batch-size=500]
|
||||
|
||||
Default mode is --dry-run. Use --apply only when you want to write changes.
|
||||
|
||||
Required environment variables:
|
||||
DATABASE_URL
|
||||
JST_SKU_DB_HOST
|
||||
JST_SKU_DB_PORT
|
||||
JST_SKU_DB_NAME
|
||||
JST_SKU_DB_USER
|
||||
JST_SKU_DB_PASSWORD
|
||||
|
||||
Optional environment variables:
|
||||
JST_SKU_DB_CHARSET=utf8mb4
|
||||
JST_SKU_DB_SSL=false
|
||||
JST_SKU_SYNC_USER_ID=jst_sku_sync
|
||||
|
||||
Supported alternative:
|
||||
JST_SKU_DATABASE_URL=mysql://user:password@host:port/database
|
||||
`.trim())
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
if (options.help) {
|
||||
printHelp()
|
||||
return
|
||||
}
|
||||
|
||||
const result = await syncJstSkuStockProducts({
|
||||
apply: options.apply,
|
||||
batchSize: options.batchSize,
|
||||
limit: options.limit,
|
||||
})
|
||||
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exitCode = 1
|
||||
}).finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
@ -39,4 +39,3 @@ export async function PUT(
|
||||
{ requireAuth: true, requireInternalContentWrite: true },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { handleRequest, jsonSuccess } from '@/server/core/response'
|
||||
import { jstPlanningDirectoryService } from '@/server/services/jst-planning-directory.service'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
try {
|
||||
const options = await jstPlanningDirectoryService.listOptions()
|
||||
return jsonSuccess(options, 200, ctx.requestId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load JST planning directory options', error)
|
||||
return jsonSuccess({ suppliers: [], warehouses: [] }, 200, ctx.requestId)
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
)
|
||||
}
|
||||
@ -1,15 +1,62 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { intakeNonIntegratedProductService } from '@/server/services/intake-non-integrated-product.service'
|
||||
import {
|
||||
intakeNonIntegratedProductService,
|
||||
type IntakeNonIntegratedProductListParams,
|
||||
} from '@/server/services/intake-non-integrated-product.service'
|
||||
|
||||
function readListParams(request: NextRequest): IntakeNonIntegratedProductListParams {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const productDevelopmentKind = searchParams.get('productDevelopmentKind')
|
||||
|
||||
return {
|
||||
page: Number(searchParams.get('page') ?? '1'),
|
||||
pageSize: Number(searchParams.get('pageSize') ?? '50'),
|
||||
productDevelopmentKind:
|
||||
productDevelopmentKind === 'series'
|
||||
? 'series'
|
||||
: productDevelopmentKind === 'product'
|
||||
? 'product'
|
||||
: undefined,
|
||||
productKind:
|
||||
searchParams.get('productKind') === 'stock'
|
||||
? 'stock'
|
||||
: searchParams.get('productKind') === 'new'
|
||||
? 'new'
|
||||
: 'all',
|
||||
quickScope:
|
||||
searchParams.get('quickScope') === 'created'
|
||||
? 'created'
|
||||
: searchParams.get('quickScope') === 'pending'
|
||||
? 'pending'
|
||||
: 'all',
|
||||
spu: searchParams.get('spu') ?? '',
|
||||
appearanceDesigner: searchParams.get('appearanceDesigner') ?? '',
|
||||
structureDesigner: searchParams.get('structureDesigner') ?? '',
|
||||
spuOwner: searchParams.get('spuOwner') ?? '',
|
||||
planningOwner: searchParams.get('planningOwner') ?? '',
|
||||
spaceDesigner: searchParams.get('spaceDesigner') ?? '',
|
||||
purchaseOwner: searchParams.get('purchaseOwner') ?? '',
|
||||
stockSku: searchParams.get('stockSku') ?? '',
|
||||
stockCategory: searchParams.get('stockCategory') ?? '',
|
||||
stockItemType: searchParams.get('stockItemType') ?? '',
|
||||
stockSalesStatus: searchParams.get('stockSalesStatus') ?? '',
|
||||
stockEnabledStatus: searchParams.get('stockEnabledStatus') ?? '',
|
||||
stockDataScope: searchParams.get('stockDataScope') ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
try {
|
||||
const products = await intakeNonIntegratedProductService.list(ctx.user)
|
||||
return jsonSuccess({ products }, 200, ctx.requestId)
|
||||
const result = await intakeNonIntegratedProductService.listPage(
|
||||
ctx.user,
|
||||
readListParams(request),
|
||||
)
|
||||
return jsonSuccess(result, 200, ctx.requestId)
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { handleRequest, jsonSuccess } from '@/server/core/response'
|
||||
import { intakeNonIntegratedProductService } from '@/server/services/intake-non-integrated-product.service'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const categories =
|
||||
await intakeNonIntegratedProductService.listStockCategoryOptions(ctx.user)
|
||||
|
||||
return jsonSuccess({ categories }, 200, ctx.requestId)
|
||||
},
|
||||
{ requireAuth: true },
|
||||
)
|
||||
}
|
||||
@ -473,6 +473,7 @@ describe('IntakeClient', () => {
|
||||
beforeEach(() => {
|
||||
jest.useRealTimers()
|
||||
window.localStorage.clear()
|
||||
window.sessionStorage.clear()
|
||||
jest.restoreAllMocks()
|
||||
mockAoaToSheet.mockClear()
|
||||
mockBookNew.mockClear()
|
||||
@ -1724,7 +1725,10 @@ describe('IntakeClient', () => {
|
||||
const actor = userEvent.setup()
|
||||
renderSelector({ eligibleSkuList: [] })
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '系列产品开发明细' }))
|
||||
const rightPanel = screen.getByTestId('right-panel')
|
||||
await actor.click(
|
||||
within(rightPanel).getByRole('button', { name: '系列产品开发明细' }),
|
||||
)
|
||||
await actor.click(screen.getByRole('button', { name: '新增产品' }))
|
||||
|
||||
expect(screen.getByRole('tab', { name: '新增系列产品' })).toHaveAttribute(
|
||||
@ -2310,6 +2314,43 @@ describe('IntakeClient', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('restores opened workspace tabs after leaving and returning to intake', async () => {
|
||||
const actor = userEvent.setup()
|
||||
const product = createNonIntegratedProduct()
|
||||
|
||||
window.localStorage.setItem(
|
||||
NON_INTEGRATED_STORAGE_KEY,
|
||||
JSON.stringify([product]),
|
||||
)
|
||||
|
||||
const firstRender = renderSelector({
|
||||
eligibleSkuList: [],
|
||||
initialNonIntegratedProducts: [product],
|
||||
})
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '修改' }))
|
||||
|
||||
expect(screen.getByRole('tab', { name: /DNZ0001/ })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
)
|
||||
|
||||
firstRender.unmount()
|
||||
|
||||
renderSelector({
|
||||
eligibleSkuList: [],
|
||||
initialNonIntegratedProducts: [product],
|
||||
})
|
||||
|
||||
expect(screen.getByRole('tab', { name: /DNZ0001/ })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
)
|
||||
expect(screen.getByTestId('embedded-non-integrated-detail')).toHaveTextContent(
|
||||
'DNZ0001-1-edit',
|
||||
)
|
||||
})
|
||||
|
||||
it('opens a separate workspace tab each time product edit is clicked', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
@ -2361,6 +2402,10 @@ describe('IntakeClient', () => {
|
||||
|
||||
await actor.click(screen.getAllByRole('tab')[0])
|
||||
}
|
||||
const beforeCopyTabs = screen.getAllByRole('tab')
|
||||
expect(
|
||||
beforeCopyTabs.filter((tab) => tab.textContent?.startsWith('SZ0230')),
|
||||
).toHaveLength(2)
|
||||
|
||||
const listRow = screen.getByText('SZ0230').closest('tr')
|
||||
expect(listRow).not.toBeNull()
|
||||
@ -2377,8 +2422,8 @@ describe('IntakeClient', () => {
|
||||
})
|
||||
|
||||
const tabs = screen.getAllByRole('tab')
|
||||
expect(tabs).toHaveLength(5)
|
||||
expect(tabs.filter((tab) => tab.textContent?.startsWith('SZ0230'))).toHaveLength(3)
|
||||
expect(tabs).toHaveLength(4)
|
||||
expect(tabs.filter((tab) => tab.textContent?.startsWith('SZ0230'))).toHaveLength(2)
|
||||
expect(tabs.some((tab) => tab.textContent?.includes(expectedSpu))).toBe(true)
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
@ -2803,6 +2848,44 @@ describe('IntakeClient', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('only offers adaptable brands that do not already have copied brand data', async () => {
|
||||
const actor = userEvent.setup()
|
||||
const [sourceBrand, existingBrand, availableBrand] = Object.keys(
|
||||
NON_INTEGRATED_BRAND_PREFIX_MAP,
|
||||
)
|
||||
|
||||
window.localStorage.setItem(
|
||||
NON_INTEGRATED_STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
createNonIntegratedProduct({
|
||||
id: 'SZ0230-source',
|
||||
spu: 'SZ0230',
|
||||
brand: sourceBrand,
|
||||
adaptableBrands: [sourceBrand, existingBrand, availableBrand],
|
||||
}),
|
||||
createNonIntegratedProduct({
|
||||
id: 'GBGJ-SZ0230-copy',
|
||||
spu: `${NON_INTEGRATED_BRAND_PREFIX_MAP[existingBrand]}SZ0230`,
|
||||
brand: existingBrand,
|
||||
adaptableBrands: [existingBrand],
|
||||
copiedFromProductId: 'SZ0230-source',
|
||||
copiedFromSpu: 'SZ0230',
|
||||
}),
|
||||
]),
|
||||
)
|
||||
|
||||
renderSelector({ eligibleSkuList: [] })
|
||||
|
||||
const sourceRow = screen.getByText('SZ0230').closest('tr')
|
||||
expect(sourceRow).not.toBeNull()
|
||||
|
||||
await actor.click(within(sourceRow as HTMLElement).getByRole('button', { name: '复制' }))
|
||||
|
||||
expect(screen.getByLabelText('目标品牌')).toHaveValue(availableBrand)
|
||||
expect(screen.queryByRole('option', { name: existingBrand })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: availableBrand })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows owner fields and filters non-integrated products by query button', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -21,6 +21,34 @@ export type NonIntegratedUploadProgressHandler = (
|
||||
progress: NonIntegratedUploadProgress,
|
||||
) => void
|
||||
|
||||
export type NonIntegratedProductPageInfo = {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export type NonIntegratedProductListParams = {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
productDevelopmentKind?: 'product' | 'series'
|
||||
productKind?: 'all' | 'new' | 'stock'
|
||||
quickScope?: 'all' | 'created' | 'pending'
|
||||
spu?: string
|
||||
appearanceDesigner?: string
|
||||
structureDesigner?: string
|
||||
spuOwner?: string
|
||||
planningOwner?: string
|
||||
spaceDesigner?: string
|
||||
purchaseOwner?: string
|
||||
stockSku?: string
|
||||
stockCategory?: string
|
||||
stockItemType?: string
|
||||
stockSalesStatus?: string
|
||||
stockEnabledStatus?: string
|
||||
stockDataScope?: string
|
||||
}
|
||||
|
||||
type UploadSharedNonIntegratedFileResponse = {
|
||||
product: NonIntegratedProductRecord
|
||||
file: NonIntegratedProductRecord['uploadFiles'][number]
|
||||
@ -185,13 +213,37 @@ async function postFormData(
|
||||
}
|
||||
|
||||
export async function listSharedNonIntegratedProducts() {
|
||||
const response = await fetchWithTimeout('/api/intake/non-integrated', {
|
||||
const data = await listSharedNonIntegratedProductPage()
|
||||
return data.products
|
||||
}
|
||||
|
||||
export async function listSharedNonIntegratedProductPage(
|
||||
params: NonIntegratedProductListParams = {},
|
||||
) {
|
||||
const searchParams = new URLSearchParams()
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') return
|
||||
searchParams.set(key, String(value))
|
||||
})
|
||||
const query = searchParams.toString()
|
||||
const response = await fetchWithTimeout(`/api/intake/non-integrated${query ? `?${query}` : ''}`, {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const data = await readApiData<{ products: NonIntegratedProductRecord[] }>(response)
|
||||
return data.products
|
||||
return readApiData<{
|
||||
products: NonIntegratedProductRecord[]
|
||||
pageInfo: NonIntegratedProductPageInfo
|
||||
}>(response)
|
||||
}
|
||||
|
||||
export async function listSharedNonIntegratedStockCategoryOptions() {
|
||||
const response = await fetchWithTimeout('/api/intake/non-integrated/stock-category-options', {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
return readApiData<{ categories: string[] }>(response)
|
||||
}
|
||||
|
||||
export async function getSharedNonIntegratedProductById(id: string) {
|
||||
|
||||
@ -555,13 +555,54 @@ describe('nonIntegratedStorage', () => {
|
||||
id: 'source-product',
|
||||
spu: 'SZ0230',
|
||||
brand: '基本盘',
|
||||
adaptableBrands: ['基本盘', '顾家', '索菲亚'],
|
||||
skuItems: [
|
||||
{
|
||||
id: 'sku-1',
|
||||
skuSequence: 1,
|
||||
batchNo: 1,
|
||||
packageGroupId: '',
|
||||
packageSuffix: '',
|
||||
image: '',
|
||||
imageName: '',
|
||||
model: 'SZ0230K01',
|
||||
skuCode: 'SZ023001',
|
||||
setCount: '1',
|
||||
specName: '款式一',
|
||||
barcode: 'SZ023001N1280V01',
|
||||
size: '140/60/75',
|
||||
color: '胡桃色',
|
||||
material: '橡胶木',
|
||||
versionNo: 'V01',
|
||||
attributeCode: 'A',
|
||||
drawingId: 'SZ0230K01AV01',
|
||||
installMethod: '拆装',
|
||||
executeStandard: '',
|
||||
nationalStandardCompliant: '',
|
||||
manufacturer: '',
|
||||
manufacturerCode: 'N1280',
|
||||
salesStatus: '销售',
|
||||
tagMaterial: '',
|
||||
model3dFileName: '',
|
||||
model3dDataUrl: '',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const copiedProduct = createCopiedNonIntegratedProduct(sourceProduct, '顾家')
|
||||
|
||||
expect(copiedProduct.id).not.toBe(sourceProduct.id)
|
||||
expect(copiedProduct.brand).toBe('顾家')
|
||||
expect(copiedProduct.adaptableBrands).toEqual(['顾家', '基本盘', '索菲亚'])
|
||||
expect(copiedProduct.openingReference.adaptableBrands).toEqual([
|
||||
'顾家',
|
||||
'基本盘',
|
||||
'索菲亚',
|
||||
])
|
||||
expect(copiedProduct.spu).toBe('GBGJ-SZ0230')
|
||||
expect(copiedProduct.skuItems[0]?.model).toBe('GBGJ-SZ0230K01')
|
||||
expect(copiedProduct.skuItems[0]?.skuCode).toBe('GBGJ-SZ023001')
|
||||
expect(copiedProduct.skuItems[0]?.barcode).toBe('GBGJ-SZ023001N1280V01')
|
||||
expect(copiedProduct.copiedFromProductId).toBe('source-product')
|
||||
expect(copiedProduct.copiedFromSpu).toBe('SZ0230')
|
||||
expect(Object.values(copiedProduct.documentWorkspaceConfirmations)).toEqual(
|
||||
|
||||
@ -86,6 +86,7 @@ export function getNonIntegratedDocumentWorkspaceCategoryLabel(
|
||||
if (category === "产品实拍图") return "产品实拍";
|
||||
if (category === "产品模型") return "产品资料";
|
||||
if (category === "评审资料") return "样品评审";
|
||||
if (category === "企划布局") return "空间布局";
|
||||
if (category === "工厂质检") return "首批质检";
|
||||
if (category === "检测报告") return "仓库抽检";
|
||||
return category;
|
||||
@ -296,6 +297,7 @@ export type NonIntegratedSkuItem = {
|
||||
size: string;
|
||||
color: string;
|
||||
material: string;
|
||||
boardMaterialType?: string;
|
||||
versionNo: string;
|
||||
attributeCode: string;
|
||||
drawingId: string;
|
||||
@ -304,12 +306,17 @@ export type NonIntegratedSkuItem = {
|
||||
nationalStandardCompliant: string;
|
||||
manufacturer: string;
|
||||
manufacturerCode: string;
|
||||
salesStatus: string;
|
||||
tagMaterial: string;
|
||||
model3dFileName: string;
|
||||
model3dDataUrl: string;
|
||||
model3dNodeOffsets?: Record<string, [number, number, number]>;
|
||||
model3dNodeRotations?: Record<string, [number, number, number, number]>;
|
||||
componentDetails?: NonIntegratedSkuComponentDetail[];
|
||||
source?: string;
|
||||
sourceTable?: string;
|
||||
sourceSkuId?: string;
|
||||
contentLocked?: boolean;
|
||||
};
|
||||
|
||||
export type NonIntegratedSkuComponentDetail = {
|
||||
@ -349,6 +356,7 @@ export type NonIntegratedPricingRow = {
|
||||
skuId: string;
|
||||
costPrice: string;
|
||||
productTag: string;
|
||||
systemEnteredAt?: string;
|
||||
};
|
||||
|
||||
export type NonIntegratedProductLimitRow = {
|
||||
@ -584,6 +592,7 @@ export type NonIntegratedProductRecord = {
|
||||
skuBatchCount?: number;
|
||||
skuBatchFlowNos?: Record<string, string>;
|
||||
skuBatchChangeTypes?: Record<string, string>;
|
||||
skuBatchConfirmedAt?: Record<string, string>;
|
||||
factoryRows: NonIntegratedFactoryRow[];
|
||||
pricingRows: NonIntegratedPricingRow[];
|
||||
planningLayoutRows: NonIntegratedPlanningLayoutRow[];
|
||||
@ -611,6 +620,7 @@ export function stripNonIntegratedBrandPrefix(value: string) {
|
||||
const normalizedValue = normalizeNonIntegratedCode(value);
|
||||
|
||||
for (const prefix of Object.values(NON_INTEGRATED_BRAND_PREFIX_MAP)) {
|
||||
if (!prefix) continue;
|
||||
if (prefix && normalizedValue.startsWith(prefix)) {
|
||||
return normalizedValue.slice(prefix.length);
|
||||
}
|
||||
@ -630,6 +640,19 @@ export function applyBrandPrefixToNonIntegratedCode(
|
||||
return `${prefix}${normalizedBaseValue}`;
|
||||
}
|
||||
|
||||
function replaceExistingBrandPrefix(value: string, targetBrand: string) {
|
||||
const normalizedValue = normalizeNonIntegratedCode(value);
|
||||
if (!normalizedValue) return "";
|
||||
|
||||
const hasBrandPrefix = Object.values(NON_INTEGRATED_BRAND_PREFIX_MAP).some(
|
||||
(prefix) => Boolean(prefix) && normalizedValue.startsWith(prefix),
|
||||
);
|
||||
|
||||
return hasBrandPrefix
|
||||
? applyBrandPrefixToNonIntegratedCode(normalizedValue, targetBrand)
|
||||
: value;
|
||||
}
|
||||
|
||||
export function getSearchableNonIntegratedSpuKeywords(spu: string) {
|
||||
const normalizedSpu = normalizeNonIntegratedCode(spu);
|
||||
const baseSpu = stripNonIntegratedBrandPrefix(spu);
|
||||
@ -1050,6 +1073,18 @@ function normalizeSkuBatchChangeTypes(value: unknown): Record<string, string> {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSkuBatchConfirmedAt(value: unknown): Record<string, string> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.map(([key, confirmedAt]) => [key, normalizeString(confirmedAt)])
|
||||
.filter(([, confirmedAt]) => confirmedAt.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSkuComponentQuantity(value: unknown) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
@ -1123,6 +1158,7 @@ function normalizeSkuItem(
|
||||
size: normalizeString(item.size),
|
||||
color: normalizeString(item.color),
|
||||
material: normalizeString(item.material),
|
||||
boardMaterialType: normalizeString(item.boardMaterialType),
|
||||
versionNo:
|
||||
typeof item.versionNo === "string"
|
||||
? normalizeString(item.versionNo)
|
||||
@ -1134,6 +1170,7 @@ function normalizeSkuItem(
|
||||
nationalStandardCompliant: normalizeString(item.nationalStandardCompliant),
|
||||
manufacturer: normalizeString(item.manufacturer),
|
||||
manufacturerCode: normalizeString(item.manufacturerCode),
|
||||
salesStatus: normalizeString(item.salesStatus),
|
||||
tagMaterial: normalizeString(item.tagMaterial),
|
||||
model3dFileName: normalizeString(item.model3dFileName),
|
||||
model3dDataUrl: normalizeNonIntegratedAssetUrl(
|
||||
@ -1144,6 +1181,10 @@ function normalizeSkuItem(
|
||||
componentDetails: normalizeNonIntegratedSkuComponentDetails(
|
||||
item.componentDetails,
|
||||
),
|
||||
source: normalizeString(item.source),
|
||||
sourceTable: normalizeString(item.sourceTable),
|
||||
sourceSkuId: normalizeString(item.sourceSkuId),
|
||||
contentLocked: item.contentLocked === true,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1187,6 +1228,7 @@ function normalizePricingRow(
|
||||
item: Partial<NonIntegratedPricingRow>,
|
||||
): NonIntegratedPricingRow | null {
|
||||
if (typeof item.skuId !== "string") return null;
|
||||
const systemEnteredAt = normalizeString(item.systemEnteredAt);
|
||||
|
||||
return {
|
||||
skuId: item.skuId,
|
||||
@ -1197,6 +1239,7 @@ function normalizePricingRow(
|
||||
? String(item.costPrice)
|
||||
: "",
|
||||
productTag: normalizeString(item.productTag),
|
||||
...(systemEnteredAt ? { systemEnteredAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -1887,6 +1930,9 @@ function toProductRecord(
|
||||
skuBatchChangeTypes: normalizeSkuBatchChangeTypes(
|
||||
item.skuBatchChangeTypes,
|
||||
),
|
||||
skuBatchConfirmedAt: normalizeSkuBatchConfirmedAt(
|
||||
item.skuBatchConfirmedAt,
|
||||
),
|
||||
factoryRows: Array.isArray(item.factoryRows)
|
||||
? item.factoryRows
|
||||
.map((entry) => normalizeFactoryRow(entry))
|
||||
@ -2163,6 +2209,16 @@ export function createCopiedNonIntegratedProduct(
|
||||
sourceProduct.spu,
|
||||
targetBrand,
|
||||
);
|
||||
const sourceOpeningReference = normalizeNonIntegratedOpeningReference(
|
||||
sourceProduct.openingReference,
|
||||
);
|
||||
const nextAdaptableBrands = Array.from(
|
||||
new Set([
|
||||
targetBrand,
|
||||
...sourceProduct.adaptableBrands,
|
||||
...sourceOpeningReference.adaptableBrands,
|
||||
].filter((brand) => brand.trim().length > 0)),
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return {
|
||||
@ -2176,13 +2232,13 @@ export function createCopiedNonIntegratedProduct(
|
||||
sourceProduct.projectReview,
|
||||
),
|
||||
openingReference: {
|
||||
...normalizeNonIntegratedOpeningReference(sourceProduct.openingReference),
|
||||
...sourceOpeningReference,
|
||||
temporaryCode: nextSpu,
|
||||
brand: targetBrand,
|
||||
adaptableBrands: [targetBrand],
|
||||
adaptableBrands: nextAdaptableBrands,
|
||||
},
|
||||
brand: targetBrand,
|
||||
adaptableBrands: [targetBrand],
|
||||
adaptableBrands: nextAdaptableBrands,
|
||||
styleDetails: sourceProduct.styleDetails.map((detail) => ({
|
||||
...detail,
|
||||
model3dNodeOffsets: detail.model3dNodeOffsets
|
||||
@ -2232,16 +2288,31 @@ export function createCopiedNonIntegratedProduct(
|
||||
...row,
|
||||
})),
|
||||
},
|
||||
skuItems: sourceProduct.skuItems.map((item) => ({
|
||||
...item,
|
||||
model: applyBrandPrefixToNonIntegratedCode(item.model, targetBrand),
|
||||
model3dNodeOffsets: item.model3dNodeOffsets
|
||||
? { ...item.model3dNodeOffsets }
|
||||
: {},
|
||||
model3dNodeRotations: item.model3dNodeRotations
|
||||
? { ...item.model3dNodeRotations }
|
||||
: {},
|
||||
})),
|
||||
skuItems: sourceProduct.skuItems.map((item) => {
|
||||
const nextModel = applyBrandPrefixToNonIntegratedCode(
|
||||
item.model,
|
||||
targetBrand,
|
||||
);
|
||||
const nextSkuCode = applyBrandPrefixToNonIntegratedCode(
|
||||
item.skuCode,
|
||||
targetBrand,
|
||||
);
|
||||
|
||||
return {
|
||||
...item,
|
||||
model: nextModel,
|
||||
skuCode: nextSkuCode,
|
||||
barcode: applyBrandPrefixToNonIntegratedCode(item.barcode, targetBrand),
|
||||
specName: replaceExistingBrandPrefix(item.specName, targetBrand),
|
||||
drawingId: replaceExistingBrandPrefix(item.drawingId, targetBrand),
|
||||
model3dNodeOffsets: item.model3dNodeOffsets
|
||||
? { ...item.model3dNodeOffsets }
|
||||
: {},
|
||||
model3dNodeRotations: item.model3dNodeRotations
|
||||
? { ...item.model3dNodeRotations }
|
||||
: {},
|
||||
};
|
||||
}),
|
||||
factoryRows: [],
|
||||
pricingRows: [],
|
||||
planningLayoutRows: sourceProduct.planningLayoutRows.map((row) => ({
|
||||
|
||||
@ -73,7 +73,7 @@ export default async function IntakePage({
|
||||
materialLibraryFinishes,
|
||||
materialLibraryCategories,
|
||||
materialLibraryDictionaries,
|
||||
initialNonIntegratedProducts,
|
||||
initialNonIntegratedProductPage,
|
||||
] = await Promise.all([
|
||||
draftGroupService.list(),
|
||||
warehouseService.listLocations(),
|
||||
@ -81,8 +81,13 @@ export default async function IntakePage({
|
||||
materialService.findAll({ limit: 500 }),
|
||||
materialService.findAllCategories(),
|
||||
assetCenterService.getSubstrateDictionaryBundle(),
|
||||
intakeNonIntegratedProductService.list(authUser),
|
||||
intakeNonIntegratedProductService.listPage(authUser, {
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
productDevelopmentKind: 'product',
|
||||
}),
|
||||
])
|
||||
const initialNonIntegratedProducts = initialNonIntegratedProductPage.products
|
||||
|
||||
const eligibleDraftGroups = draftGroups.filter((group) =>
|
||||
isDraftGroupEligibleForIntake(group.status),
|
||||
|
||||
@ -10,6 +10,7 @@ export type NonIntegratedSkuExportRow = {
|
||||
size: string
|
||||
color: string
|
||||
material: string
|
||||
boardMaterialType: string
|
||||
versionNo: string
|
||||
attributeCode: string
|
||||
drawingId: string
|
||||
@ -18,6 +19,7 @@ export type NonIntegratedSkuExportRow = {
|
||||
nationalStandardCompliant: string
|
||||
manufacturer: string
|
||||
manufacturerCode: string
|
||||
salesStatus: string
|
||||
batchNo: string
|
||||
batchChangeType: string
|
||||
dingTalkFlowNo: string
|
||||
@ -43,6 +45,7 @@ export const NON_INTEGRATED_SKU_TEMPLATE_COLUMNS: {
|
||||
{ key: 'size', label: '尺寸', width: 16 },
|
||||
{ key: 'color', label: '颜色', width: 14 },
|
||||
{ key: 'material', label: '材质', width: 18 },
|
||||
{ key: 'boardMaterialType', label: '板材类型', width: 14 },
|
||||
{ key: 'versionNo', label: '版本号', width: 10 },
|
||||
{ key: 'attributeCode', label: '属性代码', width: 12 },
|
||||
{ key: 'drawingId', label: '图纸ID', width: 22 },
|
||||
@ -51,6 +54,7 @@ export const NON_INTEGRATED_SKU_TEMPLATE_COLUMNS: {
|
||||
{ key: 'nationalStandardCompliant', label: '符合国标', width: 12 },
|
||||
{ key: 'manufacturer', label: '供应商名称', width: 18 },
|
||||
{ key: 'manufacturerCode', label: '供应商代码', width: 14 },
|
||||
{ key: 'salesStatus', label: '销售状态', width: 12 },
|
||||
{ key: 'batchNo', label: '创建批次', width: 12 },
|
||||
{ key: 'batchChangeType', label: '新增类型', width: 14 },
|
||||
{ key: 'dingTalkFlowNo', label: '钉钉流程号', width: 18 },
|
||||
@ -261,8 +265,10 @@ export async function parseNonIntegratedSkuWorkbook(file: File) {
|
||||
}
|
||||
})
|
||||
|
||||
const optionalLabels = new Set(['板材类型', '销售状态'])
|
||||
const missingLabels = NON_INTEGRATED_SKU_TEMPLATE_COLUMNS.filter(
|
||||
(column) => !headerIndexByLabel.has(column.label),
|
||||
(column) =>
|
||||
!optionalLabels.has(column.label) && !headerIndexByLabel.has(column.label),
|
||||
).map((column) => column.label)
|
||||
if (missingLabels.length > 0) {
|
||||
throw new Error(
|
||||
|
||||
@ -12,22 +12,55 @@ class IntakeNonIntegratedProductRepositoryClass extends BaseRepository<
|
||||
> {
|
||||
protected modelName = 'intakeNonIntegratedProduct'
|
||||
|
||||
private buildActiveWhere(scopeWhere?: Prisma.IntakeNonIntegratedProductWhereInput) {
|
||||
return {
|
||||
deletedAt: null,
|
||||
...(scopeWhere
|
||||
? {
|
||||
AND: [scopeWhere],
|
||||
}
|
||||
: {}),
|
||||
} satisfies Prisma.IntakeNonIntegratedProductWhereInput
|
||||
}
|
||||
|
||||
async listActive(scopeWhere?: Prisma.IntakeNonIntegratedProductWhereInput) {
|
||||
return this.prisma.intakeNonIntegratedProduct.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
...(scopeWhere
|
||||
? {
|
||||
AND: [scopeWhere],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
where: this.buildActiveWhere(scopeWhere),
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async listActivePage(params: {
|
||||
page: number
|
||||
pageSize: number
|
||||
scopeWhere?: Prisma.IntakeNonIntegratedProductWhereInput
|
||||
filterWhere?: Prisma.IntakeNonIntegratedProductWhereInput
|
||||
}) {
|
||||
const activeWhere = this.buildActiveWhere(params.scopeWhere)
|
||||
const where = params.filterWhere
|
||||
? ({
|
||||
AND: [activeWhere, params.filterWhere],
|
||||
} satisfies Prisma.IntakeNonIntegratedProductWhereInput)
|
||||
: activeWhere
|
||||
const skip = (params.page - 1) * params.pageSize
|
||||
|
||||
const [records, total] = await this.prisma.$transaction([
|
||||
this.prisma.intakeNonIntegratedProduct.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
skip,
|
||||
take: params.pageSize,
|
||||
}),
|
||||
this.prisma.intakeNonIntegratedProduct.count({ where }),
|
||||
])
|
||||
|
||||
return { records, total }
|
||||
}
|
||||
|
||||
async findActiveById(id: string) {
|
||||
return this.prisma.intakeNonIntegratedProduct.findFirst({
|
||||
where: {
|
||||
|
||||
@ -90,6 +90,30 @@ type IntakeNonIntegratedRecord = NonNullable<
|
||||
Awaited<ReturnType<IntakeNonIntegratedProductRepositoryClass['findActiveById']>>
|
||||
>
|
||||
|
||||
export type IntakeNonIntegratedProductListParams = {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
productDevelopmentKind?: 'product' | 'series'
|
||||
productKind?: 'all' | 'new' | 'stock'
|
||||
quickScope?: 'all' | 'created' | 'pending'
|
||||
spu?: string
|
||||
appearanceDesigner?: string
|
||||
structureDesigner?: string
|
||||
spuOwner?: string
|
||||
planningOwner?: string
|
||||
spaceDesigner?: string
|
||||
purchaseOwner?: string
|
||||
stockSku?: string
|
||||
stockCategory?: string
|
||||
stockItemType?: string
|
||||
stockSalesStatus?: string
|
||||
stockEnabledStatus?: string
|
||||
stockDataScope?: string
|
||||
}
|
||||
|
||||
const DEFAULT_LIST_PAGE_SIZE = 50
|
||||
const MAX_LIST_PAGE_SIZE = 100
|
||||
|
||||
type ResolveUploadFileForPreviewResult = {
|
||||
record: IntakeNonIntegratedRecord
|
||||
product: SharedNonIntegratedProductRecord
|
||||
@ -462,6 +486,361 @@ function trimOptionalString<T extends string>(value: unknown): T | undefined {
|
||||
: undefined
|
||||
}
|
||||
|
||||
function normalizeListPage(value: unknown) {
|
||||
const page = Number(value)
|
||||
return Number.isFinite(page) && page > 0 ? Math.floor(page) : 1
|
||||
}
|
||||
|
||||
function normalizeListPageSize(value: unknown) {
|
||||
const pageSize = Number(value)
|
||||
if (!Number.isFinite(pageSize) || pageSize <= 0) {
|
||||
return DEFAULT_LIST_PAGE_SIZE
|
||||
}
|
||||
|
||||
return Math.min(MAX_LIST_PAGE_SIZE, Math.max(1, Math.floor(pageSize)))
|
||||
}
|
||||
|
||||
function trimListFilter(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function jsonTextEquals(path: string[], value: string): Prisma.IntakeNonIntegratedProductWhereInput {
|
||||
return {
|
||||
payload: {
|
||||
path,
|
||||
equals: value,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function jsonTextContains(
|
||||
path: string[],
|
||||
value: string,
|
||||
): Prisma.IntakeNonIntegratedProductWhereInput {
|
||||
return {
|
||||
payload: {
|
||||
path,
|
||||
string_contains: value,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function jsonTextMissingOrNotEquals(
|
||||
path: string[],
|
||||
value: string,
|
||||
): Prisma.IntakeNonIntegratedProductWhereInput {
|
||||
return {
|
||||
OR: [
|
||||
{
|
||||
NOT: jsonTextEquals(path, value),
|
||||
},
|
||||
{
|
||||
payload: {
|
||||
path,
|
||||
equals: Prisma.AnyNull,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function buildTextContainsAny(
|
||||
value: string,
|
||||
conditions: Prisma.IntakeNonIntegratedProductWhereInput[],
|
||||
): Prisma.IntakeNonIntegratedProductWhereInput | null {
|
||||
const keyword = value.trim()
|
||||
if (!keyword) return null
|
||||
|
||||
return {
|
||||
OR: conditions,
|
||||
}
|
||||
}
|
||||
|
||||
const JST_SKU_SOURCE_PATHS = [
|
||||
['source'],
|
||||
['sourceTable'],
|
||||
['importSource'],
|
||||
['externalSource'],
|
||||
['metadata', 'source'],
|
||||
['metadata', 'sourceTable'],
|
||||
['metadata', 'importSource'],
|
||||
['metadata', 'externalSource'],
|
||||
] as const
|
||||
|
||||
function buildStockProductWhere(): Prisma.IntakeNonIntegratedProductWhereInput {
|
||||
return {
|
||||
OR: [
|
||||
{ id: { startsWith: 'jst-stock-' } },
|
||||
...JST_SKU_SOURCE_PATHS.map((path) => jsonTextEquals([...path], 'jst_sku')),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function buildDevelopmentKindWhere(
|
||||
productDevelopmentKind: IntakeNonIntegratedProductListParams['productDevelopmentKind'],
|
||||
): Prisma.IntakeNonIntegratedProductWhereInput | null {
|
||||
if (productDevelopmentKind === 'series') {
|
||||
return jsonTextEquals(['productDevelopmentKind'], 'series')
|
||||
}
|
||||
|
||||
if (productDevelopmentKind === 'product') {
|
||||
return {
|
||||
OR: [
|
||||
{
|
||||
NOT: jsonTextEquals(['productDevelopmentKind'], 'series'),
|
||||
},
|
||||
{
|
||||
payload: {
|
||||
path: ['productDevelopmentKind'],
|
||||
equals: Prisma.AnyNull,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function buildProductKindWhere(
|
||||
productKind: IntakeNonIntegratedProductListParams['productKind'],
|
||||
): Prisma.IntakeNonIntegratedProductWhereInput | null {
|
||||
const stockWhere = buildStockProductWhere()
|
||||
|
||||
if (productKind === 'stock') {
|
||||
return stockWhere
|
||||
}
|
||||
|
||||
if (productKind === 'new') {
|
||||
return {
|
||||
AND: [
|
||||
{
|
||||
id: {
|
||||
not: {
|
||||
startsWith: 'jst-stock-',
|
||||
},
|
||||
},
|
||||
},
|
||||
...JST_SKU_SOURCE_PATHS.map((path) =>
|
||||
jsonTextMissingOrNotEquals([...path], 'jst_sku'),
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function buildSpuSearchWhere(spu: string): Prisma.IntakeNonIntegratedProductWhereInput | null {
|
||||
const keyword = trimListFilter(spu)
|
||||
if (!keyword) return null
|
||||
|
||||
const normalizedKeyword = keyword.toUpperCase()
|
||||
const baseKeyword = stripNonIntegratedBrandPrefix(keyword)
|
||||
|
||||
return {
|
||||
OR: [
|
||||
{ spu: { contains: normalizedKeyword, mode: 'insensitive' } },
|
||||
{ spuBase: { contains: baseKeyword || normalizedKeyword, mode: 'insensitive' } },
|
||||
jsonTextContains(['openingReference', 'productName'], keyword),
|
||||
jsonTextContains(['openingReference', 'temporaryCode'], keyword),
|
||||
jsonTextContains(['metadata', 'sourceSpu'], keyword),
|
||||
jsonTextContains(['metadata', 'sourceSkuId'], keyword),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function buildStockTextSearchWhere(
|
||||
value: string | undefined,
|
||||
paths: string[][],
|
||||
): Prisma.IntakeNonIntegratedProductWhereInput | null {
|
||||
const keyword = trimListFilter(value)
|
||||
if (!keyword) return null
|
||||
|
||||
return {
|
||||
OR: paths.map((path) => jsonTextContains(path, keyword)),
|
||||
}
|
||||
}
|
||||
|
||||
function buildStockExactWhere(
|
||||
value: string | undefined,
|
||||
path: string[],
|
||||
): Prisma.IntakeNonIntegratedProductWhereInput | null {
|
||||
const keyword = trimListFilter(value)
|
||||
if (!keyword || keyword === 'all') return null
|
||||
|
||||
if (keyword === '__empty') {
|
||||
return {
|
||||
payload: {
|
||||
path,
|
||||
equals: Prisma.AnyNull,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return jsonTextEquals(path, keyword)
|
||||
}
|
||||
|
||||
function buildStockDataScopeWhere(
|
||||
value: string | undefined,
|
||||
): Prisma.IntakeNonIntegratedProductWhereInput | null {
|
||||
const scope = trimListFilter(value)
|
||||
if (!scope || scope === 'all') return null
|
||||
|
||||
const normalSpuWhere: Prisma.IntakeNonIntegratedProductWhereInput = {
|
||||
AND: [
|
||||
jsonTextEquals(['metadata', 'sourceItemType'], '成品'),
|
||||
{
|
||||
NOT: {
|
||||
payload: {
|
||||
path: ['metadata', 'sourceCategory'],
|
||||
equals: Prisma.AnyNull,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
NOT: jsonTextContains(['metadata', 'sourceCategory'], '其他 -'),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
if (scope === 'normalSpu') {
|
||||
return normalSpuWhere
|
||||
}
|
||||
|
||||
if (scope === 'normalOnSale') {
|
||||
return {
|
||||
AND: [
|
||||
normalSpuWhere,
|
||||
jsonTextEquals(['metadata', 'sourceSalesStatus'], '销售'),
|
||||
jsonTextEquals(['metadata', 'sourceEnabled'], '1'),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function buildStockSourceSearchWhere(
|
||||
params: IntakeNonIntegratedProductListParams,
|
||||
): Prisma.IntakeNonIntegratedProductWhereInput[] {
|
||||
return [
|
||||
buildStockTextSearchWhere(params.stockSku, [
|
||||
['metadata', 'sourceSkuId'],
|
||||
['skuItems', '0', 'barcode'],
|
||||
]),
|
||||
buildStockTextSearchWhere(params.stockCategory, [
|
||||
['metadata', 'sourceCategory'],
|
||||
]),
|
||||
buildStockExactWhere(params.stockItemType, ['metadata', 'sourceItemType']),
|
||||
buildStockExactWhere(params.stockSalesStatus, ['metadata', 'sourceSalesStatus']),
|
||||
buildStockExactWhere(params.stockEnabledStatus, ['metadata', 'sourceEnabled']),
|
||||
buildStockDataScopeWhere(params.stockDataScope),
|
||||
].filter((filter): filter is Prisma.IntakeNonIntegratedProductWhereInput => filter !== null)
|
||||
}
|
||||
|
||||
function buildPeopleSearchWhere(
|
||||
params: IntakeNonIntegratedProductListParams,
|
||||
): Prisma.IntakeNonIntegratedProductWhereInput[] {
|
||||
const appearanceDesigner = trimListFilter(params.appearanceDesigner)
|
||||
const structureDesigner = trimListFilter(params.structureDesigner)
|
||||
const spuOwner = trimListFilter(params.spuOwner)
|
||||
const planningOwner = trimListFilter(params.planningOwner)
|
||||
const spaceDesigner = trimListFilter(params.spaceDesigner)
|
||||
const purchaseOwner = trimListFilter(params.purchaseOwner)
|
||||
const filters: Array<Prisma.IntakeNonIntegratedProductWhereInput | null> = [
|
||||
buildTextContainsAny(appearanceDesigner, [
|
||||
{ designer: { contains: appearanceDesigner, mode: 'insensitive' } },
|
||||
jsonTextContains(['designer'], appearanceDesigner),
|
||||
jsonTextContains(['openingReference', 'designer'], appearanceDesigner),
|
||||
]),
|
||||
buildTextContainsAny(structureDesigner, [
|
||||
jsonTextContains(['openingReference', 'structureDesigner'], structureDesigner),
|
||||
]),
|
||||
buildTextContainsAny(spuOwner, [
|
||||
{ operator: { contains: spuOwner, mode: 'insensitive' } },
|
||||
jsonTextContains(['spuOwner'], spuOwner),
|
||||
jsonTextContains(['operationsOwner'], spuOwner),
|
||||
]),
|
||||
buildTextContainsAny(planningOwner, [
|
||||
jsonTextContains(['productPlanning'], planningOwner),
|
||||
jsonTextContains(['openingReference', 'planningOwner'], planningOwner),
|
||||
]),
|
||||
buildTextContainsAny(spaceDesigner, [
|
||||
jsonTextContains(['visualPlanning'], spaceDesigner),
|
||||
jsonTextContains(['visualDirector'], spaceDesigner),
|
||||
]),
|
||||
buildTextContainsAny(purchaseOwner, [
|
||||
{ purchaser: { contains: purchaseOwner, mode: 'insensitive' } },
|
||||
jsonTextContains(['purchaseOwner'], purchaseOwner),
|
||||
]),
|
||||
]
|
||||
|
||||
return filters.filter(
|
||||
(filter): filter is Prisma.IntakeNonIntegratedProductWhereInput => filter !== null,
|
||||
)
|
||||
}
|
||||
|
||||
function buildQuickScopeWhere(
|
||||
quickScope: IntakeNonIntegratedProductListParams['quickScope'],
|
||||
user: AuthUser,
|
||||
): Prisma.IntakeNonIntegratedProductWhereInput | null {
|
||||
if (quickScope === 'created') {
|
||||
const creatorConditions: Prisma.IntakeNonIntegratedProductWhereInput[] = [
|
||||
{ createdBy: user.id },
|
||||
]
|
||||
if (user.name.trim()) {
|
||||
creatorConditions.push({ createdBy: user.name.trim() })
|
||||
}
|
||||
|
||||
return { OR: creatorConditions }
|
||||
}
|
||||
|
||||
if (quickScope === 'pending' && user.name.trim()) {
|
||||
const userName = user.name.trim()
|
||||
return {
|
||||
OR: [
|
||||
{ designer: { contains: userName, mode: 'insensitive' } },
|
||||
{ operator: { contains: userName, mode: 'insensitive' } },
|
||||
{ purchaser: { contains: userName, mode: 'insensitive' } },
|
||||
jsonTextContains(['designer'], userName),
|
||||
jsonTextContains(['openingReference', 'designer'], userName),
|
||||
jsonTextContains(['openingReference', 'structureDesigner'], userName),
|
||||
jsonTextContains(['spuOwner'], userName),
|
||||
jsonTextContains(['operationsOwner'], userName),
|
||||
jsonTextContains(['productPlanning'], userName),
|
||||
jsonTextContains(['openingReference', 'planningOwner'], userName),
|
||||
jsonTextContains(['visualPlanning'], userName),
|
||||
jsonTextContains(['visualDirector'], userName),
|
||||
jsonTextContains(['purchaseOwner'], userName),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function buildListFilterWhere(
|
||||
params: IntakeNonIntegratedProductListParams,
|
||||
user: AuthUser,
|
||||
): Prisma.IntakeNonIntegratedProductWhereInput | undefined {
|
||||
const filters = [
|
||||
buildDevelopmentKindWhere(params.productDevelopmentKind),
|
||||
buildProductKindWhere(params.productKind),
|
||||
buildSpuSearchWhere(params.spu ?? ''),
|
||||
buildQuickScopeWhere(params.quickScope, user),
|
||||
...buildStockSourceSearchWhere(params),
|
||||
...buildPeopleSearchWhere(params),
|
||||
].filter((filter): filter is Prisma.IntakeNonIntegratedProductWhereInput => filter !== null)
|
||||
|
||||
if (filters.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return { AND: filters }
|
||||
}
|
||||
|
||||
function pickUploadFileMetadata(input: AttachUploadFileInput): AttachUploadFileMetadata {
|
||||
const productDataGroup = trimOptionalString<NonNullable<NonIntegratedUploadFile['productDataGroup']>>(
|
||||
input.productDataGroup,
|
||||
@ -543,6 +922,62 @@ class IntakeNonIntegratedProductService {
|
||||
.filter((record): record is SharedNonIntegratedProductRecord => record !== null)
|
||||
}
|
||||
|
||||
async listPage(user: AuthUser, params: IntakeNonIntegratedProductListParams = {}) {
|
||||
const page = normalizeListPage(params.page)
|
||||
const pageSize = normalizeListPageSize(params.pageSize)
|
||||
const { records, total } = await this.repository.listActivePage({
|
||||
page,
|
||||
pageSize,
|
||||
scopeWhere: intakeNonIntegratedScopeService.buildReadScopeWhere(user),
|
||||
filterWhere: buildListFilterWhere(params, user),
|
||||
})
|
||||
const products = records
|
||||
.map((record) =>
|
||||
buildHydratedProductRecord(record.payload, {
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
createdBy: record.createdBy,
|
||||
updatedBy: record.updatedBy,
|
||||
}),
|
||||
)
|
||||
.filter((record): record is SharedNonIntegratedProductRecord => record !== null)
|
||||
|
||||
return {
|
||||
products,
|
||||
pageInfo: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async listStockCategoryOptions(user: AuthUser) {
|
||||
const scopeWhere = intakeNonIntegratedScopeService.buildReadScopeWhere(user)
|
||||
const records = await this.repository.listActive({
|
||||
AND: [
|
||||
...(scopeWhere ? [scopeWhere] : []),
|
||||
buildStockProductWhere(),
|
||||
],
|
||||
})
|
||||
const categories = new Set<string>()
|
||||
|
||||
records.forEach((record) => {
|
||||
if (!isRecord(record.payload)) return
|
||||
const metadata = record.payload.metadata
|
||||
if (!isRecord(metadata)) return
|
||||
const category = typeof metadata.sourceCategory === 'string'
|
||||
? metadata.sourceCategory.trim()
|
||||
: ''
|
||||
if (category) categories.add(category)
|
||||
})
|
||||
|
||||
return Array.from(categories).sort((left, right) =>
|
||||
left.localeCompare(right, 'zh-Hans-CN'),
|
||||
)
|
||||
}
|
||||
|
||||
async getById(id: string, user: AuthUser) {
|
||||
const record = await this.repository.findActiveById(id)
|
||||
if (!record) {
|
||||
|
||||
@ -0,0 +1,178 @@
|
||||
import mysql, {
|
||||
type ConnectionOptions,
|
||||
type RowDataPacket,
|
||||
} from 'mysql2/promise'
|
||||
|
||||
export type JstPlanningSupplierOption = {
|
||||
name: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export type JstPlanningDirectoryOptions = {
|
||||
suppliers: JstPlanningSupplierOption[]
|
||||
warehouses: string[]
|
||||
}
|
||||
|
||||
const SUPPLIER_TABLE = 'jst_supplier'
|
||||
const WAREHOUSE_TABLE = 'jst_warehouseInfo'
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
let cachedOptions:
|
||||
| {
|
||||
expiresAt: number
|
||||
value: JstPlanningDirectoryOptions
|
||||
}
|
||||
| null = null
|
||||
|
||||
function readEnv(name: string) {
|
||||
return process.env[name]?.trim() ?? ''
|
||||
}
|
||||
|
||||
function readFirstEnv(names: string[]) {
|
||||
return names.map(readEnv).find(Boolean) ?? ''
|
||||
}
|
||||
|
||||
function createMysqlConnectionConfig(): string | ConnectionOptions {
|
||||
const databaseUrl = readFirstEnv([
|
||||
'JST_PLANNING_DATABASE_URL',
|
||||
'PLANNING_DATABASE_URL',
|
||||
'JST_SKU_DATABASE_URL',
|
||||
])
|
||||
|
||||
if (databaseUrl) {
|
||||
return databaseUrl
|
||||
}
|
||||
|
||||
const host = readFirstEnv([
|
||||
'JST_PLANNING_DB_HOST',
|
||||
'PLANNING_DB_HOST',
|
||||
'JST_SKU_DB_HOST',
|
||||
])
|
||||
const database = readFirstEnv([
|
||||
'JST_PLANNING_DB_NAME',
|
||||
'PLANNING_DB_NAME',
|
||||
'JST_SKU_DB_NAME',
|
||||
])
|
||||
const user = readFirstEnv([
|
||||
'JST_PLANNING_DB_USER',
|
||||
'PLANNING_DB_USER',
|
||||
'JST_SKU_DB_USER',
|
||||
])
|
||||
const password = readFirstEnv([
|
||||
'JST_PLANNING_DB_PASSWORD',
|
||||
'PLANNING_DB_PASSWORD',
|
||||
'JST_SKU_DB_PASSWORD',
|
||||
])
|
||||
|
||||
if (!host || !database || !user || !password) {
|
||||
throw new Error('JST planning database is not configured')
|
||||
}
|
||||
|
||||
const port = Number(
|
||||
readFirstEnv([
|
||||
'JST_PLANNING_DB_PORT',
|
||||
'PLANNING_DB_PORT',
|
||||
'JST_SKU_DB_PORT',
|
||||
]) || '3306',
|
||||
)
|
||||
const charset =
|
||||
readFirstEnv([
|
||||
'JST_PLANNING_DB_CHARSET',
|
||||
'PLANNING_DB_CHARSET',
|
||||
'JST_SKU_DB_CHARSET',
|
||||
]) || 'utf8mb4'
|
||||
const sslEnabled =
|
||||
readFirstEnv([
|
||||
'JST_PLANNING_DB_SSL',
|
||||
'PLANNING_DB_SSL',
|
||||
'JST_SKU_DB_SSL',
|
||||
]) === 'true'
|
||||
|
||||
return {
|
||||
host,
|
||||
port,
|
||||
database,
|
||||
user,
|
||||
password,
|
||||
charset,
|
||||
ssl: sslEnabled ? {} : undefined,
|
||||
supportBigNumbers: true,
|
||||
dateStrings: true,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeString(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : String(value ?? '').trim()
|
||||
}
|
||||
|
||||
function normalizeSupplierCode(value: unknown) {
|
||||
return normalizeString(value).toUpperCase()
|
||||
}
|
||||
|
||||
function uniqueNonEmpty(values: string[]) {
|
||||
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
|
||||
}
|
||||
|
||||
async function fetchDirectoryOptions(): Promise<JstPlanningDirectoryOptions> {
|
||||
const connectionConfig = createMysqlConnectionConfig()
|
||||
const connection =
|
||||
typeof connectionConfig === 'string'
|
||||
? await mysql.createConnection(connectionConfig)
|
||||
: await mysql.createConnection(connectionConfig)
|
||||
|
||||
try {
|
||||
const [supplierRows] = await connection.query<RowDataPacket[]>(
|
||||
`SELECT TRIM(CAST(\`scmname\` AS CHAR)) AS name,
|
||||
UPPER(TRIM(CAST(\`scmcode\` AS CHAR))) AS code
|
||||
FROM \`${SUPPLIER_TABLE}\`
|
||||
WHERE \`scmname\` IS NOT NULL
|
||||
AND TRIM(CAST(\`scmname\` AS CHAR)) <> ''
|
||||
ORDER BY name
|
||||
LIMIT 5000`,
|
||||
)
|
||||
const supplierByName = new Map<string, JstPlanningSupplierOption>()
|
||||
|
||||
supplierRows.forEach((row) => {
|
||||
const name = normalizeString(row.name)
|
||||
if (!name || supplierByName.has(name)) return
|
||||
supplierByName.set(name, {
|
||||
name,
|
||||
code: normalizeSupplierCode(row.code),
|
||||
})
|
||||
})
|
||||
|
||||
const [warehouseRows] = await connection.query<RowDataPacket[]>(
|
||||
`SELECT TRIM(CAST(\`name\` AS CHAR)) AS name
|
||||
FROM \`${WAREHOUSE_TABLE}\`
|
||||
WHERE \`name\` IS NOT NULL
|
||||
AND TRIM(CAST(\`name\` AS CHAR)) <> ''
|
||||
ORDER BY name
|
||||
LIMIT 2000`,
|
||||
)
|
||||
|
||||
return {
|
||||
suppliers: Array.from(supplierByName.values()),
|
||||
warehouses: uniqueNonEmpty(
|
||||
warehouseRows.map((row) => normalizeString(row.name)),
|
||||
),
|
||||
}
|
||||
} finally {
|
||||
await connection.end()
|
||||
}
|
||||
}
|
||||
|
||||
export const jstPlanningDirectoryService = {
|
||||
async listOptions(): Promise<JstPlanningDirectoryOptions> {
|
||||
const now = Date.now()
|
||||
if (cachedOptions && cachedOptions.expiresAt > now) {
|
||||
return cachedOptions.value
|
||||
}
|
||||
|
||||
const value = await fetchDirectoryOptions()
|
||||
cachedOptions = {
|
||||
expiresAt: now + CACHE_TTL_MS,
|
||||
value,
|
||||
}
|
||||
return value
|
||||
},
|
||||
}
|
||||
@ -0,0 +1,960 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import mysql, { type RowDataPacket } from 'mysql2/promise'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
createDefaultDocumentWorkspaceConfirmations,
|
||||
normalizeNonIntegratedProductRecord,
|
||||
stripNonIntegratedBrandPrefix,
|
||||
type NonIntegratedProductRecord,
|
||||
type NonIntegratedSkuItem,
|
||||
} from '@/app/intake/non-integrated/nonIntegratedStorage'
|
||||
|
||||
type JstSkuSourceField =
|
||||
| 'sku_id'
|
||||
| 'i_id'
|
||||
| 'name'
|
||||
| 'properties_value'
|
||||
| 'category'
|
||||
| 'item_type'
|
||||
| 'enabled'
|
||||
| 'stock_disabled'
|
||||
| 'other_5'
|
||||
| 'other_7'
|
||||
| 'other_8'
|
||||
| 'other_9'
|
||||
| 'other_10'
|
||||
| 'pic'
|
||||
| 'pic_big'
|
||||
|
||||
type JstSkuSourceRow = Partial<Record<JstSkuSourceField, unknown>>
|
||||
|
||||
type JstSkuSourceGroup = {
|
||||
sourceSpu: string
|
||||
rows: JstSkuSourceRow[]
|
||||
}
|
||||
|
||||
type JstSkuStockSyncOptions = {
|
||||
apply?: boolean
|
||||
batchSize?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
type JstSkuStockSyncCounters = {
|
||||
scanned: number
|
||||
created: number
|
||||
updated: number
|
||||
unchanged: number
|
||||
skipped: number
|
||||
conflicts: number
|
||||
}
|
||||
|
||||
export type JstSkuStockSyncResult = JstSkuStockSyncCounters & {
|
||||
apply: boolean
|
||||
batchSize: number
|
||||
limit: number | null
|
||||
startedAt: string
|
||||
finishedAt: string
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
const JST_SKU_TABLE = 'jst_sku'
|
||||
const DEFAULT_BATCH_SIZE = 500
|
||||
const MAX_BATCH_SIZE = 2000
|
||||
const FINISHED_ITEM_TYPE = '成品'
|
||||
const ON_SALE_STATUS = '销售'
|
||||
const EXCLUDED_STOCK_CATEGORY = '其他 - 配件'
|
||||
const BASIC_BRAND = '\u57fa\u672c\u76d8'
|
||||
const STOCK_PRODUCT_LABEL = '\u5b58\u91cf\u5546\u54c1'
|
||||
const DEFAULT_SYNC_USER_ID = 'jst_sku_sync'
|
||||
const REQUIRED_SOURCE_COLUMNS = ['sku_id'] as const
|
||||
const OPTIONAL_SOURCE_COLUMNS = [
|
||||
'i_id',
|
||||
'name',
|
||||
'properties_value',
|
||||
'category',
|
||||
'item_type',
|
||||
'enabled',
|
||||
'stock_disabled',
|
||||
'other_5',
|
||||
'other_7',
|
||||
'other_8',
|
||||
'other_9',
|
||||
'other_10',
|
||||
'pic',
|
||||
'pic_big',
|
||||
] as const
|
||||
|
||||
function readEnv(name: string) {
|
||||
return process.env[name]?.trim() ?? ''
|
||||
}
|
||||
|
||||
function readRequiredEnv(name: string) {
|
||||
const value = readEnv(name)
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function readPositiveInteger(value: number | undefined, fallback: number, max: number) {
|
||||
if (!Number.isFinite(value) || !value || value <= 0) return fallback
|
||||
return Math.min(max, Math.max(1, Math.floor(value)))
|
||||
}
|
||||
|
||||
function normalizeSourceString(value: unknown) {
|
||||
if (value === null || value === undefined) return ''
|
||||
return String(value).trim()
|
||||
}
|
||||
|
||||
function normalizeSkuId(value: unknown) {
|
||||
return normalizeSourceString(value)
|
||||
}
|
||||
|
||||
function buildStableId(prefix: string, value: string) {
|
||||
return `${prefix}-${createHash('sha1').update(value).digest('hex').slice(0, 20)}`
|
||||
}
|
||||
|
||||
function buildStockProductId(sourceSpu: string) {
|
||||
return `jst-stock-${createHash('sha1').update(sourceSpu).digest('hex').slice(0, 16)}`
|
||||
}
|
||||
|
||||
function pickSourceImage(row: JstSkuSourceRow) {
|
||||
return normalizeSourceString(row.pic_big) || normalizeSourceString(row.pic)
|
||||
}
|
||||
|
||||
function pickImageName(skuId: string, imageUrl: string) {
|
||||
if (!imageUrl) return ''
|
||||
|
||||
try {
|
||||
const pathname = new URL(imageUrl).pathname
|
||||
const fileName = pathname.split('/').filter(Boolean).pop()
|
||||
return fileName || `${skuId}.jpg`
|
||||
} catch {
|
||||
const fileName = imageUrl.split(/[\\/]/).filter(Boolean).pop()
|
||||
return fileName || `${skuId}.jpg`
|
||||
}
|
||||
}
|
||||
|
||||
function extractSizeFromName(name: string) {
|
||||
const normalizedName = name.replace(/\s+/g, ' ').trim()
|
||||
const match = normalizedName.match(
|
||||
/\d+(?:\.\d+)?(?:-\d+(?:\.\d+)?)?(?:\s*[/*xX]\s*\d+(?:\.\d+)?){1,3}/,
|
||||
)
|
||||
|
||||
return match?.[0]?.replace(/\s+/g, '') ?? ''
|
||||
}
|
||||
|
||||
function normalizeSetCount(value: unknown) {
|
||||
const text = normalizeSourceString(value)
|
||||
if (!text) return '1'
|
||||
|
||||
const numericMatch = text.match(/\d+/)
|
||||
if (numericMatch) return numericMatch[0]
|
||||
|
||||
const normalizedText = text.replace(/\s+/g, '')
|
||||
if (normalizedText.includes('\u975e\u53cc\u5305')) return '1'
|
||||
if (normalizedText.includes('\u53cc\u5305')) return '2'
|
||||
if (normalizedText.includes('\u4e09\u5305')) return '3'
|
||||
if (normalizedText.includes('\u56db\u5305')) return '4'
|
||||
if (normalizedText.includes('\u4e94\u5305')) return '5'
|
||||
|
||||
return '1'
|
||||
}
|
||||
|
||||
function toJsonValue(value: Record<string, unknown>): Prisma.InputJsonValue {
|
||||
return value as unknown as Prisma.InputJsonValue
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function readPayloadString(payload: unknown, key: string) {
|
||||
if (!isRecord(payload)) return ''
|
||||
return typeof payload[key] === 'string' ? payload[key].trim() : ''
|
||||
}
|
||||
|
||||
function readNestedPayloadString(payload: unknown, key: string) {
|
||||
if (!isRecord(payload)) return ''
|
||||
const metadata = payload.metadata
|
||||
if (!isRecord(metadata)) return ''
|
||||
return typeof metadata[key] === 'string' ? metadata[key].trim() : ''
|
||||
}
|
||||
|
||||
function isJstSkuPayload(payload: unknown) {
|
||||
return [
|
||||
readPayloadString(payload, 'source'),
|
||||
readPayloadString(payload, 'sourceTable'),
|
||||
readPayloadString(payload, 'sourceType'),
|
||||
readPayloadString(payload, 'importSource'),
|
||||
readPayloadString(payload, 'externalSource'),
|
||||
readNestedPayloadString(payload, 'source'),
|
||||
readNestedPayloadString(payload, 'sourceTable'),
|
||||
readNestedPayloadString(payload, 'sourceType'),
|
||||
readNestedPayloadString(payload, 'importSource'),
|
||||
readNestedPayloadString(payload, 'externalSource'),
|
||||
].some((value) => {
|
||||
const normalizedValue = value.toLowerCase().replace(/-/g, '_')
|
||||
return (
|
||||
normalizedValue === 'jst_sku' ||
|
||||
normalizedValue === 'jstsku' ||
|
||||
normalizedValue === 'jst_sku_stock_source'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function isJstSkuSkuItem(item: NonIntegratedSkuItem) {
|
||||
return (
|
||||
item.id.trim().startsWith('jst-stock-sku-') ||
|
||||
normalizeSourceString(item.sourceSkuId).length > 0 ||
|
||||
[item.source, item.sourceTable].some((value) => {
|
||||
const normalizedValue = normalizeSourceString(value).toLowerCase().replace(/-/g, '_')
|
||||
return normalizedValue === 'jst_sku' || normalizedValue === 'jstsku'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function mergeDefinedSourceFields<T extends Record<string, unknown>>(
|
||||
target: T,
|
||||
source: Partial<T>,
|
||||
keys: Array<keyof T>,
|
||||
) {
|
||||
const next = { ...target }
|
||||
|
||||
keys.forEach((key) => {
|
||||
const value = source[key]
|
||||
if (typeof value === 'string') {
|
||||
next[key] = value as T[keyof T]
|
||||
}
|
||||
})
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function buildSourceSkuItem(row: JstSkuSourceRow, sequence: number): NonIntegratedSkuItem {
|
||||
const skuId = normalizeSkuId(row.sku_id)
|
||||
const name = normalizeSourceString(row.name)
|
||||
const image = pickSourceImage(row)
|
||||
const color = normalizeSourceString(row.properties_value)
|
||||
const material = normalizeSourceString(row.other_10)
|
||||
const size = extractSizeFromName(name)
|
||||
|
||||
return {
|
||||
id: buildStableId('jst-stock-sku', skuId),
|
||||
skuSequence: sequence,
|
||||
batchNo: 1,
|
||||
packageGroupId: '',
|
||||
packageSuffix: '',
|
||||
image,
|
||||
imageName: pickImageName(skuId, image),
|
||||
model: '',
|
||||
skuCode: normalizeSourceString(row.other_7),
|
||||
setCount: normalizeSetCount(row.other_8),
|
||||
specName: name,
|
||||
barcode: skuId,
|
||||
size,
|
||||
color,
|
||||
material,
|
||||
versionNo: 'V01',
|
||||
attributeCode: '',
|
||||
drawingId: normalizeSourceString(row.other_9),
|
||||
installMethod: '',
|
||||
executeStandard: '',
|
||||
nationalStandardCompliant: '',
|
||||
manufacturer: '',
|
||||
manufacturerCode: '',
|
||||
salesStatus: normalizeSourceString(row.other_5),
|
||||
tagMaterial: '',
|
||||
model3dFileName: '',
|
||||
model3dDataUrl: '',
|
||||
componentDetails: [],
|
||||
source: 'jst_sku',
|
||||
sourceTable: JST_SKU_TABLE,
|
||||
sourceSkuId: skuId,
|
||||
} as NonIntegratedSkuItem
|
||||
}
|
||||
|
||||
function readSourceSpuKey(row: JstSkuSourceRow) {
|
||||
return normalizeSourceString(row.i_id) || normalizeSkuId(row.sku_id)
|
||||
}
|
||||
|
||||
function firstSourceValue(rows: JstSkuSourceRow[], key: JstSkuSourceField) {
|
||||
for (const row of rows) {
|
||||
const value = normalizeSourceString(row[key])
|
||||
if (value) return value
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function preferredSourceValue(
|
||||
rows: JstSkuSourceRow[],
|
||||
key: JstSkuSourceField,
|
||||
preferredValue?: string,
|
||||
) {
|
||||
if (preferredValue) {
|
||||
const matched = rows.some((row) => normalizeSourceString(row[key]) === preferredValue)
|
||||
if (matched) return preferredValue
|
||||
}
|
||||
|
||||
return firstSourceValue(rows, key)
|
||||
}
|
||||
|
||||
function isFinishedSourceSkuRow(row: JstSkuSourceRow) {
|
||||
return normalizeSourceString(row.item_type) === FINISHED_ITEM_TYPE
|
||||
}
|
||||
|
||||
function pickSkuContentRows(rows: JstSkuSourceRow[]) {
|
||||
const finishedRows = rows.filter(isFinishedSourceSkuRow)
|
||||
return finishedRows.length > 0 ? finishedRows : rows
|
||||
}
|
||||
|
||||
function pickRepresentativeSourceRow(rows: JstSkuSourceRow[]) {
|
||||
return rows.find(isFinishedSourceSkuRow) ?? rows[0] ?? {}
|
||||
}
|
||||
|
||||
function pickSourceCategory(rows: JstSkuSourceRow[]) {
|
||||
const representativeRow = pickRepresentativeSourceRow(rows)
|
||||
return normalizeSourceString(representativeRow.category) || firstSourceValue(rows, 'category')
|
||||
}
|
||||
|
||||
function shouldExcludeStockCategory(category: string) {
|
||||
return category.trim() === EXCLUDED_STOCK_CATEGORY
|
||||
}
|
||||
|
||||
function buildSourceSkuItems(rows: JstSkuSourceRow[]) {
|
||||
const seenSkuIds = new Set<string>()
|
||||
|
||||
return rows
|
||||
.map((row) => {
|
||||
const skuId = normalizeSkuId(row.sku_id)
|
||||
if (!skuId || seenSkuIds.has(skuId)) return null
|
||||
seenSkuIds.add(skuId)
|
||||
return row
|
||||
})
|
||||
.filter((row): row is JstSkuSourceRow => row !== null)
|
||||
.map((row, index) => buildSourceSkuItem(row, index + 1))
|
||||
}
|
||||
|
||||
function buildSourceProductPayload(
|
||||
group: JstSkuSourceGroup,
|
||||
syncedAt: string,
|
||||
): (NonIntegratedProductRecord & Record<string, unknown>) | null {
|
||||
const sourceSpu = group.sourceSpu.trim()
|
||||
if (!sourceSpu) return null
|
||||
|
||||
const skuContentRows = pickSkuContentRows(group.rows)
|
||||
const skuItems = buildSourceSkuItems(skuContentRows)
|
||||
if (skuItems.length === 0) return null
|
||||
|
||||
const representativeRow = pickRepresentativeSourceRow(group.rows)
|
||||
const firstSku = skuItems[0]
|
||||
const name = normalizeSourceString(representativeRow.name) || firstSku?.specName || sourceSpu
|
||||
const image =
|
||||
pickSourceImage(representativeRow) ||
|
||||
skuItems.find((item) => item.image.trim())?.image.trim() ||
|
||||
''
|
||||
const imageName = pickImageName(firstSku?.barcode || sourceSpu, image)
|
||||
const color = normalizeSourceString(representativeRow.properties_value) || firstSku?.color || ''
|
||||
const material = normalizeSourceString(representativeRow.other_10) || firstSku?.material || ''
|
||||
const size = extractSizeFromName(name)
|
||||
const sourceCategory = pickSourceCategory(group.rows)
|
||||
const sourceItemType = preferredSourceValue(group.rows, 'item_type', FINISHED_ITEM_TYPE)
|
||||
const hasOnSaleSku = group.rows.some(
|
||||
(row) =>
|
||||
isFinishedSourceSkuRow(row) &&
|
||||
normalizeSourceString(row.other_5) === ON_SALE_STATUS &&
|
||||
normalizeSourceString(row.enabled) === '1',
|
||||
)
|
||||
const sourceSalesStatus = hasOnSaleSku
|
||||
? ON_SALE_STATUS
|
||||
: normalizeSourceString(representativeRow.other_5) || firstSourceValue(group.rows, 'other_5')
|
||||
const sourceEnabled = skuContentRows.some((row) => normalizeSourceString(row.enabled) === '1')
|
||||
? '1'
|
||||
: firstSourceValue(skuContentRows, 'enabled')
|
||||
const firstSourceRow = skuContentRows[0] ?? {}
|
||||
|
||||
const rawPayload = {
|
||||
id: buildStockProductId(sourceSpu),
|
||||
productDevelopmentKind: 'product',
|
||||
spu: sourceSpu.toUpperCase(),
|
||||
projectStatus: 'approved',
|
||||
projectReview: {
|
||||
reviewDate: '',
|
||||
reviewers: '',
|
||||
designerReviewer: '',
|
||||
purchaserReviewer: '',
|
||||
planningReviewer: '',
|
||||
operatorReviewer: '',
|
||||
conclusion: '',
|
||||
revisionNotes: '',
|
||||
decidedAt: '',
|
||||
},
|
||||
openingReference: {
|
||||
productImage: image,
|
||||
productImageName: imageName,
|
||||
productName: name,
|
||||
temporaryCode: sourceSpu,
|
||||
brand: BASIC_BRAND,
|
||||
adaptableBrands: [BASIC_BRAND],
|
||||
productCategory: '',
|
||||
salesCategory: '',
|
||||
designer: '',
|
||||
planningOwner: '',
|
||||
structureDesigner: '',
|
||||
currentStage: STOCK_PRODUCT_LABEL,
|
||||
year: '',
|
||||
competitorLink: '',
|
||||
targetPriceBand: '',
|
||||
size,
|
||||
material,
|
||||
urgent: '',
|
||||
reason: '',
|
||||
},
|
||||
brand: BASIC_BRAND,
|
||||
adaptableBrands: [BASIC_BRAND],
|
||||
designer: '',
|
||||
operator: '',
|
||||
purchaser: '',
|
||||
previewImage: image,
|
||||
previewImageName: imageName,
|
||||
year: '',
|
||||
demandFlowNo: '',
|
||||
processTemplate: STOCK_PRODUCT_LABEL,
|
||||
productCategory: '',
|
||||
salesCategory: '',
|
||||
productPlanning: '',
|
||||
demandDepartment: '',
|
||||
isSelfDevelopedPatentProduct: '',
|
||||
patentOwner: '',
|
||||
spuOwner: '',
|
||||
needsClearanceHandling: '',
|
||||
operationsOwner: '',
|
||||
profitCenter: '',
|
||||
salesChannel: '',
|
||||
salesPlatform: '',
|
||||
purchaseOwner: '',
|
||||
goodsType: '',
|
||||
materialType: material,
|
||||
productionType: '',
|
||||
manualProducer: '',
|
||||
shippingMode: '',
|
||||
visualDirector: '',
|
||||
itemId: '',
|
||||
priceBand: '',
|
||||
outputRequirementType: '',
|
||||
visualPlanning: '',
|
||||
visualPlanningType: '',
|
||||
photographer: '',
|
||||
graphicDesigner: '',
|
||||
designer3d: '',
|
||||
sampleRequired: '',
|
||||
flatOutputPath: '',
|
||||
output3dPath: '',
|
||||
size,
|
||||
color,
|
||||
material,
|
||||
installMethod: '',
|
||||
installSteps: '',
|
||||
tagMaterial: '',
|
||||
packageDetail: '',
|
||||
craftDetail: '',
|
||||
styleDetails: [],
|
||||
bomDetails: [],
|
||||
reviewSheet: {
|
||||
version: '',
|
||||
factory: '',
|
||||
reviewDate: '',
|
||||
reviewMethod: '',
|
||||
productName: name,
|
||||
productImage: image,
|
||||
productImageName: imageName,
|
||||
size,
|
||||
material,
|
||||
color,
|
||||
issues: [],
|
||||
},
|
||||
reviewSheets: [],
|
||||
qualityInspectionSheet: {
|
||||
rows: [],
|
||||
},
|
||||
productCode: sourceSpu,
|
||||
skuItems,
|
||||
skuBatchCount: Math.max(1, ...skuItems.map((item) => item.batchNo || 1)),
|
||||
skuBatchFlowNos: {},
|
||||
skuBatchChangeTypes: {},
|
||||
factoryRows: [],
|
||||
pricingRows: [],
|
||||
planningLayoutRows: [],
|
||||
productLimitRows: [],
|
||||
sampleMeasurementRows: [],
|
||||
firstQualityRows: [],
|
||||
uploadFiles: [],
|
||||
documentWorkspaceConfirmations: createDefaultDocumentWorkspaceConfirmations(true),
|
||||
documentWorkspaceApprovedAt: {},
|
||||
formalSpuAssigned: true,
|
||||
createdAt: syncedAt,
|
||||
updatedAt: syncedAt,
|
||||
source: 'jst_sku',
|
||||
sourceTable: JST_SKU_TABLE,
|
||||
importSource: 'jst_sku',
|
||||
externalSource: 'jst_sku',
|
||||
metadata: {
|
||||
source: 'jst_sku',
|
||||
sourceTable: JST_SKU_TABLE,
|
||||
sourceSpu,
|
||||
sourceSkuId: firstSku?.barcode ?? '',
|
||||
sourceCategory,
|
||||
sourceItemType,
|
||||
sourceSalesStatus,
|
||||
sourceEnabled,
|
||||
sourceStockDisabled: firstSourceValue(group.rows, 'stock_disabled'),
|
||||
skuCount: skuItems.length,
|
||||
hasOnSaleSku,
|
||||
sourceFields: {
|
||||
sku_id: firstSku?.barcode ?? '',
|
||||
i_id: sourceSpu,
|
||||
name,
|
||||
category: sourceCategory,
|
||||
item_type: sourceItemType,
|
||||
enabled: sourceEnabled,
|
||||
stock_disabled: firstSourceValue(group.rows, 'stock_disabled'),
|
||||
properties_value: color,
|
||||
other_5: sourceSalesStatus,
|
||||
other_7: normalizeSourceString(firstSourceRow.other_7),
|
||||
other_8: normalizeSourceString(firstSourceRow.other_8),
|
||||
other_9: normalizeSourceString(firstSourceRow.other_9),
|
||||
other_10: material,
|
||||
pic: normalizeSourceString(firstSourceRow.pic),
|
||||
pic_big: normalizeSourceString(firstSourceRow.pic_big),
|
||||
},
|
||||
},
|
||||
} as unknown as Partial<NonIntegratedProductRecord> & Record<string, unknown>
|
||||
const normalizedProduct = normalizeNonIntegratedProductRecord(rawPayload)
|
||||
|
||||
return normalizedProduct
|
||||
? ({ ...rawPayload, ...normalizedProduct } as NonIntegratedProductRecord &
|
||||
Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
function mergeExistingProductPayload(
|
||||
existingPayload: unknown,
|
||||
sourcePayload: NonIntegratedProductRecord & Record<string, unknown>,
|
||||
) {
|
||||
const existingProduct =
|
||||
normalizeNonIntegratedProductRecord(
|
||||
(isRecord(existingPayload) ? existingPayload : {}) as Partial<NonIntegratedProductRecord>,
|
||||
) ?? sourcePayload
|
||||
const existingRecord = isRecord(existingPayload) ? existingPayload : {}
|
||||
const matchedExistingSkuIds = new Set<string>()
|
||||
const nextSourceSkus = sourcePayload.skuItems.map((sourceSku) => {
|
||||
const existingSku = existingProduct.skuItems.find((item) => {
|
||||
const matched =
|
||||
item.id === sourceSku.id ||
|
||||
item.sourceSkuId === sourceSku.sourceSkuId ||
|
||||
(sourceSku.barcode.trim() && item.barcode.trim() === sourceSku.barcode.trim())
|
||||
if (matched) matchedExistingSkuIds.add(item.id)
|
||||
return matched
|
||||
})
|
||||
|
||||
if (!existingSku) return sourceSku
|
||||
|
||||
const mergedSku = mergeDefinedSourceFields(existingSku, sourceSku, [
|
||||
'image',
|
||||
'imageName',
|
||||
'model',
|
||||
'skuCode',
|
||||
'setCount',
|
||||
'specName',
|
||||
'barcode',
|
||||
'size',
|
||||
'color',
|
||||
'material',
|
||||
'drawingId',
|
||||
'salesStatus',
|
||||
'source',
|
||||
'sourceTable',
|
||||
'sourceSkuId',
|
||||
])
|
||||
|
||||
return {
|
||||
...mergedSku,
|
||||
skuSequence: sourceSku.skuSequence,
|
||||
source: 'jst_sku',
|
||||
sourceTable: JST_SKU_TABLE,
|
||||
sourceSkuId: sourceSku.sourceSkuId,
|
||||
}
|
||||
})
|
||||
const remainingSkus = existingProduct.skuItems.filter(
|
||||
(item) => !matchedExistingSkuIds.has(item.id) && !isJstSkuSkuItem(item),
|
||||
)
|
||||
const sourceOpeningReference = sourcePayload.openingReference
|
||||
const nextOpeningReference = {
|
||||
...existingProduct.openingReference,
|
||||
productName: sourceOpeningReference.productName,
|
||||
temporaryCode: sourceOpeningReference.temporaryCode,
|
||||
brand: sourceOpeningReference.brand,
|
||||
adaptableBrands: sourceOpeningReference.adaptableBrands,
|
||||
currentStage: sourceOpeningReference.currentStage,
|
||||
size: sourceOpeningReference.size,
|
||||
material: sourceOpeningReference.material,
|
||||
...(sourceOpeningReference.productImage
|
||||
? {
|
||||
productImage: sourceOpeningReference.productImage,
|
||||
productImageName: sourceOpeningReference.productImageName,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
const nextMetadata = {
|
||||
...(isRecord(existingRecord.metadata) ? existingRecord.metadata : {}),
|
||||
...(isRecord(sourcePayload.metadata) ? sourcePayload.metadata : {}),
|
||||
}
|
||||
const nextPayload = {
|
||||
...existingRecord,
|
||||
...existingProduct,
|
||||
source: 'jst_sku',
|
||||
sourceTable: JST_SKU_TABLE,
|
||||
importSource: 'jst_sku',
|
||||
externalSource: 'jst_sku',
|
||||
metadata: nextMetadata,
|
||||
productDevelopmentKind: 'product',
|
||||
spu: sourcePayload.spu,
|
||||
brand: sourcePayload.brand,
|
||||
adaptableBrands: sourcePayload.adaptableBrands,
|
||||
processTemplate: STOCK_PRODUCT_LABEL,
|
||||
productCode: sourcePayload.productCode,
|
||||
size: sourcePayload.size,
|
||||
color: sourcePayload.color,
|
||||
material: sourcePayload.material,
|
||||
materialType: sourcePayload.materialType,
|
||||
openingReference: nextOpeningReference,
|
||||
previewImage: sourcePayload.previewImage || existingProduct.previewImage,
|
||||
previewImageName: sourcePayload.previewImage
|
||||
? sourcePayload.previewImageName
|
||||
: existingProduct.previewImageName,
|
||||
skuItems: [...nextSourceSkus, ...remainingSkus],
|
||||
skuBatchCount: Math.max(
|
||||
existingProduct.skuBatchCount ?? 1,
|
||||
sourcePayload.skuBatchCount ?? 1,
|
||||
),
|
||||
documentWorkspaceConfirmations: existingProduct.documentWorkspaceConfirmations,
|
||||
documentWorkspaceApprovedAt: existingProduct.documentWorkspaceApprovedAt,
|
||||
createdAt: existingProduct.createdAt,
|
||||
updatedAt: existingProduct.updatedAt,
|
||||
}
|
||||
|
||||
const normalizedProduct = normalizeNonIntegratedProductRecord(
|
||||
nextPayload as Partial<NonIntegratedProductRecord>,
|
||||
)
|
||||
|
||||
return normalizedProduct
|
||||
? ({ ...nextPayload, ...normalizedProduct } as NonIntegratedProductRecord &
|
||||
Record<string, unknown>)
|
||||
: sourcePayload
|
||||
}
|
||||
|
||||
function payloadHash(value: unknown) {
|
||||
return createHash('sha1').update(JSON.stringify(value)).digest('hex')
|
||||
}
|
||||
|
||||
function createMysqlConnectionConfig() {
|
||||
const databaseUrl = readEnv('JST_SKU_DATABASE_URL')
|
||||
if (databaseUrl) {
|
||||
return databaseUrl
|
||||
}
|
||||
|
||||
return {
|
||||
host: readRequiredEnv('JST_SKU_DB_HOST'),
|
||||
port: Number(readEnv('JST_SKU_DB_PORT') || '3306'),
|
||||
database: readRequiredEnv('JST_SKU_DB_NAME'),
|
||||
user: readRequiredEnv('JST_SKU_DB_USER'),
|
||||
password: readRequiredEnv('JST_SKU_DB_PASSWORD'),
|
||||
charset: readEnv('JST_SKU_DB_CHARSET') || 'utf8mb4',
|
||||
ssl: readEnv('JST_SKU_DB_SSL') === 'true' ? {} : undefined,
|
||||
supportBigNumbers: true,
|
||||
dateStrings: true,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadJstSkuColumns(connection: mysql.Connection) {
|
||||
const [rows] = await connection.query<RowDataPacket[]>(`SHOW COLUMNS FROM \`${JST_SKU_TABLE}\``)
|
||||
return new Set(rows.map((row) => normalizeSourceString(row.Field)))
|
||||
}
|
||||
|
||||
function buildSourceSelectColumns(columns: Set<string>) {
|
||||
REQUIRED_SOURCE_COLUMNS.forEach((column) => {
|
||||
if (!columns.has(column)) {
|
||||
throw new Error(`Source table ${JST_SKU_TABLE} is missing required column: ${column}`)
|
||||
}
|
||||
})
|
||||
|
||||
return [
|
||||
...REQUIRED_SOURCE_COLUMNS,
|
||||
...OPTIONAL_SOURCE_COLUMNS.filter((column) => columns.has(column)),
|
||||
]
|
||||
}
|
||||
|
||||
async function fetchSourceSpuKeys(params: {
|
||||
connection: mysql.Connection
|
||||
offset: number
|
||||
take: number
|
||||
}) {
|
||||
const [rows] = await params.connection.execute<RowDataPacket[]>(
|
||||
`SELECT DISTINCT COALESCE(NULLIF(TRIM(CAST(\`i_id\` AS CHAR)), ''), TRIM(CAST(\`sku_id\` AS CHAR))) AS source_spu
|
||||
FROM \`${JST_SKU_TABLE}\`
|
||||
WHERE \`sku_id\` IS NOT NULL
|
||||
AND TRIM(CAST(\`sku_id\` AS CHAR)) <> ''
|
||||
AND COALESCE(NULLIF(TRIM(CAST(\`i_id\` AS CHAR)), ''), TRIM(CAST(\`sku_id\` AS CHAR))) <> ''
|
||||
ORDER BY source_spu
|
||||
LIMIT ? OFFSET ?`,
|
||||
[params.take, params.offset],
|
||||
)
|
||||
|
||||
return rows
|
||||
.map((row) => normalizeSourceString(row.source_spu))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
async function fetchSourceGroups(params: {
|
||||
connection: mysql.Connection
|
||||
selectColumns: readonly string[]
|
||||
sourceSpuKeys: string[]
|
||||
}) {
|
||||
if (params.sourceSpuKeys.length === 0) return []
|
||||
|
||||
const selectSql = params.selectColumns.map((column) => `\`${column}\``).join(', ')
|
||||
const placeholders = params.sourceSpuKeys.map(() => '?').join(', ')
|
||||
const [rows] = await params.connection.execute<RowDataPacket[]>(
|
||||
`SELECT ${selectSql},
|
||||
COALESCE(NULLIF(TRIM(CAST(\`i_id\` AS CHAR)), ''), TRIM(CAST(\`sku_id\` AS CHAR))) AS __source_spu
|
||||
FROM \`${JST_SKU_TABLE}\`
|
||||
WHERE \`sku_id\` IS NOT NULL
|
||||
AND TRIM(CAST(\`sku_id\` AS CHAR)) <> ''
|
||||
AND COALESCE(NULLIF(TRIM(CAST(\`i_id\` AS CHAR)), ''), TRIM(CAST(\`sku_id\` AS CHAR))) IN (${placeholders})
|
||||
ORDER BY __source_spu, \`sku_id\``,
|
||||
params.sourceSpuKeys,
|
||||
)
|
||||
|
||||
const rowsBySourceSpu = new Map<string, JstSkuSourceRow[]>()
|
||||
;(rows as Array<JstSkuSourceRow & { __source_spu?: unknown }>).forEach((row) => {
|
||||
const sourceSpu = normalizeSourceString(row.__source_spu) || readSourceSpuKey(row)
|
||||
if (!sourceSpu) return
|
||||
const nextRow = { ...row }
|
||||
delete nextRow.__source_spu
|
||||
rowsBySourceSpu.set(sourceSpu, [...(rowsBySourceSpu.get(sourceSpu) ?? []), nextRow])
|
||||
})
|
||||
|
||||
return params.sourceSpuKeys
|
||||
.map((sourceSpu) => ({
|
||||
sourceSpu,
|
||||
rows: rowsBySourceSpu.get(sourceSpu) ?? [],
|
||||
}))
|
||||
.filter((group) => group.rows.length > 0)
|
||||
}
|
||||
|
||||
async function findExistingStockProduct(id: string, spu: string, sourceSpu: string) {
|
||||
const existingById = await prisma.intakeNonIntegratedProduct.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
if (existingById) return existingById
|
||||
|
||||
return prisma.intakeNonIntegratedProduct.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ spu },
|
||||
{
|
||||
payload: {
|
||||
path: ['metadata', 'sourceSpu'],
|
||||
equals: sourceSpu,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: 'desc',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function removeExcludedStockProduct(group: JstSkuSourceGroup, apply: boolean) {
|
||||
const sourceSpu = group.sourceSpu.trim()
|
||||
if (!sourceSpu) return 'skipped' as const
|
||||
|
||||
const id = buildStockProductId(sourceSpu)
|
||||
const spu = sourceSpu.toUpperCase()
|
||||
const existing = await findExistingStockProduct(id, spu, sourceSpu)
|
||||
|
||||
if (!existing) return 'skipped' as const
|
||||
if (!isJstSkuPayload(existing.payload) && !existing.id.startsWith('jst-stock-')) {
|
||||
return 'conflicts' as const
|
||||
}
|
||||
if (existing.deletedAt !== null) return 'unchanged' as const
|
||||
|
||||
if (apply) {
|
||||
await prisma.intakeNonIntegratedProduct.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
updatedBy: readEnv('JST_SKU_SYNC_USER_ID') || DEFAULT_SYNC_USER_ID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return 'updated' as const
|
||||
}
|
||||
|
||||
async function syncOneSourceGroup(group: JstSkuSourceGroup, apply: boolean, syncedAt: string) {
|
||||
if (shouldExcludeStockCategory(pickSourceCategory(group.rows))) {
|
||||
return removeExcludedStockProduct(group, apply)
|
||||
}
|
||||
|
||||
const sourcePayload = buildSourceProductPayload(group, syncedAt)
|
||||
if (!sourcePayload) {
|
||||
return 'skipped' as const
|
||||
}
|
||||
|
||||
const existing = await findExistingStockProduct(
|
||||
sourcePayload.id,
|
||||
sourcePayload.spu,
|
||||
group.sourceSpu,
|
||||
)
|
||||
|
||||
if (existing && existing.id !== sourcePayload.id && !isJstSkuPayload(existing.payload)) {
|
||||
return 'conflicts' as const
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
if (apply) {
|
||||
await prisma.intakeNonIntegratedProduct.create({
|
||||
data: {
|
||||
id: sourcePayload.id,
|
||||
spu: sourcePayload.spu,
|
||||
spuBase: stripNonIntegratedBrandPrefix(sourcePayload.spu),
|
||||
brand: sourcePayload.brand,
|
||||
designer: sourcePayload.designer,
|
||||
operator: sourcePayload.operator,
|
||||
purchaser: sourcePayload.purchaser,
|
||||
payload: toJsonValue(sourcePayload),
|
||||
visibility: 'PUBLIC',
|
||||
createdBy: readEnv('JST_SKU_SYNC_USER_ID') || DEFAULT_SYNC_USER_ID,
|
||||
updatedBy: readEnv('JST_SKU_SYNC_USER_ID') || DEFAULT_SYNC_USER_ID,
|
||||
createdAt: new Date(syncedAt),
|
||||
},
|
||||
})
|
||||
}
|
||||
return 'created' as const
|
||||
}
|
||||
|
||||
const nextPayload = mergeExistingProductPayload(existing.payload, sourcePayload)
|
||||
const currentPayloadHash = payloadHash(existing.payload)
|
||||
const nextPayloadHash = payloadHash(nextPayload)
|
||||
if (
|
||||
currentPayloadHash === nextPayloadHash &&
|
||||
existing.spu === sourcePayload.spu &&
|
||||
existing.deletedAt === null
|
||||
) {
|
||||
return 'unchanged' as const
|
||||
}
|
||||
|
||||
if (apply) {
|
||||
await prisma.intakeNonIntegratedProduct.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
spu: sourcePayload.spu,
|
||||
spuBase: stripNonIntegratedBrandPrefix(sourcePayload.spu),
|
||||
brand: nextPayload.brand,
|
||||
designer: nextPayload.designer,
|
||||
operator: nextPayload.operator,
|
||||
purchaser: nextPayload.purchaser,
|
||||
payload: toJsonValue(nextPayload),
|
||||
visibility: 'PUBLIC',
|
||||
updatedBy: readEnv('JST_SKU_SYNC_USER_ID') || DEFAULT_SYNC_USER_ID,
|
||||
deletedAt: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return 'updated' as const
|
||||
}
|
||||
|
||||
export async function syncJstSkuStockProducts(
|
||||
options: JstSkuStockSyncOptions = {},
|
||||
): Promise<JstSkuStockSyncResult> {
|
||||
const startedAt = new Date().toISOString()
|
||||
const apply = options.apply === true
|
||||
const batchSize = readPositiveInteger(options.batchSize, DEFAULT_BATCH_SIZE, MAX_BATCH_SIZE)
|
||||
const limit =
|
||||
typeof options.limit === 'number' && Number.isFinite(options.limit) && options.limit > 0
|
||||
? Math.floor(options.limit)
|
||||
: null
|
||||
const counters: JstSkuStockSyncCounters = {
|
||||
scanned: 0,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
unchanged: 0,
|
||||
skipped: 0,
|
||||
conflicts: 0,
|
||||
}
|
||||
const errors: string[] = []
|
||||
const mysqlConfig = createMysqlConnectionConfig()
|
||||
const connection =
|
||||
typeof mysqlConfig === 'string'
|
||||
? await mysql.createConnection(mysqlConfig)
|
||||
: await mysql.createConnection(mysqlConfig)
|
||||
|
||||
try {
|
||||
const columns = await loadJstSkuColumns(connection)
|
||||
const selectColumns = buildSourceSelectColumns(columns)
|
||||
let offset = 0
|
||||
|
||||
while (limit === null || counters.scanned < limit) {
|
||||
const remaining = limit === null ? batchSize : Math.min(batchSize, limit - counters.scanned)
|
||||
if (remaining <= 0) break
|
||||
|
||||
const sourceSpuKeys = await fetchSourceSpuKeys({
|
||||
connection,
|
||||
offset,
|
||||
take: remaining,
|
||||
})
|
||||
if (sourceSpuKeys.length === 0) break
|
||||
|
||||
const groups = await fetchSourceGroups({
|
||||
connection,
|
||||
selectColumns,
|
||||
sourceSpuKeys,
|
||||
})
|
||||
|
||||
for (const group of groups) {
|
||||
counters.scanned += 1
|
||||
try {
|
||||
const result = await syncOneSourceGroup(group, apply, new Date().toISOString())
|
||||
counters[result] += 1
|
||||
} catch (error) {
|
||||
counters.skipped += 1
|
||||
errors.push(
|
||||
`${group.sourceSpu || '<empty source_spu>'}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
offset += sourceSpuKeys.length
|
||||
if (sourceSpuKeys.length < remaining) break
|
||||
}
|
||||
} finally {
|
||||
await connection.end()
|
||||
}
|
||||
|
||||
return {
|
||||
...counters,
|
||||
apply,
|
||||
batchSize,
|
||||
limit,
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
errors,
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user