Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wuyongjie-assisted-assembly-next

This commit is contained in:
WuYongJie 2026-05-19 20:19:05 +08:00
commit 66491cf49e
24 changed files with 6275 additions and 1107 deletions

View File

@ -75,7 +75,6 @@ dist
# Gatsby文件
.cache/
public
# Vuepress构建输出
.vuepress/dist
@ -225,4 +224,4 @@ PROJECT_SETUP_COMPLETE.md
产品安装展示系统-*.md
开发部署计划.md
项目*.md
架构*.md
架构*.md

View File

@ -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
@ -237,4 +247,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/api/health || exit 1
# 启动应用
CMD ["node", "server.js"]
CMD ["node", "server.js"]

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

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

View File

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

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

View File

@ -39,4 +39,3 @@ export async function PUT(
{ requireAuth: true, requireInternalContentWrite: true },
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: {

View File

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

View File

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

View File

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