Compare commits

...
6 Commits
37 changed files with 1872 additions and 247 deletions
+1
View File
@@ -24,6 +24,7 @@ lerna-debug.log*
# Environment # Environment
.env .env
.env.local .env.local
.env.development
.env.*.local .env.*.local
# OS # OS
+1 -1
View File
@@ -51,7 +51,7 @@ PORT=3001
cd apps/api cd apps/api
pnpm prisma:generate pnpm prisma:generate
pnpm prisma:migrate pnpm prisma:migrate
pnpm start:dev pnpm --filter @inkreach/api dev
# → http://localhost:3001 · Swagger: http://localhost:3001/api/docs # → http://localhost:3001 · Swagger: http://localhost:3001/api/docs
# Terminal 2:官网 # Terminal 2:官网
+3 -2
View File
@@ -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
View File
@@ -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,
+4
View File
@@ -19,6 +19,8 @@ declare module 'vue' {
ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElContainer: typeof import('element-plus/es')['ElContainer'] ElContainer: typeof import('element-plus/es')['ElContainer']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog'] ElDialog: typeof import('element-plus/es')['ElDialog']
ElDropdown: typeof import('element-plus/es')['ElDropdown'] ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
@@ -40,6 +42,8 @@ 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']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane'] ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs'] ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag'] ElTag: typeof import('element-plus/es')['ElTag']
+45 -7
View File
@@ -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
+160 -6
View File
@@ -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; }
+36 -13
View File
@@ -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>
+3 -3
View File
@@ -29,16 +29,16 @@ export default defineConfig({
port: 5173, port: 5173,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:3001', target: 'http://127.0.0.1:3001',
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''), rewrite: (path) => path.replace(/^\/api/, ''),
}, },
'/uploads': { '/uploads': {
target: 'http://localhost:3001', target: 'http://127.0.0.1:3001',
changeOrigin: true, changeOrigin: true,
}, },
'/assets': { '/assets': {
target: 'http://localhost:3001', target: 'http://127.0.0.1:3001',
changeOrigin: true, changeOrigin: true,
}, },
}, },
+2 -1
View File
@@ -21,7 +21,8 @@
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio", "prisma:studio": "prisma studio",
"configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts" "configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts",
"import:product-detail": "ts-node prisma/import-product-detail.ts"
}, },
"dependencies": { "dependencies": {
"@nestjs/axios": "^3.0.1", "@nestjs/axios": "^3.0.1",
+36
View File
@@ -0,0 +1,36 @@
import { readFile } from 'fs/promises';
import { resolve } from 'path';
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../src/app.module';
import { SyncService } from '../src/sync/sync.service';
import { SdsProductDetail } from '../src/sync/sds-client.service';
async function main(): Promise<void> {
const inputPath = process.argv[2];
if (!inputPath) {
throw new Error('Usage: pnpm --filter @inkreach/api import:product-detail -- <product_detail.txt>');
}
const absolutePath = resolve(inputPath);
const raw = await readFile(absolutePath, 'utf8');
const jsonStart = raw.indexOf('{');
if (jsonStart < 0) throw new Error('No JSON object found in product detail file');
const detail = JSON.parse(raw.slice(jsonStart)) as SdsProductDetail;
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] });
try {
const result = await app.get(SyncService).importProductDetail(detail);
process.stdout.write(
`Imported SDS product ${result.goodId}: ${result.variants} variants, ` +
`${result.sizeRows} size rows, ${result.packageRows} package rows, ` +
`${result.configuredGoods} configured goods\n`,
);
} finally {
await app.close();
}
}
void main().catch((error: unknown) => {
const message = error instanceof Error ? error.stack ?? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
});
@@ -0,0 +1,70 @@
-- Cache SDS product details separately from website merchandising configuration.
CREATE TABLE "origin_good_details" (
"origin_good_id" BIGINT NOT NULL,
"product_code" TEXT,
"english_name" TEXT,
"blank_design_url" TEXT,
"details_page_video_url" TEXT,
"texture_name" TEXT,
"production_cycle_hours" INTEGER,
"min_weight_g" DECIMAL(12,3),
"reminder" TEXT,
"production_process" TEXT,
"material_description" TEXT,
"product_performance" TEXT,
"applicable_scenarios" TEXT,
"washing_instructions" TEXT,
"special_description" TEXT,
"design_explanation" TEXT,
"design_area" TEXT,
"picture_request" TEXT,
"size_chart" JSONB,
"package_specs" JSONB,
"options" JSONB,
"media" JSONB,
"upstream_updated_at" TIMESTAMPTZ(6),
"synced_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "origin_good_details_pkey" PRIMARY KEY ("origin_good_id")
);
CREATE TABLE "origin_good_variants" (
"origin_good_variant_id" BIGSERIAL NOT NULL,
"origin_good_id" BIGINT NOT NULL,
"sds_variant_id" TEXT NOT NULL,
"sku" TEXT NOT NULL,
"size_id" TEXT,
"size_name" TEXT,
"color_id" TEXT,
"color_name" TEXT,
"color_hex" TEXT,
"image_url" TEXT,
"price" DECIMAL(12,2),
"original_price" DECIMAL(12,2),
"weight_g" DECIMAL(12,3),
"box_length_cm" DECIMAL(12,3),
"box_width_cm" DECIMAL(12,3),
"box_height_cm" DECIMAL(12,3),
"enabled" BOOLEAN NOT NULL DEFAULT true,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"design_data" JSONB,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "origin_good_variants_pkey" PRIMARY KEY ("origin_good_variant_id")
);
CREATE UNIQUE INDEX "origin_good_variants_origin_good_id_sds_variant_id_key"
ON "origin_good_variants"("origin_good_id", "sds_variant_id");
CREATE INDEX "origin_good_variants_origin_good_id_sort_order_idx"
ON "origin_good_variants"("origin_good_id", "sort_order");
CREATE INDEX "origin_good_variants_sku_idx" ON "origin_good_variants"("sku");
ALTER TABLE "origin_good_details"
ADD CONSTRAINT "origin_good_details_origin_good_id_fkey"
FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id")
ON DELETE CASCADE ON UPDATE NO ACTION;
ALTER TABLE "origin_good_variants"
ADD CONSTRAINT "origin_good_variants_origin_good_id_fkey"
FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id")
ON DELETE CASCADE ON UPDATE NO ACTION;
@@ -0,0 +1 @@
ALTER TYPE "SyncType" ADD VALUE IF NOT EXISTS 'PRODUCT_DETAILS';
+101 -33
View File
@@ -15,23 +15,90 @@ datasource db {
// ---------- Origin Goods ---------- // ---------- Origin Goods ----------
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")
// Cached SDS product metadata (filled during sync) // Cached SDS product metadata (filled during sync)
sdsCategoryId String? @map("sds_category_id") sdsCategoryId String? @map("sds_category_id")
goodName String? @map("good_name") goodName String? @map("good_name")
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)
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)
goods Good[] goods Good[]
detail OriginGoodDetail?
variants OriginGoodVariant[]
@@index([sdsCategoryId]) @@index([sdsCategoryId])
@@map("origin_goods") @@map("origin_goods")
} }
// ---------- Origin Good Details (cached from SDS /products/{id}) ----------
model OriginGoodDetail {
originGoodId BigInt @id @map("origin_good_id")
productCode String? @map("product_code")
englishName String? @map("english_name")
blankDesignUrl String? @map("blank_design_url")
detailsPageVideoUrl String? @map("details_page_video_url")
textureName String? @map("texture_name")
productionCycleHours Int? @map("production_cycle_hours")
minWeightG Decimal? @map("min_weight_g") @db.Decimal(12, 3)
reminder String?
productionProcess String? @map("production_process")
materialDescription String? @map("material_description")
productPerformance String? @map("product_performance")
applicableScenarios String? @map("applicable_scenarios")
washingInstructions String? @map("washing_instructions")
specialDescription String? @map("special_description")
designExplanation String? @map("design_explanation")
designArea String? @map("design_area")
pictureRequest String? @map("picture_request")
sizeChart Json? @map("size_chart")
packageSpecs Json? @map("package_specs")
options Json?
media Json?
upstreamUpdatedAt DateTime? @map("upstream_updated_at") @db.Timestamptz(6)
syncedAt DateTime @default(now()) @map("synced_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@map("origin_good_details")
}
// ---------- Origin Good Variants (cached SDS child products / SKUs) ----------
model OriginGoodVariant {
id BigInt @id @default(autoincrement()) @map("origin_good_variant_id")
originGoodId BigInt @map("origin_good_id")
sdsVariantId String @map("sds_variant_id")
sku String
sizeId String? @map("size_id")
sizeName String? @map("size_name")
colorId String? @map("color_id")
colorName String? @map("color_name")
colorHex String? @map("color_hex")
imageUrl String? @map("image_url")
price Decimal? @db.Decimal(12, 2)
originalPrice Decimal? @map("original_price") @db.Decimal(12, 2)
weightG Decimal? @map("weight_g") @db.Decimal(12, 3)
boxLengthCm Decimal? @map("box_length_cm") @db.Decimal(12, 3)
boxWidthCm Decimal? @map("box_width_cm") @db.Decimal(12, 3)
boxHeightCm Decimal? @map("box_height_cm") @db.Decimal(12, 3)
enabled Boolean @default(true)
sortOrder Int @default(0) @map("sort_order")
designData Json? @map("design_data")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@unique([originGoodId, sdsVariantId])
@@index([originGoodId, sortOrder])
@@index([sku])
@@map("origin_good_variants")
}
// ---------- Countries ---------- // ---------- Countries ----------
model Country { model Country {
id BigInt @id @default(autoincrement()) @map("country_id") id BigInt @id @default(autoincrement()) @map("country_id")
@@ -48,18 +115,18 @@ model Country {
// ---------- Categories (self-referential tree) ---------- // ---------- Categories (self-referential tree) ----------
model Category { model Category {
id BigInt @id @default(autoincrement()) @map("category_id") id BigInt @id @default(autoincrement()) @map("category_id")
parentCategoryId BigInt? @map("parent_category_id") parentCategoryId BigInt? @map("parent_category_id")
categoryName String @map("category_name") categoryName String @map("category_name")
categoryIcon String? @map("category_icon") categoryIcon String? @map("category_icon")
sdsCategoryId String? @unique @map("sds_category_id") sdsCategoryId String? @unique @map("sds_category_id")
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)
parent Category? @relation("CategoryToCategory", fields: [parentCategoryId], references: [id], onDelete: Restrict, onUpdate: NoAction) parent Category? @relation("CategoryToCategory", fields: [parentCategoryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
children Category[] @relation("CategoryToCategory") children Category[] @relation("CategoryToCategory")
goods Good[] goods Good[]
positions Position[] positions Position[]
@@index([parentCategoryId]) @@index([parentCategoryId])
@@map("categories") @@map("categories")
@@ -94,7 +161,7 @@ model Tag {
goods Good[] goods Good[]
goodTags GoodTag[] goodTags GoodTag[]
tagGroup TagGroup? @relation(fields: [tagGroupId], references: [id], onDelete: SetNull, onUpdate: NoAction) tagGroup TagGroup? @relation(fields: [tagGroupId], references: [id], onDelete: SetNull, onUpdate: NoAction)
@@index([tagGroupId]) @@index([tagGroupId])
@@index([tagGroupId, sortOrder]) @@index([tagGroupId, sortOrder])
@@ -122,17 +189,17 @@ model Position {
// ---------- Goods ---------- // ---------- Goods ----------
model Good { model Good {
id BigInt @id @default(autoincrement()) @map("good_id") id BigInt @id @default(autoincrement()) @map("good_id")
originGoodId BigInt @map("origin_good_id") originGoodId BigInt @map("origin_good_id")
countryId BigInt @map("country_id") countryId BigInt @map("country_id")
categoryId BigInt @map("category_id") categoryId BigInt @map("category_id")
tagId BigInt? @map("tag_id") tagId BigInt? @map("tag_id")
positionId BigInt? @map("position_id") positionId BigInt? @map("position_id")
goodName String @map("good_name") goodName String @map("good_name")
goodImage String? @map("good_image") goodImage String? @map("good_image")
goodPriority Int @default(0) @map("good_priority") goodPriority Int @default(0) @map("good_priority")
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)
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Restrict, onUpdate: NoAction) originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Restrict, onUpdate: NoAction)
country Country @relation(fields: [countryId], references: [id], onDelete: Restrict, onUpdate: NoAction) country Country @relation(fields: [countryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
@@ -181,6 +248,7 @@ model User {
enum SyncType { enum SyncType {
CATEGORIES CATEGORIES
PRODUCTS PRODUCTS
PRODUCT_DETAILS
} }
enum SyncStatus { enum SyncStatus {
+61 -1
View File
@@ -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;
} }
+18 -18
View File
@@ -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);
}
} }
+2
View File
@@ -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],
+9 -1
View File
@@ -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);
+38 -10
View File
@@ -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;
}
} }
@@ -17,6 +17,9 @@ export class PublicCategoryNodeDto {
@ApiProperty({ type: [PublicCategoryNodeDto] }) @ApiProperty({ type: [PublicCategoryNodeDto] })
children!: PublicCategoryNodeDto[]; children!: PublicCategoryNodeDto[];
@ApiProperty({ description: '当前节点及其后代分类的商品数量' })
productCount!: number;
static from(category: PrismaCategory, children: PublicCategoryNodeDto[] = []): PublicCategoryNodeDto { static from(category: PrismaCategory, children: PublicCategoryNodeDto[] = []): PublicCategoryNodeDto {
return { return {
id: category.id.toString(), id: category.id.toString(),
@@ -26,6 +29,7 @@ export class PublicCategoryNodeDto {
? category.parentCategoryId.toString() ? category.parentCategoryId.toString()
: null, : null,
children, children,
productCount: 0,
}; };
} }
} }
@@ -0,0 +1,81 @@
import { ApiProperty } from '@nestjs/swagger';
import { PublicGoodDto } from './public-good.dto';
export class PublicGoodDetailDto extends PublicGoodDto {
@ApiProperty({ nullable: true })
productCode!: string | null;
@ApiProperty({ nullable: true })
englishName!: string | null;
@ApiProperty({ nullable: true })
productionCycleHours!: number | null;
@ApiProperty({ nullable: true })
minWeightG!: string | null;
@ApiProperty({ type: Object })
details!: Record<string, string | null>;
@ApiProperty({ nullable: true, type: Object })
media!: Record<string, unknown> | null;
@ApiProperty({ nullable: true, type: Object })
options!: Record<string, unknown> | null;
@ApiProperty({ nullable: true, type: Object })
sizeChart!: Record<string, unknown> | null;
@ApiProperty({ nullable: true, type: Object })
packageSpecs!: Record<string, unknown> | null;
@ApiProperty({ type: Array })
variants!: Array<{
id: string;
sku: string;
sizeId: string | null;
sizeName: string | null;
colorId: string | null;
colorName: string | null;
colorHex: string | null;
imageUrl: string | null;
price: string | null;
originalPrice: string | null;
weightG: string | null;
boxLengthCm: string | null;
boxWidthCm: string | null;
boxHeightCm: string | null;
enabled: boolean;
sortOrder: number;
}>;
@ApiProperty({ nullable: true })
detailSyncedAt!: string | null;
}
export class PublicTagGroupFilterDto {
@ApiProperty()
id!: string;
@ApiProperty()
groupName!: string;
@ApiProperty({ nullable: true })
groupIcon!: string | null;
@ApiProperty({ nullable: true })
groupColor!: string | null;
@ApiProperty()
sortOrder!: number;
@ApiProperty({ type: Array })
tags!: Array<{
id: string;
tagName: string;
tagColor: string | null;
tagFontColor: string | null;
sortOrder: number;
productCount: number;
}>;
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
export class PublicGoodDto { export class PublicGoodDto {
@ApiProperty() @ApiProperty()
id!: string; goodId!: string;
@ApiProperty() @ApiProperty()
goodName!: string; goodName!: string;
@@ -33,4 +33,4 @@ export class PublicGoodDto {
@ApiProperty() @ApiProperty()
createdAt!: string; createdAt!: string;
} }
@@ -1,13 +1,26 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { import {
IsArray,
IsIn,
IsInt, IsInt,
IsNumberString,
IsOptional, IsOptional,
IsString, IsString,
Matches,
Max, Max,
Min, Min,
} from 'class-validator'; } from 'class-validator';
const stringList = ({ value }: { value: unknown }): string[] | undefined => {
if (value === undefined || value === null || value === '') return undefined;
const values = Array.isArray(value) ? value : [value];
return values
.flatMap((item) => String(item).split(','))
.map((item) => item.trim())
.filter(Boolean);
};
export class PublicQueryGoodDto { export class PublicQueryGoodDto {
@ApiProperty({ required: false, default: 1 }) @ApiProperty({ required: false, default: 1 })
@IsOptional() @IsOptional()
@@ -16,7 +29,7 @@ export class PublicQueryGoodDto {
@Min(1) @Min(1)
page: number = 1; page: number = 1;
@ApiProperty({ required: false, default: 20 }) @ApiProperty({ required: false, default: 20, maximum: 200 })
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@@ -24,25 +37,66 @@ export class PublicQueryGoodDto {
@Max(200) @Max(200)
pageSize: number = 20; pageSize: number = 20;
@ApiProperty({ required: false }) @ApiProperty({ required: false, type: String, description: '不传表示全部国家' })
@IsOptional() @IsOptional()
@Type(() => Number) @IsNumberString()
@IsInt() countryId?: string;
countryId?: number;
@ApiProperty({ required: false, type: String })
@IsOptional()
@IsNumberString()
categoryId?: string;
@ApiProperty({
required: false,
type: [String],
description:
'标签筛选项,格式为 tagGroupId:tagId;支持重复参数或逗号分隔。同组 OR,跨组 AND',
example: ['1:11', '1:12', '2:25'],
})
@IsOptional()
@Transform(stringList)
@IsArray()
@Matches(/^\d+:\d+$/, {
each: true,
message: 'tagIds 每个元素必须为 tagGroupId:tagId',
})
tagIds?: string[];
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@Type(() => Number)
@IsInt()
categoryId?: number;
@ApiProperty({ required: false, description: 'Comma-separated tag IDs, e.g. "30,34"' })
@IsOptional()
@IsString() @IsString()
tagIds?: string; minPrice?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
maxPrice?: string;
@ApiProperty({ required: false, enum: ['DEFAULT', 'PRICE_ASC', 'PRICE_DESC', 'NEWEST'] })
@IsOptional()
@IsIn(['DEFAULT', 'PRICE_ASC', 'PRICE_DESC', 'NEWEST'])
sort?: 'DEFAULT' | 'PRICE_ASC' | 'PRICE_DESC' | 'NEWEST';
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsString() @IsString()
keyword?: string; keyword?: string;
} }
export class PublicCountryQueryDto {
@ApiProperty({ required: false, type: String, description: '不传表示全部国家' })
@IsOptional()
@IsNumberString()
countryId?: string;
}
export class PublicHomeGoodsQueryDto extends PublicCountryQueryDto {
@ApiProperty({ required: false, default: 10, maximum: 50 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
limit: number = 10;
}
+29 -15
View File
@@ -2,14 +2,18 @@ import {
Controller, Controller,
Get, Get,
Param, Param,
ParseIntPipe,
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiOkResponse, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import { PublicService } from './public.service'; import { PublicService } from './public.service';
import { PublicQueryGoodDto } from './dto/public-query-good.dto'; import {
PublicCountryQueryDto,
PublicHomeGoodsQueryDto,
PublicQueryGoodDto,
} from './dto/public-query-good.dto';
import { PublicTagDto } from './dto/public-tag.dto'; import { PublicTagDto } from './dto/public-tag.dto';
import { PublicTagGroupDto } from './dto/public-tag-group.dto'; import { PublicGoodDetailDto, PublicTagGroupFilterDto } from './dto/public-good-detail.dto';
import { PublicGoodDto } from './dto/public-good.dto';
@ApiTags('public') @ApiTags('public')
@Controller('public') @Controller('public')
@@ -17,9 +21,9 @@ export class PublicController {
constructor(private readonly service: PublicService) {} constructor(private readonly service: PublicService) {}
@Get('categories') @Get('categories')
@ApiOperation({ summary: 'Public list of categories that have goods' }) @ApiOperation({ summary: '获取商品分类树;countryId 不传时返回全部国家' })
getCategories() { getCategories(@Query() query: PublicCountryQueryDto) {
return this.service.getCategoriesTree(); return this.service.getCategoriesTree(query.countryId);
} }
@Get('countries') @Get('countries')
@@ -35,20 +39,30 @@ export class PublicController {
} }
@Get('tag-groups') @Get('tag-groups')
@ApiOperation({ summary: 'Public list of tag groups that have goods' }) @ApiOperation({ summary: '获取标签组及标签筛选项;countryId 不传时返回全部国家' })
getTagGroups(): Promise<PublicTagGroupDto[]> { @ApiOkResponse({ type: [PublicTagGroupFilterDto] })
return this.service.getTagGroups(); getTagGroups(@Query() query: PublicCountryQueryDto): Promise<PublicTagGroupFilterDto[]> {
return this.service.getTagGroups(query.countryId);
} }
@Get('goods') @Get('goods')
@ApiOperation({ summary: 'Public paginated goods with filters' }) @ApiOperation({ summary: '分页获取商品' })
getGoods(@Query() query: PublicQueryGoodDto) { getGoods(@Query() query: PublicQueryGoodDto) {
return this.service.getGoods(query); return this.service.getGoods(query);
} }
@Get('goods/:id') @Get('goods/:goodId')
@ApiOperation({ summary: 'Public good detail' }) @ApiOperation({ summary: '获取商品完整详情' })
getGood(@Param('id', ParseIntPipe) id: string) { @ApiParam({ name: 'goodId', type: String, example: '168746' })
return this.service.getGood(BigInt(id)); @ApiOkResponse({ type: PublicGoodDetailDto })
getGood(@Param('goodId') goodId: string): Promise<PublicGoodDetailDto> {
return this.service.getGood(goodId);
}
@Get('home-goods')
@ApiOperation({ summary: '获取首页商品;可按国家返回' })
@ApiOkResponse({ type: [PublicGoodDto] })
getHomeGoods(@Query() query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
return this.service.getHomeGoods(query);
} }
} }
+102 -11
View File
@@ -1,5 +1,5 @@
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common'; import { BadRequestException, NotFoundException } from '@nestjs/common';
import { PublicService } from './public.service'; import { PublicService } from './public.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -12,6 +12,8 @@ describe('PublicService', () => {
let childCategoryId: bigint; let childCategoryId: bigint;
let otherCategoryId: bigint; let otherCategoryId: bigint;
let tagId: bigint; let tagId: bigint;
let filterGroupIds: bigint[] = [];
let filterTagIds: bigint[] = [];
let originGoodId: bigint; let originGoodId: bigint;
let goodIds: bigint[] = []; let goodIds: bigint[] = [];
@@ -98,6 +100,50 @@ describe('PublicService', () => {
}); });
goodIds = [g1.id, g2.id, g3.id]; goodIds = [g1.id, g2.id, g3.id];
const craftGroup = await prisma.tagGroup.create({
data: { groupName: `Pub Craft ${stamp}`, sortOrder: 100 },
});
const materialGroup = await prisma.tagGroup.create({
data: { groupName: `Pub Material ${stamp}`, sortOrder: 101 },
});
filterGroupIds = [craftGroup.id, materialGroup.id];
const craftA = await prisma.tag.create({
data: { tagName: `Pub Craft A ${stamp}`, tagGroupId: craftGroup.id },
});
const craftB = await prisma.tag.create({
data: { tagName: `Pub Craft B ${stamp}`, tagGroupId: craftGroup.id },
});
const cotton = await prisma.tag.create({
data: { tagName: `Pub Cotton ${stamp}`, tagGroupId: materialGroup.id },
});
filterTagIds = [craftA.id, craftB.id, cotton.id];
await prisma.goodTag.createMany({
data: [
{ goodId: g1.id, tagId: craftA.id },
{ goodId: g1.id, tagId: cotton.id },
{ goodId: g2.id, tagId: craftB.id },
],
});
await prisma.originGoodDetail.create({
data: {
originGoodId,
productCode: 'OZ10827003',
productionProcess: '白墨烫画',
sizeChart: { columns: [], rows: [{ sizeId: 'size_0', sizeName: 'S', measurements: [] }] },
packageSpecs: { rows: [{ sizeId: 'size_0', sizeName: 'S' }] },
},
});
await prisma.originGoodVariant.create({
data: {
originGoodId,
sdsVariantId: `pub-variant-${stamp}`,
sku: `OZ${stamp}`,
sizeName: 'S',
price: 38,
},
});
// Seed a good in `otherCategory` so the "onlyHaveGoods" filter // Seed a good in `otherCategory` so the "onlyHaveGoods" filter
// returns more than one category. // returns more than one category.
await prisma.good.create({ await prisma.good.create({
@@ -134,6 +180,8 @@ describe('PublicService', () => {
where: { countryId }, where: { countryId },
}); });
await prisma.tag.delete({ where: { id: tagId } }); await prisma.tag.delete({ where: { id: tagId } });
await prisma.tag.deleteMany({ where: { id: { in: filterTagIds } } });
await prisma.tagGroup.deleteMany({ where: { id: { in: filterGroupIds } } });
await prisma.originGood.delete({ where: { id: originGoodId } }); await prisma.originGood.delete({ where: { id: originGoodId } });
// Delete children before parent (FK self-relation is RESTRICT). // Delete children before parent (FK self-relation is RESTRICT).
await prisma.category.delete({ where: { id: childCategoryId } }); await prisma.category.delete({ where: { id: childCategoryId } });
@@ -172,8 +220,8 @@ describe('PublicService', () => {
const filtered = await service.getGoods({ const filtered = await service.getGoods({
page: 1, page: 1,
pageSize: 50, pageSize: 50,
countryId: Number(countryId), countryId: countryId.toString(),
categoryId: Number(categoryId), // includes child categoryId: categoryId.toString(), // includes child
keyword: `Pub `, keyword: `Pub `,
}); });
expect(filtered.total).toBeGreaterThanOrEqual(4); // High, Mid, NoPos, ChildGood expect(filtered.total).toBeGreaterThanOrEqual(4); // High, Mid, NoPos, ChildGood
@@ -184,7 +232,7 @@ describe('PublicService', () => {
const result = await service.getGoods({ const result = await service.getGoods({
page: 1, page: 1,
pageSize: 50, pageSize: 50,
countryId: Number(countryId), countryId: countryId.toString(),
keyword: `Pub `, keyword: `Pub `,
}); });
const priorities = result.items.map((g) => g.goodPriority); const priorities = result.items.map((g) => g.goodPriority);
@@ -193,31 +241,74 @@ describe('PublicService', () => {
expect(priorities).toEqual(sorted); expect(priorities).toEqual(sorted);
}); });
it('uses OR within one tag group and AND across tag groups', async () => {
const sameGroup = await service.getGoods({
page: 1,
pageSize: 50,
countryId: countryId.toString(),
keyword: `Pub `,
tagIds: filterTagIds
.slice(0, 2)
.map((tagId) => `${filterGroupIds[0]}:${tagId}`),
});
expect(sameGroup.items.map((item) => item.goodName)).toEqual(
expect.arrayContaining([`Pub High ${stamp}`, `Pub Mid ${stamp}`]),
);
const acrossGroups = await service.getGoods({
page: 1,
pageSize: 50,
countryId: countryId.toString(),
keyword: `Pub `,
tagIds: filterTagIds.map(
(tagId, index) =>
`${index < 2 ? filterGroupIds[0] : filterGroupIds[1]}:${tagId}`,
),
});
expect(acrossGroups.items.map((item) => item.goodName)).toContain(`Pub High ${stamp}`);
expect(acrossGroups.items.map((item) => item.goodName)).not.toContain(`Pub Mid ${stamp}`);
});
it('rejects a tag paired with the wrong tag group', async () => {
await expect(
service.getGoods({
page: 1,
pageSize: 20,
tagIds: [`${filterGroupIds[1]}:${filterTagIds[0]}`],
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('returns the SDS product id as the public product id', async () => { it('returns the SDS product id as the public product id', async () => {
const result = await service.getGoods({ const result = await service.getGoods({
page: 1, page: 1,
pageSize: 1, pageSize: 1,
countryId: Number(countryId), countryId: countryId.toString(),
keyword: `Pub High ${stamp}`, keyword: `Pub High ${stamp}`,
}); });
expect(result.items).toHaveLength(1); expect(result.items).toHaveLength(1);
expect(result.items[0].id).toBe(`pub-sds-${stamp}`); expect(result.items[0].goodId).toBe(`pub-sds-${stamp}`);
expect(result.items[0].id).not.toBe(goodIds[0].toString()); expect(result.items[0].goodId).not.toBe(goodIds[0].toString());
}); });
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,
pageSize: 1, pageSize: 1,
countryId: Number(countryId), countryId: countryId.toString(),
keyword: `Pub `, keyword: `Pub `,
}); });
expect(first.items.length).toBe(1); expect(first.items.length).toBe(1);
const detail = await service.getGood(goodIds[0]); const detail = await service.getGood(`pub-sds-${stamp}`);
expect(detail.id).toBe(first.items[0].id); expect(detail.goodId).toBe(first.items[0].goodId);
expect(detail.productCode).toBe('OZ10827003');
expect(detail.details.productionProcess).toBe('白墨烫画');
expect((detail.sizeChart?.rows as unknown[])).toHaveLength(1);
expect((detail.packageSpecs?.rows as unknown[])).toHaveLength(1);
expect(detail.variants).toHaveLength(1);
await expect(service.getGood(BigInt(99999999))).rejects.toBeInstanceOf( await expect(service.getGood('99999999')).rejects.toBeInstanceOf(
NotFoundException, NotFoundException,
); );
}); });
+261 -100
View File
@@ -1,12 +1,19 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Category as PrismaCategory, Prisma } from '@prisma/client'; import { Category as PrismaCategory, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { PublicQueryGoodDto } from './dto/public-query-good.dto'; import {
PublicHomeGoodsQueryDto,
PublicQueryGoodDto,
} 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';
import { PublicTagDto } from './dto/public-tag.dto'; import { PublicTagDto } from './dto/public-tag.dto';
import { PublicTagGroupDto } from './dto/public-tag-group.dto'; import { PublicTagGroupDto } from './dto/public-tag-group.dto';
import { PublicGoodDto } from './dto/public-good.dto'; import { PublicGoodDto } from './dto/public-good.dto';
import {
PublicGoodDetailDto,
PublicTagGroupFilterDto,
} from './dto/public-good-detail.dto';
export interface PublicPaginatedGoods { export interface PublicPaginatedGoods {
items: PublicGoodDto[]; items: PublicGoodDto[];
@@ -20,17 +27,28 @@ const PUBLIC_GOOD_INCLUDE = {
category: true, category: true,
tag: { include: { tagGroup: true } }, tag: { include: { tagGroup: true } },
position: true, position: true,
originGood: true, originGood: {
include: {
detail: true,
variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
},
},
goodTags: { include: { tag: { include: { tagGroup: true } } } }, goodTags: { include: { tag: { include: { tagGroup: true } } } },
} satisfies Prisma.GoodInclude; } satisfies Prisma.GoodInclude;
type PublicGoodRow = Prisma.GoodGetPayload<{ include: typeof PUBLIC_GOOD_INCLUDE }>;
@Injectable() @Injectable()
export class PublicService { export class PublicService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async getCategoriesTree(): Promise<PublicCategoryNodeDto[]> { async getCategoriesTree(countryId?: string): Promise<PublicCategoryNodeDto[]> {
const goodsWhere: Prisma.GoodWhereInput = {
originGood: { delisted: false },
...(countryId ? { countryId: BigInt(countryId) } : {}),
};
const leafCategories = await this.prisma.category.findMany({ const leafCategories = await this.prisma.category.findMany({
where: { goods: { some: {} } }, where: { goods: { some: goodsWhere } },
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
}); });
const ancestorIds = new Set<bigint>(); const ancestorIds = new Set<bigint>();
@@ -46,22 +64,30 @@ export class PublicService {
cursor = parent.parentCategoryId; cursor = parent.parentCategoryId;
} }
} }
const ancestorRows = ancestorIds.size > 0 const ancestorRows = ancestorIds.size
? await this.prisma.category.findMany({ ? await this.prisma.category.findMany({
where: { id: { in: [...ancestorIds] } }, where: { id: { in: [...ancestorIds] } },
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
}) })
: []; : [];
const allRows = [...leafCategories, ...ancestorRows].filter( const allRows = [...leafCategories, ...ancestorRows].filter(
(row, idx, arr) => arr.findIndex((r) => r.id === row.id) === idx, (row, index, rows) => rows.findIndex((item) => item.id === row.id) === index,
); );
allRows.sort((a, b) => Number(a.id - b.id)); allRows.sort((a, b) => Number(a.id - b.id));
return this.buildTree(allRows); const directCounts = await this.prisma.good.groupBy({
by: ['categoryId'],
where: goodsWhere,
_count: { _all: true },
});
return this.buildTree(
allRows,
new Map(directCounts.map((row) => [row.categoryId, row._count._all])),
);
} }
async getCountries(): Promise<PublicCountryDto[]> { async getCountries(): Promise<PublicCountryDto[]> {
const rows = await this.prisma.country.findMany({ const rows = await this.prisma.country.findMany({
where: { goods: { some: {} } }, where: { goods: { some: { originGood: { delisted: false } } } },
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
}); });
return rows.map(PublicCountryDto.from); return rows.map(PublicCountryDto.from);
@@ -69,7 +95,7 @@ export class PublicService {
async getTags(): Promise<PublicTagDto[]> { async getTags(): Promise<PublicTagDto[]> {
const rows = await this.prisma.tag.findMany({ const rows = await this.prisma.tag.findMany({
where: { goodTags: { some: {} } }, where: { goodTags: { some: { good: { originGood: { delisted: false } } } } },
orderBy: [ orderBy: [
{ tagGroup: { sortOrder: 'asc' } }, { tagGroup: { sortOrder: 'asc' } },
{ sortOrder: 'asc' }, { sortOrder: 'asc' },
@@ -80,99 +106,134 @@ export class PublicService {
return rows.map(PublicTagDto.from); return rows.map(PublicTagDto.from);
} }
async getTagGroups(): Promise<PublicTagGroupDto[]> { async getTagGroups(countryId?: string): Promise<PublicTagGroupFilterDto[]> {
const goodWhere: Prisma.GoodWhereInput = {
originGood: { delisted: false },
...(countryId ? { countryId: BigInt(countryId) } : {}),
};
const rows = await this.prisma.tagGroup.findMany({ const rows = await this.prisma.tagGroup.findMany({
where: { tags: { some: { goodTags: { some: {} } } } }, where: { tags: { some: { goodTags: { some: { good: goodWhere } } } } },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
include: {
tags: {
where: { goodTags: { some: { good: goodWhere } } },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
include: {
_count: { select: { goodTags: { where: { good: goodWhere } } } },
},
},
},
}); });
return rows.map(PublicTagGroupDto.from); return rows.map((group) => ({
...PublicTagGroupDto.from(group),
tags: group.tags.map((tag) => ({
id: tag.id.toString(),
tagName: tag.tagName,
tagColor: tag.tagColor,
tagFontColor: tag.tagFontColor,
sortOrder: tag.sortOrder,
productCount: tag._count.goodTags,
})),
}));
} }
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> { async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
const where: Prisma.GoodWhereInput = { const where: Prisma.GoodWhereInput = { originGood: { delisted: false } };
originGood: { delisted: false }, if (query.countryId) where.countryId = BigInt(query.countryId);
}; if (query.keyword) where.goodName = { contains: query.keyword, mode: 'insensitive' };
if (query.countryId !== undefined) where.countryId = BigInt(query.countryId); if (query.categoryId) {
if (query.tagIds) { where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) };
const ids = query.tagIds
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map((s) => BigInt(s));
if (ids.length > 0) {
// AND logic: 商品必须同时具备所有选中的 tag
where.AND = ids.map((id) => ({ goodTags: { some: { tagId: id } } }));
}
} }
if (query.keyword) {
where.goodName = { contains: query.keyword, mode: 'insensitive' }; const tagFilters = await this.buildTagGroupFilters([
...new Set(query.tagIds ?? []),
]);
if (tagFilters.length) where.AND = tagFilters;
const minPrice = this.parsePrice(query.minPrice, 'minPrice');
const maxPrice = this.parsePrice(query.maxPrice, 'maxPrice');
if (minPrice !== null && maxPrice !== null && minPrice > maxPrice) {
throw new BadRequestException('minPrice 不能大于 maxPrice');
} }
if (query.categoryId !== undefined) { if (minPrice !== null || maxPrice !== null) {
const ids = await this.collectCategoryDescendants(BigInt(query.categoryId)); where.originGood = {
where.categoryId = { in: ids }; delisted: false,
goodPrice: {
...(minPrice !== null ? { gte: minPrice } : {}),
...(maxPrice !== null ? { lte: maxPrice } : {}),
},
};
} }
const orderBy: Prisma.GoodOrderByWithRelationInput[] =
query.sort === 'PRICE_ASC'
? [{ originGood: { goodPrice: 'asc' } }, { id: 'asc' }]
: query.sort === 'PRICE_DESC'
? [{ originGood: { goodPrice: 'desc' } }, { id: 'asc' }]
: query.sort === 'NEWEST'
? [{ createdAt: 'desc' }, { id: 'asc' }]
: [
{ goodPriority: 'desc' },
{ position: { indexVal: 'asc' } },
{ createdAt: 'desc' },
{ id: 'asc' },
];
const [total, rows] = await this.prisma.$transaction([ const [total, rows] = await this.prisma.$transaction([
this.prisma.good.count({ where }), this.prisma.good.count({ where }),
this.prisma.good.findMany({ this.prisma.good.findMany({
where, where,
include: PUBLIC_GOOD_INCLUDE, include: PUBLIC_GOOD_INCLUDE,
// Server-side primary sort; PublicGoodDto retains original indexes orderBy,
// for stable pagination but the final ORDER BY is mirrored below.
orderBy: [
{ goodPriority: 'desc' },
{ position: { indexVal: 'asc' } },
{ createdAt: 'desc' },
],
skip: (query.page - 1) * query.pageSize, skip: (query.page - 1) * query.pageSize,
take: query.pageSize, take: query.pageSize,
}), }),
]); ]);
return { return {
items: rows.map((g) => this.toPublicGood(g)), items: rows.map((good) => this.toPublicGood(good)),
total, total,
page: query.page, page: query.page,
pageSize: query.pageSize, pageSize: query.pageSize,
}; };
} }
async getGood(id: bigint): Promise<PublicGoodDto> { async getGood(goodId: string): Promise<PublicGoodDetailDto> {
const good = await this.prisma.good.findUnique({ const good = await this.prisma.good.findFirst({
where: { id }, where: { originGood: { sdsGoodId: goodId, delisted: false } },
include: PUBLIC_GOOD_INCLUDE, include: PUBLIC_GOOD_INCLUDE,
orderBy: [{ goodPriority: 'desc' }, { id: 'asc' }],
}); });
if (!good) throw new NotFoundException(`Good ${id} not found`); if (!good) {
return this.toPublicGood(good); throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
}
return this.toPublicGoodDetail(good);
} }
private toPublicGood(good: { async getHomeGoods(query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
id: bigint; const rows = await this.prisma.good.findMany({
goodName: string; where: {
goodImage: string | null; positionId: { not: null },
goodPriority: number; originGood: { delisted: false },
country: { id: bigint; countryName: string; countryIcon: string | null }; ...(query.countryId ? { countryId: BigInt(query.countryId) } : {}),
category: { id: bigint; categoryName: string; categoryIcon: string | null }; },
tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroup: { id: bigint; groupName: string; sortOrder: number } | null } | null; include: PUBLIC_GOOD_INCLUDE,
position: { id: bigint; indexVal: number } | null; orderBy: [
originGood: { { position: { indexVal: 'asc' } },
sdsGoodId: string; { goodPriority: 'desc' },
goodImage: string | null; { id: 'asc' },
goodPrice: { toString(): string } | null; ],
}; take: query.limit,
goodTags: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroup: { id: bigint; groupName: string; sortOrder: number } | null } }[]; });
createdAt: Date; return rows.map((good) => this.toPublicGood(good));
}): PublicGoodDto { }
const formatGroup = (g: { id: bigint; groupName: string; sortOrder: number } | null) =>
g private toPublicGood(good: PublicGoodRow): PublicGoodDto {
? { const formatGroup = (group: { id: bigint; groupName: string; sortOrder: number } | null) =>
id: g.id.toString(), group
groupName: g.groupName, ? { id: group.id.toString(), groupName: group.groupName, sortOrder: group.sortOrder }
sortOrder: g.sortOrder,
}
: null; : null;
return { return {
id: good.originGood.sdsGoodId, goodId: good.originGood.sdsGoodId,
goodName: good.goodName, goodName: good.goodName,
goodPriority: good.goodPriority, goodPriority: good.goodPriority,
country: { country: {
@@ -194,63 +255,163 @@ export class PublicService {
group: formatGroup(good.tag.tagGroup), group: formatGroup(good.tag.tagGroup),
} }
: null, : null,
tags: good.goodTags.map((gt) => ({ tags: good.goodTags.map(({ tag }) => ({
id: gt.tag.id.toString(), id: tag.id.toString(),
tagName: gt.tag.tagName, tagName: tag.tagName,
tagColor: gt.tag.tagColor, tagColor: tag.tagColor,
tagFontColor: gt.tag.tagFontColor, tagFontColor: tag.tagFontColor,
group: formatGroup(gt.tag.tagGroup), group: formatGroup(tag.tagGroup),
})), })),
position: good.position position: good.position
? { ? { id: good.position.id.toString(), indexVal: good.position.indexVal }
id: good.position.id.toString(),
indexVal: good.position.indexVal,
}
: null, : null,
image: good.goodImage ?? good.originGood?.goodImage ?? null, image: good.goodImage ?? good.originGood.goodImage,
price: price: good.originGood.goodPrice?.toString() ?? null,
good.originGood?.goodPrice === null ||
good.originGood?.goodPrice === undefined
? null
: good.originGood.goodPrice.toString(),
createdAt: good.createdAt.toISOString(), createdAt: good.createdAt.toISOString(),
}; };
} }
private toPublicGoodDetail(good: PublicGoodRow): PublicGoodDetailDto {
const base = this.toPublicGood(good);
const detail = good.originGood.detail;
return {
...base,
productCode: detail?.productCode ?? null,
englishName: detail?.englishName ?? null,
productionCycleHours: detail?.productionCycleHours ?? null,
minWeightG: detail?.minWeightG?.toString() ?? null,
details: {
reminder: detail?.reminder ?? null,
productionProcess: detail?.productionProcess ?? null,
materialDescription: detail?.materialDescription ?? null,
productPerformance: detail?.productPerformance ?? null,
applicableScenarios: detail?.applicableScenarios ?? null,
washingInstructions: detail?.washingInstructions ?? null,
specialDescription: detail?.specialDescription ?? null,
designExplanation: detail?.designExplanation ?? null,
designArea: detail?.designArea ?? null,
pictureRequest: detail?.pictureRequest ?? null,
},
media: (detail?.media as Record<string, unknown> | null) ?? null,
options: (detail?.options as Record<string, unknown> | null) ?? null,
sizeChart: (detail?.sizeChart as Record<string, unknown> | null) ?? null,
packageSpecs: (detail?.packageSpecs as Record<string, unknown> | null) ?? null,
variants: good.originGood.variants.map((variant) => ({
id: variant.sdsVariantId,
sku: variant.sku,
sizeId: variant.sizeId,
sizeName: variant.sizeName,
colorId: variant.colorId,
colorName: variant.colorName,
colorHex: variant.colorHex,
imageUrl: variant.imageUrl,
price: variant.price?.toString() ?? null,
originalPrice: variant.originalPrice?.toString() ?? null,
weightG: variant.weightG?.toString() ?? null,
boxLengthCm: variant.boxLengthCm?.toString() ?? null,
boxWidthCm: variant.boxWidthCm?.toString() ?? null,
boxHeightCm: variant.boxHeightCm?.toString() ?? null,
enabled: variant.enabled,
sortOrder: variant.sortOrder,
})),
detailSyncedAt: detail?.syncedAt.toISOString() ?? null,
};
}
private async buildTagGroupFilters(
selectedTagValues: string[],
): Promise<Prisma.GoodWhereInput[]> {
const selections = selectedTagValues.map((value) => {
const [tagGroupId, tagId] = value.split(':');
if (!tagGroupId || !tagId || !/^\d+$/.test(tagGroupId) || !/^\d+$/.test(tagId)) {
throw new BadRequestException(
'tagIds 每个元素必须为 tagGroupId:tagId',
);
}
return { tagGroupId, tagId };
});
const uniqueTagIds = [...new Set(selections.map((item) => item.tagId))];
const selected = uniqueTagIds.length
? await this.prisma.tag.findMany({
where: { id: { in: uniqueTagIds.map((id) => BigInt(id)) } },
select: { id: true, tagGroupId: true },
})
: [];
if (selected.length !== uniqueTagIds.length) {
throw new BadRequestException('包含不存在的标签 ID');
}
const actualGroups = new Map(
selected.map((tag) => [
tag.id.toString(),
tag.tagGroupId?.toString() ?? null,
]),
);
for (const selection of selections) {
if (actualGroups.get(selection.tagId) !== selection.tagGroupId) {
throw new BadRequestException(
`标签 ${selection.tagId} 不属于标签组 ${selection.tagGroupId}`,
);
}
}
const byGroup = new Map<string, bigint[]>();
for (const selection of selections) {
const key = selection.tagGroupId;
const ids = byGroup.get(key) ?? [];
const id = BigInt(selection.tagId);
if (!ids.includes(id)) ids.push(id);
byGroup.set(key, ids);
}
return [...byGroup.values()].map((ids) => ({
goodTags: { some: { tagId: { in: ids } } },
}));
}
private parsePrice(value: string | undefined, field: string): number | null {
if (value === undefined || value === '') return null;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
throw new BadRequestException(`${field} 必须是大于等于 0 的金额`);
}
return parsed;
}
private async collectCategoryDescendants(rootId: bigint): Promise<bigint[]> { private async collectCategoryDescendants(rootId: bigint): Promise<bigint[]> {
const ids: bigint[] = [rootId]; const ids: bigint[] = [rootId];
let frontier: bigint[] = [rootId]; let frontier: bigint[] = [rootId];
while (frontier.length > 0) { while (frontier.length) {
const children = await this.prisma.category.findMany({ const children = await this.prisma.category.findMany({
where: { parentCategoryId: { in: frontier } }, where: { parentCategoryId: { in: frontier } },
select: { id: true }, select: { id: true },
}); });
if (children.length === 0) break; if (!children.length) break;
const childIds = children.map((c) => c.id); frontier = children.map((child) => child.id);
ids.push(...childIds); ids.push(...frontier);
frontier = childIds;
} }
return ids; return ids;
} }
private buildTree( private buildTree(
rows: PrismaCategory[], rows: PrismaCategory[],
directCounts: Map<bigint, number>,
): PublicCategoryNodeDto[] { ): PublicCategoryNodeDto[] {
const byId = new Map<bigint, PublicCategoryNodeDto>(); const byId = new Map<bigint, PublicCategoryNodeDto>();
for (const row of rows) { for (const row of rows) {
byId.set(row.id, PublicCategoryNodeDto.from(row, [])); const node = PublicCategoryNodeDto.from(row, []);
node.productCount = directCounts.get(row.id) ?? 0;
byId.set(row.id, node);
} }
const roots: PublicCategoryNodeDto[] = []; const roots: PublicCategoryNodeDto[] = [];
for (const row of rows) { for (const row of rows) {
const node = byId.get(row.id)!; const node = byId.get(row.id)!;
if (row.parentCategoryId === null) { const parent = row.parentCategoryId === null ? null : byId.get(row.parentCategoryId);
roots.push(node); if (parent) parent.children.push(node);
} else { else roots.push(node);
const parent = byId.get(row.parentCategoryId);
if (parent) parent.children.push(node);
else roots.push(node);
}
} }
const total = (node: PublicCategoryNodeDto): number => {
node.productCount += node.children.reduce((sum, child) => sum + total(child), 0);
return node.productCount;
};
roots.forEach(total);
return roots; return roots;
} }
} }
+1 -1
View File
@@ -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';
@@ -33,4 +33,23 @@ describe('SdsClientService', () => {
expect(result).toHaveLength(20); expect(result).toHaveLength(20);
}); });
}); });
describe('fetchProductDetail', () => {
it('requests /products/{goodId} and validates the returned id', async () => {
http.get.mockReturnValue(of({ data: { id: 168746, sku: 'OZ10827003' } }));
const result = await service.fetchProductDetail('168746');
expect(result.sku).toBe('OZ10827003');
expect(http.get).toHaveBeenCalledWith(
'https://mapi.sdspod.com/products/168746',
expect.objectContaining({ headers: expect.any(Object) }),
);
});
it('rejects a mismatched product response', async () => {
http.get.mockReturnValue(of({ data: { id: 1 } }));
await expect(service.fetchProductDetail('168746')).rejects.toThrow(/id mismatch/i);
});
});
}); });
+93
View File
@@ -52,6 +52,86 @@ export interface SdsProductsPage {
[key: string]: unknown; [key: string]: unknown;
} }
export interface SdsProductVariant extends Record<string, unknown> {
id?: number | string;
sku?: string;
size?: string;
sizeId?: number | string;
sizeDto?: { id?: number | string; sizeName?: string };
colorId?: number | string;
color_name?: string;
color?: {
colorId?: number | string;
color?: string;
color_name?: string;
chineseName?: string;
};
currentPrice?: number | string;
originalPrice?: number | string;
unit_price?: number | string;
min_price?: number | string;
weight?: number | string;
box_length?: number | string;
box_width?: number | string;
box_height?: number | string;
status?: number | string;
delFlag?: number | string;
size_sort?: number | string;
attribute_sort?: string;
psd_img_url?: string;
img_url?: string;
blankDesignUrl?: string;
designPrototype?: {
detailImgUrls?: Array<{ imageUrl?: string }>;
prototypeResultGroups?: Array<{ resultImage?: string }>;
[key: string]: unknown;
};
}
export interface SdsProductDetail extends Record<string, unknown> {
id: number | string;
name?: string;
sku?: string;
english_name?: string;
blankDesignUrl?: string;
detailsPageVideoUrl?: string;
productionCycle?: number | string;
minWeight?: number | string;
min_price?: number | string;
updateTime?: number | string;
psd_img_url?: string;
img_url?: string;
texture?: { name?: string };
product_details?: {
reminder?: string;
production_process?: string;
material_description?: string;
product_performance?: string;
applicable_scenarios?: string;
washing_instructions?: string;
special_description?: string;
design_explanation?: string;
design_area?: string;
picture_request?: string;
product_size?: string;
packaging_specification?: string;
};
subproducts?: {
attributers?: Array<{
size?: string;
sizeId?: number | string;
colors?: Array<{
colorId?: number | string;
color?: string;
color_name?: string;
chineseName?: string;
colorSort?: number | string;
}>;
}>;
items?: SdsProductVariant[];
};
}
/** /**
* Thin wrapper around the SDS (mapi.sdspod.com) endpoints that the * Thin wrapper around the SDS (mapi.sdspod.com) endpoints that the
* `SyncService` consumes. * `SyncService` consumes.
@@ -137,4 +217,17 @@ export class SdsClientService {
} }
return data as SdsProductsPage; return data as SdsProductsPage;
} }
async fetchProductDetail(goodId: string | number): Promise<SdsProductDetail> {
const url = `${this.baseUrl}/products/${encodeURIComponent(String(goodId))}`;
const data = await this.request<unknown>('get', url);
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error(`SDS product detail returned ${typeof data}, expected object`);
}
const detail = data as SdsProductDetail;
if (String(detail.id) !== String(goodId)) {
throw new Error(`SDS product detail id mismatch: expected ${goodId}, got ${String(detail.id)}`);
}
return detail;
}
} }
@@ -0,0 +1,86 @@
import {
normalizeProductDetail,
parsePackageSpecs,
parseSizeChart,
} from './sds-product-detail.mapper';
const sizeTable = JSON.stringify([
['尺码', '衣长(cm/in)', '胸围(cm/in)', '肩宽(cm/in)', '袖长(cm/in)'].map((content) => ({ content, remark: '' })),
['S', '71', '92', '43', '22'].map((content) => ({ content, remark: '' })),
['M', '74', '102', '45', '22'].map((content) => ({ content, remark: '' })),
['L', '76', '112', '48', '23'].map((content) => ({ content, remark: '' })),
['XL', '79', '122', '51', '23'].map((content) => ({ content, remark: '' })),
['2XL', '82', '132', '53', '25'].map((content) => ({ content, remark: '' })),
['3XL', '84', '142', '56', '25'].map((content) => ({ content, remark: '' })),
]);
const packageTable = JSON.stringify([
['尺码', '包装尺寸(cm', '包装尺寸(in', '包装体积(cm³)', '包装体积(in³)', '含包装重量(g', '含包装重量(lb'].map((content) => ({ content })),
['S', '36.0*26.0*1.0\t', '14.17*10.24*0.39\t', '936.00', '57.12', '208.00', '0.46'].map((content) => ({ content })),
['M', '36.0*26.0*1.0', '14.17*10.24*0.39', '936.00', '57.12', '218.00', '0.48'].map((content) => ({ content })),
]);
describe('SDS product detail mapper', () => {
it('parses the product_detail.txt size table into structured rows', () => {
const chart = parseSizeChart(sizeTable) as any;
expect(chart.columns.map((column: any) => column.key)).toEqual([
'bodyLength',
'chest',
'shoulder',
'sleeveLength',
]);
expect(chart.rows).toHaveLength(6);
expect(chart.rows[0].measurements[0]).toEqual({ key: 'bodyLength', cm: '71', in: '27.95' });
});
it('parses packaging dimensions and weights', () => {
const specs = parsePackageSpecs(packageTable) as any;
expect(specs.rows).toHaveLength(2);
expect(specs.rows[0].dimensionsCm).toEqual({ length: '36.0', width: '26.0', height: '1.0' });
expect(specs.rows[0].grossWeightG).toBe('208.00');
});
it('normalizes detail text, options and variants', () => {
const normalized = normalizeProductDetail({
id: 168746,
sku: 'OZ10827003',
english_name: 't-shirt',
productionCycle: 24,
minWeight: 250,
product_details: {
production_process: '白墨烫画',
material_description: '100%纯棉',
product_size: sizeTable,
packaging_specification: packageTable,
},
subproducts: {
attributers: [{
size: 'S',
sizeId: 1922304,
colors: [{ colorId: 1139383, color: '#000300', color_name: 'black', colorSort: 1 }],
}],
items: [{
id: 168747,
sku: 'OZ10827003001',
size: 'S',
sizeId: 1922304,
colorId: 1139383,
color: { colorId: 1139383, color: '#000300', color_name: 'black' },
currentPrice: 38,
originalPrice: 38,
weight: 250,
box_length: 30,
box_width: 20,
box_height: 5,
status: 1,
delFlag: '0',
}],
},
});
expect(normalized.productCode).toBe('OZ10827003');
expect(normalized.productionProcess).toBe('白墨烫画');
expect(normalized.variants[0].sku).toBe('OZ10827003001');
expect(normalized.variants[0].price?.toString()).toBe('38');
});
});
@@ -0,0 +1,261 @@
import { Prisma } from '@prisma/client';
import { SdsProductDetail, SdsProductVariant } from './sds-client.service';
type TableCell = { content?: unknown; remark?: unknown };
type Table = TableCell[][];
export interface NormalizedVariant {
sdsVariantId: string;
sku: string;
sizeId: string | null;
sizeName: string | null;
colorId: string | null;
colorName: string | null;
colorHex: string | null;
imageUrl: string | null;
price: Prisma.Decimal | null;
originalPrice: Prisma.Decimal | null;
weightG: Prisma.Decimal | null;
boxLengthCm: Prisma.Decimal | null;
boxWidthCm: Prisma.Decimal | null;
boxHeightCm: Prisma.Decimal | null;
enabled: boolean;
sortOrder: number;
designData: Prisma.InputJsonValue | null;
}
export interface NormalizedProductDetail {
productCode: string | null;
englishName: string | null;
blankDesignUrl: string | null;
detailsPageVideoUrl: string | null;
textureName: string | null;
productionCycleHours: number | null;
minWeightG: Prisma.Decimal | null;
reminder: string | null;
productionProcess: string | null;
materialDescription: string | null;
productPerformance: string | null;
applicableScenarios: string | null;
washingInstructions: string | null;
specialDescription: string | null;
designExplanation: string | null;
designArea: string | null;
pictureRequest: string | null;
sizeChart: Prisma.InputJsonValue | null;
packageSpecs: Prisma.InputJsonValue | null;
options: Prisma.InputJsonValue | null;
media: Prisma.InputJsonValue | null;
upstreamUpdatedAt: Date | null;
variants: NormalizedVariant[];
}
const text = (value: unknown): string | null => {
if (value === undefined || value === null) return null;
const normalized = String(value).trim();
return normalized.length > 0 ? normalized : null;
};
const decimal = (value: unknown): Prisma.Decimal | null => {
if (value === undefined || value === null || value === '') return null;
const n = Number(value);
return Number.isFinite(n) ? new Prisma.Decimal(n) : null;
};
const integer = (value: unknown): number | null => {
const n = Number(value);
return Number.isInteger(n) ? n : null;
};
function parseTable(raw: unknown): Table | null {
if (typeof raw !== 'string' || raw.trim() === '') return null;
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed) || parsed.length < 2) return null;
const rows = parsed.filter(Array.isArray) as Table;
return rows.length >= 2 ? rows : null;
} catch {
return null;
}
}
const cell = (row: TableCell[], index: number): string =>
text(row[index]?.content)?.replace(/\t/g, '').trim() ?? '';
const measurementKey = (header: string, index: number): string => {
if (header.includes('衣长')) return 'bodyLength';
if (header.includes('胸围')) return 'chest';
if (header.includes('肩宽')) return 'shoulder';
if (header.includes('袖长')) return 'sleeveLength';
return `measurement${index}`;
};
export function parseSizeChart(raw: unknown): Prisma.InputJsonValue | null {
const table = parseTable(raw);
if (!table) return null;
const [header, ...body] = table;
const columns = header.slice(1).map((item, index) => {
const name = text(item.content)?.replace(/\s*\(cm\/in\)\s*/i, '') ?? `规格${index + 1}`;
return { key: measurementKey(name, index + 1), name };
});
const rows = body
.map((row, rowIndex) => {
const sizeName = cell(row, 0);
if (!sizeName) return null;
return {
sizeId: `size_${rowIndex}`,
sizeName,
measurements: columns.map((column, index) => {
const cm = cell(row, index + 1);
const cmNumber = Number(cm);
return {
key: column.key,
cm: cm || null,
in: Number.isFinite(cmNumber) ? (cmNumber / 2.54).toFixed(2) : null,
};
}),
};
})
.filter((row): row is NonNullable<typeof row> => row !== null);
return { columns, rows } as Prisma.InputJsonValue;
}
function dimensions(value: string): { length: string; width: string; height: string } | null {
const parts = value
.replace(/[×x]/gi, '*')
.split('*')
.map((part) => part.trim());
if (parts.length !== 3 || parts.some((part) => !Number.isFinite(Number(part)))) return null;
return { length: parts[0], width: parts[1], height: parts[2] };
}
export function parsePackageSpecs(raw: unknown): Prisma.InputJsonValue | null {
const table = parseTable(raw);
if (!table) return null;
const rows = table.slice(1)
.map((row, rowIndex) => {
const sizeName = cell(row, 0);
if (!sizeName) return null;
return {
sizeId: `size_${rowIndex}`,
sizeName,
dimensionsCm: dimensions(cell(row, 1)),
dimensionsIn: dimensions(cell(row, 2)),
volumeCm3: cell(row, 3) || null,
volumeIn3: cell(row, 4) || null,
grossWeightG: cell(row, 5) || null,
grossWeightLb: cell(row, 6) || null,
};
})
.filter((row): row is NonNullable<typeof row> => row !== null);
return { rows } as Prisma.InputJsonValue;
}
function normalizeOptions(detail: SdsProductDetail): Prisma.InputJsonValue | null {
const attributers = detail.subproducts?.attributers;
if (!Array.isArray(attributers)) return null;
const sizeMap = new Map<string, { id: string; name: string; sortOrder: number; enabled: boolean }>();
const colorMap = new Map<string, { id: string; name: string; hex: string | null; sortOrder: number; enabled: boolean }>();
attributers.forEach((attribute, sizeIndex) => {
const sizeName = text(attribute.size);
const sizeId = text(attribute.sizeId) ?? `size_${sizeIndex}`;
if (sizeName) sizeMap.set(sizeId, { id: sizeId, name: sizeName, sortOrder: sizeIndex, enabled: true });
if (Array.isArray(attribute.colors)) {
attribute.colors.forEach((color, colorIndex) => {
const colorId = text(color.colorId) ?? `color_${colorIndex}`;
if (!colorMap.has(colorId)) {
colorMap.set(colorId, {
id: colorId,
name: text(color.chineseName) ?? text(color.color_name) ?? colorId,
hex: text(color.color),
sortOrder: integer(color.colorSort) ?? colorIndex,
enabled: true,
});
}
});
}
});
return { sizes: [...sizeMap.values()], colors: [...colorMap.values()] } as Prisma.InputJsonValue;
}
function normalizeMedia(detail: SdsProductDetail, variants: SdsProductVariant[]): Prisma.InputJsonValue | null {
const urls: string[] = [];
const add = (value: unknown) => {
const url = text(value);
if (url && !urls.includes(url)) urls.push(url);
};
add(detail.blankDesignUrl);
add(detail.psd_img_url);
add(detail.img_url);
for (const variant of variants) {
add(variant.psd_img_url);
add(variant.img_url);
add(variant.blankDesignUrl);
for (const image of variant.designPrototype?.detailImgUrls ?? []) add(image.imageUrl);
for (const image of variant.designPrototype?.prototypeResultGroups ?? []) add(image.resultImage);
}
if (urls.length === 0) return null;
return {
primaryImageUrl: urls[0],
images: urls.map((url, index) => ({ id: `image_${index}`, url, sortOrder: index })),
} as Prisma.InputJsonValue;
}
function normalizeVariant(variant: SdsProductVariant, index: number): NormalizedVariant | null {
const sdsVariantId = text(variant.id);
const sku = text(variant.sku);
if (!sdsVariantId || !sku) return null;
return {
sdsVariantId,
sku,
sizeId: text(variant.sizeId) ?? text(variant.sizeDto?.id),
sizeName: text(variant.size) ?? text(variant.sizeDto?.sizeName),
colorId: text(variant.colorId) ?? text(variant.color?.colorId),
colorName: text(variant.color?.chineseName) ?? text(variant.color_name) ?? text(variant.color?.color_name),
colorHex: text(variant.color?.color),
imageUrl: text(variant.psd_img_url) ?? text(variant.img_url) ?? text(variant.blankDesignUrl),
price: decimal(variant.currentPrice ?? variant.unit_price ?? variant.min_price),
originalPrice: decimal(variant.originalPrice),
weightG: decimal(variant.weight),
boxLengthCm: decimal(variant.box_length),
boxWidthCm: decimal(variant.box_width),
boxHeightCm: decimal(variant.box_height),
enabled: Number(variant.status ?? 1) === 1 && String(variant.delFlag ?? '0') === '0',
sortOrder: integer(variant.attribute_sort?.split('-')[0]) ?? integer(variant.size_sort) ?? index,
designData: variant.designPrototype ? (variant.designPrototype as Prisma.InputJsonValue) : null,
};
}
export function normalizeProductDetail(detail: SdsProductDetail): NormalizedProductDetail {
const productDetails = detail.product_details ?? {};
const sourceVariants = Array.isArray(detail.subproducts?.items) ? detail.subproducts!.items! : [];
const variants = sourceVariants
.map(normalizeVariant)
.filter((variant): variant is NormalizedVariant => variant !== null);
const updatedAt = Number(detail.updateTime);
return {
productCode: text(detail.sku),
englishName: text(detail.english_name),
blankDesignUrl: text(detail.blankDesignUrl),
detailsPageVideoUrl: text(detail.detailsPageVideoUrl),
textureName: text(detail.texture?.name),
productionCycleHours: integer(detail.productionCycle),
minWeightG: decimal(detail.minWeight),
reminder: text(productDetails.reminder),
productionProcess: text(productDetails.production_process),
materialDescription: text(productDetails.material_description),
productPerformance: text(productDetails.product_performance),
applicableScenarios: text(productDetails.applicable_scenarios),
washingInstructions: text(productDetails.washing_instructions),
specialDescription: text(productDetails.special_description),
designExplanation: text(productDetails.design_explanation),
designArea: text(productDetails.design_area),
pictureRequest: text(productDetails.picture_request),
sizeChart: parseSizeChart(productDetails.product_size),
packageSpecs: parsePackageSpecs(productDetails.packaging_specification),
options: normalizeOptions(detail),
media: normalizeMedia(detail, sourceVariants),
upstreamUpdatedAt: Number.isFinite(updatedAt) ? new Date(updatedAt) : null,
variants,
};
}
+13
View File
@@ -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 })
+4
View File
@@ -19,6 +19,7 @@ describe('SyncService', () => {
const sdsMock: Partial<SdsClientService> = { const sdsMock: Partial<SdsClientService> = {
fetchCategoryTree: jest.fn(), fetchCategoryTree: jest.fn(),
fetchProductsPage: jest.fn(), fetchProductsPage: jest.fn(),
fetchProductDetail: jest.fn(async (goodId: string | number) => ({ id: goodId })),
}; };
const moduleRef = await Test.createTestingModule({ const moduleRef = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ isGlobal: true })], imports: [ConfigModule.forRoot({ isGlobal: true })],
@@ -29,6 +30,9 @@ describe('SyncService', () => {
], ],
}).compile(); }).compile();
service = moduleRef.get(SyncService); service = moduleRef.get(SyncService);
jest
.spyOn(service, 'syncConfiguredProductDetails')
.mockResolvedValue({ synced: 0, failed: 0 });
sds = moduleRef.get(SdsClientService) as jest.Mocked<SdsClientService>; sds = moduleRef.get(SdsClientService) as jest.Mocked<SdsClientService>;
prisma = moduleRef.get(PrismaService); prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit(); await prisma.onModuleInit();
+211 -6
View File
@@ -1,8 +1,14 @@
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';
import { SdsClientService, SdsCategoryTreeNode, SdsProduct } from './sds-client.service'; import {
SdsClientService,
SdsCategoryTreeNode,
SdsProduct,
SdsProductDetail,
} from './sds-client.service';
import { normalizeProductDetail } from './sds-product-detail.mapper';
export interface CategorySyncResult { export interface CategorySyncResult {
inserted: number; inserted: number;
@@ -17,6 +23,8 @@ export interface ProductSyncResult {
total: number; total: number;
leafCategories: number; leafCategories: number;
delisted: number; delisted: number;
detailsSynced: number;
detailFailures: number;
} }
/** /**
@@ -59,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,
@@ -102,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];
} }
@@ -339,15 +357,28 @@ 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}`, message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length} detailsSynced=${detailResult.synced} detailFailures=${detailResult.failed}`,
}, },
}); });
return { inserted, updated, total, leafCategories: leafRows.length, delisted: delistedCount }; return {
inserted,
updated,
total,
leafCategories: leafRows.length,
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);
await this.prisma.syncLog.update({ await this.prisma.syncLog.update({
@@ -371,6 +402,180 @@ 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 }> {
const configured = await this.prisma.originGood.findMany({
where: { delisted: false, goods: { some: {} } },
select: { id: true, sdsGoodId: true },
orderBy: { id: 'asc' },
});
let synced = 0;
let failed = 0;
for (const originGood of configured) {
try {
const upstream = await this.sds.fetchProductDetail(originGood.sdsGoodId);
await this.persistProductDetail(originGood.id, upstream);
synced++;
} catch (error) {
failed++;
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to sync SDS detail ${originGood.sdsGoodId}: ${message}`);
}
}
return { synced, failed };
}
async importProductDetail(upstream: SdsProductDetail): Promise<{
goodId: string;
variants: number;
sizeRows: number;
packageRows: number;
configuredGoods: number;
}> {
const goodId = String(upstream.id);
const normalized = normalizeProductDetail(upstream);
const originGood = await this.prisma.originGood.upsert({
where: { sdsGoodId: goodId },
create: {
sdsGoodId: goodId,
goodName: String(upstream.name ?? goodId),
goodImage: String(upstream.psd_img_url ?? upstream.img_url ?? upstream.blankDesignUrl ?? '') || null,
goodPrice:
upstream.min_price === undefined || upstream.min_price === null
? null
: new Prisma.Decimal(Number(upstream.min_price)),
},
update: {
goodName: upstream.name ? String(upstream.name) : undefined,
goodImage: String(upstream.psd_img_url ?? upstream.img_url ?? upstream.blankDesignUrl ?? '') || undefined,
goodPrice:
upstream.min_price === undefined || upstream.min_price === null
? undefined
: new Prisma.Decimal(Number(upstream.min_price)),
},
});
await this.persistProductDetail(originGood.id, upstream);
const configuredGoods = await this.prisma.good.count({
where: { originGoodId: originGood.id },
});
const sizeChart = normalized.sizeChart as { rows?: unknown[] } | null;
const packageSpecs = normalized.packageSpecs as { rows?: unknown[] } | null;
return {
goodId,
variants: normalized.variants.length,
sizeRows: sizeChart?.rows?.length ?? 0,
packageRows: packageSpecs?.rows?.length ?? 0,
configuredGoods,
};
}
private async persistProductDetail(originGoodId: bigint, upstream: SdsProductDetail): Promise<void> {
const normalized = normalizeProductDetail(upstream);
const { variants, ...detail } = normalized;
await this.prisma.$transaction(async (tx) => {
const json = (value: Prisma.InputJsonValue | null) => value ?? Prisma.DbNull;
await tx.originGoodDetail.upsert({
where: { originGoodId },
create: {
originGoodId,
...detail,
sizeChart: json(detail.sizeChart),
packageSpecs: json(detail.packageSpecs),
options: json(detail.options),
media: json(detail.media),
},
update: {
...detail,
sizeChart: json(detail.sizeChart),
packageSpecs: json(detail.packageSpecs),
options: json(detail.options),
media: json(detail.media),
syncedAt: new Date(),
},
});
const seenVariantIds: string[] = [];
for (const variant of variants) {
seenVariantIds.push(variant.sdsVariantId);
const { designData, ...data } = variant;
await tx.originGoodVariant.upsert({
where: {
originGoodId_sdsVariantId: {
originGoodId,
sdsVariantId: variant.sdsVariantId,
},
},
create: { originGoodId, ...data, designData: json(designData) },
update: { ...data, designData: json(designData) },
});
}
await tx.originGoodVariant.deleteMany({
where: {
originGoodId,
...(seenVariantIds.length ? { sdsVariantId: { notIn: seenVariantIds } } : {}),
},
});
});
}
/** /**
* Flattens the SDS nested tree into a list of `{ sdsId, parentSdsId?, name, icon? }`. * Flattens the SDS nested tree into a list of `{ sdsId, parentSdsId?, name, icon? }`.
*/ */
@@ -145,7 +145,7 @@ interface BackendTag {
} }
interface BackendProduct { interface BackendProduct {
id: string; goodId: string;
goodName: string; goodName: string;
goodPriority: number; goodPriority: number;
country: { id: string; countryName: string; countryIcon: string | null }; country: { id: string; countryName: string; countryIcon: string | null };
@@ -243,7 +243,7 @@ export function resolveDefaultProductFilters(
function mapProduct(p: BackendProduct, backendUrl: string): Product { function mapProduct(p: BackendProduct, backendUrl: string): Product {
return { return {
id: p.id, id: p.goodId,
name: p.goodName, name: p.goodName,
priority: p.goodPriority, priority: p.goodPriority,
image: resolveBackendAssetUrl(p.image, backendUrl), image: resolveBackendAssetUrl(p.image, backendUrl),
+2
View File
@@ -3,6 +3,8 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "turbo run dev", "dev": "turbo run dev",
"dev:admin": "turbo run dev --filter=@inkreach/api --filter=@inkreach/admin",
"dev:api": "pnpm --filter @inkreach/api dev",
"build": "turbo run build", "build": "turbo run build",
"lint": "turbo run lint", "lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,js,json,md,vue}\"", "format": "prettier --write \"**/*.{ts,js,json,md,vue}\"",