feat(admin): manage and sync product details
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import request from './request'
|
import request from './request'
|
||||||
import type {
|
import type {
|
||||||
Good,
|
Good,
|
||||||
|
GoodDetail,
|
||||||
CreateGoodRequest,
|
CreateGoodRequest,
|
||||||
UpdateGoodRequest,
|
UpdateGoodRequest,
|
||||||
BatchCreateGoodsRequest,
|
BatchCreateGoodsRequest,
|
||||||
@@ -17,7 +18,7 @@ export const goodsApi = {
|
|||||||
|
|
||||||
// Get good by id
|
// Get good by id
|
||||||
getGoodById: (id: string) => {
|
getGoodById: (id: string) => {
|
||||||
return request.get<any, Good>(`/goods/${id}`)
|
return request.get<any, GoodDetail>(`/goods/${id}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create good
|
// Create good
|
||||||
@@ -44,4 +45,4 @@ export const goodsApi = {
|
|||||||
batchUpdatePriority: (data: BatchPriorityRequest) => {
|
batchUpdatePriority: (data: BatchPriorityRequest) => {
|
||||||
return request.patch<any, { count: number }>('/goods/batch-priority', data)
|
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')
|
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) => {
|
getSyncStatus: (limit?: number) => {
|
||||||
return request.get<any, SyncLog[]>('/sync/status', {
|
return request.get<any, SyncLog[]>('/sync/status', {
|
||||||
params: limit ? { limit } : undefined,
|
params: limit ? { limit } : undefined,
|
||||||
|
|||||||
@@ -44,6 +44,35 @@ export interface Good {
|
|||||||
updatedAt: string
|
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 GoodDetail extends Good {
|
||||||
|
originDetail: OriginGoodDetail | null
|
||||||
|
variants: OriginGoodVariant[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateGoodRequest {
|
export interface CreateGoodRequest {
|
||||||
goodName: string
|
goodName: string
|
||||||
goodImage?: string
|
goodImage?: string
|
||||||
@@ -246,6 +275,12 @@ export interface OriginGood {
|
|||||||
delisted?: boolean
|
delisted?: boolean
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
hasDetail?: boolean
|
||||||
|
detailSyncedAt?: string | null
|
||||||
|
variantCount?: number
|
||||||
|
sizeRowCount?: number
|
||||||
|
packageRowCount?: number
|
||||||
|
productCode?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Origin Goods Tree types
|
// Origin Goods Tree types
|
||||||
@@ -259,6 +294,11 @@ export interface OriginGoodsTreeNode {
|
|||||||
configuredCount: number
|
configuredCount: number
|
||||||
configuredCountries: string[]
|
configuredCountries: string[]
|
||||||
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]
|
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 {
|
export interface OriginGoodsTreeCategoryNode {
|
||||||
@@ -280,13 +320,11 @@ export interface OriginGoodsTreeResponse {
|
|||||||
// Sync types
|
// Sync types
|
||||||
export interface SyncLog {
|
export interface SyncLog {
|
||||||
id: string
|
id: string
|
||||||
type: 'CATEGORY' | 'PRODUCT'
|
type: 'CATEGORIES' | 'PRODUCTS' | 'PRODUCT_DETAILS'
|
||||||
status: 'SUCCESS' | 'FAILED'
|
status: 'RUNNING' | 'SUCCESS' | 'FAILED'
|
||||||
message?: string
|
message: string | null
|
||||||
startTime: string
|
startedAt: string
|
||||||
endTime?: string
|
finishedAt: string | null
|
||||||
errorCount?: number
|
|
||||||
createdAt: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter types
|
// Filter types
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
|||||||
import { useVirtualList } from '@vueuse/core'
|
import { useVirtualList } from '@vueuse/core'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
Plus, Edit, Delete, Search, Top,
|
Plus, Edit, Delete, Search, Top, Refresh,
|
||||||
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, Position,
|
CategoryTree, Country, Tag, TagGroup, Good, GoodDetail, Position,
|
||||||
OriginGoodsTreeResponse,
|
OriginGoodsTreeResponse,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
import { goodsApi } from '@/api/goods'
|
import { goodsApi } from '@/api/goods'
|
||||||
@@ -17,6 +17,7 @@ import { tagsApi } from '@/api/tags'
|
|||||||
import { tagGroupsApi } from '@/api/tag-groups'
|
import { tagGroupsApi } from '@/api/tag-groups'
|
||||||
import { positionsApi } from '@/api/positions'
|
import { positionsApi } from '@/api/positions'
|
||||||
import { originGoodsApi } from '@/api/origin-goods'
|
import { originGoodsApi } from '@/api/origin-goods'
|
||||||
|
import { syncApi } from '@/api/sync'
|
||||||
|
|
||||||
const mode = ref<'category' | 'country' | 'global'>('category')
|
const mode = ref<'category' | 'country' | 'global'>('category')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -53,6 +54,7 @@ const leftPct = ref(55)
|
|||||||
const showAllLeft = ref(false)
|
const showAllLeft = ref(false)
|
||||||
const showAllRight = ref(false)
|
const showAllRight = ref(false)
|
||||||
const showUnconfiguredOnly = ref(false)
|
const showUnconfiguredOnly = ref(false)
|
||||||
|
const syncingOriginGoodIds = ref(new Set<string>())
|
||||||
|
|
||||||
function onSplitterMouseDown(e: MouseEvent) {
|
function onSplitterMouseDown(e: MouseEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -264,6 +266,11 @@ function buildRightTree(tree: OriginGoodsTreeResponse) {
|
|||||||
sdsGoodId: og.sdsGoodId,
|
sdsGoodId: og.sdsGoodId,
|
||||||
configuredCount: og.configuredCount ?? 0,
|
configuredCount: og.configuredCount ?? 0,
|
||||||
configuredCountries: og.configuredCountries ?? [],
|
configuredCountries: og.configuredCountries ?? [],
|
||||||
|
hasDetail: Boolean(og.hasDetail),
|
||||||
|
detailSyncedAt: og.detailSyncedAt ?? null,
|
||||||
|
variantCount: og.variantCount ?? 0,
|
||||||
|
sizeRowCount: og.sizeRowCount ?? 0,
|
||||||
|
packageRowCount: og.packageRowCount ?? 0,
|
||||||
}))
|
}))
|
||||||
return {
|
return {
|
||||||
id: 'rc-' + node.categoryId,
|
id: 'rc-' + node.categoryId,
|
||||||
@@ -426,13 +433,30 @@ async function handleConfigSubmit() {
|
|||||||
// ─── 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)
|
||||||
const editGood = ref<Good | null>(null)
|
const editDetailLoading = ref(false)
|
||||||
|
const detailSyncing = ref(false)
|
||||||
|
const editGood = ref<Good | GoodDetail | null>(null)
|
||||||
const editForm = ref({
|
const editForm = ref({
|
||||||
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
|
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
|
||||||
categoryId: '', tagIds: [] as string[], positionId: '',
|
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 ?? [])
|
||||||
|
|
||||||
|
async function openEdit(g: Good) {
|
||||||
editGood.value = g
|
editGood.value = g
|
||||||
editForm.value = {
|
editForm.value = {
|
||||||
id: g.id, goodName: g.goodName,
|
id: g.id, goodName: g.goodName,
|
||||||
@@ -444,6 +468,46 @@ function openEdit(g: Good) {
|
|||||||
positionId: g.positionId || '',
|
positionId: g.positionId || '',
|
||||||
}
|
}
|
||||||
editVisible.value = true
|
editVisible.value = true
|
||||||
|
editDetailLoading.value = true
|
||||||
|
try {
|
||||||
|
editGood.value = await goodsApi.getGoodById(g.id)
|
||||||
|
} 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() {
|
||||||
|
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() {
|
async function handleEditSubmit() {
|
||||||
@@ -1351,7 +1415,22 @@ onMounted(() => loadAll())
|
|||||||
v-else
|
v-else
|
||||||
class="og-badge og-badge--warn"
|
class="og-badge og-badge--warn"
|
||||||
>未配置</span>
|
>未配置</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>
|
<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
|
<el-button
|
||||||
v-if="data.configuredCount > 0"
|
v-if="data.configuredCount > 0"
|
||||||
size="small" link :icon="Aim" title="定位到官网商品"
|
size="small" link :icon="Aim" title="定位到官网商品"
|
||||||
@@ -1501,7 +1580,7 @@ onMounted(() => loadAll())
|
|||||||
</el-dialog>
|
</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="560px" 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" />
|
||||||
@@ -1509,9 +1588,28 @@ onMounted(() => loadAll())
|
|||||||
<div class="edit-og-label">关联原产品</div>
|
<div class="edit-og-label">关联原产品</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">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>
|
</div>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
size="small"
|
||||||
|
:icon="Refresh"
|
||||||
|
:loading="detailSyncing"
|
||||||
|
@click="handleSyncOneDetail"
|
||||||
|
>同步详情</el-button>
|
||||||
</div>
|
</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="名称"><el-input v-model="editForm.goodName" /></el-form-item>
|
||||||
<el-form-item label="图片">
|
<el-form-item label="图片">
|
||||||
<ImageUpload v-model="editForm.goodImage" label="上传图片" />
|
<ImageUpload v-model="editForm.goodImage" label="上传图片" />
|
||||||
@@ -1538,6 +1636,57 @@ onMounted(() => loadAll())
|
|||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
|
<el-tabs v-if="editOriginDetail" class="detail-tabs">
|
||||||
|
<el-tab-pane label="商品详情">
|
||||||
|
<el-descriptions :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-table :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-table :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})`">
|
||||||
|
<el-table :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>
|
<template #footer>
|
||||||
<el-button type="danger" @click="editGood && handleDeleteGood(editGood)">删除</el-button>
|
<el-button type="danger" @click="editGood && handleDeleteGood(editGood)">删除</el-button>
|
||||||
<el-button @click="editVisible = false">取消</el-button>
|
<el-button @click="editVisible = false">取消</el-button>
|
||||||
@@ -1826,6 +1975,8 @@ onMounted(() => loadAll())
|
|||||||
.og-badge--warn {
|
.og-badge--warn {
|
||||||
color: #ff6800; background: #fff2e8;
|
color: #ff6800; background: #fff2e8;
|
||||||
}
|
}
|
||||||
|
.og-badge--detail { color: #337ecc; background: #ecf5ff; }
|
||||||
|
.og-badge--missing { color: #909399; background: #f4f4f5; }
|
||||||
.og-config-btn {
|
.og-config-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -1852,6 +2003,9 @@ onMounted(() => loadAll())
|
|||||||
.edit-og-label { font-size: 11px; color: #909399; text-transform: uppercase; letter-spacing: 0.5px; }
|
.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-name { font-weight: 600; font-size: 14px; margin-top: 2px; }
|
||||||
.edit-og-sub { color: #909399; font-size: 12px; 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 modal */
|
||||||
.config-og-info { padding: 12px; background: #f5f7fa; border-radius: 8px; }
|
.config-og-info { padding: 12px; background: #f5f7fa; border-radius: 8px; }
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { syncApi } from '@/api/sync'
|
|||||||
const logs = ref<SyncLog[]>([])
|
const logs = ref<SyncLog[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const syncing = 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 timer: ReturnType<typeof setInterval> | null = null
|
||||||
let pollTimer: 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)
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
pollTimer = setInterval(async () => {
|
pollTimer = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -37,9 +44,9 @@ async function pollUntilDone(type: 'PRODUCTS' | 'CATEGORIES') {
|
|||||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||||
syncing.value = false
|
syncing.value = false
|
||||||
if (top.status === 'SUCCESS') {
|
if (top.status === 'SUCCESS') {
|
||||||
ElMessage.success(`${type === 'PRODUCTS' ? '产品' : '分类'}同步完成`)
|
ElMessage.success(`${syncTypeLabel(type)}同步完成`)
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(`${type === 'PRODUCTS' ? '产品' : '分类'}同步失败`)
|
ElMessage.error(`${syncTypeLabel(type)}同步失败`)
|
||||||
}
|
}
|
||||||
await refreshLogs()
|
await refreshLogs()
|
||||||
}
|
}
|
||||||
@@ -55,11 +62,15 @@ async function handleSyncCategories() {
|
|||||||
await doSync('CATEGORIES')
|
await doSync('CATEGORIES')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doSync(type: 'PRODUCTS' | 'CATEGORIES') {
|
async function handleSyncProductDetails() {
|
||||||
const label = type === 'PRODUCTS' ? '产品' : '分类'
|
await doSync('PRODUCT_DETAILS')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSync(type: SyncType) {
|
||||||
|
const label = syncTypeLabel(type)
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`确定立即执行${label}同步吗?${type === 'PRODUCTS' ? '此操作可能需要几分钟。' : ''}`,
|
`确定立即执行${label}同步吗?${type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`,
|
||||||
'确认',
|
'确认',
|
||||||
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
|
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
|
||||||
)
|
)
|
||||||
@@ -70,6 +81,8 @@ async function doSync(type: 'PRODUCTS' | 'CATEGORIES') {
|
|||||||
try {
|
try {
|
||||||
if (type === 'PRODUCTS') {
|
if (type === 'PRODUCTS') {
|
||||||
await syncApi.syncProducts()
|
await syncApi.syncProducts()
|
||||||
|
} else if (type === 'PRODUCT_DETAILS') {
|
||||||
|
await syncApi.syncProductDetails()
|
||||||
} else {
|
} else {
|
||||||
await syncApi.syncCategories()
|
await syncApi.syncCategories()
|
||||||
}
|
}
|
||||||
@@ -97,7 +110,7 @@ function formatDuration(start?: string, end?: string): string | null {
|
|||||||
const stats = computed(() => {
|
const stats = computed(() => {
|
||||||
const total = logs.value.length
|
const total = logs.value.length
|
||||||
const success = logs.value.filter(l => l.status === 'SUCCESS').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]
|
const lastLog = logs.value[0]
|
||||||
return { total, success, failed, lastLog }
|
return { total, success, failed, lastLog }
|
||||||
})
|
})
|
||||||
@@ -145,6 +158,16 @@ onUnmounted(() => {
|
|||||||
>
|
>
|
||||||
同步产品
|
同步产品
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="success"
|
||||||
|
size="large"
|
||||||
|
:loading="syncing && currentType === 'PRODUCT_DETAILS'"
|
||||||
|
:disabled="syncing"
|
||||||
|
:icon="Refresh"
|
||||||
|
@click="handleSyncProductDetails"
|
||||||
|
>
|
||||||
|
同步商品详情
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -166,7 +189,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="stat-divider" />
|
<div class="stat-divider" />
|
||||||
<div class="stat-item">
|
<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>
|
<span class="stat-label">最近同步</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,17 +220,17 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="timeline-content">
|
<div class="timeline-content">
|
||||||
<div class="timeline-header">
|
<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')">
|
<span class="timeline-status" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
|
||||||
{{ log.status === 'SUCCESS' ? '成功' : log.status === 'RUNNING' ? '进行中' : '失败' }}
|
{{ log.status === 'SUCCESS' ? '成功' : log.status === 'RUNNING' ? '进行中' : '失败' }}
|
||||||
</span>
|
</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>
|
<el-icon :size="11"><Clock /></el-icon>
|
||||||
{{ formatDuration(log.startTime, log.endTime) }}
|
{{ formatDuration(log.startedAt, log.finishedAt || undefined) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="log.message" class="timeline-message">{{ log.message }}</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "SyncType" ADD VALUE IF NOT EXISTS 'PRODUCT_DETAILS';
|
||||||
@@ -248,6 +248,7 @@ model User {
|
|||||||
enum SyncType {
|
enum SyncType {
|
||||||
CATEGORIES
|
CATEGORIES
|
||||||
PRODUCTS
|
PRODUCTS
|
||||||
|
PRODUCT_DETAILS
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SyncStatus {
|
enum SyncStatus {
|
||||||
|
|||||||
@@ -12,6 +12,24 @@ export interface GoodRelations {
|
|||||||
goodName: string | null;
|
goodName: string | null;
|
||||||
goodImage: string | null;
|
goodImage: string | null;
|
||||||
goodPrice: unknown;
|
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;
|
} | null;
|
||||||
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
|
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
|
||||||
}
|
}
|
||||||
@@ -72,6 +90,12 @@ export class GoodDto {
|
|||||||
goodName: string | null;
|
goodName: string | null;
|
||||||
goodImage: string | null;
|
goodImage: string | null;
|
||||||
goodPrice: string | null;
|
goodPrice: string | null;
|
||||||
|
hasDetail: boolean;
|
||||||
|
detailSyncedAt: string | null;
|
||||||
|
variantCount: number;
|
||||||
|
sizeRowCount: number;
|
||||||
|
packageRowCount: number;
|
||||||
|
productCode: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
static from(
|
static from(
|
||||||
@@ -137,10 +161,46 @@ export class GoodDto {
|
|||||||
rel.originGood.goodPrice === undefined
|
rel.originGood.goodPrice === undefined
|
||||||
? null
|
? null
|
||||||
: (rel.originGood.goodPrice as { toString(): string }).toString(),
|
: (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,
|
: 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 {
|
export interface PaginatedGoods {
|
||||||
@@ -148,4 +208,4 @@ export interface PaginatedGoods {
|
|||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,18 +36,30 @@ export class GoodsController {
|
|||||||
return this.service.findAll(query);
|
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()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create a good' })
|
@ApiOperation({ summary: 'Create a good' })
|
||||||
create(@Body() dto: CreateGoodDto) {
|
create(@Body() dto: CreateGoodDto) {
|
||||||
return this.service.create(dto);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get one good with relations' })
|
||||||
|
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||||
|
return this.service.findOne(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@ApiOperation({ summary: 'Update a good' })
|
@ApiOperation({ summary: 'Update a good' })
|
||||||
update(
|
update(
|
||||||
@@ -62,16 +74,4 @@ export class GoodsController {
|
|||||||
remove(@Param('id', ParseIntPipe) id: string) {
|
remove(@Param('id', ParseIntPipe) id: string) {
|
||||||
return this.service.remove(BigInt(id));
|
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 { Module } from '@nestjs/common';
|
||||||
import { GoodsController } from './goods.controller';
|
import { GoodsController } from './goods.controller';
|
||||||
import { GoodsService } from './goods.service';
|
import { GoodsService } from './goods.service';
|
||||||
|
import { SyncModule } from '../sync/sync.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [SyncModule],
|
||||||
controllers: [GoodsController],
|
controllers: [GoodsController],
|
||||||
providers: [GoodsService],
|
providers: [GoodsService],
|
||||||
exports: [GoodsService],
|
exports: [GoodsService],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { GoodsService } from './goods.service';
|
import { GoodsService } from './goods.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { SyncService } from '../sync/sync.service';
|
||||||
|
|
||||||
describe('GoodsService', () => {
|
describe('GoodsService', () => {
|
||||||
let service: GoodsService;
|
let service: GoodsService;
|
||||||
@@ -22,7 +23,14 @@ describe('GoodsService', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [GoodsService, PrismaService],
|
providers: [
|
||||||
|
GoodsService,
|
||||||
|
PrismaService,
|
||||||
|
{
|
||||||
|
provide: SyncService,
|
||||||
|
useValue: { queueProductDetailSync: jest.fn() },
|
||||||
|
},
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(GoodsService);
|
service = moduleRef.get(GoodsService);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
|
|||||||
@@ -10,20 +10,30 @@ 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 { GoodDto, PaginatedGoods } from './dto/good.dto';
|
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
|
||||||
|
import { SyncService } from '../sync/sync.service';
|
||||||
|
|
||||||
const GOOD_INCLUDE = {
|
const GOOD_INCLUDE = {
|
||||||
country: true,
|
country: true,
|
||||||
category: true,
|
category: true,
|
||||||
tag: true,
|
tag: true,
|
||||||
position: true,
|
position: true,
|
||||||
originGood: true,
|
originGood: {
|
||||||
|
include: {
|
||||||
|
detail: true,
|
||||||
|
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||||
|
_count: { select: { variants: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
goodTags: { include: { tag: true } },
|
goodTags: { include: { tag: true } },
|
||||||
} satisfies Prisma.GoodInclude;
|
} satisfies Prisma.GoodInclude;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GoodsService {
|
export class GoodsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly syncService: SyncService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
|
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
|
||||||
const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query;
|
const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query;
|
||||||
@@ -65,13 +75,13 @@ export class GoodsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: bigint): Promise<GoodDto> {
|
async findOne(id: bigint): Promise<GoodDetailDto> {
|
||||||
const good = await this.prisma.good.findUnique({
|
const good = await this.prisma.good.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: GOOD_INCLUDE,
|
include: GOOD_INCLUDE,
|
||||||
});
|
});
|
||||||
if (!good) throw new NotFoundException(`Good ${id} not found`);
|
if (!good) throw new NotFoundException(`Good ${id} not found`);
|
||||||
return GoodDto.from(good, {
|
return GoodDetailDto.fromGood(good, {
|
||||||
country: good.country,
|
country: good.country,
|
||||||
category: good.category,
|
category: good.category,
|
||||||
tag: good.tag,
|
tag: good.tag,
|
||||||
@@ -83,7 +93,7 @@ export class GoodsService {
|
|||||||
|
|
||||||
async create(dto: CreateGoodDto): Promise<GoodDto> {
|
async create(dto: CreateGoodDto): Promise<GoodDto> {
|
||||||
await this.ensureReferences(dto);
|
await this.ensureReferences(dto);
|
||||||
return this.prisma.$transaction(async (tx) => {
|
const result = await this.prisma.$transaction(async (tx) => {
|
||||||
const created = await tx.good.create({
|
const created = await tx.good.create({
|
||||||
data: {
|
data: {
|
||||||
goodName: dto.goodName,
|
goodName: dto.goodName,
|
||||||
@@ -116,6 +126,10 @@ export class GoodsService {
|
|||||||
goodTags: result.goodTags,
|
goodTags: result.goodTags,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
if (result.originGood?.sdsGoodId && !result.originGood.hasDetail) {
|
||||||
|
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> {
|
async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> {
|
||||||
@@ -148,7 +162,7 @@ export class GoodsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.$transaction(async (tx) => {
|
const result = await this.prisma.$transaction(async (tx) => {
|
||||||
if (dto.tagIds !== undefined) {
|
if (dto.tagIds !== undefined) {
|
||||||
await tx.goodTag.deleteMany({ where: { goodId: id } });
|
await tx.goodTag.deleteMany({ where: { goodId: id } });
|
||||||
if (dto.tagIds.length > 0) {
|
if (dto.tagIds.length > 0) {
|
||||||
@@ -174,6 +188,10 @@ export class GoodsService {
|
|||||||
goodTags: updated.goodTags,
|
goodTags: updated.goodTags,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
if (result.originGood?.sdsGoodId && !result.originGood.hasDetail) {
|
||||||
|
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: bigint): Promise<{ id: string }> {
|
async remove(id: bigint): Promise<{ id: string }> {
|
||||||
@@ -187,7 +205,7 @@ export class GoodsService {
|
|||||||
* or none do.
|
* or none do.
|
||||||
*/
|
*/
|
||||||
async batchUpdatePriority(dto: BatchPriorityDto): Promise<{ count: number }> {
|
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) {
|
for (const item of dto.items) {
|
||||||
await tx.good.update({
|
await tx.good.update({
|
||||||
where: { id: BigInt(item.id) },
|
where: { id: BigInt(item.id) },
|
||||||
@@ -196,6 +214,7 @@ export class GoodsService {
|
|||||||
}
|
}
|
||||||
return { count: dto.items.length };
|
return { count: dto.items.length };
|
||||||
});
|
});
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -209,7 +228,7 @@ export class GoodsService {
|
|||||||
await this.ensureTag(tagId);
|
await this.ensureTag(tagId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return this.prisma.$transaction(async (tx) => {
|
const result = await this.prisma.$transaction(async (tx) => {
|
||||||
const created: GoodDto[] = [];
|
const created: GoodDto[] = [];
|
||||||
for (const item of dto.items) {
|
for (const item of dto.items) {
|
||||||
const og = await tx.originGood.findUnique({
|
const og = await tx.originGood.findUnique({
|
||||||
@@ -254,6 +273,15 @@ export class GoodsService {
|
|||||||
}
|
}
|
||||||
return created;
|
return created;
|
||||||
});
|
});
|
||||||
|
for (const goodId of new Set(
|
||||||
|
result
|
||||||
|
.filter((item) => !item.originGood?.hasDetail)
|
||||||
|
.map((item) => item.originGood?.sdsGoodId)
|
||||||
|
.filter((id): id is string => Boolean(id)),
|
||||||
|
)) {
|
||||||
|
this.syncService.queueProductDetailSync(goodId);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -313,4 +341,4 @@ export class GoodsService {
|
|||||||
if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`);
|
if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export interface PaginatedOriginGoods {
|
|||||||
sdsCategoryId: string | null;
|
sdsCategoryId: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
hasDetail: boolean;
|
||||||
|
detailSyncedAt: string | null;
|
||||||
|
variantCount: number;
|
||||||
}>;
|
}>;
|
||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
@@ -33,6 +36,11 @@ export interface OriginGoodsTreeNode {
|
|||||||
configuredCount: number;
|
configuredCount: number;
|
||||||
configuredCountries: string[];
|
configuredCountries: string[];
|
||||||
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[];
|
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. */
|
/** A category node in the hierarchical tree, with origin goods as leaves. */
|
||||||
@@ -68,6 +76,7 @@ export class OriginGoodsService {
|
|||||||
this.prisma.originGood.findMany({
|
this.prisma.originGood.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { id: 'desc' },
|
orderBy: { id: 'desc' },
|
||||||
|
include: { detail: true, _count: { select: { variants: true } } },
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
}),
|
}),
|
||||||
@@ -83,6 +92,9 @@ export class OriginGoodsService {
|
|||||||
sdsCategoryId: r.sdsCategoryId,
|
sdsCategoryId: r.sdsCategoryId,
|
||||||
createdAt: r.createdAt.toISOString(),
|
createdAt: r.createdAt.toISOString(),
|
||||||
updatedAt: r.updatedAt.toISOString(),
|
updatedAt: r.updatedAt.toISOString(),
|
||||||
|
hasDetail: Boolean(r.detail),
|
||||||
|
detailSyncedAt: r.detail?.syncedAt.toISOString() ?? null,
|
||||||
|
variantCount: r._count.variants,
|
||||||
})),
|
})),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
@@ -111,7 +123,11 @@ export class OriginGoodsService {
|
|||||||
parentCategoryId: true,
|
parentCategoryId: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.originGood.findMany({ where: { delisted: false }, orderBy: { goodName: 'asc' } }),
|
this.prisma.originGood.findMany({
|
||||||
|
where: { delisted: false },
|
||||||
|
orderBy: { goodName: 'asc' },
|
||||||
|
include: { detail: true, _count: { select: { variants: true } } },
|
||||||
|
}),
|
||||||
this.prisma.good.groupBy({
|
this.prisma.good.groupBy({
|
||||||
by: ['originGoodId'],
|
by: ['originGoodId'],
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
@@ -212,6 +228,11 @@ export class OriginGoodsService {
|
|||||||
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
||||||
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
||||||
configuredTags: tagMap.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);
|
const childTotal = childNodes.reduce((s, n) => s + n.totalCount, 0);
|
||||||
@@ -258,6 +279,11 @@ export class OriginGoodsService {
|
|||||||
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
||||||
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
||||||
configuredTags: tagMap.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 +300,10 @@ export class OriginGoodsService {
|
|||||||
configuredCount: totalConfigured,
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,27 +53,6 @@ export class PublicQueryGoodDto {
|
|||||||
@IsNumberString({}, { each: true })
|
@IsNumberString({}, { each: true })
|
||||||
tagIds?: string[];
|
tagIds?: string[];
|
||||||
|
|
||||||
@ApiProperty({ required: false, type: [String], description: '印刷工艺标签 ID' })
|
|
||||||
@IsOptional()
|
|
||||||
@Transform(stringList)
|
|
||||||
@IsArray()
|
|
||||||
@IsNumberString({}, { each: true })
|
|
||||||
craftIds?: string[];
|
|
||||||
|
|
||||||
@ApiProperty({ required: false, type: [String], description: '材质标签 ID' })
|
|
||||||
@IsOptional()
|
|
||||||
@Transform(stringList)
|
|
||||||
@IsArray()
|
|
||||||
@IsNumberString({}, { each: true })
|
|
||||||
materialIds?: string[];
|
|
||||||
|
|
||||||
@ApiProperty({ required: false, enum: ['FREE_SHIPPING', 'NOT_FREE_SHIPPING'], isArray: true })
|
|
||||||
@IsOptional()
|
|
||||||
@Transform(stringList)
|
|
||||||
@IsArray()
|
|
||||||
@IsIn(['FREE_SHIPPING', 'NOT_FREE_SHIPPING'], { each: true })
|
|
||||||
freeShipping?: Array<'FREE_SHIPPING' | 'NOT_FREE_SHIPPING'>;
|
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -145,14 +145,9 @@ export class PublicService {
|
|||||||
where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) };
|
where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagIds = [
|
const tagFilters = await this.buildTagGroupFilters([
|
||||||
...new Set([
|
...new Set(query.tagIds ?? []),
|
||||||
...(query.tagIds ?? []),
|
]);
|
||||||
...(query.craftIds ?? []),
|
|
||||||
...(query.materialIds ?? []),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
const tagFilters = await this.buildTagGroupFilters(tagIds, query.freeShipping);
|
|
||||||
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');
|
||||||
@@ -325,7 +320,6 @@ export class PublicService {
|
|||||||
|
|
||||||
private async buildTagGroupFilters(
|
private async buildTagGroupFilters(
|
||||||
selectedTagIds: string[],
|
selectedTagIds: string[],
|
||||||
freeShipping?: Array<'FREE_SHIPPING' | 'NOT_FREE_SHIPPING'>,
|
|
||||||
): Promise<Prisma.GoodWhereInput[]> {
|
): Promise<Prisma.GoodWhereInput[]> {
|
||||||
const selected = selectedTagIds.length
|
const selected = selectedTagIds.length
|
||||||
? await this.prisma.tag.findMany({
|
? await this.prisma.tag.findMany({
|
||||||
@@ -336,19 +330,6 @@ export class PublicService {
|
|||||||
if (selected.length !== selectedTagIds.length) {
|
if (selected.length !== selectedTagIds.length) {
|
||||||
throw new BadRequestException('包含不存在的标签 ID');
|
throw new BadRequestException('包含不存在的标签 ID');
|
||||||
}
|
}
|
||||||
if (freeShipping?.length) {
|
|
||||||
const names = freeShipping.map((value) =>
|
|
||||||
value === 'FREE_SHIPPING' ? '包邮' : '不包邮',
|
|
||||||
);
|
|
||||||
const shippingTags = await this.prisma.tag.findMany({
|
|
||||||
where: { tagName: { in: names }, tagGroup: { groupName: '物流渠道' } },
|
|
||||||
select: { id: true, tagGroupId: true },
|
|
||||||
});
|
|
||||||
if (shippingTags.length !== new Set(names).size) {
|
|
||||||
throw new BadRequestException('物流渠道标签配置不完整');
|
|
||||||
}
|
|
||||||
selected.push(...shippingTags);
|
|
||||||
}
|
|
||||||
const byGroup = new Map<string, bigint[]>();
|
const byGroup = new Map<string, bigint[]>();
|
||||||
for (const tag of selected) {
|
for (const tag of selected) {
|
||||||
const key = tag.tagGroupId?.toString() ?? `tag:${tag.id.toString()}`;
|
const key = tag.tagGroupId?.toString() ?? `tag:${tag.id.toString()}`;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export class SyncLogDto {
|
|||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
type!: 'CATEGORIES' | 'PRODUCTS';
|
type!: 'CATEGORIES' | 'PRODUCTS' | 'PRODUCT_DETAILS';
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
status!: 'RUNNING' | 'SUCCESS' | 'FAILED';
|
status!: 'RUNNING' | 'SUCCESS' | 'FAILED';
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
DefaultValuePipe,
|
DefaultValuePipe,
|
||||||
Get,
|
Get,
|
||||||
|
Param,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
@@ -36,6 +37,18 @@ export class SyncController {
|
|||||||
return this.service.startProductSync();
|
return this.service.startProductSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('product-details')
|
||||||
|
@ApiOperation({ summary: 'Manually sync details for all configured 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')
|
@Get('status')
|
||||||
@ApiOperation({ summary: 'Recent sync log entries' })
|
@ApiOperation({ summary: 'Recent sync log entries' })
|
||||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { 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';
|
||||||
@@ -67,7 +67,7 @@ export function shouldRunDelistDetection(leafCategories: number, seenGoods: numb
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SyncService {
|
export class SyncService {
|
||||||
private readonly logger = new Logger(SyncService.name);
|
private readonly logger = new Logger(SyncService.name);
|
||||||
private running = { categories: false, products: false };
|
private running = { categories: false, products: false, details: false };
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
@@ -110,8 +110,18 @@ export class SyncService {
|
|||||||
return { message: 'Product sync started' };
|
return { message: 'Product sync started' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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. */
|
/** Check if a sync type is currently running. */
|
||||||
isRunning(type: 'categories' | 'products'): boolean {
|
isRunning(type: 'categories' | 'products' | 'details'): boolean {
|
||||||
return this.running[type];
|
return this.running[type];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,6 +402,66 @@ export class SyncService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async syncProductDetails(): Promise<{ 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.syncConfiguredProductDetails();
|
||||||
|
await this.prisma.syncLog.update({
|
||||||
|
where: { id: log.id },
|
||||||
|
data: {
|
||||||
|
status: 'SUCCESS',
|
||||||
|
finishedAt: new Date(),
|
||||||
|
message: `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 },
|
||||||
|
});
|
||||||
|
if (!originGood) {
|
||||||
|
throw new NotFoundException(`SDS product ${goodId} not found locally`);
|
||||||
|
}
|
||||||
|
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 }> {
|
async syncConfiguredProductDetails(): Promise<{ synced: number; failed: number }> {
|
||||||
const configured = await this.prisma.originGood.findMany({
|
const configured = await this.prisma.originGood.findMany({
|
||||||
where: { delisted: false, goods: { some: {} } },
|
where: { delisted: false, goods: { some: {} } },
|
||||||
|
|||||||
Reference in New Issue
Block a user