Compare commits

...
5 Commits
22 changed files with 1266 additions and 113 deletions
+10
View File
@@ -8,6 +8,8 @@ import type {
BatchPriorityRequest, BatchPriorityRequest,
GoodsFilter, GoodsFilter,
PaginatedResult, PaginatedResult,
CreateCustomGoodRequest,
UpdateCustomGoodContentRequest,
} from '@/types' } from '@/types'
export const goodsApi = { export const goodsApi = {
@@ -26,6 +28,14 @@ export const goodsApi = {
return request.post<any, Good>('/goods', data) 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 // Update good
updateGood: (id: string, data: UpdateGoodRequest) => { updateGood: (id: string, data: UpdateGoodRequest) => {
return request.patch<any, Good>(`/goods/${id}`, data) return request.patch<any, Good>(`/goods/${id}`, data)
+3
View File
@@ -11,6 +11,7 @@ export {}
/* prettier-ignore */ /* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside'] ElAside: typeof import('element-plus/es')['ElAside']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb'] ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem'] ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
@@ -33,6 +34,7 @@ declare module 'vue' {
ElImage: typeof import('element-plus/es')['ElImage'] ElImage: typeof import('element-plus/es')['ElImage']
ElImageViewer: typeof import('element-plus/es')['ElImageViewer'] ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMain: typeof import('element-plus/es')['ElMain'] ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
@@ -42,6 +44,7 @@ declare module 'vue' {
ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable'] ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane'] ElTabPane: typeof import('element-plus/es')['ElTabPane']
+66
View File
@@ -68,6 +68,49 @@ export interface OriginGoodVariant {
[key: string]: unknown [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 { export interface GoodDetail extends Good {
originDetail: OriginGoodDetail | null originDetail: OriginGoodDetail | null
variants: OriginGoodVariant[] variants: OriginGoodVariant[]
@@ -84,6 +127,27 @@ export interface CreateGoodRequest {
goodPriority?: number 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 { export interface UpdateGoodRequest {
goodName?: string goodName?: string
goodImage?: string | null goodImage?: string | null
@@ -271,6 +335,8 @@ export interface OriginGood {
goodImage: string | null goodImage: string | null
goodPrice: string | null goodPrice: string | null
sdsGoodId: string sdsGoodId: string
source: 'SDS' | 'CUSTOM'
isCustom: boolean
sdsCategoryId: string | null sdsCategoryId: string | null
delisted?: boolean delisted?: boolean
createdAt: string createdAt: string
+292 -37
View File
@@ -7,7 +7,7 @@ import {
FolderAdd, Aim, ArrowDown, FolderAdd, Aim, ArrowDown,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import type { import type {
CategoryTree, Country, Tag, TagGroup, Good, GoodDetail, Position, CategoryTree, Country, Tag, TagGroup, Good, GoodDetail,
OriginGoodsTreeResponse, OriginGoodsTreeResponse,
} from '@/types' } from '@/types'
import { goodsApi } from '@/api/goods' import { goodsApi } from '@/api/goods'
@@ -15,7 +15,6 @@ import { countriesApi } from '@/api/countries'
import { categoriesApi } from '@/api/categories' import { categoriesApi } from '@/api/categories'
import { tagsApi } from '@/api/tags' import { tagsApi } from '@/api/tags'
import { tagGroupsApi } from '@/api/tag-groups' import { tagGroupsApi } from '@/api/tag-groups'
import { positionsApi } from '@/api/positions'
import { originGoodsApi } from '@/api/origin-goods' import { originGoodsApi } from '@/api/origin-goods'
import { syncApi } from '@/api/sync' import { syncApi } from '@/api/sync'
@@ -170,7 +169,8 @@ function goodToNode(g: Good): any {
originGoodImage: g.originGood?.goodImage || null, originGoodImage: g.originGood?.goodImage || null,
originGoodPrice: g.originGood?.goodPrice || null, originGoodPrice: g.originGood?.goodPrice || null,
sdsGoodId: g.originGood?.sdsGoodId || 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)),
} }
} }
@@ -364,7 +364,6 @@ const configForm = ref({
countryId: '', cascaderCategory: [] as string[], categoryId: '', countryId: '', cascaderCategory: [] as string[], categoryId: '',
tagIds: [] as string[], positionId: '', goodImage: '', tagIds: [] as string[], positionId: '', goodImage: '',
}) })
const configPositions = ref<Position[]>([])
function openConfigModal(og: any, dropTarget: any) { function openConfigModal(og: any, dropTarget: any) {
configOG.value = og configOG.value = og
@@ -378,7 +377,6 @@ function openConfigModal(og: any, dropTarget: any) {
configForm.value.countryId = dropTarget.id configForm.value.countryId = dropTarget.id
} }
} }
loadConfigPositions()
configVisible.value = true configVisible.value = true
} }
@@ -392,19 +390,8 @@ function openConfigFromRightTree(data: any) {
}, null) }, 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[]) { function onConfigCascaderChange(val: string[]) {
configForm.value.categoryId = val.length ? val[val.length - 1] : '' configForm.value.categoryId = val.length ? val[val.length - 1] : ''
loadConfigPositions()
} }
async function handleConfigSubmit() { async function handleConfigSubmit() {
@@ -430,6 +417,53 @@ async function handleConfigSubmit() {
} finally { configLoading.value = false } } 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) ─── // ─── Edit Good (replaces detail — click opens edit directly) ───
const editVisible = ref(false) const editVisible = ref(false)
const editLoading = ref(false) const editLoading = ref(false)
@@ -455,6 +489,75 @@ const editSizeRows = computed(() => {
})) }))
}) })
const editPackageRows = computed(() => editOriginDetail.value?.packageSpecs?.rows ?? []) 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) { async function openEdit(g: Good) {
editGood.value = g editGood.value = g
@@ -470,7 +573,9 @@ async function openEdit(g: Good) {
editVisible.value = true editVisible.value = true
editDetailLoading.value = true editDetailLoading.value = true
try { try {
editGood.value = await goodsApi.getGoodById(g.id) const detail = await goodsApi.getGoodById(g.id)
editGood.value = detail
if (detail.originGood?.isCustom) fillCustomContent(detail)
} catch { } catch {
ElMessage.warning('商品详情加载失败,当前显示列表数据') ElMessage.warning('商品详情加载失败,当前显示列表数据')
} finally { } finally {
@@ -495,6 +600,7 @@ async function handleSyncOriginDetail(data: any) {
} }
async function handleSyncOneDetail() { async function handleSyncOneDetail() {
if (editIsCustom.value) return
const goodId = editGood.value?.originGood?.sdsGoodId const goodId = editGood.value?.originGood?.sdsGoodId
if (!goodId) return if (!goodId) return
detailSyncing.value = true detailSyncing.value = true
@@ -511,6 +617,73 @@ async function handleSyncOneDetail() {
} }
async function handleEditSubmit() { 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 editLoading.value = true
try { try {
await goodsApi.updateGood(editForm.value.id, { await goodsApi.updateGood(editForm.value.id, {
@@ -521,6 +694,7 @@ async function handleEditSubmit() {
tagIds: editForm.value.tagIds.map(Number), tagIds: editForm.value.tagIds.map(Number),
positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null, positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null,
} as any) } as any)
if (customPayload) await goodsApi.updateCustomGoodContent(editForm.value.id, customPayload)
ElMessage.success('更新成功') ElMessage.success('更新成功')
editVisible.value = false editVisible.value = false
refreshLeftTree() refreshLeftTree()
@@ -530,8 +704,9 @@ async function handleEditSubmit() {
} finally { editLoading.value = false } } finally { editLoading.value = false }
} }
function onEditCascaderChange(val: string[]) { function onEditCascaderChange(val: any) {
editForm.value.categoryId = val.length ? val[val.length - 1] : '' const path = Array.isArray(val) ? val : []
editForm.value.categoryId = path.length ? String(path[path.length - 1]) : ''
} }
async function handleDeleteGood(g: Good) { async function handleDeleteGood(g: Good) {
@@ -1264,6 +1439,7 @@ onMounted(() => loadAll())
<template #prefix><el-icon><Search /></el-icon></template> <template #prefix><el-icon><Search /></el-icon></template>
</el-input> </el-input>
<el-button size="small" type="primary" :icon="Search" @click="onSearch">搜索</el-button> <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" /> <div class="gv-filter-spacer" />
<el-radio-group v-model="mode" size="small" @change="onModeChange"> <el-radio-group v-model="mode" size="small" @change="onModeChange">
@@ -1343,7 +1519,7 @@ onMounted(() => loadAll())
</div> </div>
</div> </div>
<div v-if="data.originGoodName" class="gt-row"> <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 class="gt-val gt-val-ellipsis">{{ data.originGoodName }}</div>
</div> </div>
</div> </div>
@@ -1353,13 +1529,14 @@ onMounted(() => loadAll())
</el-tooltip> </el-tooltip>
</div> </div>
<span class="good-actions" @click.stop> <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 :icon="Edit" @click="openEdit(data.raw)" />
<el-button size="small" link type="danger" :icon="Delete" @click="handleDeleteGood(data.raw)" /> <el-button size="small" link type="danger" :icon="Delete" @click="handleDeleteGood(data.raw)" />
</span> </span>
</div> </div>
<div v-if="data.country || data.tags?.length || data.originDelisted" class="good-meta"> <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.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-if="data.country" class="good-country">{{ data.country }}</span>
<span <span
v-for="t in (data.tags || []).slice(0, 3)" :key="t.id" v-for="t in (data.tags || []).slice(0, 3)" :key="t.id"
@@ -1543,7 +1720,7 @@ onMounted(() => loadAll())
<el-form label-width="80px" style="margin-top: 16px"> <el-form label-width="80px" style="margin-top: 16px">
<el-form-item label="国家"> <el-form-item label="国家">
<div v-if="mode === 'category' || !configDropTarget" class="select-inline"> <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-option v-for="c in allCountries" :key="c.id" :label="c.countryName" :value="c.id" />
</el-select> </el-select>
<el-button text :icon="Plus" @click="quickCreateCountry(() => { const latest = allCountries.value[allCountries.value.length - 1]; if (latest) configForm.value.countryId = latest.id })" /> <el-button text :icon="Plus" @click="quickCreateCountry(() => { const latest = allCountries.value[allCountries.value.length - 1]; if (latest) configForm.value.countryId = latest.id })" />
@@ -1567,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) })" /> <el-button text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags.value[allTags.value.length - 1]; if (latest) configForm.value.tagIds.push(latest.id) })" />
</div> </div>
</el-form-item> </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> </el-form>
<template #footer> <template #footer>
<el-button @click="configVisible = false">取消</el-button> <el-button @click="configVisible = false">取消</el-button>
@@ -1579,15 +1751,50 @@ onMounted(() => loadAll())
</template> </template>
</el-dialog> </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) --> <!-- Edit Good Modal (direct edit no separate detail step) -->
<el-dialog v-model="editVisible" :title="editGood?.goodName || '编辑商品'" width="900px" destroy-on-close> <el-dialog v-model="editVisible" :title="editGood?.goodName || '编辑商品'" width="900px" destroy-on-close>
<!-- Origin product reference --> <!-- Origin product reference -->
<div v-if="editGood?.originGood" class="edit-og-ref"> <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" /> <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-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-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"> <div class="edit-og-status">
<el-tag :type="editGood.originGood.hasDetail ? 'success' : 'warning'" size="small"> <el-tag :type="editGood.originGood.hasDetail ? 'success' : 'warning'" size="small">
{{ editGood.originGood.hasDetail ? '详情已同步' : '详情未同步' }} {{ editGood.originGood.hasDetail ? '详情已同步' : '详情未同步' }}
@@ -1601,6 +1808,7 @@ onMounted(() => loadAll())
</div> </div>
</div> </div>
<el-button <el-button
v-if="!editIsCustom"
type="primary" type="primary"
plain plain
size="small" size="small"
@@ -1616,14 +1824,14 @@ onMounted(() => loadAll())
</el-form-item> </el-form-item>
<el-form-item label="国家"> <el-form-item label="国家">
<div class="select-inline"> <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-option v-for="c in allCountries" :key="c.id" :label="c.countryName" :value="c.id" />
</el-select> </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> </div>
</el-form-item> </el-form-item>
<el-form-item label="分类"> <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>
<el-form-item label="标签"> <el-form-item label="标签">
<div class="select-inline"> <div class="select-inline">
@@ -1632,14 +1840,38 @@ onMounted(() => loadAll())
<el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" /> <el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
</el-option-group> </el-option-group>
</el-select> </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> </div>
</el-form-item> </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-form>
<el-tabs v-if="editOriginDetail" class="detail-tabs"> <el-tabs v-if="editOriginDetail" class="detail-tabs">
<el-tab-pane label="商品详情"> <el-tab-pane label="商品详情">
<el-descriptions :column="2" border size="small"> <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.productCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="英文名称">{{ editOriginDetail.englishName || '-' }}</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.productionCycleHours ?? '-' }} 小时</el-descriptions-item>
@@ -1649,7 +1881,8 @@ onMounted(() => loadAll())
</el-descriptions> </el-descriptions>
</el-tab-pane> </el-tab-pane>
<el-tab-pane :label="`尺码表 (${editSizeRows.length})`"> <el-tab-pane :label="`尺码表 (${editSizeRows.length})`">
<el-table :data="editSizeRows" border max-height="320"> <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 prop="sizeName" label="尺码" width="100" fixed />
<el-table-column <el-table-column
v-for="column in editSizeColumns" v-for="column in editSizeColumns"
@@ -1661,7 +1894,8 @@ onMounted(() => loadAll())
</el-table> </el-table>
</el-tab-pane> </el-tab-pane>
<el-tab-pane :label="`包装规格 (${editPackageRows.length})`"> <el-tab-pane :label="`包装规格 (${editPackageRows.length})`">
<el-table :data="editPackageRows" border max-height="320"> <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 prop="sizeName" label="尺码" width="90" fixed />
<el-table-column label="包装尺寸 (cm)" min-width="160"> <el-table-column label="包装尺寸 (cm)" min-width="160">
<template #default="{ row }"> <template #default="{ row }">
@@ -1673,7 +1907,28 @@ onMounted(() => loadAll())
</el-table> </el-table>
</el-tab-pane> </el-tab-pane>
<el-tab-pane :label="`SKU (${editVariants.length})`"> <el-tab-pane :label="`SKU (${editVariants.length})`">
<el-table :data="editVariants" border max-height="320"> <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="sku" label="SKU" min-width="170" fixed />
<el-table-column prop="sizeName" label="尺码" width="90" /> <el-table-column prop="sizeName" label="尺码" width="90" />
<el-table-column prop="colorName" label="颜色" width="100" /> <el-table-column prop="colorName" label="颜色" width="100" />
+4 -4
View File
@@ -28,7 +28,7 @@ async function refreshLogs() {
function syncTypeLabel(type: SyncType): string { function syncTypeLabel(type: SyncType): string {
if (type === 'CATEGORIES') return '分类' if (type === 'CATEGORIES') return '分类'
if (type === 'PRODUCT_DETAILS') return '品详情' if (type === 'PRODUCT_DETAILS') return '全部原产品详情'
return '商品列表' return '商品列表'
} }
@@ -70,7 +70,7 @@ async function doSync(type: SyncType) {
const label = syncTypeLabel(type) const label = syncTypeLabel(type)
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
`确定立即执行${label}同步吗?${type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`, `确定立即执行${label}同步吗?${type === 'PRODUCT_DETAILS' ? '将同步全部有效原产品,耗时取决于原产品数量。' : type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`,
'确认', '确认',
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' } { type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
) )
@@ -136,7 +136,7 @@ onUnmounted(() => {
</div> </div>
<div> <div>
<h2 class="sync-action-title">数据同步</h2> <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> </div>
<div class="sync-action-buttons"> <div class="sync-action-buttons">
@@ -166,7 +166,7 @@ onUnmounted(() => {
:icon="Refresh" :icon="Refresh"
@click="handleSyncProductDetails" @click="handleSyncProductDetails"
> >
同步品详情 同步全部原产品详情
</el-button> </el-button>
</div> </div>
</div> </div>
@@ -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");
+7
View File
@@ -14,6 +14,11 @@ datasource db {
} }
// ---------- Origin Goods ---------- // ---------- Origin Goods ----------
enum OriginGoodSource {
SDS
CUSTOM
}
model OriginGood { model OriginGood {
id BigInt @id @default(autoincrement()) @map("origin_good_id") id BigInt @id @default(autoincrement()) @map("origin_good_id")
sdsGoodId String @unique @map("sds_good_id") sdsGoodId String @unique @map("sds_good_id")
@@ -23,6 +28,7 @@ model OriginGood {
goodImage String? @map("good_image") goodImage String? @map("good_image")
goodPrice Decimal? @map("good_price") @db.Decimal(12, 2) goodPrice Decimal? @map("good_price") @db.Decimal(12, 2)
delisted Boolean @default(false) delisted Boolean @default(false)
source OriginGoodSource @default(SDS)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
@@ -31,6 +37,7 @@ model OriginGood {
variants OriginGoodVariant[] variants OriginGoodVariant[]
@@index([sdsCategoryId]) @@index([sdsCategoryId])
@@index([source])
@@map("origin_goods") @@map("origin_goods")
} }
+236
View File
@@ -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),
) {}
+5
View File
@@ -9,6 +9,7 @@ export interface GoodRelations {
originGood?: { originGood?: {
id: bigint; id: bigint;
sdsGoodId: string; sdsGoodId: string;
source: 'SDS' | 'CUSTOM';
goodName: string | null; goodName: string | null;
goodImage: string | null; goodImage: string | null;
goodPrice: unknown; goodPrice: unknown;
@@ -87,6 +88,8 @@ export class GoodDto {
originGood?: { originGood?: {
id: string; id: string;
sdsGoodId: string; sdsGoodId: string;
source: 'SDS' | 'CUSTOM';
isCustom: boolean;
goodName: string | null; goodName: string | null;
goodImage: string | null; goodImage: string | null;
goodPrice: string | null; goodPrice: string | null;
@@ -154,6 +157,8 @@ export class GoodDto {
? { ? {
id: rel.originGood.id.toString(), id: rel.originGood.id.toString(),
sdsGoodId: rel.originGood.sdsGoodId, sdsGoodId: rel.originGood.sdsGoodId,
source: rel.originGood.source,
isCustom: rel.originGood.source === 'CUSTOM',
goodName: rel.originGood.goodName, goodName: rel.originGood.goodName,
goodImage: rel.originGood.goodImage, goodImage: rel.originGood.goodImage,
goodPrice: goodPrice:
+19
View File
@@ -22,6 +22,10 @@ import { UpdateGoodDto } from './dto/update-good.dto';
import { QueryGoodDto } from './dto/query-good.dto'; import { QueryGoodDto } from './dto/query-good.dto';
import { BatchCreateGoodDto } from './dto/batch-create-good.dto'; import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
import { BatchPriorityDto } from './dto/batch-priority.dto'; import { BatchPriorityDto } from './dto/batch-priority.dto';
import {
CreateCustomGoodDto,
UpdateCustomGoodContentDto,
} from './dto/custom-good.dto';
@ApiTags('goods') @ApiTags('goods')
@ApiBearerAuth() @ApiBearerAuth()
@@ -54,6 +58,21 @@ export class GoodsController {
return this.service.batchCreate(dto); 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') @Get(':id')
@ApiOperation({ summary: 'Get one good with relations' }) @ApiOperation({ summary: 'Get one good with relations' })
findOne(@Param('id', ParseIntPipe) id: string) { findOne(@Param('id', ParseIntPipe) id: string) {
+45
View File
@@ -119,6 +119,51 @@ describe('GoodsService', () => {
expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`); 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 () => { it('filters by countryId, tagId, positionId and keyword', async () => {
const result = await service.findAll({ const result = await service.findAll({
page: 1, page: 1,
+228 -5
View File
@@ -12,6 +12,13 @@ import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
import { BatchPriorityDto } from './dto/batch-priority.dto'; import { BatchPriorityDto } from './dto/batch-priority.dto';
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto'; import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
import { SyncService } from '../sync/sync.service'; import { SyncService } from '../sync/sync.service';
import { randomUUID } from 'crypto';
import {
CreateCustomGoodDto,
CustomGoodDetailDto,
CustomGoodVariantDto,
UpdateCustomGoodContentDto,
} from './dto/custom-good.dto';
const GOOD_INCLUDE = { const GOOD_INCLUDE = {
country: true, country: true,
@@ -126,12 +133,117 @@ export class GoodsService {
goodTags: result.goodTags, goodTags: result.goodTags,
}); });
}); });
if (result.originGood?.sdsGoodId && !result.originGood.hasDetail) { if (
result.originGood?.source === 'SDS' &&
result.originGood.sdsGoodId &&
!result.originGood.hasDetail
) {
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId); this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
} }
return result; 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> { async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> {
await this.findOne(id); await this.findOne(id);
const data: Prisma.GoodUpdateInput = {}; const data: Prisma.GoodUpdateInput = {};
@@ -188,15 +300,33 @@ export class GoodsService {
goodTags: updated.goodTags, goodTags: updated.goodTags,
}); });
}); });
if (result.originGood?.sdsGoodId && !result.originGood.hasDetail) { if (
result.originGood?.source === 'SDS' &&
result.originGood.sdsGoodId &&
!result.originGood.hasDetail
) {
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId); this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
} }
return result; return result;
} }
async remove(id: bigint): Promise<{ id: string }> { async remove(id: bigint): Promise<{ id: string }> {
await this.findOne(id); const good = await this.prisma.good.findUnique({
await this.prisma.good.delete({ where: { id } }); 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() }; return { id: id.toString() };
} }
@@ -275,7 +405,11 @@ export class GoodsService {
}); });
for (const goodId of new Set( for (const goodId of new Set(
result result
.filter((item) => !item.originGood?.hasDetail) .filter(
(item) =>
item.originGood?.source === 'SDS' &&
!item.originGood.hasDetail,
)
.map((item) => item.originGood?.sdsGoodId) .map((item) => item.originGood?.sdsGoodId)
.filter((id): id is string => Boolean(id)), .filter((id): id is string => Boolean(id)),
)) { )) {
@@ -341,4 +475,93 @@ export class GoodsService {
if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`); 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),
},
});
}
}
} }
@@ -67,9 +67,12 @@ export class OriginGoodsService {
async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> { async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> {
const { page, pageSize, keyword } = query; const { page, pageSize, keyword } = query;
const where: Prisma.OriginGoodWhereInput = keyword const where: Prisma.OriginGoodWhereInput = {
? { goodName: { contains: keyword, mode: 'insensitive' } } source: 'SDS',
: {}; ...(keyword
? { goodName: { contains: keyword, mode: 'insensitive' as const } }
: {}),
};
const [total, rows] = await this.prisma.$transaction([ const [total, rows] = await this.prisma.$transaction([
this.prisma.originGood.count({ where }), this.prisma.originGood.count({ where }),
@@ -124,7 +127,7 @@ export class OriginGoodsService {
}, },
}), }),
this.prisma.originGood.findMany({ this.prisma.originGood.findMany({
where: { delisted: false }, where: { delisted: false, source: 'SDS' },
orderBy: { goodName: 'asc' }, orderBy: { goodName: 'asc' },
include: { detail: true, _count: { select: { variants: true } } }, include: { detail: true, _count: { select: { variants: true } } },
}), }),
@@ -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,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiHideProperty, ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer'; import { plainToInstance, Transform, Type } from 'class-transformer';
import { import {
IsArray, IsArray,
IsIn, IsIn,
@@ -7,7 +7,8 @@ import {
IsNumberString, IsNumberString,
IsOptional, IsOptional,
IsString, IsString,
Matches, ValidateNested,
ArrayNotEmpty,
Max, Max,
Min, Min,
} from 'class-validator'; } from 'class-validator';
@@ -21,6 +22,32 @@ const stringList = ({ value }: { value: unknown }): string[] | undefined => {
.filter(Boolean); .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 { export class PublicQueryGoodDto {
@ApiProperty({ required: false, default: 1 }) @ApiProperty({ required: false, default: 1 })
@IsOptional() @IsOptional()
@@ -47,21 +74,12 @@ export class PublicQueryGoodDto {
@IsNumberString() @IsNumberString()
categoryId?: string; categoryId?: string;
@ApiProperty({ @ApiHideProperty()
required: false,
type: [String],
description:
'标签筛选项,格式为 tagGroupId:tagId;支持重复参数或逗号分隔。同组 OR,跨组 AND',
example: ['1:11', '1:12', '2:25'],
})
@IsOptional() @IsOptional()
@Transform(stringList) @Transform(tagFilters)
@IsArray() @IsArray()
@Matches(/^\d+:\d+$/, { @ValidateNested({ each: true })
each: true, tags?: PublicTagFilterDto[];
message: 'tagIds 每个元素必须为 tagGroupId:tagId',
})
tagIds?: string[];
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
+29 -1
View File
@@ -4,18 +4,28 @@ import {
Param, Param,
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; import {
ApiExtraModels,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
getSchemaPath,
} from '@nestjs/swagger';
import { PublicService } from './public.service'; import { PublicService } from './public.service';
import { import {
PublicCountryQueryDto, PublicCountryQueryDto,
PublicHomeGoodsQueryDto, PublicHomeGoodsQueryDto,
PublicQueryGoodDto, PublicQueryGoodDto,
PublicTagFilterDto,
} from './dto/public-query-good.dto'; } from './dto/public-query-good.dto';
import { PublicTagDto } from './dto/public-tag.dto'; import { PublicTagDto } from './dto/public-tag.dto';
import { PublicGoodDetailDto, PublicTagGroupFilterDto } from './dto/public-good-detail.dto'; import { PublicGoodDetailDto, PublicTagGroupFilterDto } from './dto/public-good-detail.dto';
import { PublicGoodDto } from './dto/public-good.dto'; import { PublicGoodDto } from './dto/public-good.dto';
@ApiTags('public') @ApiTags('public')
@ApiExtraModels(PublicTagFilterDto)
@Controller('public') @Controller('public')
export class PublicController { export class PublicController {
constructor(private readonly service: PublicService) {} constructor(private readonly service: PublicService) {}
@@ -47,6 +57,24 @@ export class PublicController {
@Get('goods') @Get('goods')
@ApiOperation({ summary: '分页获取商品' }) @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) { getGoods(@Query() query: PublicQueryGoodDto) {
return this.service.getGoods(query); return this.service.getGoods(query);
} }
+52 -8
View File
@@ -247,9 +247,12 @@ describe('PublicService', () => {
pageSize: 50, pageSize: 50,
countryId: countryId.toString(), countryId: countryId.toString(),
keyword: `Pub `, keyword: `Pub `,
tagIds: filterTagIds tags: [
.slice(0, 2) {
.map((tagId) => `${filterGroupIds[0]}:${tagId}`), tagGroupId: filterGroupIds[0].toString(),
tagIds: filterTagIds.slice(0, 2).map(String),
},
],
}); });
expect(sameGroup.items.map((item) => item.goodName)).toEqual( expect(sameGroup.items.map((item) => item.goodName)).toEqual(
expect.arrayContaining([`Pub High ${stamp}`, `Pub Mid ${stamp}`]), expect.arrayContaining([`Pub High ${stamp}`, `Pub Mid ${stamp}`]),
@@ -260,10 +263,16 @@ describe('PublicService', () => {
pageSize: 50, pageSize: 50,
countryId: countryId.toString(), countryId: countryId.toString(),
keyword: `Pub `, keyword: `Pub `,
tagIds: filterTagIds.map( tags: [
(tagId, index) => {
`${index < 2 ? filterGroupIds[0] : filterGroupIds[1]}:${tagId}`, 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)).toContain(`Pub High ${stamp}`);
expect(acrossGroups.items.map((item) => item.goodName)).not.toContain(`Pub Mid ${stamp}`); expect(acrossGroups.items.map((item) => item.goodName)).not.toContain(`Pub Mid ${stamp}`);
@@ -274,7 +283,12 @@ describe('PublicService', () => {
service.getGoods({ service.getGoods({
page: 1, page: 1,
pageSize: 20, pageSize: 20,
tagIds: [`${filterGroupIds[1]}:${filterTagIds[0]}`], tags: [
{
tagGroupId: filterGroupIds[1].toString(),
tagIds: [filterTagIds[0].toString()],
},
],
}), }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
}); });
@@ -292,6 +306,36 @@ describe('PublicService', () => {
expect(result.items[0].goodId).not.toBe(goodIds[0].toString()); 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 () => { it('getGood returns detail and 404 for unknown id', async () => {
const first = await service.getGoods({ const first = await service.getGoods({
page: 1, page: 1,
+12 -9
View File
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { import {
PublicHomeGoodsQueryDto, PublicHomeGoodsQueryDto,
PublicQueryGoodDto, PublicQueryGoodDto,
PublicTagFilterDto,
} from './dto/public-query-good.dto'; } from './dto/public-query-good.dto';
import { PublicCategoryNodeDto } from './dto/public-category.dto'; import { PublicCategoryNodeDto } from './dto/public-category.dto';
import { PublicCountryDto } from './dto/public-country.dto'; import { PublicCountryDto } from './dto/public-country.dto';
@@ -145,9 +146,7 @@ export class PublicService {
where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) }; where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) };
} }
const tagFilters = await this.buildTagGroupFilters([ const tagFilters = await this.buildTagGroupFilters(query.tags ?? []);
...new Set(query.tagIds ?? []),
]);
if (tagFilters.length) where.AND = tagFilters; if (tagFilters.length) where.AND = tagFilters;
const minPrice = this.parsePrice(query.minPrice, 'minPrice'); const minPrice = this.parsePrice(query.minPrice, 'minPrice');
@@ -319,16 +318,20 @@ export class PublicService {
} }
private async buildTagGroupFilters( private async buildTagGroupFilters(
selectedTagValues: string[], selectedGroups: PublicTagFilterDto[],
): Promise<Prisma.GoodWhereInput[]> { ): Promise<Prisma.GoodWhereInput[]> {
const selections = selectedTagValues.map((value) => { const selections = selectedGroups.flatMap((group) => {
const [tagGroupId, tagId] = value.split(':'); if (!/^\d+$/.test(group.tagGroupId) || !Array.isArray(group.tagIds)) {
if (!tagGroupId || !tagId || !/^\d+$/.test(tagGroupId) || !/^\d+$/.test(tagId)) {
throw new BadRequestException( throw new BadRequestException(
'tagIds 每个元素必须 tagGroupId:tagId', 'tags 每个元素必须包含合法的 tagGroupIdtagIds',
); );
} }
return { tagGroupId, tagId }; 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 uniqueTagIds = [...new Set(selections.map((item) => item.tagId))];
const selected = uniqueTagIds.length const selected = uniqueTagIds.length
+1 -1
View File
@@ -38,7 +38,7 @@ export class SyncController {
} }
@Post('product-details') @Post('product-details')
@ApiOperation({ summary: 'Manually sync details for all configured products (async)' }) @ApiOperation({ summary: 'Manually sync details for all active origin products (async)' })
async syncProductDetails() { async syncProductDetails() {
return this.service.startProductDetailSync(); return this.service.startProductDetailSync();
} }
+64
View File
@@ -276,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);
});
});
+102 -27
View File
@@ -1,4 +1,9 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -23,8 +28,6 @@ export interface ProductSyncResult {
total: number; total: number;
leafCategories: number; leafCategories: number;
delisted: number; delisted: number;
detailsSynced: number;
detailFailures: number;
} }
/** /**
@@ -110,6 +113,16 @@ export class SyncService {
return { message: 'Product sync started' }; 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 }> { async startProductDetailSync(): Promise<{ message: string }> {
if (this.running.details) { if (this.running.details) {
return { message: 'Product detail sync already in progress' }; return { message: 'Product detail sync already in progress' };
@@ -341,11 +354,19 @@ export class SyncService {
const runDelist = shouldRunDelistDetection(leafRows.length, seenSdsGoodIds.size); const runDelist = shouldRunDelistDetection(leafRows.length, seenSdsGoodIds.size);
if (runDelist) { if (runDelist) {
const delistedResult = await this.prisma.originGood.updateMany({ const delistedResult = await this.prisma.originGood.updateMany({
where: { sdsGoodId: { notIn: [...seenSdsGoodIds] }, delisted: false }, where: {
source: 'SDS',
sdsGoodId: { notIn: [...seenSdsGoodIds] },
delisted: false,
},
data: { delisted: true }, data: { delisted: true },
}); });
const reactivatedResult = await this.prisma.originGood.updateMany({ const reactivatedResult = await this.prisma.originGood.updateMany({
where: { sdsGoodId: { in: [...seenSdsGoodIds] }, delisted: true }, where: {
source: 'SDS',
sdsGoodId: { in: [...seenSdsGoodIds] },
delisted: true,
},
data: { delisted: false }, data: { delisted: false },
}); });
delistedCount = delistedResult.count; delistedCount = delistedResult.count;
@@ -357,17 +378,12 @@ export class SyncService {
); );
} }
// Only hydrate full details for products selected in the website catalog.
// This keeps the hourly sync bounded and preserves the existing Good/tag
// merchandising model. A failed detail request never erases cached data.
const detailResult = await this.syncConfiguredProductDetails();
await this.prisma.syncLog.update({ await this.prisma.syncLog.update({
where: { id: log.id }, where: { id: log.id },
data: { data: {
status: 'SUCCESS', status: 'SUCCESS',
finishedAt: new Date(), finishedAt: new Date(),
message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length} detailsSynced=${detailResult.synced} detailFailures=${detailResult.failed}`, message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length}`,
}, },
}); });
return { return {
@@ -376,8 +392,6 @@ export class SyncService {
total, total,
leafCategories: leafRows.length, leafCategories: leafRows.length,
delisted: delistedCount, delisted: delistedCount,
detailsSynced: detailResult.synced,
detailFailures: detailResult.failed,
}; };
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
@@ -402,7 +416,11 @@ export class SyncService {
}); });
} }
async syncProductDetails(): Promise<{ synced: number; failed: number }> { async syncProductDetails(): Promise<{
total: number;
synced: number;
failed: number;
}> {
if (this.running.details) { if (this.running.details) {
throw new Error('Product detail sync already in progress'); throw new Error('Product detail sync already in progress');
} }
@@ -411,13 +429,20 @@ export class SyncService {
data: { type: 'PRODUCT_DETAILS', status: 'RUNNING' }, data: { type: 'PRODUCT_DETAILS', status: 'RUNNING' },
}); });
try { try {
const result = await this.syncConfiguredProductDetails(); 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({ await this.prisma.syncLog.update({
where: { id: log.id }, where: { id: log.id },
data: { data: {
status: 'SUCCESS', status: 'SUCCESS',
finishedAt: new Date(), finishedAt: new Date(),
message: `synced=${result.synced} failed=${result.failed}`, message: `total=${result.total} synced=${result.synced} failed=${result.failed}`,
}, },
}); });
return result; return result;
@@ -440,11 +465,14 @@ export class SyncService {
}> { }> {
const originGood = await this.prisma.originGood.findUnique({ const originGood = await this.prisma.originGood.findUnique({
where: { sdsGoodId: goodId }, where: { sdsGoodId: goodId },
select: { id: true }, select: { id: true, source: true },
}); });
if (!originGood) { if (!originGood) {
throw new NotFoundException(`SDS product ${goodId} not found locally`); 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 upstream = await this.sds.fetchProductDetail(goodId);
const normalized = normalizeProductDetail(upstream); const normalized = normalizeProductDetail(upstream);
await this.persistProductDetail(originGood.id, upstream); await this.persistProductDetail(originGood.id, upstream);
@@ -463,25 +491,72 @@ export class SyncService {
} }
async syncConfiguredProductDetails(): Promise<{ synced: number; failed: number }> { async syncConfiguredProductDetails(): Promise<{ synced: number; failed: number }> {
const configured = await this.prisma.originGood.findMany({ const result = await this.syncMatchingProductDetails({
where: { delisted: false, goods: { some: {} } }, 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 }, select: { id: true, sdsGoodId: true },
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
}); });
let synced = 0; let synced = 0;
let failed = 0; let failed = 0;
for (const originGood of configured) { let processed = 0;
try { for (const originGood of originGoods) {
const upstream = await this.sds.fetchProductDetail(originGood.sdsGoodId); let lastError: unknown;
await this.persistProductDetail(originGood.id, upstream); let succeeded = false;
synced++; for (let attempt = 1; attempt <= attempts; attempt++) {
} catch (error) { 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++; failed++;
const message = error instanceof Error ? error.message : String(error); const message = lastError instanceof Error ? lastError.message : String(lastError);
this.logger.warn(`Failed to sync SDS detail ${originGood.sdsGoodId}: ${message}`); 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 { synced, failed }; return { total: originGoods.length, synced, failed };
} }
async importProductDetail(upstream: SdsProductDetail): Promise<{ async importProductDetail(upstream: SdsProductDetail): Promise<{
@@ -316,7 +316,15 @@ export function useProductCenter() {
if (query.countryId) params.countryId = query.countryId; if (query.countryId) params.countryId = query.countryId;
if (query.categoryId) params.categoryId = query.categoryId; if (query.categoryId) params.categoryId = query.categoryId;
if (!options.ignoreTags && query.tagIds.length > 0) { 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(); if (query.keyword?.trim()) params.keyword = query.keyword.trim();