Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ed569f5bc | ||
|
|
b04623ebdd | ||
|
|
a4151607c5 | ||
|
|
4cc99f3f23 | ||
|
|
8b38d6fef7 | ||
|
|
4963a5c463 | ||
|
|
a43353aa98 | ||
|
|
6e5f7edbb7 | ||
|
|
437d9ca93b | ||
|
|
d375df810d | ||
|
|
7d09077f1d |
@@ -24,6 +24,7 @@ lerna-debug.log*
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.*.local
|
||||
|
||||
# OS
|
||||
|
||||
@@ -51,7 +51,7 @@ PORT=3001
|
||||
cd apps/api
|
||||
pnpm prisma:generate
|
||||
pnpm prisma:migrate
|
||||
pnpm start:dev
|
||||
pnpm --filter @inkreach/api dev
|
||||
# → http://localhost:3001 · Swagger: http://localhost:3001/api/docs
|
||||
|
||||
# Terminal 2:官网
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import request from './request'
|
||||
import type {
|
||||
Good,
|
||||
GoodDetail,
|
||||
CreateGoodRequest,
|
||||
UpdateGoodRequest,
|
||||
BatchCreateGoodsRequest,
|
||||
BatchPriorityRequest,
|
||||
GoodsFilter,
|
||||
PaginatedResult,
|
||||
CreateCustomGoodRequest,
|
||||
UpdateCustomGoodContentRequest,
|
||||
} from '@/types'
|
||||
|
||||
export const goodsApi = {
|
||||
@@ -17,7 +20,7 @@ export const goodsApi = {
|
||||
|
||||
// Get good by id
|
||||
getGoodById: (id: string) => {
|
||||
return request.get<any, Good>(`/goods/${id}`)
|
||||
return request.get<any, GoodDetail>(`/goods/${id}`)
|
||||
},
|
||||
|
||||
// Create good
|
||||
@@ -25,6 +28,14 @@ export const goodsApi = {
|
||||
return request.post<any, Good>('/goods', data)
|
||||
},
|
||||
|
||||
createCustomGood: (data: CreateCustomGoodRequest) => {
|
||||
return request.post<any, GoodDetail>('/goods/custom', data)
|
||||
},
|
||||
|
||||
updateCustomGoodContent: (id: string, data: UpdateCustomGoodContentRequest) => {
|
||||
return request.patch<any, GoodDetail>(`/goods/${id}/custom-content`, data)
|
||||
},
|
||||
|
||||
// Update good
|
||||
updateGood: (id: string, data: UpdateGoodRequest) => {
|
||||
return request.patch<any, Good>(`/goods/${id}`, data)
|
||||
@@ -44,4 +55,4 @@ export const goodsApi = {
|
||||
batchUpdatePriority: (data: BatchPriorityRequest) => {
|
||||
return request.patch<any, { count: number }>('/goods/batch-priority', data)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,16 @@ export const syncApi = {
|
||||
return request.post<any, { message: string }>('/sync/categories')
|
||||
},
|
||||
|
||||
syncProductDetails: () => {
|
||||
return request.post<any, { message: string }>('/sync/product-details')
|
||||
},
|
||||
|
||||
syncOneProductDetail: (goodId: string) => {
|
||||
return request.post<any, { goodId: string; variants: number; detailSyncedAt: string }>(
|
||||
`/sync/products/${goodId}/detail`,
|
||||
)
|
||||
},
|
||||
|
||||
getSyncStatus: (limit?: number) => {
|
||||
return request.get<any, SyncLog[]>('/sync/status', {
|
||||
params: limit ? { limit } : undefined,
|
||||
|
||||
Vendored
+7
@@ -11,6 +11,7 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||
@@ -19,6 +20,8 @@ declare module 'vue' {
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
@@ -31,6 +34,7 @@ declare module 'vue' {
|
||||
ElImage: typeof import('element-plus/es')['ElImage']
|
||||
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
@@ -40,6 +44,9 @@ declare module 'vue' {
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
|
||||
@@ -44,6 +44,78 @@ export interface Good {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface OriginGoodDetail {
|
||||
productCode?: string | null
|
||||
englishName?: string | null
|
||||
productionCycleHours?: number | null
|
||||
minWeightG?: string | null
|
||||
productionProcess?: string | null
|
||||
materialDescription?: string | null
|
||||
sizeChart?: { columns?: Array<{ key: string; name: string }>; rows?: any[] } | null
|
||||
packageSpecs?: { rows?: any[] } | null
|
||||
syncedAt?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface OriginGoodVariant {
|
||||
sdsVariantId: string
|
||||
sku: string
|
||||
sizeName?: string | null
|
||||
colorName?: string | null
|
||||
colorHex?: string | null
|
||||
price?: string | null
|
||||
enabled: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface CustomGoodVariantRequest {
|
||||
sku: string
|
||||
sizeId?: string | null
|
||||
sizeName?: string | null
|
||||
colorId?: string | null
|
||||
colorName?: string | null
|
||||
colorHex?: string | null
|
||||
imageUrl?: string | null
|
||||
price?: string | null
|
||||
originalPrice?: string | null
|
||||
weightG?: string | null
|
||||
boxLengthCm?: string | null
|
||||
boxWidthCm?: string | null
|
||||
boxHeightCm?: string | null
|
||||
designData?: Record<string, unknown> | null
|
||||
enabled?: boolean
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface CustomGoodDetailRequest {
|
||||
productCode?: string | null
|
||||
englishName?: string | null
|
||||
productionCycleHours?: number | null
|
||||
minWeightG?: string | null
|
||||
productionProcess?: string | null
|
||||
materialDescription?: string | null
|
||||
blankDesignUrl?: string | null
|
||||
detailsPageVideoUrl?: string | null
|
||||
textureName?: string | null
|
||||
reminder?: string | null
|
||||
productPerformance?: string | null
|
||||
applicableScenarios?: string | null
|
||||
washingInstructions?: string | null
|
||||
specialDescription?: string | null
|
||||
designExplanation?: string | null
|
||||
designArea?: string | null
|
||||
pictureRequest?: string | null
|
||||
sizeChart?: Record<string, unknown> | null
|
||||
packageSpecs?: Record<string, unknown> | null
|
||||
options?: Record<string, unknown> | null
|
||||
media?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface GoodDetail extends Good {
|
||||
originDetail: OriginGoodDetail | null
|
||||
variants: OriginGoodVariant[]
|
||||
}
|
||||
|
||||
export interface CreateGoodRequest {
|
||||
goodName: string
|
||||
goodImage?: string
|
||||
@@ -55,6 +127,27 @@ export interface CreateGoodRequest {
|
||||
goodPriority?: number
|
||||
}
|
||||
|
||||
export interface CreateCustomGoodRequest {
|
||||
goodName: string
|
||||
goodImage?: string
|
||||
goodPrice?: string | null
|
||||
countryId: number
|
||||
categoryId: number
|
||||
tagIds?: number[]
|
||||
positionId?: number
|
||||
goodPriority?: number
|
||||
detail?: CustomGoodDetailRequest
|
||||
variants?: CustomGoodVariantRequest[]
|
||||
}
|
||||
|
||||
export interface UpdateCustomGoodContentRequest {
|
||||
goodName?: string
|
||||
goodImage?: string | null
|
||||
goodPrice?: string | null
|
||||
detail?: CustomGoodDetailRequest
|
||||
variants?: CustomGoodVariantRequest[]
|
||||
}
|
||||
|
||||
export interface UpdateGoodRequest {
|
||||
goodName?: string
|
||||
goodImage?: string | null
|
||||
@@ -242,10 +335,18 @@ export interface OriginGood {
|
||||
goodImage: string | null
|
||||
goodPrice: string | null
|
||||
sdsGoodId: string
|
||||
source: 'SDS' | 'CUSTOM'
|
||||
isCustom: boolean
|
||||
sdsCategoryId: string | null
|
||||
delisted?: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
hasDetail?: boolean
|
||||
detailSyncedAt?: string | null
|
||||
variantCount?: number
|
||||
sizeRowCount?: number
|
||||
packageRowCount?: number
|
||||
productCode?: string | null
|
||||
}
|
||||
|
||||
// Origin Goods Tree types
|
||||
@@ -259,6 +360,11 @@ export interface OriginGoodsTreeNode {
|
||||
configuredCount: number
|
||||
configuredCountries: string[]
|
||||
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]
|
||||
hasDetail: boolean
|
||||
detailSyncedAt: string | null
|
||||
variantCount: number
|
||||
sizeRowCount: number
|
||||
packageRowCount: number
|
||||
}
|
||||
|
||||
export interface OriginGoodsTreeCategoryNode {
|
||||
@@ -280,13 +386,11 @@ export interface OriginGoodsTreeResponse {
|
||||
// Sync types
|
||||
export interface SyncLog {
|
||||
id: string
|
||||
type: 'CATEGORY' | 'PRODUCT'
|
||||
status: 'SUCCESS' | 'FAILED'
|
||||
message?: string
|
||||
startTime: string
|
||||
endTime?: string
|
||||
errorCount?: number
|
||||
createdAt: string
|
||||
type: 'CATEGORIES' | 'PRODUCTS' | 'PRODUCT_DETAILS'
|
||||
status: 'RUNNING' | 'SUCCESS' | 'FAILED'
|
||||
message: string | null
|
||||
startedAt: string
|
||||
finishedAt: string | null
|
||||
}
|
||||
|
||||
// Filter types
|
||||
|
||||
@@ -3,11 +3,11 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useVirtualList } from '@vueuse/core'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Plus, Edit, Delete, Search, Top,
|
||||
Plus, Edit, Delete, Search, Top, Refresh,
|
||||
FolderAdd, Aim, ArrowDown,
|
||||
} from '@element-plus/icons-vue'
|
||||
import type {
|
||||
CategoryTree, Country, Tag, TagGroup, Good, Position,
|
||||
CategoryTree, Country, Tag, TagGroup, Good, GoodDetail,
|
||||
OriginGoodsTreeResponse,
|
||||
} from '@/types'
|
||||
import { goodsApi } from '@/api/goods'
|
||||
@@ -15,8 +15,8 @@ import { countriesApi } from '@/api/countries'
|
||||
import { categoriesApi } from '@/api/categories'
|
||||
import { tagsApi } from '@/api/tags'
|
||||
import { tagGroupsApi } from '@/api/tag-groups'
|
||||
import { positionsApi } from '@/api/positions'
|
||||
import { originGoodsApi } from '@/api/origin-goods'
|
||||
import { syncApi } from '@/api/sync'
|
||||
|
||||
const mode = ref<'category' | 'country' | 'global'>('category')
|
||||
const loading = ref(false)
|
||||
@@ -53,6 +53,7 @@ const leftPct = ref(55)
|
||||
const showAllLeft = ref(false)
|
||||
const showAllRight = ref(false)
|
||||
const showUnconfiguredOnly = ref(false)
|
||||
const syncingOriginGoodIds = ref(new Set<string>())
|
||||
|
||||
function onSplitterMouseDown(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
@@ -168,7 +169,8 @@ function goodToNode(g: Good): any {
|
||||
originGoodImage: g.originGood?.goodImage || null,
|
||||
originGoodPrice: g.originGood?.goodPrice || null,
|
||||
sdsGoodId: g.originGood?.sdsGoodId || null,
|
||||
originDelisted: !!g.originGoodId && !activeOriginGoodIds.value.has(String(g.originGoodId)),
|
||||
isCustom: g.originGood?.isCustom === true,
|
||||
originDelisted: g.originGood?.source === 'SDS' && !activeOriginGoodIds.value.has(String(g.originGoodId)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +266,11 @@ function buildRightTree(tree: OriginGoodsTreeResponse) {
|
||||
sdsGoodId: og.sdsGoodId,
|
||||
configuredCount: og.configuredCount ?? 0,
|
||||
configuredCountries: og.configuredCountries ?? [],
|
||||
hasDetail: Boolean(og.hasDetail),
|
||||
detailSyncedAt: og.detailSyncedAt ?? null,
|
||||
variantCount: og.variantCount ?? 0,
|
||||
sizeRowCount: og.sizeRowCount ?? 0,
|
||||
packageRowCount: og.packageRowCount ?? 0,
|
||||
}))
|
||||
return {
|
||||
id: 'rc-' + node.categoryId,
|
||||
@@ -357,7 +364,6 @@ const configForm = ref({
|
||||
countryId: '', cascaderCategory: [] as string[], categoryId: '',
|
||||
tagIds: [] as string[], positionId: '', goodImage: '',
|
||||
})
|
||||
const configPositions = ref<Position[]>([])
|
||||
|
||||
function openConfigModal(og: any, dropTarget: any) {
|
||||
configOG.value = og
|
||||
@@ -371,7 +377,6 @@ function openConfigModal(og: any, dropTarget: any) {
|
||||
configForm.value.countryId = dropTarget.id
|
||||
}
|
||||
}
|
||||
loadConfigPositions()
|
||||
configVisible.value = true
|
||||
}
|
||||
|
||||
@@ -385,19 +390,8 @@ function openConfigFromRightTree(data: any) {
|
||||
}, null)
|
||||
}
|
||||
|
||||
async function loadConfigPositions() {
|
||||
const params: any = { page: 1, pageSize: 200 }
|
||||
if (configForm.value.countryId) params.countryId = configForm.value.countryId
|
||||
if (configForm.value.categoryId) params.categoryId = configForm.value.categoryId
|
||||
try {
|
||||
const res = await positionsApi.getPositionsList(params) as any
|
||||
configPositions.value = Array.isArray(res) ? res : (res.items ?? [])
|
||||
} catch { configPositions.value = [] }
|
||||
}
|
||||
|
||||
function onConfigCascaderChange(val: string[]) {
|
||||
configForm.value.categoryId = val.length ? val[val.length - 1] : ''
|
||||
loadConfigPositions()
|
||||
}
|
||||
|
||||
async function handleConfigSubmit() {
|
||||
@@ -423,16 +417,149 @@ async function handleConfigSubmit() {
|
||||
} finally { configLoading.value = false }
|
||||
}
|
||||
|
||||
// ─── Custom Good ───
|
||||
const customVisible = ref(false)
|
||||
const customLoading = ref(false)
|
||||
const customForm = ref({
|
||||
goodName: '', goodImage: '', goodPrice: '', countryId: '',
|
||||
cascaderCategory: [] as string[], categoryId: '', tagIds: [] as string[],
|
||||
goodPriority: 0,
|
||||
})
|
||||
|
||||
function openCustomCreate() {
|
||||
customForm.value = {
|
||||
goodName: '', goodImage: '', goodPrice: '', countryId: '',
|
||||
cascaderCategory: [], categoryId: '', tagIds: [], goodPriority: 0,
|
||||
}
|
||||
customVisible.value = true
|
||||
}
|
||||
|
||||
function onCustomCascaderChange(val: any) {
|
||||
const path = Array.isArray(val) ? val : []
|
||||
customForm.value.categoryId = path.length ? String(path[path.length - 1]) : ''
|
||||
}
|
||||
|
||||
async function handleCustomCreate() {
|
||||
if (!customForm.value.goodName.trim()) { ElMessage.warning('请输入商品名称'); return }
|
||||
if (!customForm.value.countryId) { ElMessage.warning('请选择国家'); return }
|
||||
if (!customForm.value.categoryId) { ElMessage.warning('请选择分类'); return }
|
||||
customLoading.value = true
|
||||
try {
|
||||
const created = await goodsApi.createCustomGood({
|
||||
goodName: customForm.value.goodName.trim(),
|
||||
goodImage: customForm.value.goodImage || undefined,
|
||||
goodPrice: customForm.value.goodPrice || null,
|
||||
countryId: Number(customForm.value.countryId),
|
||||
categoryId: Number(customForm.value.categoryId),
|
||||
tagIds: customForm.value.tagIds.map(Number),
|
||||
goodPriority: customForm.value.goodPriority,
|
||||
detail: {},
|
||||
})
|
||||
ElMessage.success('自定义商品已创建,可继续完善详情、尺码、包装和 SKU')
|
||||
customVisible.value = false
|
||||
await refreshLeftTree()
|
||||
await openEdit(created)
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.response?.data?.message || '自定义商品创建失败')
|
||||
} finally { customLoading.value = false }
|
||||
}
|
||||
|
||||
// ─── Edit Good (replaces detail — click opens edit directly) ───
|
||||
const editVisible = ref(false)
|
||||
const editLoading = ref(false)
|
||||
const editGood = ref<Good | null>(null)
|
||||
const editDetailLoading = ref(false)
|
||||
const detailSyncing = ref(false)
|
||||
const editGood = ref<Good | GoodDetail | null>(null)
|
||||
const editForm = ref({
|
||||
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
|
||||
categoryId: '', tagIds: [] as string[], positionId: '',
|
||||
})
|
||||
|
||||
function openEdit(g: Good) {
|
||||
const editOriginDetail = computed(() => (editGood.value as GoodDetail | null)?.originDetail ?? null)
|
||||
const editVariants = computed(() => (editGood.value as GoodDetail | null)?.variants ?? [])
|
||||
const editSizeColumns = computed(() => editOriginDetail.value?.sizeChart?.columns ?? [])
|
||||
const editSizeRows = computed(() => {
|
||||
const rows = editOriginDetail.value?.sizeChart?.rows ?? []
|
||||
return rows.map((row: any) => ({
|
||||
...row,
|
||||
...(row.measurements ?? []).reduce((out: Record<string, string>, item: any) => {
|
||||
out[item.key] = item.cm ?? '-'
|
||||
return out
|
||||
}, {}),
|
||||
}))
|
||||
})
|
||||
const editPackageRows = computed(() => editOriginDetail.value?.packageSpecs?.rows ?? [])
|
||||
const editIsCustom = computed(() => editGood.value?.originGood?.isCustom === true)
|
||||
const customContentForm = ref({
|
||||
goodPrice: '', productCode: '', englishName: '', productionCycleHours: undefined as number | undefined,
|
||||
minWeightG: '', productionProcess: '', materialDescription: '',
|
||||
blankDesignUrl: '', detailsPageVideoUrl: '', textureName: '', reminder: '',
|
||||
productPerformance: '', applicableScenarios: '', washingInstructions: '', specialDescription: '',
|
||||
designExplanation: '', designArea: '', pictureRequest: '',
|
||||
sizeChartJson: '{\n "columns": [],\n "rows": []\n}',
|
||||
packageSpecsJson: '{\n "rows": []\n}',
|
||||
optionsJson: '{}',
|
||||
mediaJson: '{}',
|
||||
variants: [] as Array<{
|
||||
sku: string; sizeId: string; sizeName: string; colorId: string; colorName: string; colorHex: string; imageUrl: string;
|
||||
price: string; originalPrice: string; weightG: string; boxLengthCm: string;
|
||||
boxWidthCm: string; boxHeightCm: string; designDataJson: string; enabled: boolean
|
||||
}>,
|
||||
})
|
||||
|
||||
function fillCustomContent(g: GoodDetail) {
|
||||
const detail = g.originDetail ?? {}
|
||||
customContentForm.value = {
|
||||
goodPrice: g.originGood?.goodPrice ?? '',
|
||||
productCode: String(detail.productCode ?? ''),
|
||||
englishName: String(detail.englishName ?? ''),
|
||||
productionCycleHours: detail.productionCycleHours == null ? undefined : Number(detail.productionCycleHours),
|
||||
minWeightG: String(detail.minWeightG ?? ''),
|
||||
productionProcess: String(detail.productionProcess ?? ''),
|
||||
materialDescription: String(detail.materialDescription ?? ''),
|
||||
blankDesignUrl: String(detail.blankDesignUrl ?? ''),
|
||||
detailsPageVideoUrl: String(detail.detailsPageVideoUrl ?? ''),
|
||||
textureName: String(detail.textureName ?? ''),
|
||||
reminder: String(detail.reminder ?? ''),
|
||||
productPerformance: String(detail.productPerformance ?? ''),
|
||||
applicableScenarios: String(detail.applicableScenarios ?? ''),
|
||||
washingInstructions: String(detail.washingInstructions ?? ''),
|
||||
specialDescription: String(detail.specialDescription ?? ''),
|
||||
designExplanation: String(detail.designExplanation ?? ''),
|
||||
designArea: String(detail.designArea ?? ''),
|
||||
pictureRequest: String(detail.pictureRequest ?? ''),
|
||||
sizeChartJson: JSON.stringify(detail.sizeChart ?? { columns: [], rows: [] }, null, 2),
|
||||
packageSpecsJson: JSON.stringify(detail.packageSpecs ?? { rows: [] }, null, 2),
|
||||
optionsJson: JSON.stringify(detail.options ?? {}, null, 2),
|
||||
mediaJson: JSON.stringify(detail.media ?? {}, null, 2),
|
||||
variants: g.variants.map((variant) => ({
|
||||
sku: variant.sku,
|
||||
sizeId: String(variant.sizeId ?? ''),
|
||||
sizeName: String(variant.sizeName ?? ''),
|
||||
colorId: String(variant.colorId ?? ''),
|
||||
colorName: String(variant.colorName ?? ''),
|
||||
colorHex: String(variant.colorHex ?? ''),
|
||||
imageUrl: String(variant.imageUrl ?? ''),
|
||||
price: String(variant.price ?? ''),
|
||||
originalPrice: String(variant.originalPrice ?? ''),
|
||||
weightG: String(variant.weightG ?? ''),
|
||||
boxLengthCm: String(variant.boxLengthCm ?? ''),
|
||||
boxWidthCm: String(variant.boxWidthCm ?? ''),
|
||||
boxHeightCm: String(variant.boxHeightCm ?? ''),
|
||||
designDataJson: JSON.stringify(variant.designData ?? {}, null, 2),
|
||||
enabled: variant.enabled,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function addCustomVariant() {
|
||||
customContentForm.value.variants.push({
|
||||
sku: '', sizeId: '', sizeName: '', colorId: '', colorName: '', colorHex: '', imageUrl: '', price: '',
|
||||
originalPrice: '', weightG: '', boxLengthCm: '', boxWidthCm: '', boxHeightCm: '', designDataJson: '{}', enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
async function openEdit(g: Good) {
|
||||
editGood.value = g
|
||||
editForm.value = {
|
||||
id: g.id, goodName: g.goodName,
|
||||
@@ -444,9 +571,119 @@ function openEdit(g: Good) {
|
||||
positionId: g.positionId || '',
|
||||
}
|
||||
editVisible.value = true
|
||||
editDetailLoading.value = true
|
||||
try {
|
||||
const detail = await goodsApi.getGoodById(g.id)
|
||||
editGood.value = detail
|
||||
if (detail.originGood?.isCustom) fillCustomContent(detail)
|
||||
} catch {
|
||||
ElMessage.warning('商品详情加载失败,当前显示列表数据')
|
||||
} finally {
|
||||
editDetailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncOriginDetail(data: any) {
|
||||
if (!data.sdsGoodId || syncingOriginGoodIds.value.has(data.sdsGoodId)) return
|
||||
syncingOriginGoodIds.value = new Set(syncingOriginGoodIds.value).add(data.sdsGoodId)
|
||||
try {
|
||||
const result = await syncApi.syncOneProductDetail(data.sdsGoodId)
|
||||
ElMessage.success(`详情同步完成,共 ${result.variants} 个 SKU`)
|
||||
await refreshRightTree()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.response?.data?.message || '商品详情同步失败')
|
||||
} finally {
|
||||
const next = new Set(syncingOriginGoodIds.value)
|
||||
next.delete(data.sdsGoodId)
|
||||
syncingOriginGoodIds.value = next
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncOneDetail() {
|
||||
if (editIsCustom.value) return
|
||||
const goodId = editGood.value?.originGood?.sdsGoodId
|
||||
if (!goodId) return
|
||||
detailSyncing.value = true
|
||||
try {
|
||||
const result = await syncApi.syncOneProductDetail(goodId)
|
||||
editGood.value = await goodsApi.getGoodById(editGood.value!.id)
|
||||
ElMessage.success(`详情同步完成,共 ${result.variants} 个 SKU`)
|
||||
await Promise.all([refreshLeftTree(), refreshRightTree()])
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.response?.data?.message || '商品详情同步失败')
|
||||
} finally {
|
||||
detailSyncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditSubmit() {
|
||||
let customPayload: any = null
|
||||
if (editIsCustom.value) {
|
||||
let sizeChart: Record<string, unknown>
|
||||
let packageSpecs: Record<string, unknown>
|
||||
let options: Record<string, unknown>
|
||||
let media: Record<string, unknown>
|
||||
try {
|
||||
sizeChart = JSON.parse(customContentForm.value.sizeChartJson)
|
||||
packageSpecs = JSON.parse(customContentForm.value.packageSpecsJson)
|
||||
options = JSON.parse(customContentForm.value.optionsJson)
|
||||
media = JSON.parse(customContentForm.value.mediaJson)
|
||||
for (const variant of customContentForm.value.variants) JSON.parse(variant.designDataJson)
|
||||
} catch {
|
||||
ElMessage.error('尺码表、包装规格、选项、媒体或 SKU 设计数据不是有效 JSON')
|
||||
return
|
||||
}
|
||||
if (customContentForm.value.variants.some((variant) => !variant.sku.trim())) {
|
||||
ElMessage.error('SKU 不能为空')
|
||||
return
|
||||
}
|
||||
customPayload = {
|
||||
goodName: editForm.value.goodName,
|
||||
goodImage: editForm.value.goodImage || null,
|
||||
goodPrice: customContentForm.value.goodPrice || null,
|
||||
detail: {
|
||||
productCode: customContentForm.value.productCode || null,
|
||||
englishName: customContentForm.value.englishName || null,
|
||||
productionCycleHours: customContentForm.value.productionCycleHours ?? null,
|
||||
minWeightG: customContentForm.value.minWeightG || null,
|
||||
productionProcess: customContentForm.value.productionProcess || null,
|
||||
materialDescription: customContentForm.value.materialDescription || null,
|
||||
blankDesignUrl: customContentForm.value.blankDesignUrl || null,
|
||||
detailsPageVideoUrl: customContentForm.value.detailsPageVideoUrl || null,
|
||||
textureName: customContentForm.value.textureName || null,
|
||||
reminder: customContentForm.value.reminder || null,
|
||||
productPerformance: customContentForm.value.productPerformance || null,
|
||||
applicableScenarios: customContentForm.value.applicableScenarios || null,
|
||||
washingInstructions: customContentForm.value.washingInstructions || null,
|
||||
specialDescription: customContentForm.value.specialDescription || null,
|
||||
designExplanation: customContentForm.value.designExplanation || null,
|
||||
designArea: customContentForm.value.designArea || null,
|
||||
pictureRequest: customContentForm.value.pictureRequest || null,
|
||||
sizeChart,
|
||||
packageSpecs,
|
||||
options,
|
||||
media,
|
||||
},
|
||||
variants: customContentForm.value.variants.map((variant: any, index: number) => ({
|
||||
sku: variant.sku.trim(),
|
||||
sizeId: variant.sizeId || null,
|
||||
sizeName: variant.sizeName || null,
|
||||
colorId: variant.colorId || null,
|
||||
colorName: variant.colorName || null,
|
||||
colorHex: variant.colorHex || null,
|
||||
imageUrl: variant.imageUrl || null,
|
||||
price: variant.price || null,
|
||||
originalPrice: variant.originalPrice || null,
|
||||
weightG: variant.weightG || null,
|
||||
boxLengthCm: variant.boxLengthCm || null,
|
||||
boxWidthCm: variant.boxWidthCm || null,
|
||||
boxHeightCm: variant.boxHeightCm || null,
|
||||
designData: JSON.parse(variant.designDataJson),
|
||||
enabled: variant.enabled,
|
||||
sortOrder: index,
|
||||
})),
|
||||
}
|
||||
}
|
||||
editLoading.value = true
|
||||
try {
|
||||
await goodsApi.updateGood(editForm.value.id, {
|
||||
@@ -457,6 +694,7 @@ async function handleEditSubmit() {
|
||||
tagIds: editForm.value.tagIds.map(Number),
|
||||
positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null,
|
||||
} as any)
|
||||
if (customPayload) await goodsApi.updateCustomGoodContent(editForm.value.id, customPayload)
|
||||
ElMessage.success('更新成功')
|
||||
editVisible.value = false
|
||||
refreshLeftTree()
|
||||
@@ -466,8 +704,9 @@ async function handleEditSubmit() {
|
||||
} finally { editLoading.value = false }
|
||||
}
|
||||
|
||||
function onEditCascaderChange(val: string[]) {
|
||||
editForm.value.categoryId = val.length ? val[val.length - 1] : ''
|
||||
function onEditCascaderChange(val: any) {
|
||||
const path = Array.isArray(val) ? val : []
|
||||
editForm.value.categoryId = path.length ? String(path[path.length - 1]) : ''
|
||||
}
|
||||
|
||||
async function handleDeleteGood(g: Good) {
|
||||
@@ -1200,6 +1439,7 @@ onMounted(() => loadAll())
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button size="small" type="primary" :icon="Search" @click="onSearch">搜索</el-button>
|
||||
<el-button size="small" type="success" :icon="Plus" @click="openCustomCreate">新增自定义商品</el-button>
|
||||
|
||||
<div class="gv-filter-spacer" />
|
||||
<el-radio-group v-model="mode" size="small" @change="onModeChange">
|
||||
@@ -1279,7 +1519,7 @@ onMounted(() => loadAll())
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="data.originGoodName" class="gt-row">
|
||||
<div class="gt-label">原产品</div>
|
||||
<div class="gt-label">{{ data.isCustom ? '来源' : '原产品' }}</div>
|
||||
<div class="gt-val gt-val-ellipsis">{{ data.originGoodName }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1289,13 +1529,14 @@ onMounted(() => loadAll())
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<span class="good-actions" @click.stop>
|
||||
<el-button size="small" link :icon="Aim" title="定位原产品" @click="locateInRightTree(data.originGoodId)" />
|
||||
<el-button v-if="!data.isCustom" size="small" link :icon="Aim" title="定位原产品" @click="locateInRightTree(data.originGoodId)" />
|
||||
<el-button size="small" link :icon="Edit" @click="openEdit(data.raw)" />
|
||||
<el-button size="small" link type="danger" :icon="Delete" @click="handleDeleteGood(data.raw)" />
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="data.country || data.tags?.length || data.originDelisted" class="good-meta">
|
||||
<span v-if="data.originDelisted" class="good-delisted-badge">下架</span>
|
||||
<span v-if="data.isCustom" class="good-tag">自定义</span>
|
||||
<span v-if="data.country" class="good-country">{{ data.country }}</span>
|
||||
<span
|
||||
v-for="t in (data.tags || []).slice(0, 3)" :key="t.id"
|
||||
@@ -1351,7 +1592,22 @@ onMounted(() => loadAll())
|
||||
v-else
|
||||
class="og-badge og-badge--warn"
|
||||
>未配置</span>
|
||||
<span
|
||||
class="og-badge"
|
||||
:class="data.hasDetail ? 'og-badge--detail' : 'og-badge--missing'"
|
||||
:title="data.detailSyncedAt ? `详情同步于 ${new Date(data.detailSyncedAt).toLocaleString()}` : '尚未同步商品详情'"
|
||||
>
|
||||
{{ data.hasDetail ? `详情 · ${data.variantCount} SKU` : '缺详情' }}
|
||||
</span>
|
||||
<span v-if="data.goodPrice" class="og-price">¥{{ data.goodPrice }}</span>
|
||||
<el-button
|
||||
size="small"
|
||||
link
|
||||
:icon="Refresh"
|
||||
:loading="syncingOriginGoodIds.has(data.sdsGoodId)"
|
||||
:title="data.hasDetail ? '重新同步商品详情' : '同步商品详情'"
|
||||
@click.stop="handleSyncOriginDetail(data)"
|
||||
/>
|
||||
<el-button
|
||||
v-if="data.configuredCount > 0"
|
||||
size="small" link :icon="Aim" title="定位到官网商品"
|
||||
@@ -1464,7 +1720,7 @@ onMounted(() => loadAll())
|
||||
<el-form label-width="80px" style="margin-top: 16px">
|
||||
<el-form-item label="国家">
|
||||
<div v-if="mode === 'category' || !configDropTarget" class="select-inline">
|
||||
<el-select v-model="configForm.countryId" placeholder="请选择国家" filterable @change="loadConfigPositions">
|
||||
<el-select v-model="configForm.countryId" placeholder="请选择国家" filterable>
|
||||
<el-option v-for="c in allCountries" :key="c.id" :label="c.countryName" :value="c.id" />
|
||||
</el-select>
|
||||
<el-button text :icon="Plus" @click="quickCreateCountry(() => { const latest = allCountries.value[allCountries.value.length - 1]; if (latest) configForm.value.countryId = latest.id })" />
|
||||
@@ -1488,11 +1744,6 @@ onMounted(() => loadAll())
|
||||
<el-button text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags.value[allTags.value.length - 1]; if (latest) configForm.value.tagIds.push(latest.id) })" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="位置">
|
||||
<el-select v-model="configForm.positionId" clearable placeholder="可选">
|
||||
<el-option v-for="p in configPositions" :key="p.id" :label="`#${p.indexVal}`" :value="p.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="configVisible = false">取消</el-button>
|
||||
@@ -1500,32 +1751,87 @@ onMounted(() => loadAll())
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Custom Good Modal -->
|
||||
<el-dialog v-model="customVisible" title="新增自定义商品" width="620px" destroy-on-close>
|
||||
<el-alert
|
||||
title="自定义商品不关联 SDS 原产品,名称、图片、价格、详情、尺码、包装和 SKU 均可维护。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
style="margin-bottom:16px"
|
||||
/>
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="商品名称" required><el-input v-model="customForm.goodName" /></el-form-item>
|
||||
<el-form-item label="商品图片"><ImageUpload v-model="customForm.goodImage" label="上传图片" /></el-form-item>
|
||||
<el-form-item label="基础价格"><el-input v-model="customForm.goodPrice" placeholder="例如 28.00" /></el-form-item>
|
||||
<el-form-item label="国家" required>
|
||||
<el-select v-model="customForm.countryId" filterable placeholder="请选择国家" style="width:100%">
|
||||
<el-option v-for="c in allCountries" :key="c.id" :label="c.countryName" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类" required>
|
||||
<el-cascader v-model="customForm.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" style="width:100%" @change="onCustomCascaderChange" />
|
||||
</el-form-item>
|
||||
<el-form-item label="标签">
|
||||
<el-select v-model="customForm.tagIds" multiple filterable placeholder="请选择标签" style="width:100%">
|
||||
<el-option-group v-for="g in groupedTagOptions" :key="g.id" :label="g.label">
|
||||
<el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="优先级"><el-input-number v-model="customForm.goodPriority" :min="0" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="customVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="customLoading" @click="handleCustomCreate">创建并完善详情</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Edit Good Modal (direct edit — no separate detail step) -->
|
||||
<el-dialog v-model="editVisible" :title="editGood?.goodName || '编辑商品'" width="560px" destroy-on-close>
|
||||
<el-dialog v-model="editVisible" :title="editGood?.goodName || '编辑商品'" width="900px" destroy-on-close>
|
||||
<!-- Origin product reference -->
|
||||
<div v-if="editGood?.originGood" class="edit-og-ref">
|
||||
<el-image v-if="editGood.originGood.goodImage" :src="editGood.originGood.goodImage" fit="cover" class="edit-og-img" />
|
||||
<div class="edit-og-meta">
|
||||
<div class="edit-og-label">关联原产品</div>
|
||||
<div class="edit-og-label">{{ editIsCustom ? '自定义商品' : '关联原产品' }}</div>
|
||||
<div class="edit-og-name">{{ editGood.originGood.goodName }}</div>
|
||||
<div class="edit-og-sub">SDS ID: {{ editGood.originGood.sdsGoodId }}<template v-if="editGood.originGood.goodPrice"> · ¥{{ editGood.originGood.goodPrice }}</template></div>
|
||||
<div class="edit-og-sub">{{ editIsCustom ? '自定义 ID' : 'SDS ID' }}: {{ editGood.originGood.sdsGoodId }}<template v-if="editGood.originGood.goodPrice"> · ¥{{ editGood.originGood.goodPrice }}</template></div>
|
||||
<div class="edit-og-status">
|
||||
<el-tag :type="editGood.originGood.hasDetail ? 'success' : 'warning'" size="small">
|
||||
{{ editGood.originGood.hasDetail ? '详情已同步' : '详情未同步' }}
|
||||
</el-tag>
|
||||
<span v-if="editGood.originGood.detailSyncedAt">
|
||||
{{ new Date(editGood.originGood.detailSyncedAt).toLocaleString() }}
|
||||
</span>
|
||||
<span>SKU {{ editGood.originGood.variantCount || 0 }}</span>
|
||||
<span>尺码 {{ editGood.originGood.sizeRowCount || 0 }}</span>
|
||||
<span>包装 {{ editGood.originGood.packageRowCount || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="!editIsCustom"
|
||||
type="primary"
|
||||
plain
|
||||
size="small"
|
||||
:icon="Refresh"
|
||||
:loading="detailSyncing"
|
||||
@click="handleSyncOneDetail"
|
||||
>同步详情</el-button>
|
||||
</div>
|
||||
<el-form label-width="80px" style="margin-top: 16px">
|
||||
<el-form v-loading="editDetailLoading" label-width="80px" style="margin-top: 16px">
|
||||
<el-form-item label="名称"><el-input v-model="editForm.goodName" /></el-form-item>
|
||||
<el-form-item label="图片">
|
||||
<ImageUpload v-model="editForm.goodImage" label="上传图片" />
|
||||
</el-form-item>
|
||||
<el-form-item label="国家">
|
||||
<div class="select-inline">
|
||||
<el-select v-model="editForm.countryId" filterable>
|
||||
<el-select v-model="editForm.countryId" filterable placeholder="请选择国家">
|
||||
<el-option v-for="c in allCountries" :key="c.id" :label="c.countryName" :value="c.id" />
|
||||
</el-select>
|
||||
<el-button text :icon="Plus" @click="quickCreateCountry(() => { const latest = allCountries.value[allCountries.value.length - 1]; if (latest) editForm.value.countryId = latest.id })" />
|
||||
<el-button text :icon="Plus" @click="quickCreateCountry(() => { const latest = allCountries[allCountries.length - 1]; if (latest) editForm.countryId = latest.id })" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-cascader v-model="editForm.cascaderCategory" :options="categoryCascader" :props="{ checkStrictly: true }" @change="onEditCascaderChange" />
|
||||
<el-cascader v-model="editForm.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onEditCascaderChange" />
|
||||
</el-form-item>
|
||||
<el-form-item label="标签">
|
||||
<div class="select-inline">
|
||||
@@ -1534,10 +1840,108 @@ onMounted(() => loadAll())
|
||||
<el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
<el-button text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags.value[allTags.value.length - 1]; if (latest) editForm.value.tagIds.push(latest.id) })" />
|
||||
<el-button text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags[allTags.length - 1]; if (latest) editForm.tagIds.push(latest.id) })" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editIsCustom" label="基础价格">
|
||||
<el-input v-model="customContentForm.goodPrice" placeholder="例如 28.00" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-tabs v-if="editOriginDetail" class="detail-tabs">
|
||||
<el-tab-pane label="商品详情">
|
||||
<el-form v-if="editIsCustom" label-width="100px" class="custom-detail-form">
|
||||
<el-form-item label="商品编码"><el-input v-model="customContentForm.productCode" /></el-form-item>
|
||||
<el-form-item label="英文名称"><el-input v-model="customContentForm.englishName" /></el-form-item>
|
||||
<el-form-item label="生产周期"><el-input-number v-model="customContentForm.productionCycleHours" :min="0" /><span style="margin-left:8px">小时</span></el-form-item>
|
||||
<el-form-item label="净重"><el-input v-model="customContentForm.minWeightG"><template #append>g</template></el-input></el-form-item>
|
||||
<el-form-item label="生产工艺"><el-input v-model="customContentForm.productionProcess" type="textarea" /></el-form-item>
|
||||
<el-form-item label="材质"><el-input v-model="customContentForm.materialDescription" type="textarea" /></el-form-item>
|
||||
<el-form-item label="空白设计图"><el-input v-model="customContentForm.blankDesignUrl" /></el-form-item>
|
||||
<el-form-item label="详情视频"><el-input v-model="customContentForm.detailsPageVideoUrl" /></el-form-item>
|
||||
<el-form-item label="面料名称"><el-input v-model="customContentForm.textureName" /></el-form-item>
|
||||
<el-form-item label="温馨提示"><el-input v-model="customContentForm.reminder" type="textarea" /></el-form-item>
|
||||
<el-form-item label="产品性能"><el-input v-model="customContentForm.productPerformance" type="textarea" /></el-form-item>
|
||||
<el-form-item label="适用场景"><el-input v-model="customContentForm.applicableScenarios" type="textarea" /></el-form-item>
|
||||
<el-form-item label="洗涤说明"><el-input v-model="customContentForm.washingInstructions" type="textarea" /></el-form-item>
|
||||
<el-form-item label="特殊说明"><el-input v-model="customContentForm.specialDescription" type="textarea" /></el-form-item>
|
||||
<el-form-item label="设计说明"><el-input v-model="customContentForm.designExplanation" type="textarea" /></el-form-item>
|
||||
<el-form-item label="设计区域"><el-input v-model="customContentForm.designArea" /></el-form-item>
|
||||
<el-form-item label="图片要求"><el-input v-model="customContentForm.pictureRequest" type="textarea" /></el-form-item>
|
||||
<el-form-item label="商品选项 JSON"><el-input v-model="customContentForm.optionsJson" type="textarea" :rows="6" /></el-form-item>
|
||||
<el-form-item label="媒体数据 JSON"><el-input v-model="customContentForm.mediaJson" type="textarea" :rows="6" /></el-form-item>
|
||||
</el-form>
|
||||
<el-descriptions v-else :column="2" border size="small">
|
||||
<el-descriptions-item label="商品编码">{{ editOriginDetail.productCode || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="英文名称">{{ editOriginDetail.englishName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="生产周期">{{ editOriginDetail.productionCycleHours ?? '-' }} 小时</el-descriptions-item>
|
||||
<el-descriptions-item label="净重">{{ editOriginDetail.minWeightG ?? '-' }} g</el-descriptions-item>
|
||||
<el-descriptions-item label="生产工艺">{{ editOriginDetail.productionProcess || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="材质">{{ editOriginDetail.materialDescription || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="`尺码表 (${editSizeRows.length})`">
|
||||
<el-input v-if="editIsCustom" v-model="customContentForm.sizeChartJson" type="textarea" :rows="14" placeholder="尺码表 JSON" />
|
||||
<el-table v-else :data="editSizeRows" border max-height="320">
|
||||
<el-table-column prop="sizeName" label="尺码" width="100" fixed />
|
||||
<el-table-column
|
||||
v-for="column in editSizeColumns"
|
||||
:key="column.key"
|
||||
:prop="column.key"
|
||||
:label="`${column.name} (cm)`"
|
||||
min-width="120"
|
||||
/>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="`包装规格 (${editPackageRows.length})`">
|
||||
<el-input v-if="editIsCustom" v-model="customContentForm.packageSpecsJson" type="textarea" :rows="14" placeholder="包装规格 JSON" />
|
||||
<el-table v-else :data="editPackageRows" border max-height="320">
|
||||
<el-table-column prop="sizeName" label="尺码" width="90" fixed />
|
||||
<el-table-column label="包装尺寸 (cm)" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.dimensionsCm ? `${row.dimensionsCm.length}×${row.dimensionsCm.width}×${row.dimensionsCm.height}` : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="volumeCm3" label="体积 (cm³)" width="120" />
|
||||
<el-table-column prop="grossWeightG" label="含包装重量 (g)" width="150" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="`SKU (${editVariants.length})`">
|
||||
<template v-if="editIsCustom">
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:8px"><el-button size="small" :icon="Plus" @click="addCustomVariant">添加 SKU</el-button></div>
|
||||
<el-table :data="customContentForm.variants" border max-height="320">
|
||||
<el-table-column label="SKU" min-width="160"><template #default="{ row }"><el-input v-model="row.sku" /></template></el-table-column>
|
||||
<el-table-column label="尺码 ID" width="120"><template #default="{ row }"><el-input v-model="row.sizeId" /></template></el-table-column>
|
||||
<el-table-column label="尺码" width="120"><template #default="{ row }"><el-input v-model="row.sizeName" /></template></el-table-column>
|
||||
<el-table-column label="颜色 ID" width="120"><template #default="{ row }"><el-input v-model="row.colorId" /></template></el-table-column>
|
||||
<el-table-column label="颜色" width="120"><template #default="{ row }"><el-input v-model="row.colorName" /></template></el-table-column>
|
||||
<el-table-column label="色值" width="120"><template #default="{ row }"><el-input v-model="row.colorHex" placeholder="#FFFFFF" /></template></el-table-column>
|
||||
<el-table-column label="图片" min-width="180"><template #default="{ row }"><el-input v-model="row.imageUrl" /></template></el-table-column>
|
||||
<el-table-column label="价格" width="120"><template #default="{ row }"><el-input v-model="row.price" /></template></el-table-column>
|
||||
<el-table-column label="原价" width="120"><template #default="{ row }"><el-input v-model="row.originalPrice" /></template></el-table-column>
|
||||
<el-table-column label="重量(g)" width="120"><template #default="{ row }"><el-input v-model="row.weightG" /></template></el-table-column>
|
||||
<el-table-column label="包装长(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxLengthCm" /></template></el-table-column>
|
||||
<el-table-column label="包装宽(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxWidthCm" /></template></el-table-column>
|
||||
<el-table-column label="包装高(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxHeightCm" /></template></el-table-column>
|
||||
<el-table-column label="设计数据 JSON" min-width="220"><template #default="{ row }"><el-input v-model="row.designDataJson" type="textarea" :rows="2" /></template></el-table-column>
|
||||
<el-table-column label="启用" width="80"><template #default="{ row }"><el-switch v-model="row.enabled" /></template></el-table-column>
|
||||
<el-table-column label="操作" width="70"><template #default="{ $index }"><el-button link type="danger" @click="customContentForm.variants.splice($index, 1)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<el-table v-else :data="editVariants" border max-height="320">
|
||||
<el-table-column prop="sku" label="SKU" min-width="170" fixed />
|
||||
<el-table-column prop="sizeName" label="尺码" width="90" />
|
||||
<el-table-column prop="colorName" label="颜色" width="100" />
|
||||
<el-table-column prop="price" label="价格" width="100" />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? '可用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<el-empty v-else-if="!editDetailLoading" description="尚未同步商品详情" :image-size="60" />
|
||||
<template #footer>
|
||||
<el-button type="danger" @click="editGood && handleDeleteGood(editGood)">删除</el-button>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
@@ -1826,6 +2230,8 @@ onMounted(() => loadAll())
|
||||
.og-badge--warn {
|
||||
color: #ff6800; background: #fff2e8;
|
||||
}
|
||||
.og-badge--detail { color: #337ecc; background: #ecf5ff; }
|
||||
.og-badge--missing { color: #909399; background: #f4f4f5; }
|
||||
.og-config-btn {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
@@ -1852,6 +2258,9 @@ onMounted(() => loadAll())
|
||||
.edit-og-label { font-size: 11px; color: #909399; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.edit-og-name { font-weight: 600; font-size: 14px; margin-top: 2px; }
|
||||
.edit-og-sub { color: #909399; font-size: 12px; margin-top: 2px; }
|
||||
.edit-og-meta { flex: 1; min-width: 0; }
|
||||
.edit-og-status { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; margin-top: 6px; color: #909399; font-size: 12px; }
|
||||
.detail-tabs { margin-top: 12px; padding-top: 4px; border-top: 1px solid #ebeef5; }
|
||||
|
||||
/* Config modal */
|
||||
.config-og-info { padding: 12px; background: #f5f7fa; border-radius: 8px; }
|
||||
|
||||
@@ -8,7 +8,8 @@ import { syncApi } from '@/api/sync'
|
||||
const logs = ref<SyncLog[]>([])
|
||||
const loading = ref(false)
|
||||
const syncing = ref(false)
|
||||
const currentType = ref<'PRODUCTS' | 'CATEGORIES'>('PRODUCTS')
|
||||
type SyncType = 'PRODUCTS' | 'CATEGORIES' | 'PRODUCT_DETAILS'
|
||||
const currentType = ref<SyncType>('PRODUCTS')
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
@@ -25,7 +26,13 @@ async function refreshLogs() {
|
||||
}
|
||||
}
|
||||
|
||||
async function pollUntilDone(type: 'PRODUCTS' | 'CATEGORIES') {
|
||||
function syncTypeLabel(type: SyncType): string {
|
||||
if (type === 'CATEGORIES') return '分类'
|
||||
if (type === 'PRODUCT_DETAILS') return '全部原产品详情'
|
||||
return '商品列表'
|
||||
}
|
||||
|
||||
async function pollUntilDone(type: SyncType) {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
@@ -37,9 +44,9 @@ async function pollUntilDone(type: 'PRODUCTS' | 'CATEGORIES') {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
syncing.value = false
|
||||
if (top.status === 'SUCCESS') {
|
||||
ElMessage.success(`${type === 'PRODUCTS' ? '产品' : '分类'}同步完成`)
|
||||
ElMessage.success(`${syncTypeLabel(type)}同步完成`)
|
||||
} else {
|
||||
ElMessage.error(`${type === 'PRODUCTS' ? '产品' : '分类'}同步失败`)
|
||||
ElMessage.error(`${syncTypeLabel(type)}同步失败`)
|
||||
}
|
||||
await refreshLogs()
|
||||
}
|
||||
@@ -55,11 +62,15 @@ async function handleSyncCategories() {
|
||||
await doSync('CATEGORIES')
|
||||
}
|
||||
|
||||
async function doSync(type: 'PRODUCTS' | 'CATEGORIES') {
|
||||
const label = type === 'PRODUCTS' ? '产品' : '分类'
|
||||
async function handleSyncProductDetails() {
|
||||
await doSync('PRODUCT_DETAILS')
|
||||
}
|
||||
|
||||
async function doSync(type: SyncType) {
|
||||
const label = syncTypeLabel(type)
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定立即执行${label}同步吗?${type === 'PRODUCTS' ? '此操作可能需要几分钟。' : ''}`,
|
||||
`确定立即执行${label}同步吗?${type === 'PRODUCT_DETAILS' ? '将同步全部有效原产品,耗时取决于原产品数量。' : type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`,
|
||||
'确认',
|
||||
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
|
||||
)
|
||||
@@ -70,6 +81,8 @@ async function doSync(type: 'PRODUCTS' | 'CATEGORIES') {
|
||||
try {
|
||||
if (type === 'PRODUCTS') {
|
||||
await syncApi.syncProducts()
|
||||
} else if (type === 'PRODUCT_DETAILS') {
|
||||
await syncApi.syncProductDetails()
|
||||
} else {
|
||||
await syncApi.syncCategories()
|
||||
}
|
||||
@@ -97,7 +110,7 @@ function formatDuration(start?: string, end?: string): string | null {
|
||||
const stats = computed(() => {
|
||||
const total = logs.value.length
|
||||
const success = logs.value.filter(l => l.status === 'SUCCESS').length
|
||||
const failed = total - success
|
||||
const failed = logs.value.filter(l => l.status === 'FAILED').length
|
||||
const lastLog = logs.value[0]
|
||||
return { total, success, failed, lastLog }
|
||||
})
|
||||
@@ -123,7 +136,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="sync-action-title">数据同步</h2>
|
||||
<p class="sync-action-desc">从上游 SDS 系统拉取最新分类和产品数据并同步到本地数据库</p>
|
||||
<p class="sync-action-desc">分类和产品每小时自动同步;全部 SDS 原产品详情每天 03:30 自动同步,也可手动执行</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sync-action-buttons">
|
||||
@@ -145,6 +158,16 @@ onUnmounted(() => {
|
||||
>
|
||||
同步产品
|
||||
</el-button>
|
||||
<el-button
|
||||
type="success"
|
||||
size="large"
|
||||
:loading="syncing && currentType === 'PRODUCT_DETAILS'"
|
||||
:disabled="syncing"
|
||||
:icon="Refresh"
|
||||
@click="handleSyncProductDetails"
|
||||
>
|
||||
同步全部原产品详情
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -166,7 +189,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-value stat-time">{{ stats.lastLog ? formatTime(stats.lastLog.startTime) : '-' }}</span>
|
||||
<span class="stat-value stat-time">{{ stats.lastLog ? formatTime(stats.lastLog.startedAt) : '-' }}</span>
|
||||
<span class="stat-label">最近同步</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,17 +220,17 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
<div class="timeline-header">
|
||||
<span class="timeline-type">{{ log.type === 'PRODUCTS' ? '产品' : '分类' }}</span>
|
||||
<span class="timeline-type">{{ syncTypeLabel(log.type) }}</span>
|
||||
<span class="timeline-status" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
|
||||
{{ log.status === 'SUCCESS' ? '成功' : log.status === 'RUNNING' ? '进行中' : '失败' }}
|
||||
</span>
|
||||
<span v-if="formatDuration(log.startTime, log.endTime)" class="timeline-duration">
|
||||
<span v-if="formatDuration(log.startedAt, log.finishedAt || undefined)" class="timeline-duration">
|
||||
<el-icon :size="11"><Clock /></el-icon>
|
||||
{{ formatDuration(log.startTime, log.endTime) }}
|
||||
{{ formatDuration(log.startedAt, log.finishedAt || undefined) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="log.message" class="timeline-message">{{ log.message }}</p>
|
||||
<span class="timeline-time">{{ formatTime(log.startTime) }}</span>
|
||||
<span class="timeline-time">{{ formatTime(log.startedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,16 +29,16 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
target: 'http://127.0.0.1:3001',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://localhost:3001',
|
||||
target: 'http://127.0.0.1:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/assets': {
|
||||
target: 'http://localhost:3001',
|
||||
target: 'http://127.0.0.1:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:studio": "prisma studio",
|
||||
"configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts"
|
||||
"configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts",
|
||||
"import:product-detail": "ts-node prisma/import-product-detail.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/axios": "^3.0.1",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
import { resolve } from 'path';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { SyncService } from '../src/sync/sync.service';
|
||||
import { SdsProductDetail } from '../src/sync/sds-client.service';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const inputPath = process.argv[2];
|
||||
if (!inputPath) {
|
||||
throw new Error('Usage: pnpm --filter @inkreach/api import:product-detail -- <product_detail.txt>');
|
||||
}
|
||||
const absolutePath = resolve(inputPath);
|
||||
const raw = await readFile(absolutePath, 'utf8');
|
||||
const jsonStart = raw.indexOf('{');
|
||||
if (jsonStart < 0) throw new Error('No JSON object found in product detail file');
|
||||
const detail = JSON.parse(raw.slice(jsonStart)) as SdsProductDetail;
|
||||
|
||||
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] });
|
||||
try {
|
||||
const result = await app.get(SyncService).importProductDetail(detail);
|
||||
process.stdout.write(
|
||||
`Imported SDS product ${result.goodId}: ${result.variants} variants, ` +
|
||||
`${result.sizeRows} size rows, ${result.packageRows} package rows, ` +
|
||||
`${result.configuredGoods} configured goods\n`,
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.stack ?? error.message : String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
-- Cache SDS product details separately from website merchandising configuration.
|
||||
CREATE TABLE "origin_good_details" (
|
||||
"origin_good_id" BIGINT NOT NULL,
|
||||
"product_code" TEXT,
|
||||
"english_name" TEXT,
|
||||
"blank_design_url" TEXT,
|
||||
"details_page_video_url" TEXT,
|
||||
"texture_name" TEXT,
|
||||
"production_cycle_hours" INTEGER,
|
||||
"min_weight_g" DECIMAL(12,3),
|
||||
"reminder" TEXT,
|
||||
"production_process" TEXT,
|
||||
"material_description" TEXT,
|
||||
"product_performance" TEXT,
|
||||
"applicable_scenarios" TEXT,
|
||||
"washing_instructions" TEXT,
|
||||
"special_description" TEXT,
|
||||
"design_explanation" TEXT,
|
||||
"design_area" TEXT,
|
||||
"picture_request" TEXT,
|
||||
"size_chart" JSONB,
|
||||
"package_specs" JSONB,
|
||||
"options" JSONB,
|
||||
"media" JSONB,
|
||||
"upstream_updated_at" TIMESTAMPTZ(6),
|
||||
"synced_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "origin_good_details_pkey" PRIMARY KEY ("origin_good_id")
|
||||
);
|
||||
|
||||
CREATE TABLE "origin_good_variants" (
|
||||
"origin_good_variant_id" BIGSERIAL NOT NULL,
|
||||
"origin_good_id" BIGINT NOT NULL,
|
||||
"sds_variant_id" TEXT NOT NULL,
|
||||
"sku" TEXT NOT NULL,
|
||||
"size_id" TEXT,
|
||||
"size_name" TEXT,
|
||||
"color_id" TEXT,
|
||||
"color_name" TEXT,
|
||||
"color_hex" TEXT,
|
||||
"image_url" TEXT,
|
||||
"price" DECIMAL(12,2),
|
||||
"original_price" DECIMAL(12,2),
|
||||
"weight_g" DECIMAL(12,3),
|
||||
"box_length_cm" DECIMAL(12,3),
|
||||
"box_width_cm" DECIMAL(12,3),
|
||||
"box_height_cm" DECIMAL(12,3),
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"design_data" JSONB,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "origin_good_variants_pkey" PRIMARY KEY ("origin_good_variant_id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "origin_good_variants_origin_good_id_sds_variant_id_key"
|
||||
ON "origin_good_variants"("origin_good_id", "sds_variant_id");
|
||||
CREATE INDEX "origin_good_variants_origin_good_id_sort_order_idx"
|
||||
ON "origin_good_variants"("origin_good_id", "sort_order");
|
||||
CREATE INDEX "origin_good_variants_sku_idx" ON "origin_good_variants"("sku");
|
||||
|
||||
ALTER TABLE "origin_good_details"
|
||||
ADD CONSTRAINT "origin_good_details_origin_good_id_fkey"
|
||||
FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id")
|
||||
ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
ALTER TABLE "origin_good_variants"
|
||||
ADD CONSTRAINT "origin_good_variants_origin_good_id_fkey"
|
||||
FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id")
|
||||
ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "SyncType" ADD VALUE IF NOT EXISTS 'PRODUCT_DETAILS';
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TYPE "OriginGoodSource" AS ENUM ('SDS', 'CUSTOM');
|
||||
|
||||
ALTER TABLE "origin_goods"
|
||||
ADD COLUMN "source" "OriginGoodSource" NOT NULL DEFAULT 'SDS';
|
||||
|
||||
CREATE INDEX "origin_goods_source_idx" ON "origin_goods"("source");
|
||||
+110
-35
@@ -14,24 +14,98 @@ datasource db {
|
||||
}
|
||||
|
||||
// ---------- Origin Goods ----------
|
||||
model OriginGood {
|
||||
id BigInt @id @default(autoincrement()) @map("origin_good_id")
|
||||
sdsGoodId String @unique @map("sds_good_id")
|
||||
// Cached SDS product metadata (filled during sync)
|
||||
sdsCategoryId String? @map("sds_category_id")
|
||||
goodName String? @map("good_name")
|
||||
goodImage String? @map("good_image")
|
||||
goodPrice Decimal? @map("good_price") @db.Decimal(12, 2)
|
||||
delisted Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
enum OriginGoodSource {
|
||||
SDS
|
||||
CUSTOM
|
||||
}
|
||||
|
||||
goods Good[]
|
||||
model OriginGood {
|
||||
id BigInt @id @default(autoincrement()) @map("origin_good_id")
|
||||
sdsGoodId String @unique @map("sds_good_id")
|
||||
// Cached SDS product metadata (filled during sync)
|
||||
sdsCategoryId String? @map("sds_category_id")
|
||||
goodName String? @map("good_name")
|
||||
goodImage String? @map("good_image")
|
||||
goodPrice Decimal? @map("good_price") @db.Decimal(12, 2)
|
||||
delisted Boolean @default(false)
|
||||
source OriginGoodSource @default(SDS)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
goods Good[]
|
||||
detail OriginGoodDetail?
|
||||
variants OriginGoodVariant[]
|
||||
|
||||
@@index([sdsCategoryId])
|
||||
@@index([source])
|
||||
@@map("origin_goods")
|
||||
}
|
||||
|
||||
// ---------- Origin Good Details (cached from SDS /products/{id}) ----------
|
||||
model OriginGoodDetail {
|
||||
originGoodId BigInt @id @map("origin_good_id")
|
||||
productCode String? @map("product_code")
|
||||
englishName String? @map("english_name")
|
||||
blankDesignUrl String? @map("blank_design_url")
|
||||
detailsPageVideoUrl String? @map("details_page_video_url")
|
||||
textureName String? @map("texture_name")
|
||||
productionCycleHours Int? @map("production_cycle_hours")
|
||||
minWeightG Decimal? @map("min_weight_g") @db.Decimal(12, 3)
|
||||
reminder String?
|
||||
productionProcess String? @map("production_process")
|
||||
materialDescription String? @map("material_description")
|
||||
productPerformance String? @map("product_performance")
|
||||
applicableScenarios String? @map("applicable_scenarios")
|
||||
washingInstructions String? @map("washing_instructions")
|
||||
specialDescription String? @map("special_description")
|
||||
designExplanation String? @map("design_explanation")
|
||||
designArea String? @map("design_area")
|
||||
pictureRequest String? @map("picture_request")
|
||||
sizeChart Json? @map("size_chart")
|
||||
packageSpecs Json? @map("package_specs")
|
||||
options Json?
|
||||
media Json?
|
||||
upstreamUpdatedAt DateTime? @map("upstream_updated_at") @db.Timestamptz(6)
|
||||
syncedAt DateTime @default(now()) @map("synced_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
||||
@@map("origin_good_details")
|
||||
}
|
||||
|
||||
// ---------- Origin Good Variants (cached SDS child products / SKUs) ----------
|
||||
model OriginGoodVariant {
|
||||
id BigInt @id @default(autoincrement()) @map("origin_good_variant_id")
|
||||
originGoodId BigInt @map("origin_good_id")
|
||||
sdsVariantId String @map("sds_variant_id")
|
||||
sku String
|
||||
sizeId String? @map("size_id")
|
||||
sizeName String? @map("size_name")
|
||||
colorId String? @map("color_id")
|
||||
colorName String? @map("color_name")
|
||||
colorHex String? @map("color_hex")
|
||||
imageUrl String? @map("image_url")
|
||||
price Decimal? @db.Decimal(12, 2)
|
||||
originalPrice Decimal? @map("original_price") @db.Decimal(12, 2)
|
||||
weightG Decimal? @map("weight_g") @db.Decimal(12, 3)
|
||||
boxLengthCm Decimal? @map("box_length_cm") @db.Decimal(12, 3)
|
||||
boxWidthCm Decimal? @map("box_width_cm") @db.Decimal(12, 3)
|
||||
boxHeightCm Decimal? @map("box_height_cm") @db.Decimal(12, 3)
|
||||
enabled Boolean @default(true)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
designData Json? @map("design_data")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
||||
@@unique([originGoodId, sdsVariantId])
|
||||
@@index([originGoodId, sortOrder])
|
||||
@@index([sku])
|
||||
@@map("origin_good_variants")
|
||||
}
|
||||
|
||||
// ---------- Countries ----------
|
||||
model Country {
|
||||
id BigInt @id @default(autoincrement()) @map("country_id")
|
||||
@@ -48,18 +122,18 @@ model Country {
|
||||
|
||||
// ---------- Categories (self-referential tree) ----------
|
||||
model Category {
|
||||
id BigInt @id @default(autoincrement()) @map("category_id")
|
||||
parentCategoryId BigInt? @map("parent_category_id")
|
||||
categoryName String @map("category_name")
|
||||
categoryIcon String? @map("category_icon")
|
||||
sdsCategoryId String? @unique @map("sds_category_id")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
id BigInt @id @default(autoincrement()) @map("category_id")
|
||||
parentCategoryId BigInt? @map("parent_category_id")
|
||||
categoryName String @map("category_name")
|
||||
categoryIcon String? @map("category_icon")
|
||||
sdsCategoryId String? @unique @map("sds_category_id")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
parent Category? @relation("CategoryToCategory", fields: [parentCategoryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
children Category[] @relation("CategoryToCategory")
|
||||
goods Good[]
|
||||
positions Position[]
|
||||
parent Category? @relation("CategoryToCategory", fields: [parentCategoryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
children Category[] @relation("CategoryToCategory")
|
||||
goods Good[]
|
||||
positions Position[]
|
||||
|
||||
@@index([parentCategoryId])
|
||||
@@map("categories")
|
||||
@@ -94,7 +168,7 @@ model Tag {
|
||||
|
||||
goods Good[]
|
||||
goodTags GoodTag[]
|
||||
tagGroup TagGroup? @relation(fields: [tagGroupId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||
tagGroup TagGroup? @relation(fields: [tagGroupId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||
|
||||
@@index([tagGroupId])
|
||||
@@index([tagGroupId, sortOrder])
|
||||
@@ -122,17 +196,17 @@ model Position {
|
||||
|
||||
// ---------- Goods ----------
|
||||
model Good {
|
||||
id BigInt @id @default(autoincrement()) @map("good_id")
|
||||
originGoodId BigInt @map("origin_good_id")
|
||||
countryId BigInt @map("country_id")
|
||||
categoryId BigInt @map("category_id")
|
||||
tagId BigInt? @map("tag_id")
|
||||
positionId BigInt? @map("position_id")
|
||||
goodName String @map("good_name")
|
||||
goodImage String? @map("good_image")
|
||||
goodPriority Int @default(0) @map("good_priority")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
id BigInt @id @default(autoincrement()) @map("good_id")
|
||||
originGoodId BigInt @map("origin_good_id")
|
||||
countryId BigInt @map("country_id")
|
||||
categoryId BigInt @map("category_id")
|
||||
tagId BigInt? @map("tag_id")
|
||||
positionId BigInt? @map("position_id")
|
||||
goodName String @map("good_name")
|
||||
goodImage String? @map("good_image")
|
||||
goodPriority Int @default(0) @map("good_priority")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
country Country @relation(fields: [countryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
@@ -181,6 +255,7 @@ model User {
|
||||
enum SyncType {
|
||||
CATEGORIES
|
||||
PRODUCTS
|
||||
PRODUCT_DETAILS
|
||||
}
|
||||
|
||||
enum SyncStatus {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { ApiProperty, OmitType, PartialType } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsNumberString,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { CreateGoodDto } from './create-good.dto';
|
||||
|
||||
export class CustomGoodDetailDto {
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
productCode?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
englishName?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
blankDesignUrl?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
detailsPageVideoUrl?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
textureName?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
productionCycleHours?: number | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, example: '208.000' })
|
||||
@IsOptional()
|
||||
@IsNumberString()
|
||||
minWeightG?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
productionProcess?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
materialDescription?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reminder?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
productPerformance?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
applicableScenarios?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
washingInstructions?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
specialDescription?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
designExplanation?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
designArea?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
pictureRequest?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, type: Object })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
sizeChart?: Record<string, unknown> | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, type: Object })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
packageSpecs?: Record<string, unknown> | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, type: Object })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
options?: Record<string, unknown> | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, type: Object })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
media?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export class CustomGoodVariantDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
sku!: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sizeName?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sizeId?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
colorName?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
colorHex?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
colorId?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageUrl?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, example: '28.00' })
|
||||
@IsOptional()
|
||||
@IsNumberString()
|
||||
price?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsNumberString()
|
||||
originalPrice?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsNumberString()
|
||||
weightG?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsNumberString()
|
||||
boxLengthCm?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsNumberString()
|
||||
boxWidthCm?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsNumberString()
|
||||
boxHeightCm?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, type: Object })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
designData?: Record<string, unknown> | null;
|
||||
|
||||
@ApiProperty({ required: false, default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export class CreateCustomGoodDto extends OmitType(CreateGoodDto, [
|
||||
'originGoodId',
|
||||
] as const) {
|
||||
@ApiProperty({ required: false, nullable: true, example: '28.00' })
|
||||
@IsOptional()
|
||||
@IsNumberString()
|
||||
goodPrice?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, type: CustomGoodDetailDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => CustomGoodDetailDto)
|
||||
detail?: CustomGoodDetailDto;
|
||||
|
||||
@ApiProperty({ required: false, type: [CustomGoodVariantDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CustomGoodVariantDto)
|
||||
variants?: CustomGoodVariantDto[];
|
||||
}
|
||||
|
||||
export class UpdateCustomGoodContentDto extends PartialType(
|
||||
OmitType(CreateCustomGoodDto, [
|
||||
'countryId',
|
||||
'categoryId',
|
||||
'tagIds',
|
||||
'positionId',
|
||||
'goodPriority',
|
||||
] as const),
|
||||
) {}
|
||||
@@ -9,9 +9,28 @@ export interface GoodRelations {
|
||||
originGood?: {
|
||||
id: bigint;
|
||||
sdsGoodId: string;
|
||||
source: 'SDS' | 'CUSTOM';
|
||||
goodName: string | null;
|
||||
goodImage: string | null;
|
||||
goodPrice: unknown;
|
||||
detail?: {
|
||||
productCode: string | null;
|
||||
syncedAt: Date;
|
||||
sizeChart: unknown;
|
||||
packageSpecs: unknown;
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
variants?: Array<{
|
||||
sdsVariantId: string;
|
||||
sku: string;
|
||||
sizeName: string | null;
|
||||
colorName: string | null;
|
||||
colorHex: string | null;
|
||||
price: unknown;
|
||||
enabled: boolean;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
_count?: { variants: number };
|
||||
} | null;
|
||||
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
|
||||
}
|
||||
@@ -69,9 +88,17 @@ export class GoodDto {
|
||||
originGood?: {
|
||||
id: string;
|
||||
sdsGoodId: string;
|
||||
source: 'SDS' | 'CUSTOM';
|
||||
isCustom: boolean;
|
||||
goodName: string | null;
|
||||
goodImage: string | null;
|
||||
goodPrice: string | null;
|
||||
hasDetail: boolean;
|
||||
detailSyncedAt: string | null;
|
||||
variantCount: number;
|
||||
sizeRowCount: number;
|
||||
packageRowCount: number;
|
||||
productCode: string | null;
|
||||
} | null;
|
||||
|
||||
static from(
|
||||
@@ -130,6 +157,8 @@ export class GoodDto {
|
||||
? {
|
||||
id: rel.originGood.id.toString(),
|
||||
sdsGoodId: rel.originGood.sdsGoodId,
|
||||
source: rel.originGood.source,
|
||||
isCustom: rel.originGood.source === 'CUSTOM',
|
||||
goodName: rel.originGood.goodName,
|
||||
goodImage: rel.originGood.goodImage,
|
||||
goodPrice:
|
||||
@@ -137,10 +166,46 @@ export class GoodDto {
|
||||
rel.originGood.goodPrice === undefined
|
||||
? null
|
||||
: (rel.originGood.goodPrice as { toString(): string }).toString(),
|
||||
hasDetail: Boolean(rel.originGood.detail),
|
||||
detailSyncedAt: rel.originGood.detail?.syncedAt.toISOString() ?? null,
|
||||
variantCount: rel.originGood._count?.variants ?? rel.originGood.variants?.length ?? 0,
|
||||
sizeRowCount: GoodDto.jsonRows(rel.originGood.detail?.sizeChart),
|
||||
packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs),
|
||||
productCode: rel.originGood.detail?.productCode ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private static jsonRows(value: unknown): number {
|
||||
if (!value || typeof value !== 'object' || !('rows' in value)) return 0;
|
||||
const rows = (value as { rows?: unknown }).rows;
|
||||
return Array.isArray(rows) ? rows.length : 0;
|
||||
}
|
||||
}
|
||||
|
||||
export class GoodDetailDto extends GoodDto {
|
||||
@ApiProperty({ nullable: true, type: Object })
|
||||
originDetail!: Record<string, unknown> | null;
|
||||
|
||||
@ApiProperty({ type: Array })
|
||||
variants!: Array<Record<string, unknown>>;
|
||||
|
||||
static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto {
|
||||
const base = GoodDto.from(good, rel);
|
||||
const detail = rel.originGood?.detail;
|
||||
return {
|
||||
...base,
|
||||
originDetail: detail ? { ...detail, syncedAt: detail.syncedAt.toISOString() } : null,
|
||||
variants: (rel.originGood?.variants ?? []).map((variant) => ({
|
||||
...variant,
|
||||
price:
|
||||
variant.price === null || variant.price === undefined
|
||||
? null
|
||||
: (variant.price as { toString(): string }).toString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface PaginatedGoods {
|
||||
@@ -148,4 +213,4 @@ export interface PaginatedGoods {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ import { UpdateGoodDto } from './dto/update-good.dto';
|
||||
import { QueryGoodDto } from './dto/query-good.dto';
|
||||
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
|
||||
import { BatchPriorityDto } from './dto/batch-priority.dto';
|
||||
import {
|
||||
CreateCustomGoodDto,
|
||||
UpdateCustomGoodContentDto,
|
||||
} from './dto/custom-good.dto';
|
||||
|
||||
@ApiTags('goods')
|
||||
@ApiBearerAuth()
|
||||
@@ -36,18 +40,45 @@ export class GoodsController {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one good with relations' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a good' })
|
||||
create(@Body() dto: CreateGoodDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch('batch-priority')
|
||||
@ApiOperation({ summary: 'Batch update good priorities (transaction)' })
|
||||
batchPriority(@Body() dto: BatchPriorityDto) {
|
||||
return this.service.batchUpdatePriority(dto);
|
||||
}
|
||||
|
||||
@Post('batch')
|
||||
@ApiOperation({ summary: 'Batch create goods from origin goods (transaction)' })
|
||||
batchCreate(@Body() dto: BatchCreateGoodDto) {
|
||||
return this.service.batchCreate(dto);
|
||||
}
|
||||
|
||||
@Post('custom')
|
||||
@ApiOperation({ summary: 'Create a fully editable custom product' })
|
||||
createCustom(@Body() dto: CreateCustomGoodDto) {
|
||||
return this.service.createCustom(dto);
|
||||
}
|
||||
|
||||
@Patch(':id/custom-content')
|
||||
@ApiOperation({ summary: 'Update editable content for a custom product' })
|
||||
updateCustomContent(
|
||||
@Param('id', ParseIntPipe) id: string,
|
||||
@Body() dto: UpdateCustomGoodContentDto,
|
||||
) {
|
||||
return this.service.updateCustomContent(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one good with relations' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a good' })
|
||||
update(
|
||||
@@ -62,16 +93,4 @@ export class GoodsController {
|
||||
remove(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
|
||||
@Patch('batch-priority')
|
||||
@ApiOperation({ summary: 'Batch update good priorities (transaction)' })
|
||||
batchPriority(@Body() dto: BatchPriorityDto) {
|
||||
return this.service.batchUpdatePriority(dto);
|
||||
}
|
||||
|
||||
@Post('batch')
|
||||
@ApiOperation({ summary: 'Batch create goods from origin goods (transaction)' })
|
||||
batchCreate(@Body() dto: BatchCreateGoodDto) {
|
||||
return this.service.batchCreate(dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GoodsController } from './goods.controller';
|
||||
import { GoodsService } from './goods.service';
|
||||
import { SyncModule } from '../sync/sync.module';
|
||||
|
||||
@Module({
|
||||
imports: [SyncModule],
|
||||
controllers: [GoodsController],
|
||||
providers: [GoodsService],
|
||||
exports: [GoodsService],
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { GoodsService } from './goods.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
|
||||
describe('GoodsService', () => {
|
||||
let service: GoodsService;
|
||||
@@ -22,7 +23,14 @@ describe('GoodsService', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [GoodsService, PrismaService],
|
||||
providers: [
|
||||
GoodsService,
|
||||
PrismaService,
|
||||
{
|
||||
provide: SyncService,
|
||||
useValue: { queueProductDetailSync: jest.fn() },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(GoodsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
@@ -111,6 +119,51 @@ describe('GoodsService', () => {
|
||||
expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`);
|
||||
});
|
||||
|
||||
it('creates, edits, and removes a fully editable custom good', async () => {
|
||||
const created = await service.createCustom({
|
||||
goodName: `Goods Test ${stamp} custom`,
|
||||
goodImage: 'https://example.com/custom.png',
|
||||
goodPrice: '29.90',
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
tagIds: [Number(tagId)],
|
||||
detail: {
|
||||
productCode: `CUSTOM-${stamp}`,
|
||||
materialDescription: 'Cotton',
|
||||
sizeChart: { columns: [], rows: [] },
|
||||
packageSpecs: { rows: [] },
|
||||
},
|
||||
variants: [
|
||||
{ sku: `CUSTOM-SKU-${stamp}`, sizeName: 'S', price: '29.90' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(created.originGood?.source).toBe('CUSTOM');
|
||||
expect(created.originGood?.isCustom).toBe(true);
|
||||
expect(created.originGood?.goodPrice).toBe('29.9');
|
||||
expect(created.variants).toHaveLength(1);
|
||||
|
||||
const updated = await service.updateCustomContent(BigInt(created.id), {
|
||||
goodName: `Goods Test ${stamp} custom edited`,
|
||||
goodPrice: '39.90',
|
||||
detail: { materialDescription: 'Organic cotton' },
|
||||
variants: [
|
||||
{ sku: `CUSTOM-SKU-${stamp}-M`, sizeName: 'M', price: '39.90' },
|
||||
],
|
||||
});
|
||||
expect(updated.goodName).toContain('custom edited');
|
||||
expect(updated.originGood?.goodPrice).toBe('39.9');
|
||||
expect(updated.originDetail?.materialDescription).toBe('Organic cotton');
|
||||
expect(updated.originDetail?.productCode).toBe(`CUSTOM-${stamp}`);
|
||||
expect(updated.variants[0]?.sizeName).toBe('M');
|
||||
|
||||
const customOriginId = BigInt(updated.originGoodId);
|
||||
await service.remove(BigInt(updated.id));
|
||||
await expect(
|
||||
prisma.originGood.findUnique({ where: { id: customOriginId } }),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('filters by countryId, tagId, positionId and keyword', async () => {
|
||||
const result = await service.findAll({
|
||||
page: 1,
|
||||
|
||||
@@ -10,20 +10,37 @@ import { UpdateGoodDto } from './dto/update-good.dto';
|
||||
import { QueryGoodDto } from './dto/query-good.dto';
|
||||
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
|
||||
import { BatchPriorityDto } from './dto/batch-priority.dto';
|
||||
import { GoodDto, PaginatedGoods } from './dto/good.dto';
|
||||
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
CreateCustomGoodDto,
|
||||
CustomGoodDetailDto,
|
||||
CustomGoodVariantDto,
|
||||
UpdateCustomGoodContentDto,
|
||||
} from './dto/custom-good.dto';
|
||||
|
||||
const GOOD_INCLUDE = {
|
||||
country: true,
|
||||
category: true,
|
||||
tag: true,
|
||||
position: true,
|
||||
originGood: true,
|
||||
originGood: {
|
||||
include: {
|
||||
detail: true,
|
||||
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||
_count: { select: { variants: true } },
|
||||
},
|
||||
},
|
||||
goodTags: { include: { tag: true } },
|
||||
} satisfies Prisma.GoodInclude;
|
||||
|
||||
@Injectable()
|
||||
export class GoodsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly syncService: SyncService,
|
||||
) {}
|
||||
|
||||
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
|
||||
const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query;
|
||||
@@ -65,13 +82,13 @@ export class GoodsService {
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: bigint): Promise<GoodDto> {
|
||||
async findOne(id: bigint): Promise<GoodDetailDto> {
|
||||
const good = await this.prisma.good.findUnique({
|
||||
where: { id },
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
if (!good) throw new NotFoundException(`Good ${id} not found`);
|
||||
return GoodDto.from(good, {
|
||||
return GoodDetailDto.fromGood(good, {
|
||||
country: good.country,
|
||||
category: good.category,
|
||||
tag: good.tag,
|
||||
@@ -83,7 +100,7 @@ export class GoodsService {
|
||||
|
||||
async create(dto: CreateGoodDto): Promise<GoodDto> {
|
||||
await this.ensureReferences(dto);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.good.create({
|
||||
data: {
|
||||
goodName: dto.goodName,
|
||||
@@ -116,6 +133,115 @@ export class GoodsService {
|
||||
goodTags: result.goodTags,
|
||||
});
|
||||
});
|
||||
if (
|
||||
result.originGood?.source === 'SDS' &&
|
||||
result.originGood.sdsGoodId &&
|
||||
!result.originGood.hasDetail
|
||||
) {
|
||||
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async createCustom(dto: CreateCustomGoodDto): Promise<GoodDetailDto> {
|
||||
await this.ensureCountry(dto.countryId);
|
||||
await this.ensureCategory(dto.categoryId);
|
||||
if (dto.positionId !== undefined) await this.ensurePosition(dto.positionId);
|
||||
for (const tagId of dto.tagIds ?? []) await this.ensureTag(tagId);
|
||||
|
||||
const goodId = await this.prisma.$transaction(async (tx) => {
|
||||
const originGood = await tx.originGood.create({
|
||||
data: {
|
||||
source: 'CUSTOM',
|
||||
sdsGoodId: `custom-${randomUUID()}`,
|
||||
goodName: dto.goodName,
|
||||
goodImage: dto.goodImage ?? null,
|
||||
goodPrice: this.decimal(dto.goodPrice),
|
||||
detail: {
|
||||
create: this.customDetailData(
|
||||
dto.detail ?? {},
|
||||
) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (dto.variants?.length) {
|
||||
await this.replaceCustomVariants(tx, originGood.id, dto.variants);
|
||||
}
|
||||
const good = await tx.good.create({
|
||||
data: {
|
||||
originGoodId: originGood.id,
|
||||
countryId: BigInt(dto.countryId),
|
||||
categoryId: BigInt(dto.categoryId),
|
||||
positionId:
|
||||
dto.positionId === undefined ? null : BigInt(dto.positionId),
|
||||
goodName: dto.goodName,
|
||||
goodImage: dto.goodImage ?? null,
|
||||
goodPriority: dto.goodPriority ?? 0,
|
||||
},
|
||||
});
|
||||
if (dto.tagIds?.length) {
|
||||
await tx.goodTag.createMany({
|
||||
data: dto.tagIds.map((tagId) => ({
|
||||
goodId: good.id,
|
||||
tagId: BigInt(tagId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
return good.id;
|
||||
});
|
||||
return this.findOne(goodId);
|
||||
}
|
||||
|
||||
async updateCustomContent(
|
||||
id: bigint,
|
||||
dto: UpdateCustomGoodContentDto,
|
||||
): Promise<GoodDetailDto> {
|
||||
const existing = await this.prisma.good.findUnique({
|
||||
where: { id },
|
||||
include: { originGood: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException(`Good ${id} not found`);
|
||||
if (existing.originGood.source !== 'CUSTOM') {
|
||||
throw new BadRequestException('SDS 映射商品的上游信息不可修改');
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.originGood.update({
|
||||
where: { id: existing.originGoodId },
|
||||
data: {
|
||||
goodName: dto.goodName,
|
||||
goodImage: dto.goodImage,
|
||||
goodPrice:
|
||||
dto.goodPrice === undefined ? undefined : this.decimal(dto.goodPrice),
|
||||
},
|
||||
});
|
||||
if (dto.detail !== undefined) {
|
||||
await tx.originGoodDetail.upsert({
|
||||
where: { originGoodId: existing.originGoodId },
|
||||
create: {
|
||||
originGoodId: existing.originGoodId,
|
||||
...(this.customDetailData(
|
||||
dto.detail,
|
||||
) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput),
|
||||
},
|
||||
update: this.customDetailData(dto.detail, true),
|
||||
});
|
||||
}
|
||||
if (dto.variants !== undefined) {
|
||||
await this.replaceCustomVariants(
|
||||
tx,
|
||||
existing.originGoodId,
|
||||
dto.variants,
|
||||
);
|
||||
}
|
||||
const goodData: Prisma.GoodUpdateInput = {};
|
||||
if (dto.goodName !== undefined) goodData.goodName = dto.goodName;
|
||||
if (dto.goodImage !== undefined) goodData.goodImage = dto.goodImage;
|
||||
if (Object.keys(goodData).length) {
|
||||
await tx.good.update({ where: { id }, data: goodData });
|
||||
}
|
||||
});
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> {
|
||||
@@ -148,7 +274,7 @@ export class GoodsService {
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
if (dto.tagIds !== undefined) {
|
||||
await tx.goodTag.deleteMany({ where: { goodId: id } });
|
||||
if (dto.tagIds.length > 0) {
|
||||
@@ -174,11 +300,33 @@ export class GoodsService {
|
||||
goodTags: updated.goodTags,
|
||||
});
|
||||
});
|
||||
if (
|
||||
result.originGood?.source === 'SDS' &&
|
||||
result.originGood.sdsGoodId &&
|
||||
!result.originGood.hasDetail
|
||||
) {
|
||||
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async remove(id: bigint): Promise<{ id: string }> {
|
||||
await this.findOne(id);
|
||||
await this.prisma.good.delete({ where: { id } });
|
||||
const good = await this.prisma.good.findUnique({
|
||||
where: { id },
|
||||
include: { originGood: true },
|
||||
});
|
||||
if (!good) throw new NotFoundException(`Good ${id} not found`);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.good.delete({ where: { id } });
|
||||
if (good.originGood.source === 'CUSTOM') {
|
||||
const remaining = await tx.good.count({
|
||||
where: { originGoodId: good.originGoodId },
|
||||
});
|
||||
if (remaining === 0) {
|
||||
await tx.originGood.delete({ where: { id: good.originGoodId } });
|
||||
}
|
||||
}
|
||||
});
|
||||
return { id: id.toString() };
|
||||
}
|
||||
|
||||
@@ -187,7 +335,7 @@ export class GoodsService {
|
||||
* or none do.
|
||||
*/
|
||||
async batchUpdatePriority(dto: BatchPriorityDto): Promise<{ count: number }> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
for (const item of dto.items) {
|
||||
await tx.good.update({
|
||||
where: { id: BigInt(item.id) },
|
||||
@@ -196,6 +344,7 @@ export class GoodsService {
|
||||
}
|
||||
return { count: dto.items.length };
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,7 +358,7 @@ export class GoodsService {
|
||||
await this.ensureTag(tagId);
|
||||
}
|
||||
}
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
const created: GoodDto[] = [];
|
||||
for (const item of dto.items) {
|
||||
const og = await tx.originGood.findUnique({
|
||||
@@ -254,6 +403,19 @@ export class GoodsService {
|
||||
}
|
||||
return created;
|
||||
});
|
||||
for (const goodId of new Set(
|
||||
result
|
||||
.filter(
|
||||
(item) =>
|
||||
item.originGood?.source === 'SDS' &&
|
||||
!item.originGood.hasDetail,
|
||||
)
|
||||
.map((item) => item.originGood?.sdsGoodId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
)) {
|
||||
this.syncService.queueProductDetailSync(goodId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -313,4 +475,93 @@ export class GoodsService {
|
||||
if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ensurePosition(id: number) {
|
||||
const position = await this.prisma.position.findUnique({
|
||||
where: { id: BigInt(id) },
|
||||
});
|
||||
if (!position) throw new BadRequestException(`Position ${id} not found`);
|
||||
}
|
||||
|
||||
private decimal(value: string | null | undefined): Prisma.Decimal | null {
|
||||
return value === undefined || value === null || value === ''
|
||||
? null
|
||||
: new Prisma.Decimal(value);
|
||||
}
|
||||
|
||||
private customDetailData(
|
||||
detail: CustomGoodDetailDto,
|
||||
preserveMissing = false,
|
||||
): Prisma.OriginGoodDetailUncheckedUpdateInput {
|
||||
const nullable = <T>(value: T | null | undefined): T | null | undefined =>
|
||||
preserveMissing && value === undefined ? undefined : value ?? null;
|
||||
const decimal = (value: string | null | undefined) =>
|
||||
preserveMissing && value === undefined ? undefined : this.decimal(value);
|
||||
const json = (
|
||||
value: Record<string, unknown> | null | undefined,
|
||||
): Prisma.InputJsonValue | Prisma.NullTypes.DbNull | undefined =>
|
||||
preserveMissing && value === undefined
|
||||
? undefined
|
||||
: value === null || value === undefined
|
||||
? Prisma.DbNull
|
||||
: (value as Prisma.InputJsonValue);
|
||||
return {
|
||||
productCode: nullable(detail.productCode),
|
||||
englishName: nullable(detail.englishName),
|
||||
blankDesignUrl: nullable(detail.blankDesignUrl),
|
||||
detailsPageVideoUrl: nullable(detail.detailsPageVideoUrl),
|
||||
textureName: nullable(detail.textureName),
|
||||
productionCycleHours: nullable(detail.productionCycleHours),
|
||||
minWeightG: decimal(detail.minWeightG),
|
||||
reminder: nullable(detail.reminder),
|
||||
productionProcess: nullable(detail.productionProcess),
|
||||
materialDescription: nullable(detail.materialDescription),
|
||||
productPerformance: nullable(detail.productPerformance),
|
||||
applicableScenarios: nullable(detail.applicableScenarios),
|
||||
washingInstructions: nullable(detail.washingInstructions),
|
||||
specialDescription: nullable(detail.specialDescription),
|
||||
designExplanation: nullable(detail.designExplanation),
|
||||
designArea: nullable(detail.designArea),
|
||||
pictureRequest: nullable(detail.pictureRequest),
|
||||
sizeChart: json(detail.sizeChart),
|
||||
packageSpecs: json(detail.packageSpecs),
|
||||
options: json(detail.options),
|
||||
media: json(detail.media),
|
||||
};
|
||||
}
|
||||
|
||||
private async replaceCustomVariants(
|
||||
tx: Prisma.TransactionClient,
|
||||
originGoodId: bigint,
|
||||
variants: CustomGoodVariantDto[],
|
||||
): Promise<void> {
|
||||
await tx.originGoodVariant.deleteMany({ where: { originGoodId } });
|
||||
for (const variant of variants) {
|
||||
await tx.originGoodVariant.create({
|
||||
data: {
|
||||
originGoodId,
|
||||
sdsVariantId: `custom-${randomUUID()}`,
|
||||
sku: variant.sku,
|
||||
sizeId: variant.sizeId ?? null,
|
||||
sizeName: variant.sizeName ?? null,
|
||||
colorId: variant.colorId ?? null,
|
||||
colorName: variant.colorName ?? null,
|
||||
colorHex: variant.colorHex ?? null,
|
||||
imageUrl: variant.imageUrl ?? null,
|
||||
price: this.decimal(variant.price),
|
||||
originalPrice: this.decimal(variant.originalPrice),
|
||||
weightG: this.decimal(variant.weightG),
|
||||
boxLengthCm: this.decimal(variant.boxLengthCm),
|
||||
boxWidthCm: this.decimal(variant.boxWidthCm),
|
||||
boxHeightCm: this.decimal(variant.boxHeightCm),
|
||||
enabled: variant.enabled ?? true,
|
||||
sortOrder: variant.sortOrder ?? 0,
|
||||
designData:
|
||||
variant.designData === null || variant.designData === undefined
|
||||
? Prisma.DbNull
|
||||
: (variant.designData as Prisma.InputJsonValue),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ export interface PaginatedOriginGoods {
|
||||
sdsCategoryId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
hasDetail: boolean;
|
||||
detailSyncedAt: string | null;
|
||||
variantCount: number;
|
||||
}>;
|
||||
total: number;
|
||||
page: number;
|
||||
@@ -33,6 +36,11 @@ export interface OriginGoodsTreeNode {
|
||||
configuredCount: number;
|
||||
configuredCountries: string[];
|
||||
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[];
|
||||
hasDetail: boolean;
|
||||
detailSyncedAt: string | null;
|
||||
variantCount: number;
|
||||
sizeRowCount: number;
|
||||
packageRowCount: number;
|
||||
}
|
||||
|
||||
/** A category node in the hierarchical tree, with origin goods as leaves. */
|
||||
@@ -59,15 +67,19 @@ export class OriginGoodsService {
|
||||
|
||||
async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> {
|
||||
const { page, pageSize, keyword } = query;
|
||||
const where: Prisma.OriginGoodWhereInput = keyword
|
||||
? { goodName: { contains: keyword, mode: 'insensitive' } }
|
||||
: {};
|
||||
const where: Prisma.OriginGoodWhereInput = {
|
||||
source: 'SDS',
|
||||
...(keyword
|
||||
? { goodName: { contains: keyword, mode: 'insensitive' as const } }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [total, rows] = await this.prisma.$transaction([
|
||||
this.prisma.originGood.count({ where }),
|
||||
this.prisma.originGood.findMany({
|
||||
where,
|
||||
orderBy: { id: 'desc' },
|
||||
include: { detail: true, _count: { select: { variants: true } } },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
@@ -83,6 +95,9 @@ export class OriginGoodsService {
|
||||
sdsCategoryId: r.sdsCategoryId,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
hasDetail: Boolean(r.detail),
|
||||
detailSyncedAt: r.detail?.syncedAt.toISOString() ?? null,
|
||||
variantCount: r._count.variants,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
@@ -111,7 +126,11 @@ export class OriginGoodsService {
|
||||
parentCategoryId: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.originGood.findMany({ where: { delisted: false }, orderBy: { goodName: 'asc' } }),
|
||||
this.prisma.originGood.findMany({
|
||||
where: { delisted: false, source: 'SDS' },
|
||||
orderBy: { goodName: 'asc' },
|
||||
include: { detail: true, _count: { select: { variants: true } } },
|
||||
}),
|
||||
this.prisma.good.groupBy({
|
||||
by: ['originGoodId'],
|
||||
_count: { _all: true },
|
||||
@@ -212,6 +231,11 @@ export class OriginGoodsService {
|
||||
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
||||
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
||||
configuredTags: tagMap.get(og.id.toString()) ?? [],
|
||||
hasDetail: Boolean(og.detail),
|
||||
detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null,
|
||||
variantCount: og._count.variants,
|
||||
sizeRowCount: this.jsonRows(og.detail?.sizeChart),
|
||||
packageRowCount: this.jsonRows(og.detail?.packageSpecs),
|
||||
}));
|
||||
|
||||
const childTotal = childNodes.reduce((s, n) => s + n.totalCount, 0);
|
||||
@@ -258,6 +282,11 @@ export class OriginGoodsService {
|
||||
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
||||
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
||||
configuredTags: tagMap.get(og.id.toString()) ?? [],
|
||||
hasDetail: Boolean(og.detail),
|
||||
detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null,
|
||||
variantCount: og._count.variants,
|
||||
sizeRowCount: this.jsonRows(og.detail?.sizeChart),
|
||||
packageRowCount: this.jsonRows(og.detail?.packageSpecs),
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -274,4 +303,10 @@ export class OriginGoodsService {
|
||||
configuredCount: totalConfigured,
|
||||
};
|
||||
}
|
||||
|
||||
private jsonRows(value: unknown): number {
|
||||
if (!value || typeof value !== 'object' || !('rows' in value)) return 0;
|
||||
const rows = (value as { rows?: unknown }).rows;
|
||||
return Array.isArray(rows) ? rows.length : 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ export class PublicCategoryNodeDto {
|
||||
@ApiProperty({ type: [PublicCategoryNodeDto] })
|
||||
children!: PublicCategoryNodeDto[];
|
||||
|
||||
@ApiProperty({ description: '当前节点及其后代分类的商品数量' })
|
||||
productCount!: number;
|
||||
|
||||
static from(category: PrismaCategory, children: PublicCategoryNodeDto[] = []): PublicCategoryNodeDto {
|
||||
return {
|
||||
id: category.id.toString(),
|
||||
@@ -26,6 +29,7 @@ export class PublicCategoryNodeDto {
|
||||
? category.parentCategoryId.toString()
|
||||
: null,
|
||||
children,
|
||||
productCount: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { PublicGoodDto } from './public-good.dto';
|
||||
|
||||
export class PublicGoodDetailDto extends PublicGoodDto {
|
||||
@ApiProperty({ nullable: true })
|
||||
productCode!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
englishName!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
productionCycleHours!: number | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
minWeightG!: string | null;
|
||||
|
||||
@ApiProperty({ type: Object })
|
||||
details!: Record<string, string | null>;
|
||||
|
||||
@ApiProperty({ nullable: true, type: Object })
|
||||
media!: Record<string, unknown> | null;
|
||||
|
||||
@ApiProperty({ nullable: true, type: Object })
|
||||
options!: Record<string, unknown> | null;
|
||||
|
||||
@ApiProperty({ nullable: true, type: Object })
|
||||
sizeChart!: Record<string, unknown> | null;
|
||||
|
||||
@ApiProperty({ nullable: true, type: Object })
|
||||
packageSpecs!: Record<string, unknown> | null;
|
||||
|
||||
@ApiProperty({ type: Array })
|
||||
variants!: Array<{
|
||||
id: string;
|
||||
sku: string;
|
||||
sizeId: string | null;
|
||||
sizeName: string | null;
|
||||
colorId: string | null;
|
||||
colorName: string | null;
|
||||
colorHex: string | null;
|
||||
imageUrl: string | null;
|
||||
price: string | null;
|
||||
originalPrice: string | null;
|
||||
weightG: string | null;
|
||||
boxLengthCm: string | null;
|
||||
boxWidthCm: string | null;
|
||||
boxHeightCm: string | null;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
detailSyncedAt!: string | null;
|
||||
}
|
||||
|
||||
export class PublicTagGroupFilterDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
groupName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
groupIcon!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
groupColor!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
sortOrder!: number;
|
||||
|
||||
@ApiProperty({ type: Array })
|
||||
tags!: Array<{
|
||||
id: string;
|
||||
tagName: string;
|
||||
tagColor: string | null;
|
||||
tagFontColor: string | null;
|
||||
sortOrder: number;
|
||||
productCount: number;
|
||||
}>;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class PublicGoodDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
goodId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
goodName!: string;
|
||||
@@ -33,4 +33,4 @@ export class PublicGoodDto {
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import {
|
||||
PublicQueryGoodDto,
|
||||
PublicTagFilterDto,
|
||||
} from './public-query-good.dto';
|
||||
|
||||
describe('PublicQueryGoodDto', () => {
|
||||
it('parses tags from a JSON query parameter into nested DTOs', async () => {
|
||||
const dto = plainToInstance(PublicQueryGoodDto, {
|
||||
tags: JSON.stringify([
|
||||
{ tagGroupId: '1', tagIds: ['11', '12'] },
|
||||
{ tagGroupId: '2', tagIds: ['25'] },
|
||||
]),
|
||||
});
|
||||
|
||||
expect(dto.tags).toHaveLength(2);
|
||||
expect(dto.tags?.[0]).toBeInstanceOf(PublicTagFilterDto);
|
||||
expect(dto.tags?.[0]).toEqual({
|
||||
tagGroupId: '1',
|
||||
tagIds: ['11', '12'],
|
||||
});
|
||||
await expect(validate(dto)).resolves.toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects malformed group and tag ids', async () => {
|
||||
const dto = plainToInstance(PublicQueryGoodDto, {
|
||||
tags: JSON.stringify([
|
||||
{ tagGroupId: 'craft', tagIds: ['11', 'bad'] },
|
||||
]),
|
||||
});
|
||||
|
||||
expect(await validate(dto)).not.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,53 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiHideProperty, ApiProperty } from '@nestjs/swagger';
|
||||
import { plainToInstance, Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumberString,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
ArrayNotEmpty,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
const stringList = ({ value }: { value: unknown }): string[] | undefined => {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
return values
|
||||
.flatMap((item) => String(item).split(','))
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const tagFilters = ({ value }: { value: unknown }): unknown => {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
try {
|
||||
return values.flatMap((item) => {
|
||||
if (typeof item !== 'string') return [item];
|
||||
const parsed = JSON.parse(item) as unknown;
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
}).map((item) => plainToInstance(PublicTagFilterDto, item));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
export class PublicTagFilterDto {
|
||||
@ApiProperty({ example: '1' })
|
||||
@IsNumberString()
|
||||
tagGroupId!: string;
|
||||
|
||||
@ApiProperty({ type: [String], example: ['11', '12', '13'] })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsNumberString({}, { each: true })
|
||||
tagIds!: string[];
|
||||
}
|
||||
|
||||
export class PublicQueryGoodDto {
|
||||
@ApiProperty({ required: false, default: 1 })
|
||||
@IsOptional()
|
||||
@@ -16,7 +56,7 @@ export class PublicQueryGoodDto {
|
||||
@Min(1)
|
||||
page: number = 1;
|
||||
|
||||
@ApiProperty({ required: false, default: 20 })
|
||||
@ApiProperty({ required: false, default: 20, maximum: 200 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@@ -24,25 +64,57 @@ export class PublicQueryGoodDto {
|
||||
@Max(200)
|
||||
pageSize: number = 20;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@ApiProperty({ required: false, type: String, description: '不传表示全部国家' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
countryId?: number;
|
||||
@IsNumberString()
|
||||
countryId?: string;
|
||||
|
||||
@ApiProperty({ required: false, type: String })
|
||||
@IsOptional()
|
||||
@IsNumberString()
|
||||
categoryId?: string;
|
||||
|
||||
@ApiHideProperty()
|
||||
@IsOptional()
|
||||
@Transform(tagFilters)
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
tags?: PublicTagFilterDto[];
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
categoryId?: number;
|
||||
|
||||
@ApiProperty({ required: false, description: 'Comma-separated tag IDs, e.g. "30,34"' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tagIds?: string;
|
||||
minPrice?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
maxPrice?: string;
|
||||
|
||||
@ApiProperty({ required: false, enum: ['DEFAULT', 'PRICE_ASC', 'PRICE_DESC', 'NEWEST'] })
|
||||
@IsOptional()
|
||||
@IsIn(['DEFAULT', 'PRICE_ASC', 'PRICE_DESC', 'NEWEST'])
|
||||
sort?: 'DEFAULT' | 'PRICE_ASC' | 'PRICE_DESC' | 'NEWEST';
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export class PublicCountryQueryDto {
|
||||
@ApiProperty({ required: false, type: String, description: '不传表示全部国家' })
|
||||
@IsOptional()
|
||||
@IsNumberString()
|
||||
countryId?: string;
|
||||
}
|
||||
|
||||
export class PublicHomeGoodsQueryDto extends PublicCountryQueryDto {
|
||||
@ApiProperty({ required: false, default: 10, maximum: 50 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(50)
|
||||
limit: number = 10;
|
||||
}
|
||||
|
||||
@@ -2,24 +2,38 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiExtraModels,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
getSchemaPath,
|
||||
} from '@nestjs/swagger';
|
||||
import { PublicService } from './public.service';
|
||||
import { PublicQueryGoodDto } from './dto/public-query-good.dto';
|
||||
import {
|
||||
PublicCountryQueryDto,
|
||||
PublicHomeGoodsQueryDto,
|
||||
PublicQueryGoodDto,
|
||||
PublicTagFilterDto,
|
||||
} from './dto/public-query-good.dto';
|
||||
import { PublicTagDto } from './dto/public-tag.dto';
|
||||
import { PublicTagGroupDto } from './dto/public-tag-group.dto';
|
||||
import { PublicGoodDetailDto, PublicTagGroupFilterDto } from './dto/public-good-detail.dto';
|
||||
import { PublicGoodDto } from './dto/public-good.dto';
|
||||
|
||||
@ApiTags('public')
|
||||
@ApiExtraModels(PublicTagFilterDto)
|
||||
@Controller('public')
|
||||
export class PublicController {
|
||||
constructor(private readonly service: PublicService) {}
|
||||
|
||||
@Get('categories')
|
||||
@ApiOperation({ summary: 'Public list of categories that have goods' })
|
||||
getCategories() {
|
||||
return this.service.getCategoriesTree();
|
||||
@ApiOperation({ summary: '获取商品分类树;countryId 不传时返回全部国家' })
|
||||
getCategories(@Query() query: PublicCountryQueryDto) {
|
||||
return this.service.getCategoriesTree(query.countryId);
|
||||
}
|
||||
|
||||
@Get('countries')
|
||||
@@ -35,20 +49,48 @@ export class PublicController {
|
||||
}
|
||||
|
||||
@Get('tag-groups')
|
||||
@ApiOperation({ summary: 'Public list of tag groups that have goods' })
|
||||
getTagGroups(): Promise<PublicTagGroupDto[]> {
|
||||
return this.service.getTagGroups();
|
||||
@ApiOperation({ summary: '获取标签组及标签筛选项;countryId 不传时返回全部国家' })
|
||||
@ApiOkResponse({ type: [PublicTagGroupFilterDto] })
|
||||
getTagGroups(@Query() query: PublicCountryQueryDto): Promise<PublicTagGroupFilterDto[]> {
|
||||
return this.service.getTagGroups(query.countryId);
|
||||
}
|
||||
|
||||
@Get('goods')
|
||||
@ApiOperation({ summary: 'Public paginated goods with filters' })
|
||||
@ApiOperation({ summary: '分页获取商品' })
|
||||
@ApiQuery({
|
||||
name: 'tags',
|
||||
required: false,
|
||||
description:
|
||||
'标签筛选分组。参数值为 JSON 数组;同组 tagIds 按 OR 匹配,不同标签组按 AND 匹配',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'array',
|
||||
items: { $ref: getSchemaPath(PublicTagFilterDto) },
|
||||
},
|
||||
example: [
|
||||
{ tagGroupId: '1', tagIds: ['11', '12'] },
|
||||
{ tagGroupId: '2', tagIds: ['25'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
getGoods(@Query() query: PublicQueryGoodDto) {
|
||||
return this.service.getGoods(query);
|
||||
}
|
||||
|
||||
@Get('goods/:id')
|
||||
@ApiOperation({ summary: 'Public good detail' })
|
||||
getGood(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.getGood(BigInt(id));
|
||||
@Get('goods/:goodId')
|
||||
@ApiOperation({ summary: '获取商品完整详情' })
|
||||
@ApiParam({ name: 'goodId', type: String, example: '168746' })
|
||||
@ApiOkResponse({ type: PublicGoodDetailDto })
|
||||
getGood(@Param('goodId') goodId: string): Promise<PublicGoodDetailDto> {
|
||||
return this.service.getGood(goodId);
|
||||
}
|
||||
|
||||
@Get('home-goods')
|
||||
@ApiOperation({ summary: '获取首页商品;可按国家返回' })
|
||||
@ApiOkResponse({ type: [PublicGoodDto] })
|
||||
getHomeGoods(@Query() query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
|
||||
return this.service.getHomeGoods(query);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { PublicService } from './public.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@@ -12,6 +12,8 @@ describe('PublicService', () => {
|
||||
let childCategoryId: bigint;
|
||||
let otherCategoryId: bigint;
|
||||
let tagId: bigint;
|
||||
let filterGroupIds: bigint[] = [];
|
||||
let filterTagIds: bigint[] = [];
|
||||
let originGoodId: bigint;
|
||||
let goodIds: bigint[] = [];
|
||||
|
||||
@@ -98,6 +100,50 @@ describe('PublicService', () => {
|
||||
});
|
||||
goodIds = [g1.id, g2.id, g3.id];
|
||||
|
||||
const craftGroup = await prisma.tagGroup.create({
|
||||
data: { groupName: `Pub Craft ${stamp}`, sortOrder: 100 },
|
||||
});
|
||||
const materialGroup = await prisma.tagGroup.create({
|
||||
data: { groupName: `Pub Material ${stamp}`, sortOrder: 101 },
|
||||
});
|
||||
filterGroupIds = [craftGroup.id, materialGroup.id];
|
||||
const craftA = await prisma.tag.create({
|
||||
data: { tagName: `Pub Craft A ${stamp}`, tagGroupId: craftGroup.id },
|
||||
});
|
||||
const craftB = await prisma.tag.create({
|
||||
data: { tagName: `Pub Craft B ${stamp}`, tagGroupId: craftGroup.id },
|
||||
});
|
||||
const cotton = await prisma.tag.create({
|
||||
data: { tagName: `Pub Cotton ${stamp}`, tagGroupId: materialGroup.id },
|
||||
});
|
||||
filterTagIds = [craftA.id, craftB.id, cotton.id];
|
||||
await prisma.goodTag.createMany({
|
||||
data: [
|
||||
{ goodId: g1.id, tagId: craftA.id },
|
||||
{ goodId: g1.id, tagId: cotton.id },
|
||||
{ goodId: g2.id, tagId: craftB.id },
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.originGoodDetail.create({
|
||||
data: {
|
||||
originGoodId,
|
||||
productCode: 'OZ10827003',
|
||||
productionProcess: '白墨烫画',
|
||||
sizeChart: { columns: [], rows: [{ sizeId: 'size_0', sizeName: 'S', measurements: [] }] },
|
||||
packageSpecs: { rows: [{ sizeId: 'size_0', sizeName: 'S' }] },
|
||||
},
|
||||
});
|
||||
await prisma.originGoodVariant.create({
|
||||
data: {
|
||||
originGoodId,
|
||||
sdsVariantId: `pub-variant-${stamp}`,
|
||||
sku: `OZ${stamp}`,
|
||||
sizeName: 'S',
|
||||
price: 38,
|
||||
},
|
||||
});
|
||||
|
||||
// Seed a good in `otherCategory` so the "onlyHaveGoods" filter
|
||||
// returns more than one category.
|
||||
await prisma.good.create({
|
||||
@@ -134,6 +180,8 @@ describe('PublicService', () => {
|
||||
where: { countryId },
|
||||
});
|
||||
await prisma.tag.delete({ where: { id: tagId } });
|
||||
await prisma.tag.deleteMany({ where: { id: { in: filterTagIds } } });
|
||||
await prisma.tagGroup.deleteMany({ where: { id: { in: filterGroupIds } } });
|
||||
await prisma.originGood.delete({ where: { id: originGoodId } });
|
||||
// Delete children before parent (FK self-relation is RESTRICT).
|
||||
await prisma.category.delete({ where: { id: childCategoryId } });
|
||||
@@ -172,8 +220,8 @@ describe('PublicService', () => {
|
||||
const filtered = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId), // includes child
|
||||
countryId: countryId.toString(),
|
||||
categoryId: categoryId.toString(), // includes child
|
||||
keyword: `Pub `,
|
||||
});
|
||||
expect(filtered.total).toBeGreaterThanOrEqual(4); // High, Mid, NoPos, ChildGood
|
||||
@@ -184,7 +232,7 @@ describe('PublicService', () => {
|
||||
const result = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
countryId: Number(countryId),
|
||||
countryId: countryId.toString(),
|
||||
keyword: `Pub `,
|
||||
});
|
||||
const priorities = result.items.map((g) => g.goodPriority);
|
||||
@@ -193,31 +241,118 @@ describe('PublicService', () => {
|
||||
expect(priorities).toEqual(sorted);
|
||||
});
|
||||
|
||||
it('uses OR within one tag group and AND across tag groups', async () => {
|
||||
const sameGroup = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
countryId: countryId.toString(),
|
||||
keyword: `Pub `,
|
||||
tags: [
|
||||
{
|
||||
tagGroupId: filterGroupIds[0].toString(),
|
||||
tagIds: filterTagIds.slice(0, 2).map(String),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(sameGroup.items.map((item) => item.goodName)).toEqual(
|
||||
expect.arrayContaining([`Pub High ${stamp}`, `Pub Mid ${stamp}`]),
|
||||
);
|
||||
|
||||
const acrossGroups = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
countryId: countryId.toString(),
|
||||
keyword: `Pub `,
|
||||
tags: [
|
||||
{
|
||||
tagGroupId: filterGroupIds[0].toString(),
|
||||
tagIds: filterTagIds.slice(0, 2).map(String),
|
||||
},
|
||||
{
|
||||
tagGroupId: filterGroupIds[1].toString(),
|
||||
tagIds: [filterTagIds[2].toString()],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(acrossGroups.items.map((item) => item.goodName)).toContain(`Pub High ${stamp}`);
|
||||
expect(acrossGroups.items.map((item) => item.goodName)).not.toContain(`Pub Mid ${stamp}`);
|
||||
});
|
||||
|
||||
it('rejects a tag paired with the wrong tag group', async () => {
|
||||
await expect(
|
||||
service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
tags: [
|
||||
{
|
||||
tagGroupId: filterGroupIds[1].toString(),
|
||||
tagIds: [filterTagIds[0].toString()],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('returns the SDS product id as the public product id', async () => {
|
||||
const result = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
countryId: Number(countryId),
|
||||
countryId: countryId.toString(),
|
||||
keyword: `Pub High ${stamp}`,
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].id).toBe(`pub-sds-${stamp}`);
|
||||
expect(result.items[0].id).not.toBe(goodIds[0].toString());
|
||||
expect(result.items[0].goodId).toBe(`pub-sds-${stamp}`);
|
||||
expect(result.items[0].goodId).not.toBe(goodIds[0].toString());
|
||||
});
|
||||
|
||||
it('returns custom goods through the same public product contract', async () => {
|
||||
const customPublicId = `custom-public-${stamp}`;
|
||||
const origin = await prisma.originGood.create({
|
||||
data: {
|
||||
source: 'CUSTOM',
|
||||
sdsGoodId: customPublicId,
|
||||
goodName: `Pub Custom ${stamp}`,
|
||||
goodPrice: 42,
|
||||
detail: { create: { productCode: `CUSTOM-${stamp}` } },
|
||||
},
|
||||
});
|
||||
const good = await prisma.good.create({
|
||||
data: {
|
||||
originGoodId: origin.id,
|
||||
countryId,
|
||||
categoryId,
|
||||
goodName: `Pub Custom ${stamp}`,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const detail = await service.getGood(customPublicId);
|
||||
expect(detail.goodId).toBe(customPublicId);
|
||||
expect(detail.goodName).toBe(`Pub Custom ${stamp}`);
|
||||
expect(detail.productCode).toBe(`CUSTOM-${stamp}`);
|
||||
} finally {
|
||||
await prisma.good.delete({ where: { id: good.id } });
|
||||
await prisma.originGood.delete({ where: { id: origin.id } });
|
||||
}
|
||||
});
|
||||
|
||||
it('getGood returns detail and 404 for unknown id', async () => {
|
||||
const first = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
countryId: Number(countryId),
|
||||
countryId: countryId.toString(),
|
||||
keyword: `Pub `,
|
||||
});
|
||||
expect(first.items.length).toBe(1);
|
||||
const detail = await service.getGood(goodIds[0]);
|
||||
expect(detail.id).toBe(first.items[0].id);
|
||||
const detail = await service.getGood(`pub-sds-${stamp}`);
|
||||
expect(detail.goodId).toBe(first.items[0].goodId);
|
||||
expect(detail.productCode).toBe('OZ10827003');
|
||||
expect(detail.details.productionProcess).toBe('白墨烫画');
|
||||
expect((detail.sizeChart?.rows as unknown[])).toHaveLength(1);
|
||||
expect((detail.packageSpecs?.rows as unknown[])).toHaveLength(1);
|
||||
expect(detail.variants).toHaveLength(1);
|
||||
|
||||
await expect(service.getGood(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||
await expect(service.getGood('99999999')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Category as PrismaCategory, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PublicQueryGoodDto } from './dto/public-query-good.dto';
|
||||
import {
|
||||
PublicHomeGoodsQueryDto,
|
||||
PublicQueryGoodDto,
|
||||
PublicTagFilterDto,
|
||||
} from './dto/public-query-good.dto';
|
||||
import { PublicCategoryNodeDto } from './dto/public-category.dto';
|
||||
import { PublicCountryDto } from './dto/public-country.dto';
|
||||
import { PublicTagDto } from './dto/public-tag.dto';
|
||||
import { PublicTagGroupDto } from './dto/public-tag-group.dto';
|
||||
import { PublicGoodDto } from './dto/public-good.dto';
|
||||
import {
|
||||
PublicGoodDetailDto,
|
||||
PublicTagGroupFilterDto,
|
||||
} from './dto/public-good-detail.dto';
|
||||
|
||||
export interface PublicPaginatedGoods {
|
||||
items: PublicGoodDto[];
|
||||
@@ -20,17 +28,28 @@ const PUBLIC_GOOD_INCLUDE = {
|
||||
category: true,
|
||||
tag: { include: { tagGroup: true } },
|
||||
position: true,
|
||||
originGood: true,
|
||||
originGood: {
|
||||
include: {
|
||||
detail: true,
|
||||
variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||
},
|
||||
},
|
||||
goodTags: { include: { tag: { include: { tagGroup: true } } } },
|
||||
} satisfies Prisma.GoodInclude;
|
||||
|
||||
type PublicGoodRow = Prisma.GoodGetPayload<{ include: typeof PUBLIC_GOOD_INCLUDE }>;
|
||||
|
||||
@Injectable()
|
||||
export class PublicService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getCategoriesTree(): Promise<PublicCategoryNodeDto[]> {
|
||||
async getCategoriesTree(countryId?: string): Promise<PublicCategoryNodeDto[]> {
|
||||
const goodsWhere: Prisma.GoodWhereInput = {
|
||||
originGood: { delisted: false },
|
||||
...(countryId ? { countryId: BigInt(countryId) } : {}),
|
||||
};
|
||||
const leafCategories = await this.prisma.category.findMany({
|
||||
where: { goods: { some: {} } },
|
||||
where: { goods: { some: goodsWhere } },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
const ancestorIds = new Set<bigint>();
|
||||
@@ -46,22 +65,30 @@ export class PublicService {
|
||||
cursor = parent.parentCategoryId;
|
||||
}
|
||||
}
|
||||
const ancestorRows = ancestorIds.size > 0
|
||||
const ancestorRows = ancestorIds.size
|
||||
? await this.prisma.category.findMany({
|
||||
where: { id: { in: [...ancestorIds] } },
|
||||
orderBy: { id: 'asc' },
|
||||
})
|
||||
: [];
|
||||
const allRows = [...leafCategories, ...ancestorRows].filter(
|
||||
(row, idx, arr) => arr.findIndex((r) => r.id === row.id) === idx,
|
||||
(row, index, rows) => rows.findIndex((item) => item.id === row.id) === index,
|
||||
);
|
||||
allRows.sort((a, b) => Number(a.id - b.id));
|
||||
return this.buildTree(allRows);
|
||||
const directCounts = await this.prisma.good.groupBy({
|
||||
by: ['categoryId'],
|
||||
where: goodsWhere,
|
||||
_count: { _all: true },
|
||||
});
|
||||
return this.buildTree(
|
||||
allRows,
|
||||
new Map(directCounts.map((row) => [row.categoryId, row._count._all])),
|
||||
);
|
||||
}
|
||||
|
||||
async getCountries(): Promise<PublicCountryDto[]> {
|
||||
const rows = await this.prisma.country.findMany({
|
||||
where: { goods: { some: {} } },
|
||||
where: { goods: { some: { originGood: { delisted: false } } } },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
return rows.map(PublicCountryDto.from);
|
||||
@@ -69,7 +96,7 @@ export class PublicService {
|
||||
|
||||
async getTags(): Promise<PublicTagDto[]> {
|
||||
const rows = await this.prisma.tag.findMany({
|
||||
where: { goodTags: { some: {} } },
|
||||
where: { goodTags: { some: { good: { originGood: { delisted: false } } } } },
|
||||
orderBy: [
|
||||
{ tagGroup: { sortOrder: 'asc' } },
|
||||
{ sortOrder: 'asc' },
|
||||
@@ -80,99 +107,132 @@ export class PublicService {
|
||||
return rows.map(PublicTagDto.from);
|
||||
}
|
||||
|
||||
async getTagGroups(): Promise<PublicTagGroupDto[]> {
|
||||
async getTagGroups(countryId?: string): Promise<PublicTagGroupFilterDto[]> {
|
||||
const goodWhere: Prisma.GoodWhereInput = {
|
||||
originGood: { delisted: false },
|
||||
...(countryId ? { countryId: BigInt(countryId) } : {}),
|
||||
};
|
||||
const rows = await this.prisma.tagGroup.findMany({
|
||||
where: { tags: { some: { goodTags: { some: {} } } } },
|
||||
where: { tags: { some: { goodTags: { some: { good: goodWhere } } } } },
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
include: {
|
||||
tags: {
|
||||
where: { goodTags: { some: { good: goodWhere } } },
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
include: {
|
||||
_count: { select: { goodTags: { where: { good: goodWhere } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return rows.map(PublicTagGroupDto.from);
|
||||
return rows.map((group) => ({
|
||||
...PublicTagGroupDto.from(group),
|
||||
tags: group.tags.map((tag) => ({
|
||||
id: tag.id.toString(),
|
||||
tagName: tag.tagName,
|
||||
tagColor: tag.tagColor,
|
||||
tagFontColor: tag.tagFontColor,
|
||||
sortOrder: tag.sortOrder,
|
||||
productCount: tag._count.goodTags,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
|
||||
const where: Prisma.GoodWhereInput = {
|
||||
originGood: { delisted: false },
|
||||
};
|
||||
if (query.countryId !== undefined) where.countryId = BigInt(query.countryId);
|
||||
if (query.tagIds) {
|
||||
const ids = query.tagIds
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => BigInt(s));
|
||||
if (ids.length > 0) {
|
||||
// AND logic: 商品必须同时具备所有选中的 tag
|
||||
where.AND = ids.map((id) => ({ goodTags: { some: { tagId: id } } }));
|
||||
}
|
||||
const where: Prisma.GoodWhereInput = { originGood: { delisted: false } };
|
||||
if (query.countryId) where.countryId = BigInt(query.countryId);
|
||||
if (query.keyword) where.goodName = { contains: query.keyword, mode: 'insensitive' };
|
||||
if (query.categoryId) {
|
||||
where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) };
|
||||
}
|
||||
if (query.keyword) {
|
||||
where.goodName = { contains: query.keyword, mode: 'insensitive' };
|
||||
|
||||
const tagFilters = await this.buildTagGroupFilters(query.tags ?? []);
|
||||
if (tagFilters.length) where.AND = tagFilters;
|
||||
|
||||
const minPrice = this.parsePrice(query.minPrice, 'minPrice');
|
||||
const maxPrice = this.parsePrice(query.maxPrice, 'maxPrice');
|
||||
if (minPrice !== null && maxPrice !== null && minPrice > maxPrice) {
|
||||
throw new BadRequestException('minPrice 不能大于 maxPrice');
|
||||
}
|
||||
if (query.categoryId !== undefined) {
|
||||
const ids = await this.collectCategoryDescendants(BigInt(query.categoryId));
|
||||
where.categoryId = { in: ids };
|
||||
if (minPrice !== null || maxPrice !== null) {
|
||||
where.originGood = {
|
||||
delisted: false,
|
||||
goodPrice: {
|
||||
...(minPrice !== null ? { gte: minPrice } : {}),
|
||||
...(maxPrice !== null ? { lte: maxPrice } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const orderBy: Prisma.GoodOrderByWithRelationInput[] =
|
||||
query.sort === 'PRICE_ASC'
|
||||
? [{ originGood: { goodPrice: 'asc' } }, { id: 'asc' }]
|
||||
: query.sort === 'PRICE_DESC'
|
||||
? [{ originGood: { goodPrice: 'desc' } }, { id: 'asc' }]
|
||||
: query.sort === 'NEWEST'
|
||||
? [{ createdAt: 'desc' }, { id: 'asc' }]
|
||||
: [
|
||||
{ goodPriority: 'desc' },
|
||||
{ position: { indexVal: 'asc' } },
|
||||
{ createdAt: 'desc' },
|
||||
{ id: 'asc' },
|
||||
];
|
||||
|
||||
const [total, rows] = await this.prisma.$transaction([
|
||||
this.prisma.good.count({ where }),
|
||||
this.prisma.good.findMany({
|
||||
where,
|
||||
include: PUBLIC_GOOD_INCLUDE,
|
||||
// Server-side primary sort; PublicGoodDto retains original indexes
|
||||
// for stable pagination but the final ORDER BY is mirrored below.
|
||||
orderBy: [
|
||||
{ goodPriority: 'desc' },
|
||||
{ position: { indexVal: 'asc' } },
|
||||
{ createdAt: 'desc' },
|
||||
],
|
||||
orderBy,
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((g) => this.toPublicGood(g)),
|
||||
items: rows.map((good) => this.toPublicGood(good)),
|
||||
total,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async getGood(id: bigint): Promise<PublicGoodDto> {
|
||||
const good = await this.prisma.good.findUnique({
|
||||
where: { id },
|
||||
async getGood(goodId: string): Promise<PublicGoodDetailDto> {
|
||||
const good = await this.prisma.good.findFirst({
|
||||
where: { originGood: { sdsGoodId: goodId, delisted: false } },
|
||||
include: PUBLIC_GOOD_INCLUDE,
|
||||
orderBy: [{ goodPriority: 'desc' }, { id: 'asc' }],
|
||||
});
|
||||
if (!good) throw new NotFoundException(`Good ${id} not found`);
|
||||
return this.toPublicGood(good);
|
||||
if (!good) {
|
||||
throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
|
||||
}
|
||||
return this.toPublicGoodDetail(good);
|
||||
}
|
||||
|
||||
private toPublicGood(good: {
|
||||
id: bigint;
|
||||
goodName: string;
|
||||
goodImage: string | null;
|
||||
goodPriority: number;
|
||||
country: { id: bigint; countryName: string; countryIcon: string | null };
|
||||
category: { id: bigint; categoryName: string; categoryIcon: string | null };
|
||||
tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroup: { id: bigint; groupName: string; sortOrder: number } | null } | null;
|
||||
position: { id: bigint; indexVal: number } | null;
|
||||
originGood: {
|
||||
sdsGoodId: string;
|
||||
goodImage: string | null;
|
||||
goodPrice: { toString(): string } | null;
|
||||
};
|
||||
goodTags: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroup: { id: bigint; groupName: string; sortOrder: number } | null } }[];
|
||||
createdAt: Date;
|
||||
}): PublicGoodDto {
|
||||
const formatGroup = (g: { id: bigint; groupName: string; sortOrder: number } | null) =>
|
||||
g
|
||||
? {
|
||||
id: g.id.toString(),
|
||||
groupName: g.groupName,
|
||||
sortOrder: g.sortOrder,
|
||||
}
|
||||
async getHomeGoods(query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
|
||||
const rows = await this.prisma.good.findMany({
|
||||
where: {
|
||||
positionId: { not: null },
|
||||
originGood: { delisted: false },
|
||||
...(query.countryId ? { countryId: BigInt(query.countryId) } : {}),
|
||||
},
|
||||
include: PUBLIC_GOOD_INCLUDE,
|
||||
orderBy: [
|
||||
{ position: { indexVal: 'asc' } },
|
||||
{ goodPriority: 'desc' },
|
||||
{ id: 'asc' },
|
||||
],
|
||||
take: query.limit,
|
||||
});
|
||||
return rows.map((good) => this.toPublicGood(good));
|
||||
}
|
||||
|
||||
private toPublicGood(good: PublicGoodRow): PublicGoodDto {
|
||||
const formatGroup = (group: { id: bigint; groupName: string; sortOrder: number } | null) =>
|
||||
group
|
||||
? { id: group.id.toString(), groupName: group.groupName, sortOrder: group.sortOrder }
|
||||
: null;
|
||||
return {
|
||||
id: good.originGood.sdsGoodId,
|
||||
goodId: good.originGood.sdsGoodId,
|
||||
goodName: good.goodName,
|
||||
goodPriority: good.goodPriority,
|
||||
country: {
|
||||
@@ -194,63 +254,167 @@ export class PublicService {
|
||||
group: formatGroup(good.tag.tagGroup),
|
||||
}
|
||||
: null,
|
||||
tags: good.goodTags.map((gt) => ({
|
||||
id: gt.tag.id.toString(),
|
||||
tagName: gt.tag.tagName,
|
||||
tagColor: gt.tag.tagColor,
|
||||
tagFontColor: gt.tag.tagFontColor,
|
||||
group: formatGroup(gt.tag.tagGroup),
|
||||
tags: good.goodTags.map(({ tag }) => ({
|
||||
id: tag.id.toString(),
|
||||
tagName: tag.tagName,
|
||||
tagColor: tag.tagColor,
|
||||
tagFontColor: tag.tagFontColor,
|
||||
group: formatGroup(tag.tagGroup),
|
||||
})),
|
||||
position: good.position
|
||||
? {
|
||||
id: good.position.id.toString(),
|
||||
indexVal: good.position.indexVal,
|
||||
}
|
||||
? { id: good.position.id.toString(), indexVal: good.position.indexVal }
|
||||
: null,
|
||||
image: good.goodImage ?? good.originGood?.goodImage ?? null,
|
||||
price:
|
||||
good.originGood?.goodPrice === null ||
|
||||
good.originGood?.goodPrice === undefined
|
||||
? null
|
||||
: good.originGood.goodPrice.toString(),
|
||||
image: good.goodImage ?? good.originGood.goodImage,
|
||||
price: good.originGood.goodPrice?.toString() ?? null,
|
||||
createdAt: good.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private toPublicGoodDetail(good: PublicGoodRow): PublicGoodDetailDto {
|
||||
const base = this.toPublicGood(good);
|
||||
const detail = good.originGood.detail;
|
||||
return {
|
||||
...base,
|
||||
productCode: detail?.productCode ?? null,
|
||||
englishName: detail?.englishName ?? null,
|
||||
productionCycleHours: detail?.productionCycleHours ?? null,
|
||||
minWeightG: detail?.minWeightG?.toString() ?? null,
|
||||
details: {
|
||||
reminder: detail?.reminder ?? null,
|
||||
productionProcess: detail?.productionProcess ?? null,
|
||||
materialDescription: detail?.materialDescription ?? null,
|
||||
productPerformance: detail?.productPerformance ?? null,
|
||||
applicableScenarios: detail?.applicableScenarios ?? null,
|
||||
washingInstructions: detail?.washingInstructions ?? null,
|
||||
specialDescription: detail?.specialDescription ?? null,
|
||||
designExplanation: detail?.designExplanation ?? null,
|
||||
designArea: detail?.designArea ?? null,
|
||||
pictureRequest: detail?.pictureRequest ?? null,
|
||||
},
|
||||
media: (detail?.media as Record<string, unknown> | null) ?? null,
|
||||
options: (detail?.options as Record<string, unknown> | null) ?? null,
|
||||
sizeChart: (detail?.sizeChart as Record<string, unknown> | null) ?? null,
|
||||
packageSpecs: (detail?.packageSpecs as Record<string, unknown> | null) ?? null,
|
||||
variants: good.originGood.variants.map((variant) => ({
|
||||
id: variant.sdsVariantId,
|
||||
sku: variant.sku,
|
||||
sizeId: variant.sizeId,
|
||||
sizeName: variant.sizeName,
|
||||
colorId: variant.colorId,
|
||||
colorName: variant.colorName,
|
||||
colorHex: variant.colorHex,
|
||||
imageUrl: variant.imageUrl,
|
||||
price: variant.price?.toString() ?? null,
|
||||
originalPrice: variant.originalPrice?.toString() ?? null,
|
||||
weightG: variant.weightG?.toString() ?? null,
|
||||
boxLengthCm: variant.boxLengthCm?.toString() ?? null,
|
||||
boxWidthCm: variant.boxWidthCm?.toString() ?? null,
|
||||
boxHeightCm: variant.boxHeightCm?.toString() ?? null,
|
||||
enabled: variant.enabled,
|
||||
sortOrder: variant.sortOrder,
|
||||
})),
|
||||
detailSyncedAt: detail?.syncedAt.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildTagGroupFilters(
|
||||
selectedGroups: PublicTagFilterDto[],
|
||||
): Promise<Prisma.GoodWhereInput[]> {
|
||||
const selections = selectedGroups.flatMap((group) => {
|
||||
if (!/^\d+$/.test(group.tagGroupId) || !Array.isArray(group.tagIds)) {
|
||||
throw new BadRequestException(
|
||||
'tags 每个元素必须包含合法的 tagGroupId 和 tagIds',
|
||||
);
|
||||
}
|
||||
return group.tagIds.map((tagId) => {
|
||||
if (!/^\d+$/.test(tagId)) {
|
||||
throw new BadRequestException('tagIds 必须全部为数字字符串');
|
||||
}
|
||||
return { tagGroupId: group.tagGroupId, tagId };
|
||||
});
|
||||
});
|
||||
const uniqueTagIds = [...new Set(selections.map((item) => item.tagId))];
|
||||
const selected = uniqueTagIds.length
|
||||
? await this.prisma.tag.findMany({
|
||||
where: { id: { in: uniqueTagIds.map((id) => BigInt(id)) } },
|
||||
select: { id: true, tagGroupId: true },
|
||||
})
|
||||
: [];
|
||||
if (selected.length !== uniqueTagIds.length) {
|
||||
throw new BadRequestException('包含不存在的标签 ID');
|
||||
}
|
||||
const actualGroups = new Map(
|
||||
selected.map((tag) => [
|
||||
tag.id.toString(),
|
||||
tag.tagGroupId?.toString() ?? null,
|
||||
]),
|
||||
);
|
||||
for (const selection of selections) {
|
||||
if (actualGroups.get(selection.tagId) !== selection.tagGroupId) {
|
||||
throw new BadRequestException(
|
||||
`标签 ${selection.tagId} 不属于标签组 ${selection.tagGroupId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const byGroup = new Map<string, bigint[]>();
|
||||
for (const selection of selections) {
|
||||
const key = selection.tagGroupId;
|
||||
const ids = byGroup.get(key) ?? [];
|
||||
const id = BigInt(selection.tagId);
|
||||
if (!ids.includes(id)) ids.push(id);
|
||||
byGroup.set(key, ids);
|
||||
}
|
||||
return [...byGroup.values()].map((ids) => ({
|
||||
goodTags: { some: { tagId: { in: ids } } },
|
||||
}));
|
||||
}
|
||||
|
||||
private parsePrice(value: string | undefined, field: string): number | null {
|
||||
if (value === undefined || value === '') return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
throw new BadRequestException(`${field} 必须是大于等于 0 的金额`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private async collectCategoryDescendants(rootId: bigint): Promise<bigint[]> {
|
||||
const ids: bigint[] = [rootId];
|
||||
let frontier: bigint[] = [rootId];
|
||||
while (frontier.length > 0) {
|
||||
while (frontier.length) {
|
||||
const children = await this.prisma.category.findMany({
|
||||
where: { parentCategoryId: { in: frontier } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (children.length === 0) break;
|
||||
const childIds = children.map((c) => c.id);
|
||||
ids.push(...childIds);
|
||||
frontier = childIds;
|
||||
if (!children.length) break;
|
||||
frontier = children.map((child) => child.id);
|
||||
ids.push(...frontier);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private buildTree(
|
||||
rows: PrismaCategory[],
|
||||
directCounts: Map<bigint, number>,
|
||||
): PublicCategoryNodeDto[] {
|
||||
const byId = new Map<bigint, PublicCategoryNodeDto>();
|
||||
for (const row of rows) {
|
||||
byId.set(row.id, PublicCategoryNodeDto.from(row, []));
|
||||
const node = PublicCategoryNodeDto.from(row, []);
|
||||
node.productCount = directCounts.get(row.id) ?? 0;
|
||||
byId.set(row.id, node);
|
||||
}
|
||||
const roots: PublicCategoryNodeDto[] = [];
|
||||
for (const row of rows) {
|
||||
const node = byId.get(row.id)!;
|
||||
if (row.parentCategoryId === null) {
|
||||
roots.push(node);
|
||||
} else {
|
||||
const parent = byId.get(row.parentCategoryId);
|
||||
if (parent) parent.children.push(node);
|
||||
else roots.push(node);
|
||||
}
|
||||
const parent = row.parentCategoryId === null ? null : byId.get(row.parentCategoryId);
|
||||
if (parent) parent.children.push(node);
|
||||
else roots.push(node);
|
||||
}
|
||||
const total = (node: PublicCategoryNodeDto): number => {
|
||||
node.productCount += node.children.reduce((sum, child) => sum + total(child), 0);
|
||||
return node.productCount;
|
||||
};
|
||||
roots.forEach(total);
|
||||
return roots;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export class SyncLogDto {
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
type!: 'CATEGORIES' | 'PRODUCTS';
|
||||
type!: 'CATEGORIES' | 'PRODUCTS' | 'PRODUCT_DETAILS';
|
||||
|
||||
@ApiProperty()
|
||||
status!: 'RUNNING' | 'SUCCESS' | 'FAILED';
|
||||
|
||||
@@ -33,4 +33,23 @@ describe('SdsClientService', () => {
|
||||
expect(result).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchProductDetail', () => {
|
||||
it('requests /products/{goodId} and validates the returned id', async () => {
|
||||
http.get.mockReturnValue(of({ data: { id: 168746, sku: 'OZ10827003' } }));
|
||||
|
||||
const result = await service.fetchProductDetail('168746');
|
||||
|
||||
expect(result.sku).toBe('OZ10827003');
|
||||
expect(http.get).toHaveBeenCalledWith(
|
||||
'https://mapi.sdspod.com/products/168746',
|
||||
expect.objectContaining({ headers: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a mismatched product response', async () => {
|
||||
http.get.mockReturnValue(of({ data: { id: 1 } }));
|
||||
await expect(service.fetchProductDetail('168746')).rejects.toThrow(/id mismatch/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,86 @@ export interface SdsProductsPage {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SdsProductVariant extends Record<string, unknown> {
|
||||
id?: number | string;
|
||||
sku?: string;
|
||||
size?: string;
|
||||
sizeId?: number | string;
|
||||
sizeDto?: { id?: number | string; sizeName?: string };
|
||||
colorId?: number | string;
|
||||
color_name?: string;
|
||||
color?: {
|
||||
colorId?: number | string;
|
||||
color?: string;
|
||||
color_name?: string;
|
||||
chineseName?: string;
|
||||
};
|
||||
currentPrice?: number | string;
|
||||
originalPrice?: number | string;
|
||||
unit_price?: number | string;
|
||||
min_price?: number | string;
|
||||
weight?: number | string;
|
||||
box_length?: number | string;
|
||||
box_width?: number | string;
|
||||
box_height?: number | string;
|
||||
status?: number | string;
|
||||
delFlag?: number | string;
|
||||
size_sort?: number | string;
|
||||
attribute_sort?: string;
|
||||
psd_img_url?: string;
|
||||
img_url?: string;
|
||||
blankDesignUrl?: string;
|
||||
designPrototype?: {
|
||||
detailImgUrls?: Array<{ imageUrl?: string }>;
|
||||
prototypeResultGroups?: Array<{ resultImage?: string }>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SdsProductDetail extends Record<string, unknown> {
|
||||
id: number | string;
|
||||
name?: string;
|
||||
sku?: string;
|
||||
english_name?: string;
|
||||
blankDesignUrl?: string;
|
||||
detailsPageVideoUrl?: string;
|
||||
productionCycle?: number | string;
|
||||
minWeight?: number | string;
|
||||
min_price?: number | string;
|
||||
updateTime?: number | string;
|
||||
psd_img_url?: string;
|
||||
img_url?: string;
|
||||
texture?: { name?: string };
|
||||
product_details?: {
|
||||
reminder?: string;
|
||||
production_process?: string;
|
||||
material_description?: string;
|
||||
product_performance?: string;
|
||||
applicable_scenarios?: string;
|
||||
washing_instructions?: string;
|
||||
special_description?: string;
|
||||
design_explanation?: string;
|
||||
design_area?: string;
|
||||
picture_request?: string;
|
||||
product_size?: string;
|
||||
packaging_specification?: string;
|
||||
};
|
||||
subproducts?: {
|
||||
attributers?: Array<{
|
||||
size?: string;
|
||||
sizeId?: number | string;
|
||||
colors?: Array<{
|
||||
colorId?: number | string;
|
||||
color?: string;
|
||||
color_name?: string;
|
||||
chineseName?: string;
|
||||
colorSort?: number | string;
|
||||
}>;
|
||||
}>;
|
||||
items?: SdsProductVariant[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper around the SDS (mapi.sdspod.com) endpoints that the
|
||||
* `SyncService` consumes.
|
||||
@@ -137,4 +217,17 @@ export class SdsClientService {
|
||||
}
|
||||
return data as SdsProductsPage;
|
||||
}
|
||||
|
||||
async fetchProductDetail(goodId: string | number): Promise<SdsProductDetail> {
|
||||
const url = `${this.baseUrl}/products/${encodeURIComponent(String(goodId))}`;
|
||||
const data = await this.request<unknown>('get', url);
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
throw new Error(`SDS product detail returned ${typeof data}, expected object`);
|
||||
}
|
||||
const detail = data as SdsProductDetail;
|
||||
if (String(detail.id) !== String(goodId)) {
|
||||
throw new Error(`SDS product detail id mismatch: expected ${goodId}, got ${String(detail.id)}`);
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
normalizeProductDetail,
|
||||
parsePackageSpecs,
|
||||
parseSizeChart,
|
||||
} from './sds-product-detail.mapper';
|
||||
|
||||
const sizeTable = JSON.stringify([
|
||||
['尺码', '衣长(cm/in)', '胸围(cm/in)', '肩宽(cm/in)', '袖长(cm/in)'].map((content) => ({ content, remark: '' })),
|
||||
['S', '71', '92', '43', '22'].map((content) => ({ content, remark: '' })),
|
||||
['M', '74', '102', '45', '22'].map((content) => ({ content, remark: '' })),
|
||||
['L', '76', '112', '48', '23'].map((content) => ({ content, remark: '' })),
|
||||
['XL', '79', '122', '51', '23'].map((content) => ({ content, remark: '' })),
|
||||
['2XL', '82', '132', '53', '25'].map((content) => ({ content, remark: '' })),
|
||||
['3XL', '84', '142', '56', '25'].map((content) => ({ content, remark: '' })),
|
||||
]);
|
||||
|
||||
const packageTable = JSON.stringify([
|
||||
['尺码', '包装尺寸(cm)', '包装尺寸(in)', '包装体积(cm³)', '包装体积(in³)', '含包装重量(g)', '含包装重量(lb)'].map((content) => ({ content })),
|
||||
['S', '36.0*26.0*1.0\t', '14.17*10.24*0.39\t', '936.00', '57.12', '208.00', '0.46'].map((content) => ({ content })),
|
||||
['M', '36.0*26.0*1.0', '14.17*10.24*0.39', '936.00', '57.12', '218.00', '0.48'].map((content) => ({ content })),
|
||||
]);
|
||||
|
||||
describe('SDS product detail mapper', () => {
|
||||
it('parses the product_detail.txt size table into structured rows', () => {
|
||||
const chart = parseSizeChart(sizeTable) as any;
|
||||
expect(chart.columns.map((column: any) => column.key)).toEqual([
|
||||
'bodyLength',
|
||||
'chest',
|
||||
'shoulder',
|
||||
'sleeveLength',
|
||||
]);
|
||||
expect(chart.rows).toHaveLength(6);
|
||||
expect(chart.rows[0].measurements[0]).toEqual({ key: 'bodyLength', cm: '71', in: '27.95' });
|
||||
});
|
||||
|
||||
it('parses packaging dimensions and weights', () => {
|
||||
const specs = parsePackageSpecs(packageTable) as any;
|
||||
expect(specs.rows).toHaveLength(2);
|
||||
expect(specs.rows[0].dimensionsCm).toEqual({ length: '36.0', width: '26.0', height: '1.0' });
|
||||
expect(specs.rows[0].grossWeightG).toBe('208.00');
|
||||
});
|
||||
|
||||
it('normalizes detail text, options and variants', () => {
|
||||
const normalized = normalizeProductDetail({
|
||||
id: 168746,
|
||||
sku: 'OZ10827003',
|
||||
english_name: 't-shirt',
|
||||
productionCycle: 24,
|
||||
minWeight: 250,
|
||||
product_details: {
|
||||
production_process: '白墨烫画',
|
||||
material_description: '100%纯棉',
|
||||
product_size: sizeTable,
|
||||
packaging_specification: packageTable,
|
||||
},
|
||||
subproducts: {
|
||||
attributers: [{
|
||||
size: 'S',
|
||||
sizeId: 1922304,
|
||||
colors: [{ colorId: 1139383, color: '#000300', color_name: 'black', colorSort: 1 }],
|
||||
}],
|
||||
items: [{
|
||||
id: 168747,
|
||||
sku: 'OZ10827003001',
|
||||
size: 'S',
|
||||
sizeId: 1922304,
|
||||
colorId: 1139383,
|
||||
color: { colorId: 1139383, color: '#000300', color_name: 'black' },
|
||||
currentPrice: 38,
|
||||
originalPrice: 38,
|
||||
weight: 250,
|
||||
box_length: 30,
|
||||
box_width: 20,
|
||||
box_height: 5,
|
||||
status: 1,
|
||||
delFlag: '0',
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
expect(normalized.productCode).toBe('OZ10827003');
|
||||
expect(normalized.productionProcess).toBe('白墨烫画');
|
||||
expect(normalized.variants[0].sku).toBe('OZ10827003001');
|
||||
expect(normalized.variants[0].price?.toString()).toBe('38');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { SdsProductDetail, SdsProductVariant } from './sds-client.service';
|
||||
|
||||
type TableCell = { content?: unknown; remark?: unknown };
|
||||
type Table = TableCell[][];
|
||||
|
||||
export interface NormalizedVariant {
|
||||
sdsVariantId: string;
|
||||
sku: string;
|
||||
sizeId: string | null;
|
||||
sizeName: string | null;
|
||||
colorId: string | null;
|
||||
colorName: string | null;
|
||||
colorHex: string | null;
|
||||
imageUrl: string | null;
|
||||
price: Prisma.Decimal | null;
|
||||
originalPrice: Prisma.Decimal | null;
|
||||
weightG: Prisma.Decimal | null;
|
||||
boxLengthCm: Prisma.Decimal | null;
|
||||
boxWidthCm: Prisma.Decimal | null;
|
||||
boxHeightCm: Prisma.Decimal | null;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
designData: Prisma.InputJsonValue | null;
|
||||
}
|
||||
|
||||
export interface NormalizedProductDetail {
|
||||
productCode: string | null;
|
||||
englishName: string | null;
|
||||
blankDesignUrl: string | null;
|
||||
detailsPageVideoUrl: string | null;
|
||||
textureName: string | null;
|
||||
productionCycleHours: number | null;
|
||||
minWeightG: Prisma.Decimal | null;
|
||||
reminder: string | null;
|
||||
productionProcess: string | null;
|
||||
materialDescription: string | null;
|
||||
productPerformance: string | null;
|
||||
applicableScenarios: string | null;
|
||||
washingInstructions: string | null;
|
||||
specialDescription: string | null;
|
||||
designExplanation: string | null;
|
||||
designArea: string | null;
|
||||
pictureRequest: string | null;
|
||||
sizeChart: Prisma.InputJsonValue | null;
|
||||
packageSpecs: Prisma.InputJsonValue | null;
|
||||
options: Prisma.InputJsonValue | null;
|
||||
media: Prisma.InputJsonValue | null;
|
||||
upstreamUpdatedAt: Date | null;
|
||||
variants: NormalizedVariant[];
|
||||
}
|
||||
|
||||
const text = (value: unknown): string | null => {
|
||||
if (value === undefined || value === null) return null;
|
||||
const normalized = String(value).trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
};
|
||||
|
||||
const decimal = (value: unknown): Prisma.Decimal | null => {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? new Prisma.Decimal(n) : null;
|
||||
};
|
||||
|
||||
const integer = (value: unknown): number | null => {
|
||||
const n = Number(value);
|
||||
return Number.isInteger(n) ? n : null;
|
||||
};
|
||||
|
||||
function parseTable(raw: unknown): Table | null {
|
||||
if (typeof raw !== 'string' || raw.trim() === '') return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed) || parsed.length < 2) return null;
|
||||
const rows = parsed.filter(Array.isArray) as Table;
|
||||
return rows.length >= 2 ? rows : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const cell = (row: TableCell[], index: number): string =>
|
||||
text(row[index]?.content)?.replace(/\t/g, '').trim() ?? '';
|
||||
|
||||
const measurementKey = (header: string, index: number): string => {
|
||||
if (header.includes('衣长')) return 'bodyLength';
|
||||
if (header.includes('胸围')) return 'chest';
|
||||
if (header.includes('肩宽')) return 'shoulder';
|
||||
if (header.includes('袖长')) return 'sleeveLength';
|
||||
return `measurement${index}`;
|
||||
};
|
||||
|
||||
export function parseSizeChart(raw: unknown): Prisma.InputJsonValue | null {
|
||||
const table = parseTable(raw);
|
||||
if (!table) return null;
|
||||
const [header, ...body] = table;
|
||||
const columns = header.slice(1).map((item, index) => {
|
||||
const name = text(item.content)?.replace(/\s*\(cm\/in\)\s*/i, '') ?? `规格${index + 1}`;
|
||||
return { key: measurementKey(name, index + 1), name };
|
||||
});
|
||||
const rows = body
|
||||
.map((row, rowIndex) => {
|
||||
const sizeName = cell(row, 0);
|
||||
if (!sizeName) return null;
|
||||
return {
|
||||
sizeId: `size_${rowIndex}`,
|
||||
sizeName,
|
||||
measurements: columns.map((column, index) => {
|
||||
const cm = cell(row, index + 1);
|
||||
const cmNumber = Number(cm);
|
||||
return {
|
||||
key: column.key,
|
||||
cm: cm || null,
|
||||
in: Number.isFinite(cmNumber) ? (cmNumber / 2.54).toFixed(2) : null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
})
|
||||
.filter((row): row is NonNullable<typeof row> => row !== null);
|
||||
return { columns, rows } as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
function dimensions(value: string): { length: string; width: string; height: string } | null {
|
||||
const parts = value
|
||||
.replace(/[×x]/gi, '*')
|
||||
.split('*')
|
||||
.map((part) => part.trim());
|
||||
if (parts.length !== 3 || parts.some((part) => !Number.isFinite(Number(part)))) return null;
|
||||
return { length: parts[0], width: parts[1], height: parts[2] };
|
||||
}
|
||||
|
||||
export function parsePackageSpecs(raw: unknown): Prisma.InputJsonValue | null {
|
||||
const table = parseTable(raw);
|
||||
if (!table) return null;
|
||||
const rows = table.slice(1)
|
||||
.map((row, rowIndex) => {
|
||||
const sizeName = cell(row, 0);
|
||||
if (!sizeName) return null;
|
||||
return {
|
||||
sizeId: `size_${rowIndex}`,
|
||||
sizeName,
|
||||
dimensionsCm: dimensions(cell(row, 1)),
|
||||
dimensionsIn: dimensions(cell(row, 2)),
|
||||
volumeCm3: cell(row, 3) || null,
|
||||
volumeIn3: cell(row, 4) || null,
|
||||
grossWeightG: cell(row, 5) || null,
|
||||
grossWeightLb: cell(row, 6) || null,
|
||||
};
|
||||
})
|
||||
.filter((row): row is NonNullable<typeof row> => row !== null);
|
||||
return { rows } as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
function normalizeOptions(detail: SdsProductDetail): Prisma.InputJsonValue | null {
|
||||
const attributers = detail.subproducts?.attributers;
|
||||
if (!Array.isArray(attributers)) return null;
|
||||
const sizeMap = new Map<string, { id: string; name: string; sortOrder: number; enabled: boolean }>();
|
||||
const colorMap = new Map<string, { id: string; name: string; hex: string | null; sortOrder: number; enabled: boolean }>();
|
||||
attributers.forEach((attribute, sizeIndex) => {
|
||||
const sizeName = text(attribute.size);
|
||||
const sizeId = text(attribute.sizeId) ?? `size_${sizeIndex}`;
|
||||
if (sizeName) sizeMap.set(sizeId, { id: sizeId, name: sizeName, sortOrder: sizeIndex, enabled: true });
|
||||
if (Array.isArray(attribute.colors)) {
|
||||
attribute.colors.forEach((color, colorIndex) => {
|
||||
const colorId = text(color.colorId) ?? `color_${colorIndex}`;
|
||||
if (!colorMap.has(colorId)) {
|
||||
colorMap.set(colorId, {
|
||||
id: colorId,
|
||||
name: text(color.chineseName) ?? text(color.color_name) ?? colorId,
|
||||
hex: text(color.color),
|
||||
sortOrder: integer(color.colorSort) ?? colorIndex,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return { sizes: [...sizeMap.values()], colors: [...colorMap.values()] } as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
function normalizeMedia(detail: SdsProductDetail, variants: SdsProductVariant[]): Prisma.InputJsonValue | null {
|
||||
const urls: string[] = [];
|
||||
const add = (value: unknown) => {
|
||||
const url = text(value);
|
||||
if (url && !urls.includes(url)) urls.push(url);
|
||||
};
|
||||
add(detail.blankDesignUrl);
|
||||
add(detail.psd_img_url);
|
||||
add(detail.img_url);
|
||||
for (const variant of variants) {
|
||||
add(variant.psd_img_url);
|
||||
add(variant.img_url);
|
||||
add(variant.blankDesignUrl);
|
||||
for (const image of variant.designPrototype?.detailImgUrls ?? []) add(image.imageUrl);
|
||||
for (const image of variant.designPrototype?.prototypeResultGroups ?? []) add(image.resultImage);
|
||||
}
|
||||
if (urls.length === 0) return null;
|
||||
return {
|
||||
primaryImageUrl: urls[0],
|
||||
images: urls.map((url, index) => ({ id: `image_${index}`, url, sortOrder: index })),
|
||||
} as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
function normalizeVariant(variant: SdsProductVariant, index: number): NormalizedVariant | null {
|
||||
const sdsVariantId = text(variant.id);
|
||||
const sku = text(variant.sku);
|
||||
if (!sdsVariantId || !sku) return null;
|
||||
return {
|
||||
sdsVariantId,
|
||||
sku,
|
||||
sizeId: text(variant.sizeId) ?? text(variant.sizeDto?.id),
|
||||
sizeName: text(variant.size) ?? text(variant.sizeDto?.sizeName),
|
||||
colorId: text(variant.colorId) ?? text(variant.color?.colorId),
|
||||
colorName: text(variant.color?.chineseName) ?? text(variant.color_name) ?? text(variant.color?.color_name),
|
||||
colorHex: text(variant.color?.color),
|
||||
imageUrl: text(variant.psd_img_url) ?? text(variant.img_url) ?? text(variant.blankDesignUrl),
|
||||
price: decimal(variant.currentPrice ?? variant.unit_price ?? variant.min_price),
|
||||
originalPrice: decimal(variant.originalPrice),
|
||||
weightG: decimal(variant.weight),
|
||||
boxLengthCm: decimal(variant.box_length),
|
||||
boxWidthCm: decimal(variant.box_width),
|
||||
boxHeightCm: decimal(variant.box_height),
|
||||
enabled: Number(variant.status ?? 1) === 1 && String(variant.delFlag ?? '0') === '0',
|
||||
sortOrder: integer(variant.attribute_sort?.split('-')[0]) ?? integer(variant.size_sort) ?? index,
|
||||
designData: variant.designPrototype ? (variant.designPrototype as Prisma.InputJsonValue) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeProductDetail(detail: SdsProductDetail): NormalizedProductDetail {
|
||||
const productDetails = detail.product_details ?? {};
|
||||
const sourceVariants = Array.isArray(detail.subproducts?.items) ? detail.subproducts!.items! : [];
|
||||
const variants = sourceVariants
|
||||
.map(normalizeVariant)
|
||||
.filter((variant): variant is NormalizedVariant => variant !== null);
|
||||
const updatedAt = Number(detail.updateTime);
|
||||
return {
|
||||
productCode: text(detail.sku),
|
||||
englishName: text(detail.english_name),
|
||||
blankDesignUrl: text(detail.blankDesignUrl),
|
||||
detailsPageVideoUrl: text(detail.detailsPageVideoUrl),
|
||||
textureName: text(detail.texture?.name),
|
||||
productionCycleHours: integer(detail.productionCycle),
|
||||
minWeightG: decimal(detail.minWeight),
|
||||
reminder: text(productDetails.reminder),
|
||||
productionProcess: text(productDetails.production_process),
|
||||
materialDescription: text(productDetails.material_description),
|
||||
productPerformance: text(productDetails.product_performance),
|
||||
applicableScenarios: text(productDetails.applicable_scenarios),
|
||||
washingInstructions: text(productDetails.washing_instructions),
|
||||
specialDescription: text(productDetails.special_description),
|
||||
designExplanation: text(productDetails.design_explanation),
|
||||
designArea: text(productDetails.design_area),
|
||||
pictureRequest: text(productDetails.picture_request),
|
||||
sizeChart: parseSizeChart(productDetails.product_size),
|
||||
packageSpecs: parsePackageSpecs(productDetails.packaging_specification),
|
||||
options: normalizeOptions(detail),
|
||||
media: normalizeMedia(detail, sourceVariants),
|
||||
upstreamUpdatedAt: Number.isFinite(updatedAt) ? new Date(updatedAt) : null,
|
||||
variants,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
@@ -36,6 +37,18 @@ export class SyncController {
|
||||
return this.service.startProductSync();
|
||||
}
|
||||
|
||||
@Post('product-details')
|
||||
@ApiOperation({ summary: 'Manually sync details for all active origin products (async)' })
|
||||
async syncProductDetails() {
|
||||
return this.service.startProductDetailSync();
|
||||
}
|
||||
|
||||
@Post('products/:goodId/detail')
|
||||
@ApiOperation({ summary: 'Immediately sync one SDS product detail' })
|
||||
async syncOneProductDetail(@Param('goodId') goodId: string) {
|
||||
return this.service.syncOneProductDetail(goodId);
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
@ApiOperation({ summary: 'Recent sync log entries' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
|
||||
@@ -19,6 +19,7 @@ describe('SyncService', () => {
|
||||
const sdsMock: Partial<SdsClientService> = {
|
||||
fetchCategoryTree: jest.fn(),
|
||||
fetchProductsPage: jest.fn(),
|
||||
fetchProductDetail: jest.fn(async (goodId: string | number) => ({ id: goodId })),
|
||||
};
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ isGlobal: true })],
|
||||
@@ -29,6 +30,9 @@ describe('SyncService', () => {
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(SyncService);
|
||||
jest
|
||||
.spyOn(service, 'syncConfiguredProductDetails')
|
||||
.mockResolvedValue({ synced: 0, failed: 0 });
|
||||
sds = moduleRef.get(SdsClientService) as jest.Mocked<SdsClientService>;
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
@@ -272,3 +276,67 @@ describe('SyncService', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SyncService product detail scopes', () => {
|
||||
const originGoods = [
|
||||
{ id: 1n, sdsGoodId: 'all-1' },
|
||||
{ id: 2n, sdsGoodId: 'all-2' },
|
||||
];
|
||||
|
||||
function createService() {
|
||||
const prisma = {
|
||||
originGood: { findMany: jest.fn().mockResolvedValue(originGoods) },
|
||||
} as unknown as PrismaService;
|
||||
const sds = {
|
||||
fetchProductDetail: jest.fn(async (goodId: string) => ({ id: goodId })),
|
||||
} as unknown as SdsClientService;
|
||||
const scopedService = new SyncService(prisma, sds);
|
||||
jest
|
||||
.spyOn(scopedService as any, 'persistProductDetail')
|
||||
.mockResolvedValue(undefined);
|
||||
return { scopedService, prisma, sds };
|
||||
}
|
||||
|
||||
it('manual detail sync selects every active origin product', async () => {
|
||||
const { scopedService, prisma, sds } = createService();
|
||||
const result = await scopedService.syncAllProductDetails();
|
||||
|
||||
expect(prisma.originGood.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { delisted: false, source: 'SDS' } }),
|
||||
);
|
||||
expect(sds.fetchProductDetail).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual({ total: 2, synced: 2, failed: 0 });
|
||||
});
|
||||
|
||||
it('hourly detail refresh remains limited to configured products', async () => {
|
||||
const { scopedService, prisma } = createService();
|
||||
await scopedService.syncConfiguredProductDetails();
|
||||
|
||||
expect(prisma.originGood.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { delisted: false, source: 'SDS', goods: { some: {} } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps hourly category/product sync separate from the daily detail sync', async () => {
|
||||
const { scopedService } = createService();
|
||||
const categories = jest.spyOn(scopedService, 'syncCategories').mockResolvedValue({
|
||||
inserted: 0, updated: 0, total: 0, deletedStale: 0,
|
||||
});
|
||||
const products = jest.spyOn(scopedService, 'syncProducts').mockResolvedValue({
|
||||
inserted: 0, updated: 0, total: 0, leafCategories: 0, delisted: 0,
|
||||
});
|
||||
const details = jest.spyOn(scopedService, 'syncProductDetails').mockResolvedValue({
|
||||
total: 0, synced: 0, failed: 0,
|
||||
});
|
||||
|
||||
await scopedService.hourlyCron();
|
||||
expect(categories).toHaveBeenCalledTimes(1);
|
||||
expect(products).toHaveBeenCalledTimes(1);
|
||||
expect(details).not.toHaveBeenCalled();
|
||||
|
||||
await scopedService.dailyProductDetailCron();
|
||||
expect(details).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SdsClientService, SdsCategoryTreeNode, SdsProduct } from './sds-client.service';
|
||||
import {
|
||||
SdsClientService,
|
||||
SdsCategoryTreeNode,
|
||||
SdsProduct,
|
||||
SdsProductDetail,
|
||||
} from './sds-client.service';
|
||||
import { normalizeProductDetail } from './sds-product-detail.mapper';
|
||||
|
||||
export interface CategorySyncResult {
|
||||
inserted: number;
|
||||
@@ -59,7 +70,7 @@ export function shouldRunDelistDetection(leafCategories: number, seenGoods: numb
|
||||
@Injectable()
|
||||
export class SyncService {
|
||||
private readonly logger = new Logger(SyncService.name);
|
||||
private running = { categories: false, products: false };
|
||||
private running = { categories: false, products: false, details: false };
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@@ -102,8 +113,28 @@ export class SyncService {
|
||||
return { message: 'Product sync started' };
|
||||
}
|
||||
|
||||
/** Refresh all active SDS product details once per day at 03:30. */
|
||||
@Cron('0 30 3 * * *', { timeZone: 'Asia/Shanghai' })
|
||||
async dailyProductDetailCron(): Promise<void> {
|
||||
try {
|
||||
await this.syncProductDetails();
|
||||
} catch (err) {
|
||||
this.logger.error('Daily product detail sync failed', err as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async startProductDetailSync(): Promise<{ message: string }> {
|
||||
if (this.running.details) {
|
||||
return { message: 'Product detail sync already in progress' };
|
||||
}
|
||||
void this.syncProductDetails().catch((err) =>
|
||||
this.logger.error('Product detail sync failed', err as Error),
|
||||
);
|
||||
return { message: 'Product detail sync started' };
|
||||
}
|
||||
|
||||
/** Check if a sync type is currently running. */
|
||||
isRunning(type: 'categories' | 'products'): boolean {
|
||||
isRunning(type: 'categories' | 'products' | 'details'): boolean {
|
||||
return this.running[type];
|
||||
}
|
||||
|
||||
@@ -323,11 +354,19 @@ export class SyncService {
|
||||
const runDelist = shouldRunDelistDetection(leafRows.length, seenSdsGoodIds.size);
|
||||
if (runDelist) {
|
||||
const delistedResult = await this.prisma.originGood.updateMany({
|
||||
where: { sdsGoodId: { notIn: [...seenSdsGoodIds] }, delisted: false },
|
||||
where: {
|
||||
source: 'SDS',
|
||||
sdsGoodId: { notIn: [...seenSdsGoodIds] },
|
||||
delisted: false,
|
||||
},
|
||||
data: { delisted: true },
|
||||
});
|
||||
const reactivatedResult = await this.prisma.originGood.updateMany({
|
||||
where: { sdsGoodId: { in: [...seenSdsGoodIds] }, delisted: true },
|
||||
where: {
|
||||
source: 'SDS',
|
||||
sdsGoodId: { in: [...seenSdsGoodIds] },
|
||||
delisted: true,
|
||||
},
|
||||
data: { delisted: false },
|
||||
});
|
||||
delistedCount = delistedResult.count;
|
||||
@@ -347,7 +386,13 @@ export class SyncService {
|
||||
message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length}`,
|
||||
},
|
||||
});
|
||||
return { inserted, updated, total, leafCategories: leafRows.length, delisted: delistedCount };
|
||||
return {
|
||||
inserted,
|
||||
updated,
|
||||
total,
|
||||
leafCategories: leafRows.length,
|
||||
delisted: delistedCount,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await this.prisma.syncLog.update({
|
||||
@@ -371,6 +416,241 @@ export class SyncService {
|
||||
});
|
||||
}
|
||||
|
||||
async syncProductDetails(): Promise<{
|
||||
total: number;
|
||||
synced: number;
|
||||
failed: number;
|
||||
}> {
|
||||
if (this.running.details) {
|
||||
throw new Error('Product detail sync already in progress');
|
||||
}
|
||||
this.running.details = true;
|
||||
const log = await this.prisma.syncLog.create({
|
||||
data: { type: 'PRODUCT_DETAILS', status: 'RUNNING' },
|
||||
});
|
||||
try {
|
||||
const result = await this.syncAllProductDetails(async (progress) => {
|
||||
await this.prisma.syncLog.update({
|
||||
where: { id: log.id },
|
||||
data: {
|
||||
message: `processed=${progress.processed}/${progress.total} synced=${progress.synced} failed=${progress.failed}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
await this.prisma.syncLog.update({
|
||||
where: { id: log.id },
|
||||
data: {
|
||||
status: 'SUCCESS',
|
||||
finishedAt: new Date(),
|
||||
message: `total=${result.total} synced=${result.synced} failed=${result.failed}`,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await this.prisma.syncLog.update({
|
||||
where: { id: log.id },
|
||||
data: { status: 'FAILED', finishedAt: new Date(), message },
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
this.running.details = false;
|
||||
}
|
||||
}
|
||||
|
||||
async syncOneProductDetail(goodId: string): Promise<{
|
||||
goodId: string;
|
||||
variants: number;
|
||||
detailSyncedAt: string;
|
||||
}> {
|
||||
const originGood = await this.prisma.originGood.findUnique({
|
||||
where: { sdsGoodId: goodId },
|
||||
select: { id: true, source: true },
|
||||
});
|
||||
if (!originGood) {
|
||||
throw new NotFoundException(`SDS product ${goodId} not found locally`);
|
||||
}
|
||||
if (originGood.source !== 'SDS') {
|
||||
throw new BadRequestException('自定义商品不支持从 SDS 同步详情');
|
||||
}
|
||||
const upstream = await this.sds.fetchProductDetail(goodId);
|
||||
const normalized = normalizeProductDetail(upstream);
|
||||
await this.persistProductDetail(originGood.id, upstream);
|
||||
return {
|
||||
goodId,
|
||||
variants: normalized.variants.length,
|
||||
detailSyncedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
queueProductDetailSync(goodId: string): void {
|
||||
void this.syncOneProductDetail(goodId).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Queued detail sync failed for ${goodId}: ${message}`);
|
||||
});
|
||||
}
|
||||
|
||||
async syncConfiguredProductDetails(): Promise<{ synced: number; failed: number }> {
|
||||
const result = await this.syncMatchingProductDetails({
|
||||
delisted: false,
|
||||
source: 'SDS',
|
||||
goods: { some: {} },
|
||||
});
|
||||
return { synced: result.synced, failed: result.failed };
|
||||
}
|
||||
|
||||
async syncAllProductDetails(
|
||||
onProgress?: (progress: {
|
||||
processed: number;
|
||||
total: number;
|
||||
synced: number;
|
||||
failed: number;
|
||||
}) => Promise<void>,
|
||||
): Promise<{ total: number; synced: number; failed: number }> {
|
||||
return this.syncMatchingProductDetails(
|
||||
{ delisted: false, source: 'SDS' },
|
||||
onProgress,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
private async syncMatchingProductDetails(
|
||||
where: Prisma.OriginGoodWhereInput,
|
||||
onProgress?: (progress: {
|
||||
processed: number;
|
||||
total: number;
|
||||
synced: number;
|
||||
failed: number;
|
||||
}) => Promise<void>,
|
||||
attempts = 1,
|
||||
): Promise<{ total: number; synced: number; failed: number }> {
|
||||
const originGoods = await this.prisma.originGood.findMany({
|
||||
where,
|
||||
select: { id: true, sdsGoodId: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
let synced = 0;
|
||||
let failed = 0;
|
||||
let processed = 0;
|
||||
for (const originGood of originGoods) {
|
||||
let lastError: unknown;
|
||||
let succeeded = false;
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
try {
|
||||
const upstream = await this.sds.fetchProductDetail(originGood.sdsGoodId);
|
||||
await this.persistProductDetail(originGood.id, upstream);
|
||||
synced++;
|
||||
succeeded = true;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
if (!succeeded) {
|
||||
failed++;
|
||||
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
||||
this.logger.warn(`Failed to sync SDS detail ${originGood.sdsGoodId}: ${message}`);
|
||||
}
|
||||
processed++;
|
||||
if (onProgress && (processed % 10 === 0 || processed === originGoods.length)) {
|
||||
await onProgress({ processed, total: originGoods.length, synced, failed });
|
||||
}
|
||||
}
|
||||
return { total: originGoods.length, synced, failed };
|
||||
}
|
||||
|
||||
async importProductDetail(upstream: SdsProductDetail): Promise<{
|
||||
goodId: string;
|
||||
variants: number;
|
||||
sizeRows: number;
|
||||
packageRows: number;
|
||||
configuredGoods: number;
|
||||
}> {
|
||||
const goodId = String(upstream.id);
|
||||
const normalized = normalizeProductDetail(upstream);
|
||||
const originGood = await this.prisma.originGood.upsert({
|
||||
where: { sdsGoodId: goodId },
|
||||
create: {
|
||||
sdsGoodId: goodId,
|
||||
goodName: String(upstream.name ?? goodId),
|
||||
goodImage: String(upstream.psd_img_url ?? upstream.img_url ?? upstream.blankDesignUrl ?? '') || null,
|
||||
goodPrice:
|
||||
upstream.min_price === undefined || upstream.min_price === null
|
||||
? null
|
||||
: new Prisma.Decimal(Number(upstream.min_price)),
|
||||
},
|
||||
update: {
|
||||
goodName: upstream.name ? String(upstream.name) : undefined,
|
||||
goodImage: String(upstream.psd_img_url ?? upstream.img_url ?? upstream.blankDesignUrl ?? '') || undefined,
|
||||
goodPrice:
|
||||
upstream.min_price === undefined || upstream.min_price === null
|
||||
? undefined
|
||||
: new Prisma.Decimal(Number(upstream.min_price)),
|
||||
},
|
||||
});
|
||||
await this.persistProductDetail(originGood.id, upstream);
|
||||
const configuredGoods = await this.prisma.good.count({
|
||||
where: { originGoodId: originGood.id },
|
||||
});
|
||||
const sizeChart = normalized.sizeChart as { rows?: unknown[] } | null;
|
||||
const packageSpecs = normalized.packageSpecs as { rows?: unknown[] } | null;
|
||||
return {
|
||||
goodId,
|
||||
variants: normalized.variants.length,
|
||||
sizeRows: sizeChart?.rows?.length ?? 0,
|
||||
packageRows: packageSpecs?.rows?.length ?? 0,
|
||||
configuredGoods,
|
||||
};
|
||||
}
|
||||
|
||||
private async persistProductDetail(originGoodId: bigint, upstream: SdsProductDetail): Promise<void> {
|
||||
const normalized = normalizeProductDetail(upstream);
|
||||
const { variants, ...detail } = normalized;
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const json = (value: Prisma.InputJsonValue | null) => value ?? Prisma.DbNull;
|
||||
await tx.originGoodDetail.upsert({
|
||||
where: { originGoodId },
|
||||
create: {
|
||||
originGoodId,
|
||||
...detail,
|
||||
sizeChart: json(detail.sizeChart),
|
||||
packageSpecs: json(detail.packageSpecs),
|
||||
options: json(detail.options),
|
||||
media: json(detail.media),
|
||||
},
|
||||
update: {
|
||||
...detail,
|
||||
sizeChart: json(detail.sizeChart),
|
||||
packageSpecs: json(detail.packageSpecs),
|
||||
options: json(detail.options),
|
||||
media: json(detail.media),
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const seenVariantIds: string[] = [];
|
||||
for (const variant of variants) {
|
||||
seenVariantIds.push(variant.sdsVariantId);
|
||||
const { designData, ...data } = variant;
|
||||
await tx.originGoodVariant.upsert({
|
||||
where: {
|
||||
originGoodId_sdsVariantId: {
|
||||
originGoodId,
|
||||
sdsVariantId: variant.sdsVariantId,
|
||||
},
|
||||
},
|
||||
create: { originGoodId, ...data, designData: json(designData) },
|
||||
update: { ...data, designData: json(designData) },
|
||||
});
|
||||
}
|
||||
await tx.originGoodVariant.deleteMany({
|
||||
where: {
|
||||
originGoodId,
|
||||
...(seenVariantIds.length ? { sdsVariantId: { notIn: seenVariantIds } } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens the SDS nested tree into a list of `{ sdsId, parentSdsId?, name, icon? }`.
|
||||
*/
|
||||
|
||||
@@ -145,7 +145,7 @@ interface BackendTag {
|
||||
}
|
||||
|
||||
interface BackendProduct {
|
||||
id: string;
|
||||
goodId: string;
|
||||
goodName: string;
|
||||
goodPriority: number;
|
||||
country: { id: string; countryName: string; countryIcon: string | null };
|
||||
@@ -243,7 +243,7 @@ export function resolveDefaultProductFilters(
|
||||
|
||||
function mapProduct(p: BackendProduct, backendUrl: string): Product {
|
||||
return {
|
||||
id: p.id,
|
||||
id: p.goodId,
|
||||
name: p.goodName,
|
||||
priority: p.goodPriority,
|
||||
image: resolveBackendAssetUrl(p.image, backendUrl),
|
||||
@@ -316,7 +316,15 @@ export function useProductCenter() {
|
||||
if (query.countryId) params.countryId = query.countryId;
|
||||
if (query.categoryId) params.categoryId = query.categoryId;
|
||||
if (!options.ignoreTags && query.tagIds.length > 0) {
|
||||
params.tagIds = query.tagIds.join(',');
|
||||
const grouped = new Map<string, string[]>();
|
||||
for (const tagId of query.tagIds) {
|
||||
const groupId = tags.value.find((tag) => tag.id === tagId)?.group?.id;
|
||||
if (!groupId) continue;
|
||||
grouped.set(groupId, [...(grouped.get(groupId) ?? []), tagId]);
|
||||
}
|
||||
params.tags = JSON.stringify(
|
||||
[...grouped].map(([tagGroupId, tagIds]) => ({ tagGroupId, tagIds })),
|
||||
);
|
||||
}
|
||||
if (query.keyword?.trim()) params.keyword = query.keyword.trim();
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"dev:admin": "turbo run dev --filter=@inkreach/api --filter=@inkreach/admin",
|
||||
"dev:api": "pnpm --filter @inkreach/api dev",
|
||||
"build": "turbo run build",
|
||||
"lint": "turbo run lint",
|
||||
"format": "prettier --write \"**/*.{ts,js,json,md,vue}\"",
|
||||
|
||||
Reference in New Issue
Block a user