feat: 积分商城-供应商-对账-发票4
This commit is contained in:
@@ -4,6 +4,10 @@ const API_BASE = import.meta.env.VITE_SUPPLIED_ADMIN_API_BASE || 'http://127.0.0
|
||||
|
||||
const normalizeBase = (base: string) => base.replace(/\/$/, '')
|
||||
|
||||
export const resolveImageUrl = (value: string) => /^\/api\/(?:file|upload-preview)\?/.test(value)
|
||||
? `${normalizeBase(API_BASE).replace(/\/api\/admin$/, '')}${value.replace('/api/upload-preview?', '/api/file?')}`
|
||||
: value
|
||||
|
||||
export const postAdmin = async <T>(name: string, body: Record<string, unknown>): Promise<T> => {
|
||||
const token = readAdminSession()?.token
|
||||
const response = await fetch(`${normalizeBase(API_BASE)}/${name}`, {
|
||||
|
||||
@@ -1,23 +1,111 @@
|
||||
<template>
|
||||
<section class="page-card"><div class="page-title"><div><h2>{{ title }}</h2><p>{{ description }}</p></div></div><el-form :inline="true" class="query-form"><el-form-item label="供应商 ID"><el-input v-model="supplierId" placeholder="请输入供应商 ID" clearable /></el-form-item><el-form-item><el-button type="primary" :loading="loading" @click="loadRecords">查询</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form><el-table v-loading="loading" :data="records" border><el-table-column prop="serviceOrderId" label="售后单号" min-width="170" /><el-table-column prop="serviceTypeText" label="售后类型" width="110" /><el-table-column prop="serviceStateText" label="状态" width="130" /><el-table-column prop="reason" label="原因" min-width="160" /><el-table-column prop="applyTime" label="申请时间" min-width="170" /><el-table-column label="操作" min-width="160" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button v-if="scene === 'audit'" link type="success" @click="audit(row, 1)">通过</el-button><el-button v-if="scene === 'audit'" link type="danger" @click="audit(row, 2)">拒绝</el-button><el-button v-if="canReceive(row)" link type="success" @click="receive(row)">确认收货</el-button><el-button v-if="canShip(row)" link type="primary" @click="openShipDialog(row)">换货发货</el-button></template></el-table-column><template #empty><el-empty :image-size="80" description="暂无售后记录" /></template></el-table><el-dialog v-model="detailVisible" title="售后详情" width="620px"><pre class="detail-json">{{ JSON.stringify(detail, null, 2) }}</pre></el-dialog><el-dialog v-model="shipVisible" title="换货发货" width="460px"><el-form label-width="90px"><el-form-item label="快递公司"><el-input v-model="shipForm.logisticsCompany" /></el-form-item><el-form-item label="快递单号"><el-input v-model="shipForm.logisticsNo" /></el-form-item></el-form><template #footer><el-button @click="shipVisible = false">取消</el-button><el-button type="primary" @click="shipExchange">确认发货</el-button></template></el-dialog></section>
|
||||
<section class="page-card"><div class="page-title"><div><h2>{{ title }}</h2><p>{{ description }}</p></div></div><el-form :inline="true" class="query-form" @submit.prevent="loadRecords"><el-form-item label="售后单号"><el-input v-model="serviceOrderId" placeholder="请输入售后单号" clearable /></el-form-item><el-form-item><el-button type="primary" :loading="loading" @click="loadRecords">查询</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form><div v-if="scene !== 'process'" v-loading="loading" class="audit-list">
|
||||
<article v-for="row in records" :key="row.serviceOrderId" class="audit-card">
|
||||
<header class="audit-card-header"><strong>{{ row.serviceOrderId }}</strong><el-tag type="warning">{{ row.serviceTypeText || '-' }}</el-tag></header>
|
||||
<div v-for="product in row.products || []" :key="product.id || product.sku" class="audit-product">
|
||||
<el-image :src="resolveImageUrl(product.image || '')" :preview-src-list="product.image ? [resolveImageUrl(product.image)] : []" preview-teleported fit="cover"><template #error><span class="image-empty">暂无图片</span></template></el-image>
|
||||
<div class="audit-product-info"><strong>{{ product.name }}</strong><span>{{ specText(product.sku) }}</span><b>¥{{ money(product.price) }}</b></div><span class="audit-quantity">×{{ product.number }}</span>
|
||||
</div>
|
||||
<div class="audit-summary"><dl><dt>原因</dt><dd>{{ row.reason || '-' }}</dd><dt>描述</dt><dd>{{ row.problemDescription || '-' }}</dd></dl><el-tag :type="row.isSigned === true ? 'success' : 'info'">{{ row.isSigned === true ? '已签收' : row.isSigned === false ? '未签收' : '签收状态未知' }}</el-tag></div>
|
||||
<div v-if="scene === 'audit'" class="after-sale-status"><el-tag :type="row.serviceState === 0 ? 'warning' : 'primary'">{{ currentStatusText(row) }}</el-tag></div>
|
||||
<div v-if="scene === 'history'" class="history-result"><span class="result-stamp" :class="{ approved: row.serviceState === 5, rejected: row.serviceState === 2 }">{{ historyResultText(row) }}</span></div>
|
||||
<footer class="audit-card-footer"><span>{{ row.applyTime || '-' }}</span><div><el-button @click="openDetail(row)">查看详情</el-button><el-button v-if="scene === 'audit' && row.serviceState === 0" type="danger" plain :disabled="auditing" @click="audit(row, 2)">拒绝</el-button><el-button v-if="scene === 'audit' && row.serviceState === 0" type="primary" :disabled="auditing" @click="audit(row, 1)">通过</el-button><el-button v-if="canReceive(row)" type="primary" :disabled="auditing" @click="receive(row)">确认收货</el-button><el-button v-if="canShip(row)" type="primary" :disabled="auditing" @click="openShipDialog(row)">换货发货</el-button></div></footer>
|
||||
</article>
|
||||
<el-empty v-if="!loading && !records.length" :description="scene === 'audit' ? '暂无待处理售后' : '暂无售后记录'" />
|
||||
</div><el-table v-else v-loading="loading" :data="records" border><el-table-column prop="serviceOrderId" label="售后单号" min-width="170" /><el-table-column prop="serviceTypeText" label="售后类型" width="110" /><el-table-column prop="serviceStateText" label="状态" width="130" /><el-table-column prop="reason" label="原因" min-width="160" /><el-table-column prop="applyTime" label="申请时间" min-width="170" /><el-table-column label="操作" min-width="160" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button v-if="scene === 'audit'" link type="success" @click="audit(row, 1)">通过</el-button><el-button v-if="scene === 'audit'" link type="danger" @click="audit(row, 2)">拒绝</el-button><el-button v-if="canReceive(row)" link type="success" @click="receive(row)">确认收货</el-button><el-button v-if="canShip(row)" link type="primary" @click="openShipDialog(row)">换货发货</el-button></template></el-table-column><template #empty><el-empty :image-size="80" description="暂无售后记录" /></template></el-table><el-dialog v-model="detailVisible" title="售后详情" :width="scene !== 'process' ? '1080px' : '760px'">
|
||||
<div v-if="scene !== 'process' && detail.afterSale" class="audit-detail">
|
||||
<section class="audit-detail-section audit-progress"><span v-if="scene === 'history'" class="result-stamp detail-result-stamp" :class="{ approved: detail.afterSale.serviceState === 5, rejected: detail.afterSale.serviceState === 2 }" aria-label="处理结果">{{ historyResultText(detail.afterSale) }}</span><h3>售后进度</h3><el-steps align-center>
|
||||
<el-step title="提交申请" status="success"><template #description><p>用户提交售后申请</p><p>{{ detail.afterSale.applyTime || '-' }}</p></template></el-step>
|
||||
<el-step title="商家审核" :status="detail.afterSale.auditTime ? (detail.afterSale.auditStatus === 2 ? 'error' : 'success') : detail.afterSale.serviceState === 6 ? 'wait' : 'process'"><template #description><p>{{ detail.afterSale.auditRemark || (detail.afterSale.serviceState === 6 ? '申请已撤销' : '待商家审核') }}</p><p>{{ detail.afterSale.auditTime || '-' }}</p></template></el-step>
|
||||
<el-step v-if="detail.afterSale.isSigned && detail.afterSale.auditStatus === 1" title="商品寄回" :status="detail.afterSale.userShipTime ? 'success' : 'process'"><template #description><p>{{ detail.afterSale.userShipTime ? '用户已寄回商品' : '等待用户填写寄回物流' }}</p><p>{{ detail.afterSale.userShipTime || '-' }}</p></template></el-step>
|
||||
<el-step v-if="detail.afterSale.isSigned && detail.afterSale.auditStatus === 1" title="商家收货" :status="detail.afterSale.merchantReceiveTime ? 'success' : detail.afterSale.userShipTime ? 'process' : 'wait'"><template #description><p>{{ detail.afterSale.merchantReceiveTime ? '商家已确认收货' : '等待商家收到商品' }}</p><p>{{ detail.afterSale.merchantReceiveTime || '-' }}</p></template></el-step>
|
||||
<el-step :title="detail.afterSale.serviceState === 6 ? '已撤销' : detail.afterSale.serviceState === 2 ? '审核未通过' : '售后完成'" :status="detail.afterSale.serviceState === 5 ? 'success' : 'wait'"><template #description><p>{{ detail.afterSale.serviceState === 5 ? '售后已完成' : detail.afterSale.serviceState === 2 ? '审核未通过,售后已结束' : detail.afterSale.serviceState === 6 ? '用户已撤销售后申请' : '等待售后处理完成' }}</p><p>{{ detail.afterSale.finishTime || '-' }}</p></template></el-step>
|
||||
</el-steps></section>
|
||||
<section class="audit-detail-section"><h3>售后商品</h3><div v-for="product in detail.afterSale.products || []" :key="product.id || product.sku" class="audit-product"><el-image :src="resolveImageUrl(product.image || '')" :preview-src-list="product.image ? [resolveImageUrl(product.image)] : []" preview-teleported fit="cover"><template #error><span class="image-empty">暂无图片</span></template></el-image><div class="audit-product-info"><strong>{{ product.name }}</strong><span>{{ specText(product.sku) }}</span><b>¥{{ money(product.price) }}</b></div><span class="audit-quantity">×{{ product.number }}</span></div></section>
|
||||
<section class="audit-detail-section"><h3>售后信息</h3><el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="售后单号">{{ detail.afterSale.serviceOrderId }}</el-descriptions-item><el-descriptions-item label="子订单号">{{ detail.afterSale.orderNumberId || detail.afterSale.subOrderId || '-' }}</el-descriptions-item><el-descriptions-item label="售后类型"><el-tag type="warning">{{ detail.afterSale.serviceTypeText || '-' }}</el-tag></el-descriptions-item><el-descriptions-item label="签收状态"><el-tag :type="detail.afterSale.isSigned === true ? 'success' : 'info'">{{ detail.afterSale.isSigned === true ? '已签收' : detail.afterSale.isSigned === false ? '未签收' : '-' }}</el-tag></el-descriptions-item><el-descriptions-item label="当前状态"><el-tag>{{ currentStatusText(detail.afterSale) }}</el-tag></el-descriptions-item><el-descriptions-item v-if="detail.afterSale.userLogisticsNo" label="寄回物流">{{ detail.afterSale.userLogisticsCompany || '-' }} / {{ detail.afterSale.userLogisticsNo }}</el-descriptions-item><el-descriptions-item v-if="detail.afterSale.merchantReceiveTime" label="商家收货时间">{{ detail.afterSale.merchantReceiveTime }}</el-descriptions-item><el-descriptions-item v-if="detail.afterSale.merchantLogisticsNo" label="换货物流">{{ detail.afterSale.merchantLogisticsCompany || '-' }} / {{ detail.afterSale.merchantLogisticsNo }}</el-descriptions-item><el-descriptions-item label="售后原因">{{ detail.afterSale.reason || '-' }}</el-descriptions-item><el-descriptions-item label="问题描述">{{ detail.afterSale.problemDescription || '-' }}</el-descriptions-item><el-descriptions-item label="申请时间">{{ detail.afterSale.applyTime || '-' }}</el-descriptions-item>
|
||||
</el-descriptions><div v-if="detailImages.length" class="audit-evidence"><el-image v-for="url in detailImages" :key="url" :src="url" :preview-src-list="detailImages" preview-teleported fit="cover" /></div></section>
|
||||
</div><pre v-else class="detail-json">{{ JSON.stringify(detail, null, 2) }}</pre>
|
||||
<template #footer><el-button @click="detailVisible = false">关闭</el-button><template v-if="scene === 'audit' && detail.afterSale?.serviceState === 0"><el-button type="danger" plain :disabled="auditing" @click="audit(detail.afterSale, 2)">拒绝</el-button><el-button type="primary" :disabled="auditing" @click="audit(detail.afterSale, 1)">通过</el-button></template><el-button v-if="detail.afterSale && canReceive(detail.afterSale)" type="primary" :disabled="auditing" @click="receive(detail.afterSale)">确认收货</el-button><el-button v-if="detail.afterSale && canShip(detail.afterSale)" type="primary" :disabled="auditing" @click="openShipDialog(detail.afterSale)">换货发货</el-button></template>
|
||||
</el-dialog><el-dialog v-model="shipVisible" title="换货发货" width="460px" append-to-body><el-form label-width="90px"><el-form-item label="快递公司"><el-input v-model="shipForm.logisticsCompany" /></el-form-item><el-form-item label="快递单号"><el-input v-model="shipForm.logisticsNo" /></el-form-item></el-form><template #footer><el-button @click="shipVisible = false">取消</el-button><el-button type="primary" :loading="auditing" @click="shipExchange">确认发货</el-button></template></el-dialog></section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { postAdmin } from '@/api/admin'
|
||||
import { postAdmin, resolveImageUrl } from '@/api/admin'
|
||||
const props = defineProps<{ scene: 'audit' | 'process' | 'history'; title: string; description: string }>()
|
||||
type AfterSale = { serviceOrderId: string; serviceState?: number; serviceType?: number; isSigned?: boolean; serviceTypeText?: string; serviceStateText?: string; reason?: string; applyTime?: string }
|
||||
const supplierId = ref(''); const records = ref<AfterSale[]>([]); const loading = ref(false); const queried = ref(false); const detailVisible = ref(false); const detail = ref<unknown>({}); const shipVisible = ref(false); const current = ref<AfterSale>(); const shipForm = ref({ logisticsCompany: '', logisticsNo: '' })
|
||||
const loadRecords = async () => { if (!supplierId.value.trim()) return ElMessage.warning('请输入供应商 ID'); loading.value = true; try { const data = await postAdmin<{ afterSaleItems?: AfterSale[] }>('merchant-after-sale-list', { supplierId: supplierId.value.trim(), scene: props.scene }); records.value = data.afterSaleItems || []; queried.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '售后查询失败') } finally { loading.value = false } }
|
||||
const reset = () => { supplierId.value = ''; records.value = []; queried.value = false }
|
||||
const openDetail = async (row: AfterSale) => { try { detail.value = await postAdmin('merchant-after-sale-detail', { supplierId: supplierId.value.trim(), serviceOrderId: row.serviceOrderId }); detailVisible.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '售后详情查询失败') } }
|
||||
const audit = async (row: AfterSale, auditStatus: number) => { try { await ElMessageBox.confirm(auditStatus === 1 ? '确认通过该售后申请?' : '确认拒绝该售后申请?', '售后审核'); await postAdmin('merchant-after-sale-audit', { supplierId: supplierId.value.trim(), serviceOrderId: row.serviceOrderId, auditStatus, auditRemark: auditStatus === 1 ? '审核通过' : '商家拒绝' }); ElMessage.success('处理成功'); await loadRecords() } catch (error) { if (error !== 'cancel') ElMessage.error(error instanceof Error ? error.message : '处理失败') } }
|
||||
const canReceive = (row: AfterSale) => props.scene === 'process' && Number(row.serviceState) === 3
|
||||
const canShip = (row: AfterSale) => props.scene === 'process' && Number(row.serviceType) === 5 && (Number(row.serviceState) === 4 || (row.isSigned === false && Number(row.serviceState) === 1))
|
||||
const receive = async (row: AfterSale) => { try { await ElMessageBox.confirm('确认已收到寄回商品?', '售后处理'); await postAdmin('merchant-after-sale-receive', { supplierId: supplierId.value.trim(), serviceOrderId: row.serviceOrderId }); ElMessage.success('已确认收货'); await loadRecords() } catch (error) { if (error !== 'cancel') ElMessage.error(error instanceof Error ? error.message : '操作失败') } }
|
||||
type AfterSaleProduct = { id?: number; sku: string; name: string; number: number; price: string; image?: string }
|
||||
type AfterSale = { serviceOrderId: string; supplierId: string; serviceState?: number; logisticsState?: number; serviceType?: number; isSigned?: boolean; serviceTypeText?: string; serviceStateText?: string; reason?: string; applyTime?: string; problemDescription?: string; products?: AfterSaleProduct[]; subOrderId?: string; orderNumberId?: string; auditStatus?: number; auditTime?: string; auditRemark?: string; finishTime?: string; userLogisticsNo?: string; userLogisticsCompany?: string; merchantLogisticsNo?: string; merchantLogisticsCompany?: string; userShipTime?: string; merchantReceiveTime?: string; merchantShipTime?: string; images?: Array<string | { url?: string; previewUrl?: string; fileUrl?: string }> }
|
||||
const serviceOrderId = ref('')
|
||||
const auditing = ref(false)
|
||||
const currentStatusText = (row: AfterSale) => {
|
||||
if (row.serviceState === 1 && row.logisticsState === 1) return '审核通过,待用户寄回'
|
||||
if (row.serviceState === 3) return '用户已寄回,待商家收货'
|
||||
if (row.serviceState === 4 && row.serviceType === 5) return '商家已收货,待换货发货'
|
||||
if (row.serviceState === 1 && row.serviceType === 5 && row.isSigned === false) return '审核通过,待换货发货'
|
||||
return row.serviceStateText || '-'
|
||||
}
|
||||
const historyResultText = (row: AfterSale) => row.serviceState === 5 ? '审核通过' : row.serviceStateText || '-'
|
||||
const money = (value: string) => Number(value || 0).toFixed(2)
|
||||
const specText = (sku: string) => {
|
||||
const labels: Record<string, string> = { cream: '奶油白', blue: '雾霾蓝', orange: '暖橙色', standard: '标准款', plus: '升级款' }
|
||||
const parts = String(sku || '').split('-').slice(-2)
|
||||
return parts.some(part => labels[part]) ? parts.map(part => labels[part] || part).join(' | ') : '-'
|
||||
}
|
||||
const detailImages = computed(() => (detail.value.afterSale?.images || []).map(item => resolveImageUrl(typeof item === 'string' ? item : item.fileUrl || item.url || item.previewUrl || '')).filter(Boolean))
|
||||
const records = ref<AfterSale[]>([]); const loading = ref(false); const queried = ref(false); const detailVisible = ref(false); const detail = ref<{ afterSale?: AfterSale }>({}); const shipVisible = ref(false); const current = ref<AfterSale>(); const shipForm = ref({ logisticsCompany: '', logisticsNo: '' })
|
||||
const loadRecords = async () => { loading.value = true; try { const data = await postAdmin<{ afterSaleItems?: AfterSale[] }>('merchant-after-sale-list', { scene: props.scene, serviceOrderId: serviceOrderId.value.trim() }); records.value = data.afterSaleItems || []; queried.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '售后查询失败') } finally { loading.value = false } }
|
||||
const reset = () => { serviceOrderId.value = ''; return loadRecords() }
|
||||
const openDetail = async (row: AfterSale) => { try { detail.value = await postAdmin<{ afterSale: AfterSale }>('merchant-after-sale-detail', { supplierId: row.supplierId, serviceOrderId: row.serviceOrderId }); detailVisible.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '售后详情查询失败') } }
|
||||
const audit = async (row: AfterSale, auditStatus: number) => { if (auditing.value) return; auditing.value = true; try { await ElMessageBox.confirm(auditStatus === 1 ? '确认通过该售后申请?' : '确认拒绝该售后申请?', '售后审核'); await postAdmin('merchant-after-sale-audit', { supplierId: row.supplierId, serviceOrderId: row.serviceOrderId, auditStatus, auditRemark: auditStatus === 1 ? '审核通过' : '商家拒绝' }); ElMessage.success('处理成功'); detailVisible.value = false; await loadRecords() } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '处理失败') } finally { auditing.value = false } }
|
||||
const canReceive = (row: AfterSale) => props.scene !== 'history' && Number(row.serviceState) === 3
|
||||
const canShip = (row: AfterSale) => props.scene !== 'history' && Number(row.serviceType) === 5 && (Number(row.serviceState) === 4 || (row.isSigned === false && Number(row.serviceState) === 1))
|
||||
const receive = async (row: AfterSale) => { if (auditing.value) return; auditing.value = true; try { await ElMessageBox.confirm('确认已收到寄回商品?', '售后处理'); await postAdmin('merchant-after-sale-receive', { supplierId: row.supplierId, serviceOrderId: row.serviceOrderId }); ElMessage.success('已确认收货'); await loadRecords(); if (detailVisible.value) await openDetail(row) } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '操作失败') } finally { auditing.value = false } }
|
||||
const openShipDialog = (row: AfterSale) => { current.value = row; shipForm.value = { logisticsCompany: '', logisticsNo: '' }; shipVisible.value = true }
|
||||
const shipExchange = async () => { if (!current.value || !shipForm.value.logisticsCompany || !shipForm.value.logisticsNo) return ElMessage.warning('请填写完整发货信息'); try { await postAdmin('merchant-after-sale-ship', { supplierId: supplierId.value.trim(), serviceOrderId: current.value.serviceOrderId, ...shipForm.value }); ElMessage.success('换货已发货'); shipVisible.value = false; await loadRecords() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '发货失败') } }
|
||||
const shipExchange = async () => { if (auditing.value) return; if (!current.value || !shipForm.value.logisticsCompany || !shipForm.value.logisticsNo) return ElMessage.warning('请填写完整发货信息'); auditing.value = true; try { await postAdmin('merchant-after-sale-ship', { supplierId: current.value.supplierId, serviceOrderId: current.value.serviceOrderId, ...shipForm.value }); ElMessage.success('换货已发货'); shipVisible.value = false; await loadRecords(); if (detailVisible.value) await openDetail(current.value) } catch (error) { ElMessage.error(error instanceof Error ? error.message : '发货失败') } finally { auditing.value = false } }
|
||||
onMounted(loadRecords)
|
||||
</script>
|
||||
|
||||
<style scoped>.detail-json { max-height: 420px; overflow: auto; padding: 12px; border-radius: 4px; background: #f5f7fa; white-space: pre-wrap; word-break: break-all; }</style>
|
||||
<style scoped>
|
||||
.after-sale-status { margin-top: 16px; }
|
||||
.audit-card-footer > div { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.audit-card-footer > div .el-button { margin-left: 0; }
|
||||
.history-result { display: flex; justify-content: flex-end; margin-top: 16px; padding: 8px 6px; }
|
||||
.result-stamp { position: relative; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 112px; height: 56px; margin: 6px 0; border: 2px solid currentColor; border-radius: 50%; color: #909399; font-size: 16px; font-weight: 700; letter-spacing: 2px; line-height: 1; transform: rotate(-14deg); }
|
||||
.result-stamp::after { position: absolute; inset: 4px; border: 1px dashed currentColor; border-radius: 50%; content: ""; }
|
||||
.result-stamp.approved { color: #389e63; }
|
||||
.result-stamp.rejected { color: #d9534f; }
|
||||
.audit-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 390px), 1fr)); gap: 20px; }
|
||||
.audit-list > .el-empty { grid-column: 1 / -1; }
|
||||
.audit-card { padding: 20px; border: 1px solid #e9eef5; border-radius: 12px; background: #fff; }
|
||||
.audit-card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 18px; }
|
||||
.audit-card-header strong { min-width: 0; overflow-wrap: anywhere; line-height: 22px; }
|
||||
.audit-card-header .el-tag { flex-shrink: 0; }
|
||||
.audit-product { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; }
|
||||
.audit-product > .el-image { width: 72px; height: 72px; flex-shrink: 0; border-radius: 8px; }
|
||||
.audit-product-info { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 6px; }
|
||||
.audit-product-info strong { overflow-wrap: anywhere; }
|
||||
.audit-product-info span, .audit-card-footer > span { color: #8793a5; font-size: 12px; }
|
||||
.audit-product-info b { color: #ff6b35; }
|
||||
.audit-quantity { flex-shrink: 0; font-weight: 600; }
|
||||
.image-empty { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; background: #f5f7fa; color: #8793a5; font-size: 12px; }
|
||||
.audit-summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.audit-summary dl { display: grid; grid-template-columns: 36px minmax(0, 1fr); gap: 10px 12px; flex: 1; min-width: 0; margin: 0; font-size: 13px; }
|
||||
.audit-summary dt { color: #8793a5; }
|
||||
.audit-summary dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.audit-card-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px; margin-top: 20px; padding-top: 16px; border-top: 1px solid #edf0f5; }
|
||||
.audit-detail { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); align-items: start; gap: 20px; }
|
||||
.audit-progress { position: relative; grid-column: 1 / -1; }
|
||||
.detail-result-stamp { position: absolute; z-index: 10; top: -18px; right: 8px; pointer-events: none; width: 128px; height: 128px; margin: 0; border-width: 3px; font-size: 20px; }
|
||||
.detail-result-stamp::after { inset: 7px; border-width: 2px; }
|
||||
.audit-detail-section { min-width: 0; padding: 20px; border: 1px solid #e9eef5; border-radius: 10px; }
|
||||
@media (max-width: 767px) { .audit-detail { grid-template-columns: minmax(0, 1fr); } }
|
||||
.audit-detail-section h3 { margin: 0 0 18px; font-size: 15px; }
|
||||
.audit-progress :deep(.el-step__description) { overflow-wrap: anywhere; }
|
||||
.audit-detail-section p { margin: 6px 0 0; color: #8793a5; font-size: 12px; }
|
||||
.audit-detail :deep(.el-descriptions__label) { width: 100px; white-space: nowrap; }
|
||||
.audit-detail :deep(.el-descriptions__content) { overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.audit-evidence { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
|
||||
.audit-evidence .el-image { width: 80px; height: 80px; border-radius: 6px; }
|
||||
.detail-json { max-height: 420px; overflow: auto; padding: 12px; border-radius: 4px; background: #f5f7fa; white-space: pre-wrap; word-break: break-all; }</style>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<el-aside :width="appStore.sidebarCollapsed ? '64px' : '224px'" class="sidebar">
|
||||
<div class="brand"><el-icon><ShoppingBag /></el-icon><span v-show="!appStore.sidebarCollapsed">供应商管理</span></div>
|
||||
<el-menu :collapse="appStore.sidebarCollapsed" :default-active="$route.path" background-color="#152238" text-color="#afbed3" active-text-color="#ffffff" router>
|
||||
<el-menu-item index="/reconciliation"><el-icon><Tickets /></el-icon><template #title>订单对账</template></el-menu-item>
|
||||
<el-sub-menu index="/orders"><template #title><el-icon><List /></el-icon><span>订单管理</span></template><el-menu-item index="/orders/list">订单列表</el-menu-item><el-menu-item index="/orders/after-sales/audit">售后审核</el-menu-item><el-menu-item index="/orders/after-sales/process">售后处理</el-menu-item><el-menu-item index="/orders/after-sales/history">售后记录</el-menu-item></el-sub-menu>
|
||||
<el-sub-menu index="/billing"><template #title><el-icon><Tickets /></el-icon><span>对账开票</span></template><el-menu-item index="/reconciliation">订单对账</el-menu-item><el-menu-item index="/invoices">发票管理</el-menu-item></el-sub-menu>
|
||||
<el-sub-menu index="/orders"><template #title><el-icon><List /></el-icon><span>订单管理</span></template><el-menu-item index="/orders/list">订单列表</el-menu-item><el-menu-item index="/orders/after-sales/audit">订单售后</el-menu-item><el-menu-item index="/orders/after-sales/history">售后记录</el-menu-item></el-sub-menu>
|
||||
<el-menu-item index="/products"><el-icon><Goods /></el-icon><template #title>商品管理</template></el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
@@ -6,10 +6,11 @@ import { clearAdminSession, readAdminSession } from '@/utils/auth'
|
||||
|
||||
const routes: RouteRecordRaw[] = [{ path: '/login', name: 'Login', component: () => import('@/views/login/index.vue'), meta: { title: '登录' } }, { path: '/', component: AdminLayout, redirect: '/reconciliation', children: [
|
||||
{ path: 'reconciliation', name: 'Reconciliation', component: () => import('@/views/reconciliation/index.vue'), meta: { title: '订单对账' } },
|
||||
{ path: 'invoices', name: 'Invoices', component: () => import('@/views/invoices/index.vue'), meta: { title: '发票管理' } },
|
||||
{ path: 'orders', redirect: '/orders/list' },
|
||||
{ path: 'orders/list', name: 'Orders', component: () => import('@/views/orders/index.vue'), meta: { title: '订单列表' } },
|
||||
{ path: 'orders/after-sales/audit', name: 'AfterSaleAudit', component: () => import('@/views/after-sales/audit.vue'), meta: { title: '售后审核' } },
|
||||
{ path: 'orders/after-sales/process', name: 'AfterSaleProcess', component: () => import('@/views/after-sales/process.vue'), meta: { title: '售后处理' } },
|
||||
{ path: 'orders/after-sales/audit', name: 'AfterSaleAudit', component: () => import('@/views/after-sales/audit.vue'), meta: { title: '订单售后' } },
|
||||
{ path: 'orders/after-sales/process', name: 'AfterSaleProcess', redirect: '/orders/after-sales/audit' },
|
||||
{ path: 'orders/after-sales/history', name: 'AfterSaleHistory', component: () => import('@/views/after-sales/history.vue'), meta: { title: '售后记录' } },
|
||||
{ path: 'products', name: 'Products', component: () => import('@/views/products/index.vue'), meta: { title: '商品管理' } }
|
||||
] }]
|
||||
|
||||
@@ -34,9 +34,9 @@ button, input, textarea { font: inherit; }
|
||||
.page-title h2 { margin: 0 0 8px; color: #202e45; font-size: 22px; font-weight: 600; letter-spacing: .5px; }
|
||||
.page-title p { margin: 0; color: #78859a; font-size: 13px; line-height: 1.7; }
|
||||
.query-form.el-form--inline { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 16px 20px; padding: 20px; margin-bottom: 24px; border: 1px solid #e9eef5; border-radius: 10px; background: #f8fafc; }
|
||||
.query-form.el-form--inline .el-form-item { display: flex; flex-direction: column; align-items: stretch; margin: 0; }
|
||||
.query-form .el-form-item__label { justify-content: flex-start; height: auto; padding: 0 0 8px; color: #526078; font-size: 13px; line-height: 18px; }
|
||||
.query-form .el-form-item__content { min-height: 36px; }
|
||||
.query-form.el-form--inline .el-form-item { display: flex; flex-direction: row; align-items: center; margin: 0; }
|
||||
.query-form .el-form-item__label { justify-content: flex-start; flex-shrink: 0; height: 36px; padding: 0 12px 0 0; color: #526078; font-size: 13px; line-height: 36px; white-space: nowrap; }
|
||||
.query-form .el-form-item__content { min-width: 0; min-height: 36px; }
|
||||
.query-form .el-input, .query-form .el-select { width: 208px; max-width: 100%; }
|
||||
.query-form .el-button { min-width: 76px; height: 36px; }
|
||||
.el-input__wrapper, .el-select__wrapper { min-height: 36px; }
|
||||
@@ -78,7 +78,7 @@ button, input, textarea { font: inherit; }
|
||||
.el-dialog .el-form-item:last-child { margin-bottom: 0; }
|
||||
.el-dialog .el-select, .el-dialog .el-input-number { width: 100%; }
|
||||
.el-dialog__footer .el-button { min-width: 80px; height: 36px; }
|
||||
.el-message-box { max-width: calc(100vw - 32px); padding: 20px; border-radius: 12px; }
|
||||
.el-message-box { width: 360px; max-width: calc(100vw - 32px); padding: 20px; border-radius: 12px; }
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.page-card { padding: 22px; }
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
<template><MerchantAfterSaleTable scene="audit" title="售后审核" description="审核用户提交的退货、换货申请。" /></template>
|
||||
<template><MerchantAfterSaleTable scene="audit" title="订单售后" description="审核退换货申请,处理寄回收货和换货发货。" /></template>
|
||||
<script setup lang="ts">import MerchantAfterSaleTable from '@/components/MerchantAfterSaleTable.vue'</script>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<section class="page-card">
|
||||
<div class="page-title"><div><h2>发票管理</h2><p>查看开票结果、关联订单及电子发票。</p></div></div>
|
||||
<el-form inline class="query-form" @submit.prevent="search">
|
||||
<el-form-item label="结算单号"><el-input v-model="query.settlementId" placeholder="请输入结算单号" clearable /></el-form-item>
|
||||
<el-form-item label="开票状态"><el-select v-model="query.invoiceStatus" placeholder="全部" clearable><el-option v-for="(name, value) in statuses" :key="value" :label="name" :value="Number(value)" /></el-select></el-form-item>
|
||||
<el-form-item><el-button type="primary" :loading="loading" @click="search">查询</el-button><el-button @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-table :data="items" v-loading="loading" border>
|
||||
<el-table-column prop="settlementId" label="结算单号" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="invoiceIds" label="发票号码" min-width="190" show-overflow-tooltip />
|
||||
<el-table-column label="开票状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.invoiceStatus)">{{ statuses[row.invoiceStatus] || '-' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="价税合计" width="120"><template #default="{ row }">¥{{ row.invoiceAmount }}</template></el-table-column>
|
||||
<el-table-column label="开票日期" width="130"><template #default="{ row }">{{ dateText(row.invoiceDate) }}</template></el-table-column>
|
||||
<el-table-column prop="failureMessage" label="说明" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.failureMessage || '-' }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button v-if="row.hasFile" link type="primary" :disabled="fileLoading" @click="openFile(row)">预览</el-button><el-button v-if="row.hasFile" link type="primary" :disabled="fileLoading" @click="openFile(row, true)">下载</el-button></template></el-table-column>
|
||||
<template #empty><el-empty :image-size="80" description="暂无发票记录" /></template>
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="query.pageNumber" :page-size="query.pageSize" :total="totalCount" layout="total, prev, pager, next, jumper" @current-change="load" />
|
||||
<el-dialog v-model="detailVisible" title="发票详情" width="880px">
|
||||
<template v-if="current"><el-descriptions :column="2" border class="invoice-detail">
|
||||
<el-descriptions-item label="结算单号">{{ current.settlementId }}</el-descriptions-item><el-descriptions-item label="开票状态"><el-tag :type="statusType(current.invoiceStatus)">{{ statuses[current.invoiceStatus] || '-' }}</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item label="发票号码">{{ current.invoiceIds || '-' }}</el-descriptions-item><el-descriptions-item label="发票代码">{{ current.invoiceCode || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="开票日期">{{ dateText(current.invoiceDate) }}</el-descriptions-item><el-descriptions-item label="发票类型">{{ current.invoiceType === 9 ? '全电专票' : current.invoiceType === 10 ? '全电普票' : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="不含税金额">¥{{ current.invoiceNakeAmount }}</el-descriptions-item><el-descriptions-item label="税率">{{ current.invoiceTaxRate ? `${Number(current.invoiceTaxRate) * 100}%` : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="税额">¥{{ current.invoiceTaxAmount }}</el-descriptions-item><el-descriptions-item label="价税合计">¥{{ current.invoiceAmount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="纳税人识别号">{{ current.taxIdNumber || '-' }}</el-descriptions-item><el-descriptions-item label="校验码">{{ current.checkCode || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="说明" :span="2">{{ current.failureMessage || '-' }}</el-descriptions-item>
|
||||
</el-descriptions><h3 class="orders-title">关联订单</h3><div class="invoice-orders"><el-tag v-for="orderId in current.orderIds.split(',').filter(Boolean)" :key="orderId" type="info">{{ orderId }}</el-tag><span v-if="!current.orderIds">暂无关联订单</span></div></template>
|
||||
<template #footer><el-button @click="detailVisible = false">关闭</el-button><template v-if="current?.hasFile"><el-button :disabled="fileLoading" @click="openFile(current, true)">下载PDF</el-button><el-button type="primary" :disabled="fileLoading" @click="openFile(current)">预览PDF</el-button></template></template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="previewVisible" title="发票预览" width="1000px" append-to-body @closed="previewUrl = ''"><iframe v-if="previewUrl" :src="previewUrl" title="电子发票" class="invoice-preview" /></el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { postAdmin } from '@/api/admin'
|
||||
type Invoice = { id: number; settlementId: string; invoiceIds: string; invoiceCode: string; invoiceStatus: number; invoiceDate: string | null; invoiceType: number | null; invoiceAmount: string; invoiceNakeAmount: string; invoiceTaxAmount: string; invoiceTaxRate: string; taxIdNumber: string; checkCode: string; orderIds: string; failureMessage: string; hasFile: boolean }
|
||||
const statuses: Record<number, string> = { 0: '未开票', 1: '开票中', 2: '成功', 3: '失败' }
|
||||
const statusType = (status: number) => status === 2 ? 'success' : status === 3 ? 'danger' : status === 1 ? 'warning' : 'info'
|
||||
const dateText = (value: string | null) => value ? new Date(value).toLocaleDateString('sv-SE') : '-'
|
||||
const query = reactive<{ settlementId: string; invoiceStatus?: number; pageNumber: number; pageSize: number }>({ settlementId: '', pageNumber: 1, pageSize: 10 })
|
||||
const items = ref<Invoice[]>([])
|
||||
const totalCount = ref(0)
|
||||
const loading = ref(false)
|
||||
const current = ref<Invoice>()
|
||||
const detailVisible = ref(false)
|
||||
const fileLoading = ref(false)
|
||||
const previewVisible = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const load = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await postAdmin<{ invoiceItems: Invoice[]; totalCount: number }>('merchant-invoice-list', query)
|
||||
items.value = data.invoiceItems || []
|
||||
totalCount.value = data.totalCount || 0
|
||||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '发票查询失败') } finally { loading.value = false }
|
||||
}
|
||||
const search = () => { query.pageNumber = 1; return load() }
|
||||
const reset = () => { query.settlementId = ''; query.invoiceStatus = undefined; return search() }
|
||||
const openDetail = (row: Invoice) => { current.value = row; detailVisible.value = true }
|
||||
const openFile = async (row: Invoice, download = false) => {
|
||||
if (fileLoading.value) return
|
||||
fileLoading.value = true
|
||||
try {
|
||||
const data = await postAdmin<{ url: string }>('merchant-invoice-file', { id: row.id, download })
|
||||
if (download) {
|
||||
const link = document.createElement('a')
|
||||
link.href = data.url
|
||||
link.download = `${row.invoiceIds || 'invoice'}.pdf`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
} else { previewUrl.value = data.url; previewVisible.value = true }
|
||||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '获取发票文件失败') } finally { fileLoading.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.invoice-detail :deep(.el-descriptions__content) { overflow-wrap: anywhere; }
|
||||
.invoice-detail :deep(.el-descriptions__label) { width: 110px; }
|
||||
.orders-title { margin: 24px 0 12px; font-size: 15px; }
|
||||
.invoice-orders { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.invoice-preview { display: block; width: 100%; height: 60vh; border: 0; }
|
||||
</style>
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<section class="page-card">
|
||||
<div class="page-title"><div><h2>订单列表</h2><p>查询订单并对待发货订单确认发货。</p></div><el-button type="primary" :loading="batchShipping" :disabled="loading || submitting || shipDialogVisible || Boolean(repushingOrderId) || !pendingShipOrders.length" @click="quickShipAll">一键发货</el-button></div>
|
||||
<el-form :inline="true" class="query-form"><el-form-item label="供应商"><el-select v-model="query.supplierId" placeholder="请选择供应商" filterable clearable><el-option v-for="supplier in suppliers" :key="supplier.supplierId" :label="`${supplier.supplierName}(${supplier.supplierId})`" :value="supplier.supplierId" /></el-select></el-form-item><el-form-item label="订单号"><el-input v-model="query.orderId" placeholder="订单编号或子订单号" clearable /></el-form-item><el-form-item label="工号"><el-input v-model="query.userId" placeholder="请输入工号" clearable /></el-form-item><el-form-item label="订单状态"><el-select v-model="query.orderState" placeholder="全部" clearable style="width: 160px"><el-option v-for="status in orderStates" :key="status.value" :label="status.label" :value="status.value" /></el-select></el-form-item><el-form-item><el-button type="primary" :loading="loading" @click="searchOrders">查询</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form>
|
||||
<el-form :inline="true" class="query-form"><el-form-item label="订单号"><el-input v-model="query.orderId" placeholder="订单编号" clearable /></el-form-item><el-form-item label="用户昵称"><el-input v-model="query.userNickname" placeholder="请输入用户昵称" clearable /></el-form-item><el-form-item label="订单状态"><el-select v-model="query.orderState" placeholder="全部" clearable style="width: 160px"><el-option v-for="status in orderStates" :key="status.value" :label="status.label" :value="status.value" /></el-select></el-form-item><el-form-item><el-button type="primary" :loading="loading" @click="searchOrders">查询</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form>
|
||||
<el-table v-loading="loading" :data="orders" border><el-table-column prop="orderNumberId" label="订单编号" min-width="200" show-overflow-tooltip /><el-table-column label="用户昵称" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.userNickname || '-' }}</template></el-table-column><el-table-column label="商品信息" min-width="300"><template #default="{ row }">
|
||||
<div v-for="(product, index) in row.products || []" :key="product.id || index" class="order-product">
|
||||
<el-image class="order-product__image" :src="product.image" fit="cover" :preview-src-list="product.image ? [product.image] : []" preview-teleported>
|
||||
<el-image class="order-product__image" :src="resolveImageUrl(product.image)" fit="cover" :preview-src-list="product.image ? [resolveImageUrl(product.image)] : []" preview-teleported>
|
||||
<template #error><span class="order-product__placeholder">暂无图片</span></template>
|
||||
</el-image>
|
||||
<div class="order-product__info">
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="!row.products?.length">-</span>
|
||||
</template></el-table-column><el-table-column label="订单金额" width="120" align="right"><template #default="{ row }"><span class="order-amount">{{ formatPrice(row.amount) }}</span></template></el-table-column><el-table-column label="订单状态" width="110"><template #default="{ row }"><el-tag :type="[4, 5].includes(row.orderState) ? 'success' : [0, 1, 6].includes(row.orderState) ? 'warning' : row.orderState === 2 ? 'primary' : 'info'">{{ stateText(row.orderState) }}</el-tag></template></el-table-column><el-table-column prop="createTime" label="下单时间" min-width="170" /><el-table-column label="操作" width="240" fixed="right"><template #default="{ row }"><el-button v-if="row.orderState === 1" link type="primary" :disabled="batchShipping" @click="openShipDialog(row)">确认发货</el-button><span v-else>-</span><el-button v-if="row.orderState === 1" link type="warning" :loading="repushingOrderId === row.orderId" :disabled="batchShipping || Boolean(repushingOrderId)" @click="repushSplit(row)">重新推送拆单</el-button></template></el-table-column><template #empty><el-empty :image-size="80" description="暂无订单" /></template></el-table>
|
||||
</template></el-table-column><el-table-column label="订单金额" width="120" align="right"><template #default="{ row }"><span class="order-amount">{{ formatPrice(row.amount) }}</span></template></el-table-column><el-table-column prop="createTime" label="下单时间" min-width="170" /><el-table-column label="订单状态" width="110"><template #default="{ row }"><el-tag :type="[4, 5].includes(row.orderState) ? 'success' : [0, 1, 6].includes(row.orderState) ? 'warning' : row.orderState === 2 ? 'primary' : 'info'">{{ stateText(row.orderState) }}</el-tag></template></el-table-column><el-table-column label="操作" width="240" fixed="right"><template #default="{ row }"><el-button v-if="row.orderState === 1" link type="primary" :disabled="batchShipping" @click="openShipDialog(row)">确认发货</el-button><span v-else>-</span><el-button v-if="row.orderState === 1" link type="warning" :loading="repushingOrderId === row.orderId" :disabled="batchShipping || Boolean(repushingOrderId)" @click="repushSplit(row)">重新推送拆单</el-button></template></el-table-column><template #empty><el-empty :image-size="80" description="暂无订单" /></template></el-table>
|
||||
<el-pagination v-model:current-page="query.pageNumber" :page-size="query.pageSize" :total="totalCount" layout="total, prev, pager, next, jumper" @current-change="loadOrders" />
|
||||
|
||||
<el-dialog v-model="shipDialogVisible" title="确认发货" width="460px"><el-form label-width="90px"><el-form-item label="快递公司"><el-input v-model="shipForm.crrgBsnNm" placeholder="请输入快递公司" /></el-form-item><el-form-item label="快递单号"><el-input v-model="shipForm.logisticsOrderId" placeholder="请输入快递单号" /></el-form-item></el-form><template #footer><el-button @click="shipDialogVisible = false">取消</el-button><el-button type="primary" :loading="submitting" @click="submitShip">确认发货</el-button></template></el-dialog>
|
||||
@@ -23,20 +23,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { postAdmin } from '@/api/admin'
|
||||
import { postAdmin, resolveImageUrl } from '@/api/admin'
|
||||
|
||||
type OrderProduct = { id?: number; name?: string; image?: string; price?: string | number; number?: number }
|
||||
type OrderItem = { orderId: string; supplierId: string; orderNumberId?: string; userId?: string; userNickname?: string; orderState: number; createTime?: string; crrgBsnNm?: string; logisticsOrderId?: string; amount?: string | number; products?: OrderProduct[] }
|
||||
type SupplierItem = { supplierId: string; supplierName: string }
|
||||
const orderStates = [{ label: '待付款', value: 0 }, { label: '待发货', value: 1 }, { label: '已发货', value: 2 }, { label: '已取消', value: 3 }, { label: '已签收', value: 4 }, { label: '已完成', value: 5 }, { label: '换货', value: 6 }, { label: '已退款', value: 7 }]
|
||||
const query = reactive<{ supplierId: string; orderId: string; userId: string; orderState?: number; pageNumber: number; pageSize: number }>({ supplierId: '', orderId: '', userId: '', pageNumber: 1, pageSize: 10 })
|
||||
const suppliers = ref<SupplierItem[]>([]); const orders = ref<OrderItem[]>([]); const loading = ref(false); const queried = ref(false); const submitting = ref(false); const shipDialogVisible = ref(false); const currentOrder = ref<OrderItem>(); const shipForm = ref({ crrgBsnNm: '', logisticsOrderId: '' })
|
||||
const query = reactive<{ orderId: string; userNickname: string; orderState?: number; pageNumber: number; pageSize: number }>({ orderId: '', userNickname: '', pageNumber: 1, pageSize: 10 })
|
||||
const orders = ref<OrderItem[]>([]); const loading = ref(false); const queried = ref(false); const submitting = ref(false); const shipDialogVisible = ref(false); const currentOrder = ref<OrderItem>(); const shipForm = ref({ crrgBsnNm: '', logisticsOrderId: '' })
|
||||
const totalCount = ref(0)
|
||||
const stateText = (state: number) => orderStates.find(item => item.value === state)?.label || '未知'
|
||||
const loadSuppliers = async () => { try { const data = await postAdmin<{ suppliers?: SupplierItem[] }>('merchant-supplier-list', {}); suppliers.value = data.suppliers || [] } catch (error) { ElMessage.error(error instanceof Error ? error.message : '供应商加载失败') } }
|
||||
const loadOrders = async () => { loading.value = true; try { const data = await postAdmin<{ orderItems?: OrderItem[]; totalCount?: number }>('merchant-order-list', query); orders.value = data.orderItems || []; totalCount.value = data.totalCount || 0; queried.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '订单查询失败') } finally { loading.value = false } }
|
||||
const searchOrders = () => { query.pageNumber = 1; return loadOrders() }
|
||||
const reset = () => { query.supplierId = ''; query.orderId = ''; query.userId = ''; query.orderState = undefined; searchOrders() }
|
||||
const reset = () => { query.orderId = ''; query.userNickname = ''; query.orderState = undefined; searchOrders() }
|
||||
const formatPrice = (value?: string | number) => value !== undefined && value !== null && value !== '' && Number.isFinite(Number(value)) ? `¥${Number(value).toFixed(2)}` : '-'
|
||||
const openShipDialog = (order: OrderItem) => { currentOrder.value = order; shipForm.value = { crrgBsnNm: '韵达快递', logisticsOrderId: `YT${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}` }; shipDialogVisible.value = true }
|
||||
const submitShip = async () => { if (!currentOrder.value || !shipForm.value.crrgBsnNm || !shipForm.value.logisticsOrderId) return ElMessage.warning('请填写完整发货信息'); submitting.value = true; try { await postAdmin('merchant-order-ship', { supplierId: currentOrder.value.supplierId, orderId: currentOrder.value.orderId, userId: currentOrder.value.userId, ...shipForm.value }); ElMessage.success('发货成功'); shipDialogVisible.value = false; await loadOrders() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '发货失败') } finally { submitting.value = false } }
|
||||
@@ -84,7 +82,7 @@ const repushSplit = async (order: OrderItem) => {
|
||||
repushingOrderId.value = ''
|
||||
}
|
||||
}
|
||||
onMounted(() => { loadSuppliers(); loadOrders() })
|
||||
onMounted(loadOrders)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,32 +1,69 @@
|
||||
<template>
|
||||
<section class="page-card">
|
||||
<div class="page-title"><div><h2>商品管理</h2><p>管理供应商商品的展示、库存和上下架状态。</p></div><el-button type="primary" @click="openCreate">新增商品</el-button></div>
|
||||
<el-form inline class="query-form" @submit.prevent><el-form-item label="商品"><el-input v-model="query.keyword" placeholder="商品名称或 SKU" clearable /></el-form-item><el-form-item label="分类"><el-select v-model="query.categoryId" placeholder="全部" clearable><el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item><el-button type="primary" :loading="loading" @click="load">查询</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form>
|
||||
<el-form inline class="query-form" @submit.prevent><el-form-item label="商品"><el-input v-model="query.keyword" placeholder="商品名称或 SKU" clearable /></el-form-item><el-form-item label="分类"><el-select v-model="query.categoryId" placeholder="全部" clearable><el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item><el-button type="primary" :loading="loading" @click="search">查询</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form>
|
||||
<el-table :data="products" v-loading="loading" border><el-table-column prop="sku" label="SKU" min-width="140" /><el-table-column prop="name" label="商品名称" min-width="180" /><el-table-column prop="category" label="分类" width="120" /><el-table-column prop="price" label="售价" width="100" /><el-table-column prop="stock" label="库存" width="90" /><el-table-column prop="sortOrder" label="排序" width="80" /><el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '上架' : '下架' }}</el-tag></template></el-table-column><el-table-column label="操作" width="130" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="remove(row)">删除</el-button></template></el-table-column><template #empty><el-empty :image-size="80" description="暂无商品" /></template></el-table>
|
||||
|
||||
<el-dialog v-model="visible" :title="form.id ? '编辑商品' : '新增商品'" width="680px" destroy-on-close><el-form :model="form" label-width="90px"><el-row :gutter="16"><el-col :span="12"><el-form-item label="商品SKU" required><el-input v-model="form.sku" /></el-form-item></el-col><el-col :span="12"><el-form-item label="商品名称" required><el-input v-model="form.name" /></el-form-item></el-col><el-col :span="12"><el-form-item label="售价" required><el-input-number v-model="form.price" :min="0" :precision="2" /></el-form-item></el-col><el-col :span="12"><el-form-item label="原价"><el-input-number v-model="form.originPrice" :min="0" :precision="2" /></el-form-item></el-col><el-col :span="12"><el-form-item label="库存"><el-input-number v-model="form.stock" :min="0" :precision="0" /></el-form-item></el-col><el-col :span="12"><el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" :precision="0" /></el-form-item></el-col><el-col :span="12"><el-form-item label="分类"><el-select v-model="form.categoryId" @change="setCategory"><el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="状态"><el-switch v-model="form.enabled" active-text="上架" inactive-text="下架" /></el-form-item></el-col></el-row><el-form-item label="图片地址"><el-input v-model="form.imageUrl" /></el-form-item><el-form-item label="商品描述"><el-input v-model="form.description" type="textarea" :rows="3" /></el-form-item></el-form><template #footer><el-button @click="visible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template></el-dialog>
|
||||
<el-pagination v-model:current-page="query.pageNumber" :page-size="query.pageSize" :total="totalCount" layout="total, prev, pager, next, jumper" @current-change="load" />
|
||||
<el-dialog v-model="visible" :title="form.id ? '编辑商品' : '新增商品'" width="760px" destroy-on-close><el-form class="product-form" :model="form" label-width="90px"><el-row :gutter="24"><el-col :xs="24" :sm="12"><el-form-item label="商品SKU" required><el-input v-model="form.sku" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="商品名称" required><el-input v-model="form.name" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="售价" required><el-input-number v-model="form.price" :min="0" :precision="2" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="原价"><el-input-number v-model="form.originPrice" :min="0" :precision="2" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="库存"><el-input-number v-model="form.stock" :min="0" :precision="0" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" :precision="0" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="分类"><el-select v-model="form.categoryId" @change="setCategory"><el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="状态"><el-switch v-model="form.enabled" active-text="上架" inactive-text="下架" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="商品标签"><el-input v-model="form.tag" placeholder="如:赠品、积分兑换" clearable /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="已售数量"><el-input v-model="form.sales" disabled placeholder="0" clearable /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="商品数量"><el-input-number v-model="form.number" :min="0" :precision="0" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="税率"><el-input-number v-model="form.taxRate" :min="0" :max="1" :step="0.01" :precision="4" /><span class="product-field-hint">按小数填写,例如 0.13 表示 13%</span></el-form-item></el-col></el-row><el-form-item label="商品图片"><el-upload class="product-image-upload" list-type="picture-card" accept="image/*" :auto-upload="false" :show-file-list="false" :disabled="uploading" :on-change="uploadProductImage" v-loading="uploading" :aria-label="form.imageUrl ? '更换商品图片' : '上传商品图片'"><template v-if="form.imageUrl"><img :src="resolveImageUrl(form.imageUrl)" alt="商品图片" class="product-image-preview" /><span class="product-image-replace">点击更换</span></template><div v-else class="product-image-placeholder"><span class="product-image-plus" aria-hidden="true">+</span><span>上传图片</span></div></el-upload></el-form-item><el-form-item label="商品描述"><el-input v-model="form.description" type="textarea" :rows="3" /></el-form-item><el-form-item label="商品详情"><el-input v-model="form.detail" type="textarea" :rows="4" /></el-form-item></el-form><template #footer><el-button @click="visible = false">取消</el-button><el-button type="primary" :loading="saving" :disabled="uploading" @click="save">保存</el-button></template></el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { postAdmin } from '@/api/admin'
|
||||
import { postAdmin, resolveImageUrl } from '@/api/admin'
|
||||
import type { UploadFile } from 'element-plus'
|
||||
|
||||
type Category = { id: string; name: string }
|
||||
type Product = { id: number; sku: string; name: string; categoryId: string; category: string; price: string; originPrice: string; stock: number; sortOrder: number; enabled: boolean; imageUrl: string; description: string }
|
||||
type ProductForm = { id?: number; sku: string; name: string; price: number; originPrice?: number; stock: number; sortOrder: number; categoryId: string; category: string; imageUrl: string; description: string; enabled: boolean }
|
||||
const query = reactive({ keyword: '', categoryId: '' })
|
||||
type Product = { id: number; sku: string; name: string; categoryId: string; category: string; price: string; originPrice: string; stock: number; sortOrder: number; enabled: boolean; imageUrl: string; description: string; tag: string; sales: string; detail: string; number: number; taxRate: number }
|
||||
type ProductForm = { id?: number; sku: string; name: string; price: number; originPrice?: number; stock: number; sortOrder: number; categoryId: string; category: string; imageUrl: string; description: string; enabled: boolean; tag: string; sales: string; detail: string; number: number; taxRate: number }
|
||||
const query = reactive({ keyword: '', categoryId: '', pageNumber: 1, pageSize: 10 })
|
||||
const totalCount = ref(0)
|
||||
const uploading = ref(false)
|
||||
const uploadProductImage = async (file: UploadFile) => {
|
||||
if (!file.raw || uploading.value) return
|
||||
if (!file.raw.type.startsWith('image/')) return ElMessage.warning('请选择图片文件')
|
||||
uploading.value = true
|
||||
try {
|
||||
const contentBase64 = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result || ''))
|
||||
reader.onerror = () => reject(new Error('图片读取失败'))
|
||||
reader.readAsDataURL(file.raw!)
|
||||
})
|
||||
const data = await postAdmin<{ fileUrl: string }>('upload', { fileName: file.name, contentType: file.raw.type, contentBase64, folder: 'supplied/product' })
|
||||
form.imageUrl = data.fileUrl
|
||||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '图片上传失败') } finally { uploading.value = false }
|
||||
}
|
||||
const categories = ref<Category[]>([]); const products = ref<Product[]>([]); const loading = ref(false); const loaded = ref(false); const visible = ref(false); const saving = ref(false)
|
||||
const emptyForm = (): ProductForm => ({ sku: '', name: '', price: 0, stock: 0, sortOrder: 0, categoryId: '', category: '', imageUrl: '', description: '', enabled: true })
|
||||
const emptyForm = (): ProductForm => ({ sku: '', name: '', price: 0, stock: 0, sortOrder: 0, categoryId: '', category: '', imageUrl: '', description: '', enabled: true, tag: '', sales: '0', detail: '', number: 0, taxRate: 0.13 })
|
||||
const form = reactive<ProductForm>(emptyForm())
|
||||
const setForm = (value: ProductForm) => Object.assign(form, value)
|
||||
const load = async () => { loading.value = true; try { const data = await postAdmin<{ categories: Category[]; products: Product[] }>('merchant-product-list', query); categories.value = data.categories || []; products.value = data.products || []; loaded.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '商品查询失败') } finally { loading.value = false } }
|
||||
const reset = () => { query.keyword = ''; query.categoryId = ''; load() }
|
||||
const load = async () => { loading.value = true; try { const data = await postAdmin<{ categories: Category[]; products: Product[]; totalCount: number }>('merchant-product-list', query); categories.value = data.categories || []; products.value = data.products || []; totalCount.value = data.totalCount || 0; loaded.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '商品查询失败') } finally { loading.value = false } }
|
||||
const search = () => { query.pageNumber = 1; return load() }
|
||||
const reset = () => { query.keyword = ''; query.categoryId = ''; return search() }
|
||||
const openCreate = () => { setForm(emptyForm()); visible.value = true }
|
||||
const openEdit = (product: Product) => { setForm({ id: product.id, sku: product.sku, name: product.name, price: Number(product.price), originPrice: product.originPrice ? Number(product.originPrice) : undefined, stock: product.stock, sortOrder: product.sortOrder, categoryId: product.categoryId, category: product.category, imageUrl: product.imageUrl, description: product.description, enabled: product.enabled }); visible.value = true }
|
||||
const openEdit = (product: Product) => { setForm({ id: product.id, sku: product.sku, name: product.name, price: Number(product.price), originPrice: product.originPrice ? Number(product.originPrice) : undefined, stock: product.stock, sortOrder: product.sortOrder, categoryId: product.categoryId, category: product.category, imageUrl: product.imageUrl, description: product.description || '', enabled: product.enabled, tag: product.tag || '', sales: product.sales?.trim() || '0', detail: product.detail || '', number: product.number ?? 0, taxRate: product.taxRate ?? 0.13 }); visible.value = true }
|
||||
const setCategory = (categoryId: string) => { form.category = categories.value.find(item => item.id === categoryId)?.name || '' }
|
||||
const save = async () => { saving.value = true; try { await postAdmin('merchant-product-save', form); ElMessage.success('保存成功'); visible.value = false; await load() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '保存失败') } finally { saving.value = false } }
|
||||
const remove = async (product: Product) => { try { await ElMessageBox.confirm(`确认删除商品“${product.name}”?`, '删除商品', { type: 'warning' }); await postAdmin('merchant-product-delete', { id: product.id }); ElMessage.success('删除成功'); await load() } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '删除失败') } }
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-field-hint { margin-top: 6px; color: #8793a5; font-size: 12px; line-height: 18px; }
|
||||
.product-image-upload :deep(.el-upload--picture-card) { position: relative; width: 128px; height: 128px; overflow: hidden; border-radius: 8px; }
|
||||
.product-image-preview { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.product-image-placeholder { display: flex; flex-direction: column; align-items: center; gap: 8px; color: #8793a5; font-size: 13px; }
|
||||
.product-image-plus { font-size: 32px; line-height: 32px; }
|
||||
.product-image-replace { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 12px; background: rgba(0, 0, 0, .4); color: #fff; font-size: 13px; line-height: 20px; opacity: 0; }
|
||||
.product-image-upload :deep(.el-upload--picture-card:hover) .product-image-replace,
|
||||
.product-image-upload :deep(.el-upload--picture-card:focus-within) .product-image-replace { opacity: 1; }
|
||||
@media (hover: none) { .product-image-replace { top: auto; padding: 6px 12px; opacity: 1; } }
|
||||
.product-form :deep(.el-row .el-form-item) { margin-bottom: 24px; }
|
||||
.product-form :deep(.el-form-item__content) { min-width: 0; }
|
||||
.product-form :deep(.el-input),
|
||||
.product-form :deep(.el-select),
|
||||
.product-form :deep(.el-input-number) { width: 100%; }
|
||||
.product-form :deep(.el-form-item__label) { line-height: 36px; }
|
||||
</style>
|
||||
|
||||
@@ -3,23 +3,20 @@
|
||||
<section class="page-card">
|
||||
<div class="page-title"><div><h2>订单对账</h2><p>查看 ZHGH 回传的子订单对账结果,并处理异常订单。</p></div></div>
|
||||
<el-form inline class="query-form" @submit.prevent>
|
||||
<el-form-item label="供应商"><el-input v-model="query.supplierId" placeholder="供应商 ID" clearable /></el-form-item>
|
||||
<el-form-item label="子订单"><el-input v-model="query.orderId" placeholder="子订单 ID" clearable /></el-form-item>
|
||||
<el-form-item label="对账状态"><el-select v-model="query.reconciliationStatus" clearable placeholder="全部"><el-option label="未对账" :value="0" /><el-option label="对账成功" :value="1" /><el-option label="对账异常" :value="2" /></el-select></el-form-item>
|
||||
<el-form-item><el-button type="primary" :loading="loading" @click="load">查询</el-button><el-button @click="reset">重置</el-button></el-form-item>
|
||||
<el-form-item><el-button type="primary" :loading="loading" @click="search">查询</el-button><el-button @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-table :data="items" v-loading="loading" border>
|
||||
<el-table-column prop="orderId" label="子订单ID" min-width="180" />
|
||||
<el-table-column prop="mainOrderId" label="主订单ID" min-width="180" />
|
||||
<el-table-column prop="reconciliationStatus" label="对账状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.reconciliationStatus)">{{ statusText(row.reconciliationStatus) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="diffReason" label="差异说明" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="平台金额" min-width="120"><template #default="{ row }">{{ row.platformTotalPrice || '-' }}</template></el-table-column>
|
||||
<el-table-column label="供应商金额" min-width="120"><template #default="{ row }">{{ row.supplierTotalPrice || '-' }}</template></el-table-column>
|
||||
<el-table-column prop="reconciliationBatchNo" label="对账批次" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="reconciliationTime" label="对账时间" min-width="170" />
|
||||
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openEdit(row)">处理</el-button></template></el-table-column>
|
||||
<template #empty><el-empty :image-size="80" description="暂无对账结果" /></template></el-table>
|
||||
|
||||
<el-pagination v-model:current-page="query.pageNumber" :page-size="query.pageSize" :total="totalCount" layout="total, prev, pager, next, jumper" @current-change="load" />
|
||||
</section>
|
||||
<el-dialog v-model="dialogVisible" title="处理对账结果" width="460px">
|
||||
<el-form label-width="90px"><el-form-item label="子订单"><span>{{ editing?.orderId }}</span></el-form-item><el-form-item label="对账状态"><el-select v-model="form.reconciliationStatus"><el-option label="未对账" :value="0" /><el-option label="对账成功" :value="1" /><el-option label="对账异常" :value="2" /></el-select></el-form-item><el-form-item label="差异类型"><el-input-number v-model="form.diffType" :min="0" :precision="0" /></el-form-item><el-form-item label="差异说明"><el-input v-model="form.diffReason" type="textarea" :rows="3" /></el-form-item></el-form>
|
||||
@@ -29,12 +26,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { postAdmin } from '@/api/admin'
|
||||
|
||||
type ReconciliationItem = { supplierId: string; orderId: string; mainOrderId: string; reconciliationStatus: number; diffType?: number; diffReason: string; platformTotalPrice: string; supplierTotalPrice: string; reconciliationBatchNo: string; reconciliationTime: string }
|
||||
const query = reactive<{ supplierId: string; orderId: string; reconciliationStatus?: number }>({ supplierId: '', orderId: '' })
|
||||
const query = reactive<{ orderId: string; reconciliationStatus?: number; pageNumber: number; pageSize: number }>({ orderId: '', pageNumber: 1, pageSize: 10 })
|
||||
const totalCount = ref(0)
|
||||
const items = ref<ReconciliationItem[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
@@ -46,15 +44,16 @@ const statusText = (status: number) => ['未对账', '对账成功', '对账异
|
||||
const statusType = (status: number) => status === 1 ? 'success' : status === 2 ? 'danger' : 'info'
|
||||
|
||||
const load = async () => {
|
||||
if (!query.supplierId.trim()) return ElMessage.warning('请输入供应商 ID')
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await postAdmin<{ reconciliationItems: ReconciliationItem[] }>('merchant-reconciliation-list', query)
|
||||
const data = await postAdmin<{ reconciliationItems: ReconciliationItem[]; totalCount: number }>('merchant-reconciliation-list', query)
|
||||
items.value = data.reconciliationItems || []
|
||||
totalCount.value = data.totalCount || 0
|
||||
loaded.value = true
|
||||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '查询失败') } finally { loading.value = false }
|
||||
}
|
||||
const reset = () => { query.supplierId = ''; query.orderId = ''; query.reconciliationStatus = undefined; items.value = []; loaded.value = false }
|
||||
const search = () => { query.pageNumber = 1; return load() }
|
||||
const reset = () => { query.orderId = ''; query.reconciliationStatus = undefined; return search() }
|
||||
const openEdit = (row: ReconciliationItem) => { editing.value = row; form.reconciliationStatus = row.reconciliationStatus; form.diffType = row.diffType; form.diffReason = row.diffReason || ''; dialogVisible.value = true }
|
||||
const save = async () => {
|
||||
if (!editing.value) return
|
||||
@@ -64,4 +63,5 @@ const save = async () => {
|
||||
ElMessage.success('保存成功'); dialogVisible.value = false; await load()
|
||||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '保存失败') } finally { saving.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
const STORAGE_KEY = 'supplied-h5-merchant-session'
|
||||
export const clearMerchantSession = () => sessionStorage.removeItem(STORAGE_KEY)
|
||||
export const saveMerchantSession = session => sessionStorage.setItem(STORAGE_KEY, JSON.stringify(session))
|
||||
export const readMerchantSession = () => {
|
||||
try {
|
||||
const session = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || 'null')
|
||||
if (session?.token && session?.user && Date.parse(session.expiresAt) > Date.now()) return session
|
||||
} catch { /* 损坏的会话按未登录处理。 */ }
|
||||
clearMerchantSession()
|
||||
return null
|
||||
}
|
||||
export const merchantLoginLocation = () => {
|
||||
const redirect = window.location.hash.slice(1) || '/joyful-selection/merchant'
|
||||
return `#/joyful-selection/merchant/login?redirect=${encodeURIComponent(redirect)}`
|
||||
}
|
||||
@@ -1,7 +1,20 @@
|
||||
import { readMerchantSession, clearMerchantSession, merchantLoginLocation } from './merchant-session'
|
||||
|
||||
const API_BASE = process.env.VUE_APP_SUPPLIED_API_BASE || 'http://localhost:8888/api'
|
||||
|
||||
const normalizeBase = base => base.replace(/\/$/, '')
|
||||
|
||||
const resolveFileUrls = value => {
|
||||
if (typeof value === 'string' && /^\/api\/(?:file|upload-preview)\?/.test(value)) {
|
||||
return `${normalizeBase(API_BASE).replace(/\/api$/, '')}${value.replace('/api/upload-preview?', '/api/file?')}`
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(resolveFileUrls)
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, resolveFileUrls(item)]))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export const getFunction = async (name, params = {}) => {
|
||||
const url = new URL(`${normalizeBase(API_BASE)}/${name}`)
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
@@ -18,25 +31,34 @@ export const getFunction = async (name, params = {}) => {
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
return payload.data
|
||||
return resolveFileUrls(payload.data)
|
||||
}
|
||||
|
||||
export const postFunction = async (name, body = {}) => {
|
||||
const response = await fetch(`${normalizeBase(API_BASE)}/${name}`, {
|
||||
const merchantRequest = name.startsWith('merchant-') || ['login', 'admin-session', 'admin-logout'].includes(name)
|
||||
const token = merchantRequest ? readMerchantSession()?.token : ''
|
||||
const response = await fetch(`${normalizeBase(API_BASE)}${merchantRequest ? '/admin' : ''}/${name}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (merchantRequest && name !== 'login' && (response.status === 401 || payload.error?.code === 'UNAUTHORIZED')) {
|
||||
clearMerchantSession()
|
||||
if (name !== 'admin-session' && !window.location.hash.startsWith('#/joyful-selection/merchant/login')) {
|
||||
window.location.replace(merchantLoginLocation())
|
||||
}
|
||||
}
|
||||
if (!response.ok || payload.success === false) {
|
||||
const message = payload.error?.message || payload.msg || '请求失败'
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
return payload.data
|
||||
return resolveFileUrls(payload.data)
|
||||
}
|
||||
|
||||
export const uploadImageFunction = async (file, folder = 'after-sale') => {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import { Toast } from 'vant'
|
||||
import { postFunction } from '@/api/supplied-service'
|
||||
import { readMerchantSession, clearMerchantSession } from '@/api/merchant-session'
|
||||
import SupplierHome from '@/view/index.vue'
|
||||
import JoyfulSelectionLayout from '@/view/joyful-selection/layout.vue'
|
||||
import JoyfulSelectionHome from '@/view/joyful-selection/index.vue'
|
||||
@@ -28,10 +31,11 @@ import HappyShopping from '@/view/happy-shopping/index.vue'
|
||||
import FreshFruit from '@/view/fresh-fruit/index.vue'
|
||||
import CuteSelection from '@/view/cute-selection/index.vue'
|
||||
|
||||
export default createRouter({
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: SupplierHome },
|
||||
{ path: '/joyful-selection/merchant/login', component: () => import('@/view/joyful-selection/merchant/login.vue') },
|
||||
{
|
||||
path: '/joyful-selection',
|
||||
component: JoyfulSelectionLayout,
|
||||
@@ -54,8 +58,8 @@ export default createRouter({
|
||||
{ path: 'after-sale/select', component: JoyfulSelectionAfterSaleSelect },
|
||||
{ path: 'after-sale/return', component: JoyfulSelectionAfterSaleReturn },
|
||||
{ path: 'after-sale/exchange', component: JoyfulSelectionAfterSaleExchange },
|
||||
{ path: 'merchant', component: JoyfulSelectionMerchant },
|
||||
{ path: 'merchant/after-sale-detail', component: JoyfulSelectionMerchantAfterSaleDetail },
|
||||
{ path: 'merchant', component: JoyfulSelectionMerchant, meta: { merchantAuth: true } },
|
||||
{ path: 'merchant/after-sale-detail', component: JoyfulSelectionMerchantAfterSaleDetail, meta: { merchantAuth: true } },
|
||||
{ path: 'supplier-logs', component: JoyfulSelectionSupplierLogs },
|
||||
{ path: 'profile', component: JoyfulSelectionProfile },
|
||||
{ path: 'points', component: JoyfulSelectionPoints }
|
||||
@@ -67,3 +71,19 @@ export default createRouter({
|
||||
{ path: '/cute-selection', component: CuteSelection }
|
||||
]
|
||||
})
|
||||
|
||||
router.beforeEach(async to => {
|
||||
if (!to.meta.merchantAuth) return true
|
||||
const login = { path: '/joyful-selection/merchant/login', query: { redirect: to.fullPath } }
|
||||
if (!readMerchantSession()) return login
|
||||
try {
|
||||
await postFunction('admin-session')
|
||||
return true
|
||||
} catch (error) {
|
||||
clearMerchantSession()
|
||||
Toast(error.message || '登录已失效,请重新登录')
|
||||
return login
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -50,6 +50,10 @@ export default {
|
||||
...mapActions(useCartStore, ['loadCart']),
|
||||
...mapActions(useUserStore, ['hydrateFromRoute', 'fetchBridgeToken']),
|
||||
async prepareUserSession() {
|
||||
if (this.$route.meta.merchantAuth) {
|
||||
this.sessionLoading = false
|
||||
return
|
||||
}
|
||||
this.sessionLoading = true
|
||||
try {
|
||||
await this.hydrateFromRoute(this.$route.query)
|
||||
|
||||
@@ -336,7 +336,7 @@ export default {
|
||||
this.loadDetail()
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useUserStore, ['fetchBridgeToken']),
|
||||
...mapActions(useUserStore, ['fetchSupplierInfo']),
|
||||
normalizeSteps(steps) {
|
||||
const serviceState = Number(this.afterSale.serviceState)
|
||||
return steps.map((step, index) => ({
|
||||
@@ -359,9 +359,9 @@ export default {
|
||||
async loadDetail() {
|
||||
try {
|
||||
this.loading = true
|
||||
await this.fetchBridgeToken()
|
||||
if (!this.supplierId && !this.$route.query.supplierId) await this.fetchSupplierInfo()
|
||||
const data = await postFunction('merchant-after-sale-detail', {
|
||||
supplierId: this.supplierId,
|
||||
supplierId: String(this.$route.query.supplierId || this.supplierId),
|
||||
serviceOrderId: String(this.$route.query.serviceOrderId || '')
|
||||
})
|
||||
|
||||
@@ -407,7 +407,7 @@ export default {
|
||||
message: auditStatus === 1 ? '通过后用户可寄回商品。' : '拒绝后该售后申请将结束。'
|
||||
})
|
||||
await postFunction('merchant-after-sale-audit', {
|
||||
supplierId: this.supplierId,
|
||||
supplierId: this.afterSale.supplierId || this.supplierId,
|
||||
serviceOrderId: this.afterSale.serviceOrderId,
|
||||
auditStatus,
|
||||
auditRemark: auditStatus === 1 ? '审核通过' : '商家拒绝'
|
||||
@@ -421,7 +421,7 @@ export default {
|
||||
message: '确认后退货售后会进入完成,换货售后会进入待商家寄货。'
|
||||
})
|
||||
await postFunction('merchant-after-sale-receive', {
|
||||
supplierId: this.supplierId,
|
||||
supplierId: this.afterSale.supplierId || this.supplierId,
|
||||
serviceOrderId: this.afterSale.serviceOrderId
|
||||
})
|
||||
Toast('已确认收货')
|
||||
@@ -441,7 +441,7 @@ export default {
|
||||
}
|
||||
|
||||
await postFunction('merchant-after-sale-ship', {
|
||||
supplierId: this.supplierId,
|
||||
supplierId: this.afterSale.supplierId || this.supplierId,
|
||||
serviceOrderId: this.afterSale.serviceOrderId,
|
||||
logisticsCompany: this.exchangeShipForm.logisticsCompany,
|
||||
logisticsNo: this.exchangeShipForm.logisticsNo
|
||||
|
||||
@@ -252,7 +252,7 @@ export default {
|
||||
this.prepareSupplier().then(() => this.loadAllTabs())
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useUserStore, ['fetchSupplierInfo', 'fetchBridgeToken']),
|
||||
...mapActions(useUserStore, ['fetchSupplierInfo']),
|
||||
...mapActions(useUiStore, ['loadUi', 'setMerchantTab']),
|
||||
skuText(item = {}) {
|
||||
const sku = String(item.sku || item.skuText || '').trim()
|
||||
@@ -273,7 +273,6 @@ export default {
|
||||
return count > 0 ? count : ''
|
||||
},
|
||||
async prepareSupplier() {
|
||||
await this.fetchBridgeToken()
|
||||
if (!this.supplierId) {
|
||||
await this.fetchSupplierInfo()
|
||||
}
|
||||
@@ -353,7 +352,8 @@ export default {
|
||||
this.$router.push({
|
||||
path: '/joyful-selection/merchant/after-sale-detail',
|
||||
query: {
|
||||
serviceOrderId: item.serviceOrderId
|
||||
serviceOrderId: item.serviceOrderId,
|
||||
supplierId: item.supplierId || this.supplierId
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<main class="merchant-login-page">
|
||||
<van-nav-bar title="商家登录" left-arrow @click-left="$router.replace('/joyful-selection/home')" />
|
||||
<section class="merchant-login-card">
|
||||
<div class="login-icon"><van-icon name="shop-o" size="36" /></div>
|
||||
<h1>商家处理台</h1><p>使用供应商管理账号登录</p>
|
||||
<van-form @submit="submit">
|
||||
<van-cell-group inset>
|
||||
<van-field v-model="username" name="username" label="账号" placeholder="请输入账号" autocomplete="username" :rules="[{ required: true, message: '请输入账号' }]" />
|
||||
<van-field v-model="password" name="password" type="password" label="密码" placeholder="请输入密码" autocomplete="current-password" :rules="[{ required: true, message: '请输入密码' }]" />
|
||||
</van-cell-group>
|
||||
<van-button block round type="primary" native-type="submit" :loading="submitting" :disabled="submitting" color="#ff5b20" class="login-submit">登录</van-button>
|
||||
</van-form>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Toast } from 'vant'
|
||||
import { postFunction } from '@/api/supplied-service'
|
||||
import { saveMerchantSession, clearMerchantSession } from '@/api/merchant-session'
|
||||
|
||||
export default {
|
||||
name: 'MerchantLogin',
|
||||
data: () => ({ username: '', password: '', submitting: false }),
|
||||
methods: {
|
||||
async submit() {
|
||||
if (this.submitting) return
|
||||
this.submitting = true
|
||||
try {
|
||||
const session = await postFunction('login', { username: this.username.trim(), password: this.password })
|
||||
saveMerchantSession(session)
|
||||
await postFunction('admin-session')
|
||||
this.password = ''
|
||||
const target = typeof this.$route.query.redirect === 'string' ? this.$route.query.redirect : ''
|
||||
const resolved = this.$router.resolve(target || '/joyful-selection/merchant')
|
||||
await this.$router.replace(resolved.meta.merchantAuth ? resolved.fullPath : '/joyful-selection/merchant')
|
||||
} catch (error) {
|
||||
clearMerchantSession()
|
||||
Toast(error.message || '登录失败')
|
||||
} finally { this.submitting = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.merchant-login-page { min-height: 100vh; background: #f7f8fa; }
|
||||
.merchant-login-card { max-width: 420px; margin: 48px auto; padding: 28px 20px; background: #fff; border-radius: 16px; }
|
||||
.login-icon { display: flex; align-items: center; justify-content: center; width: 72px; height: 72px; margin: 0 auto 20px; border-radius: 20px; color: #ff5b20; background: #fff1e9; }
|
||||
.merchant-login-card h1 { margin: 0; text-align: center; font-size: 23px; }
|
||||
.merchant-login-card > p { margin: 12px 0 28px; text-align: center; color: #8793a5; font-size: 14px; }
|
||||
.merchant-login-card :deep(.van-cell-group--inset) { margin: 0; }
|
||||
.login-submit { margin-top: 28px; }
|
||||
@media (max-width: 480px) { .merchant-login-card { margin: 32px 16px; } }
|
||||
</style>
|
||||
@@ -43,7 +43,7 @@
|
||||
<div class="goods-bottom">
|
||||
<div class="price-stack">
|
||||
<div><b>¥{{ item.price }}</b></div>
|
||||
<div><span>已售{{ item.sales }}</span></div>
|
||||
<div><span>已售{{ formatSales(item.sales) }}件</span></div>
|
||||
</div>
|
||||
<button type="button" class="cart-button" @click.stop="openSku(item)">
|
||||
<van-icon name="cart-o" />
|
||||
@@ -96,6 +96,11 @@ export default {
|
||||
this.loadProducts()
|
||||
},
|
||||
methods: {
|
||||
formatSales(value) {
|
||||
const number = Number(value)
|
||||
const count = Number.isFinite(number) ? Math.max(0, Math.floor(number)) : 0
|
||||
return count >= 10000 ? `${Math.floor(count / 10000)}万` : String(count)
|
||||
},
|
||||
async loadProducts() {
|
||||
this.loading = true
|
||||
try {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
src="https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg"
|
||||
/>
|
||||
<div>
|
||||
<b>张三</b>
|
||||
<b>{{ nickname || '-' }}</b>
|
||||
<p>欢迎来到悦享优选</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,7 +103,7 @@ export default {
|
||||
}
|
||||
}),
|
||||
computed: {
|
||||
...mapState(useUserStore, ['pointsBalance', 'supplierId', 'userId']),
|
||||
...mapState(useUserStore, ['pointsBalance', 'supplierId', 'userId', 'nickname']),
|
||||
formattedPoints() {
|
||||
return Number(this.pointsBalance || 0).toLocaleString('zh-CN', {
|
||||
maximumFractionDigits: 2
|
||||
|
||||
@@ -40,7 +40,19 @@ export const uploadController = withErrorBoundary(async (request: Request, _cont
|
||||
return toHttpResponse(await minioUploadService.upload(await readJson<UploadInput>(request)))
|
||||
})
|
||||
|
||||
export const fileController = withErrorBoundary(async (request: Request, _context: Context) => {
|
||||
const methodResponse = guardMethod(request, ['GET'])
|
||||
if (methodResponse) return methodResponse
|
||||
const result = await minioUploadService.imageUrl(new URL(request.url).searchParams.get('fid')?.trim() || '')
|
||||
if (!result.success) return toHttpResponse(result)
|
||||
return new Response(null, { status: 302, headers: { Location: result.data.url, 'Cache-Control': 'no-store' } })
|
||||
})
|
||||
|
||||
export const uploadPreviewController = withErrorBoundary(async (request: Request, _context: Context) => {
|
||||
if (request.method === 'GET') {
|
||||
const fid = new URL(request.url).searchParams.get('fid') || ''
|
||||
return new Response(null, { status: 302, headers: { Location: `/api/file?fid=${encodeURIComponent(fid)}`, 'Cache-Control': 'no-store' } })
|
||||
}
|
||||
const methodResponse = guardMethod(request, ['POST'])
|
||||
if (methodResponse) return methodResponse
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { withErrorBoundary } from '../utils/handler.js'
|
||||
import { guardMethod } from '../utils/method.js'
|
||||
import { getBearerToken, readJson } from '../utils/request.js'
|
||||
import { adminAuthService } from '../services/admin-auth.service.js'
|
||||
import { serviceFail } from '../types/service-result.js'
|
||||
import { merchantInvoiceService, type MerchantInvoiceListInput, type MerchantInvoiceFileInput } from '../services/merchant-invoice.service.js'
|
||||
import { toHttpResponse } from './controller-response.js'
|
||||
|
||||
export const merchantInvoiceListController = withErrorBoundary(async request => {
|
||||
const methodResponse = guardMethod(request, ['POST'])
|
||||
if (methodResponse) return methodResponse
|
||||
if (!await adminAuthService.findSession(getBearerToken(request))) return toHttpResponse(serviceFail('登录已失效,请重新登录', 401, 'UNAUTHORIZED'))
|
||||
return toHttpResponse(await merchantInvoiceService.list(await readJson<MerchantInvoiceListInput>(request)))
|
||||
})
|
||||
|
||||
export const merchantInvoiceFileController = withErrorBoundary(async request => {
|
||||
const methodResponse = guardMethod(request, ['POST'])
|
||||
if (methodResponse) return methodResponse
|
||||
if (!await adminAuthService.findSession(getBearerToken(request))) return toHttpResponse(serviceFail('登录已失效,请重新登录', 401, 'UNAUTHORIZED'))
|
||||
return toHttpResponse(await merchantInvoiceService.file(await readJson<MerchantInvoiceFileInput>(request)))
|
||||
})
|
||||
@@ -31,8 +31,8 @@ export const merchantProductListController = withErrorBoundary(async (request: R
|
||||
const methodResponse = guardMethod(request, ['POST'])
|
||||
if (methodResponse) return methodResponse
|
||||
|
||||
const body = await readJson<{ keyword?: string; categoryId?: string }>(request)
|
||||
return toHttpResponse(await productService.listManageProducts(String(body.keyword || '').trim(), String(body.categoryId || '').trim()))
|
||||
const body = await readJson<{ keyword?: string; categoryId?: string; pageNumber?: number; pageSize?: number }>(request)
|
||||
return toHttpResponse(await productService.listManageProducts(String(body.keyword || '').trim(), String(body.categoryId || '').trim(), body.pageNumber, body.pageSize))
|
||||
})
|
||||
|
||||
export const merchantProductSaveController = withErrorBoundary(async (request: Request, _context: Context) => {
|
||||
|
||||
@@ -171,13 +171,14 @@ export const orderDao = {
|
||||
})
|
||||
},
|
||||
|
||||
async findMerchantSubOrders(supplierId: string, orderState?: number, orderId?: string, userId?: string, excludedOrderIds: string[] = [], pagination?: { pageNumber: number; pageSize: number }) {
|
||||
async findMerchantSubOrders(supplierId: string, orderState?: number, orderId?: string, userId?: string, excludedOrderIds: string[] = [], pagination?: { pageNumber: number; pageSize: number }, matchingUserIds?: string[]) {
|
||||
const where: Prisma.SupplierOrderSubWhereInput = {
|
||||
...(supplierId ? { supplierId } : {}),
|
||||
deleted: 0,
|
||||
...(Number.isInteger(orderState) ? { orderState } : {}),
|
||||
...(orderId ? { OR: [{ orderId: { contains: orderId } }, { orderNumberId: { contains: orderId } }] } : {}),
|
||||
...(userId ? { userId: { contains: userId } } : {}),
|
||||
...(matchingUserIds !== undefined ? { AND: [{ userId: { in: matchingUserIds } }] } : {}),
|
||||
...(excludedOrderIds.length ? { orderId: { notIn: excludedOrderIds } } : {})
|
||||
}
|
||||
const [orders, totalCount] = await prisma.$transaction([
|
||||
@@ -201,12 +202,13 @@ export const orderDao = {
|
||||
return { orders, totalCount }
|
||||
},
|
||||
|
||||
findMerchantAfterSales(supplierId: string, serviceStates?: number[]) {
|
||||
findMerchantAfterSales(supplierId: string, serviceStates?: number[], serviceOrderId = '') {
|
||||
return prisma.supplierAfterSale.findMany({
|
||||
where: {
|
||||
...(supplierId ? { supplierId } : {}),
|
||||
deleted: 0,
|
||||
...(serviceStates?.length ? { serviceState: { in: serviceStates } } : {})
|
||||
...(serviceStates?.length ? { serviceState: { in: serviceStates } } : {}),
|
||||
...(serviceOrderId ? { serviceOrderId: { contains: serviceOrderId } } : {})
|
||||
},
|
||||
orderBy: [
|
||||
{ createTime: 'desc' },
|
||||
|
||||
@@ -35,18 +35,22 @@ export const productDao = {
|
||||
})
|
||||
},
|
||||
|
||||
findManageProducts(keyword: string, categoryId: string) {
|
||||
return prisma.supplierProduct.findMany({
|
||||
where: {
|
||||
deleted: 0,
|
||||
...(keyword ? { OR: [{ name: { contains: keyword } }, { sku: { contains: keyword } }] } : {}),
|
||||
...(categoryId ? { categoryId } : {})
|
||||
},
|
||||
orderBy: [
|
||||
{ sortOrder: 'asc' },
|
||||
{ createdAt: 'desc' }
|
||||
]
|
||||
})
|
||||
async findManageProducts(keyword: string, categoryId: string, pageNumber = 1, pageSize = 10) {
|
||||
const where = {
|
||||
deleted: 0,
|
||||
...(keyword ? { OR: [{ name: { contains: keyword } }, { sku: { contains: keyword } }] } : {}),
|
||||
...(categoryId ? { categoryId } : {})
|
||||
}
|
||||
const [products, totalCount] = await prisma.$transaction([
|
||||
prisma.supplierProduct.findMany({
|
||||
where,
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (pageNumber - 1) * pageSize,
|
||||
take: pageSize
|
||||
}),
|
||||
prisma.supplierProduct.count({ where })
|
||||
])
|
||||
return { products, totalCount }
|
||||
},
|
||||
|
||||
createProduct(data: Parameters<typeof prisma.supplierProduct.create>[0]['data']) {
|
||||
|
||||
@@ -31,6 +31,11 @@ export const suppliedUserDao = {
|
||||
if (!user) throw new Error('获取供应商用户信息失败')
|
||||
return user
|
||||
},
|
||||
async findVirtualIdsByNickname(nickname: string) {
|
||||
return prisma.$queryRaw<Array<{ virtualId: string }>>`
|
||||
SELECT virtualId FROM supplied_user
|
||||
WHERE virtualId IS NOT NULL AND LOCATE(${nickname}, nickname) > 0`
|
||||
},
|
||||
async findByVirtualIds(virtualIds: string[]) {
|
||||
if (!virtualIds.length) return []
|
||||
return prisma.$queryRaw<SuppliedCustomer[]>`
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { fileController as default } from '../controllers/basic.controller.js'
|
||||
@@ -0,0 +1 @@
|
||||
export { merchantInvoiceFileController as default } from '../controllers/merchant-invoice.controller.js'
|
||||
@@ -0,0 +1 @@
|
||||
export { merchantInvoiceListController as default } from '../controllers/merchant-invoice.controller.js'
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
loginController,
|
||||
uploadController,
|
||||
uploadPreviewController,
|
||||
fileController,
|
||||
userInfoController
|
||||
} from './controllers/basic.controller.js'
|
||||
import {
|
||||
@@ -78,7 +79,11 @@ const app = new Koa()
|
||||
const router = new Router()
|
||||
const port = Number(process.env.PORT || 8888)
|
||||
|
||||
import { merchantInvoiceListController, merchantInvoiceFileController } from './controllers/merchant-invoice.controller.js'
|
||||
|
||||
const handlers: Record<string, FunctionHandler> = {
|
||||
'merchant-invoice-list': merchantInvoiceListController,
|
||||
'merchant-invoice-file': merchantInvoiceFileController,
|
||||
'address-default': addressDefaultController,
|
||||
'address-delete': addressDeleteController,
|
||||
'address-list': addressListController,
|
||||
@@ -132,6 +137,7 @@ const handlers: Record<string, FunctionHandler> = {
|
||||
'supplier-token': supplierTokenController,
|
||||
upload: uploadController,
|
||||
'upload-preview': uploadPreviewController,
|
||||
file: fileController,
|
||||
'user-info': userInfoController
|
||||
}
|
||||
|
||||
@@ -176,6 +182,11 @@ const writeResponse = async (ctx: Koa.Context, response: Response, wrapBa7 = fal
|
||||
ctx.status = response.status
|
||||
Object.entries(corsHeaders).forEach(([key, value]) => ctx.set(key, value))
|
||||
response.headers.forEach((value, key) => ctx.set(key, value))
|
||||
if (response.status >= 300 && response.status < 400 && response.headers.has('Location')) {
|
||||
ctx.body = ''
|
||||
ctx.status = response.status
|
||||
return
|
||||
}
|
||||
const responseText = response.body ? await response.text() : ''
|
||||
ctx.body = wrapBa7 ? ba7ResponseBody(responseText) : responseText || undefined
|
||||
}
|
||||
@@ -199,6 +210,14 @@ const dispatch = (name: string) => async (ctx: Koa.Context) => {
|
||||
ctx.body = { success: false, error: { code: 'NOT_FOUND', message: '接口不存在' } }
|
||||
return
|
||||
}
|
||||
if (name.startsWith('merchant-')) {
|
||||
const token = ctx.get('Authorization').match(/^Bearer\s+(.+)$/i)?.[1] || ''
|
||||
if (!await adminAuthService.findSession(token)) {
|
||||
ctx.status = 401
|
||||
ctx.body = { success: false, error: { code: 'UNAUTHORIZED', message: '登录已失效,请重新登录' } }
|
||||
return
|
||||
}
|
||||
}
|
||||
const response = await handler(toRequest(ctx), {})
|
||||
await writeResponse(ctx, response, ctx.path.startsWith('/supplierOpenApi/'))
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import {
|
||||
AFTER_SALE_LOGISTICS_STATE,
|
||||
AFTER_SALE_SERVICE_STATE,
|
||||
@@ -132,8 +133,8 @@ const toDateTimeText = (date?: Date | null) => {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
const createServiceOrderId = (subOrderId: string, serviceType: number) => {
|
||||
return `AS${serviceType}_${subOrderId}_${Date.now()}`
|
||||
const createServiceOrderId = (serviceType: number) => {
|
||||
return `AS${serviceType}_${randomBytes(8).toString('hex')}`
|
||||
}
|
||||
|
||||
const isOrderSigned = (orderState?: number | null) => signedOrderStates.has(Number(orderState))
|
||||
@@ -323,7 +324,7 @@ export const afterSaleService = {
|
||||
|
||||
const now = new Date()
|
||||
const amount = Number(input.amount || subOrder.totalPrice || 0)
|
||||
const serviceOrderId = createServiceOrderId(subOrderId, serviceType)
|
||||
const serviceOrderId = createServiceOrderId(serviceType)
|
||||
const { isSigned: _isSigned, ...requestBody } = input
|
||||
|
||||
await orderDao.transaction(async tx => {
|
||||
@@ -490,7 +491,7 @@ export const afterSaleService = {
|
||||
const subOrders = record.subOrderId
|
||||
? await orderDao.findOpenApiSubOrders(supplierId, [record.subOrderId])
|
||||
: []
|
||||
const isSigned = isOrderSigned(subOrders[0]?.orderState)
|
||||
const isSigned = Boolean(record.pickupMethod || record.pickupName || record.pickupMobile || record.pickupAddress) || isOrderSigned(subOrders[0]?.orderState)
|
||||
const approved = auditStatus === 1
|
||||
const isExchange = record.serviceType === ORDER_EVENT_TYPE.EXCHANGE_ORDER
|
||||
const finishUnsignedReturn = approved && !isSigned && record.serviceType === ORDER_EVENT_TYPE.RETURN_ORDER
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { prisma } from '../utils/prisma.js'
|
||||
import { serviceFail, serviceOk } from '../types/service-result.js'
|
||||
import { supplierFileService } from './supplier-file.service.js'
|
||||
|
||||
export type MerchantInvoiceListInput = { settlementId?: string; invoiceStatus?: number; pageNumber?: number; pageSize?: number }
|
||||
export type MerchantInvoiceFileInput = { id?: number; download?: boolean }
|
||||
|
||||
export const merchantInvoiceService = {
|
||||
async list(input: MerchantInvoiceListInput) {
|
||||
const settlementId = String(input.settlementId || '').trim()
|
||||
const invoiceStatus = input.invoiceStatus === undefined || input.invoiceStatus === null ? undefined : Number(input.invoiceStatus)
|
||||
if (invoiceStatus !== undefined && ![0, 1, 2, 3].includes(invoiceStatus)) return serviceFail('开票状态不正确')
|
||||
const pageNumber = Number.isInteger(Number(input.pageNumber)) && Number(input.pageNumber) > 0 ? Number(input.pageNumber) : 1
|
||||
const pageSize = Number.isInteger(Number(input.pageSize)) && Number(input.pageSize) > 0 ? Math.min(Number(input.pageSize), 100) : 10
|
||||
const where = { deleted: 0, ...(settlementId ? { settlementId: { contains: settlementId } } : {}), ...(invoiceStatus === undefined ? {} : { invoiceStatus }) }
|
||||
const [records, totalCount] = await prisma.$transaction([
|
||||
prisma.supplierInvoiceInfo.findMany({ where, orderBy: [{ createTime: 'desc' }, { id: 'desc' }], skip: (pageNumber - 1) * pageSize, take: pageSize }),
|
||||
prisma.supplierInvoiceInfo.count({ where })
|
||||
])
|
||||
const invoiceItems = records.map(record => ({
|
||||
id: record.id, settlementId: record.settlementId || record.invoiceBizId, invoiceIds: record.invoiceIds || '',
|
||||
invoiceCode: record.invoiceCode || '', invoiceStatus: record.invoiceStatus, invoiceDate: record.invoiceDate,
|
||||
invoiceType: record.invoiceType, invoiceAmount: record.invoiceAmount?.toFixed(2) || '0.00',
|
||||
invoiceNakeAmount: record.invoiceNakeAmount?.toFixed(2) || '0.00', invoiceTaxAmount: record.invoiceTaxAmount?.toFixed(2) || '0.00',
|
||||
invoiceTaxRate: record.invoiceTaxRate?.toString() || '', taxIdNumber: record.taxIdNumber || '', checkCode: record.checkCode || '',
|
||||
orderIds: record.orderIds || '', failureMessage: record.failureMessage || '', createTime: record.createTime,
|
||||
hasFile: record.invoiceStatus === 2 && Boolean(record.fileId)
|
||||
}))
|
||||
return serviceOk({ invoiceItems, totalCount, pageNumber, pageSize })
|
||||
},
|
||||
async file(input: MerchantInvoiceFileInput) {
|
||||
const id = Number(input.id)
|
||||
if (!Number.isInteger(id) || id <= 0) return serviceFail('发票参数不正确')
|
||||
const invoice = await prisma.supplierInvoiceInfo.findFirst({ where: { id, deleted: 0, invoiceStatus: 2 } })
|
||||
if (!invoice?.fileId) return serviceFail('发票文件不存在', 404, 'INVOICE_FILE_NOT_FOUND')
|
||||
const url = await supplierFileService.pdfUrl(invoice.fileId, invoice.supplierId, input.download === true)
|
||||
if (!url) return serviceFail('发票文件不存在', 404, 'INVOICE_FILE_NOT_FOUND')
|
||||
return serviceOk({ url })
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export type MerchantOrderListInput = {
|
||||
orderState?: number
|
||||
pageNumber?: number
|
||||
pageSize?: number
|
||||
userNickname?: string
|
||||
orderId?: string
|
||||
userId?: string
|
||||
}
|
||||
@@ -34,6 +35,7 @@ export type MerchantShipInput = {
|
||||
export type MerchantAfterSaleListInput = {
|
||||
supplierId?: string
|
||||
scene?: string
|
||||
serviceOrderId?: string
|
||||
}
|
||||
|
||||
const serviceStateText = (serviceState: number) => {
|
||||
@@ -76,7 +78,7 @@ const firstImage = (picInfo: unknown) => {
|
||||
}
|
||||
|
||||
const afterSaleStatesByScene = (scene: string) => {
|
||||
if (scene === 'audit') return [AFTER_SALE_SERVICE_STATE.PENDING_AUDIT]
|
||||
if (scene === 'audit') return [AFTER_SALE_SERVICE_STATE.PENDING_AUDIT, AFTER_SALE_SERVICE_STATE.APPROVED, AFTER_SALE_SERVICE_STATE.USER_SHIPPED, AFTER_SALE_SERVICE_STATE.RECEIVED]
|
||||
if (scene === 'process') {
|
||||
return [
|
||||
AFTER_SALE_SERVICE_STATE.APPROVED,
|
||||
@@ -123,7 +125,9 @@ export const merchantService = {
|
||||
const pagination = input.pageNumber !== undefined || input.pageSize !== undefined ? { pageNumber, pageSize } : undefined
|
||||
const afterSales = await orderDao.findMerchantAfterSales(supplierId, pendingShipExcludeAfterSaleStates)
|
||||
const afterSaleSubOrderIds = afterSales.map(item => item.subOrderId).filter((id): id is string => Boolean(id))
|
||||
const { orders, totalCount } = await orderDao.findMerchantSubOrders(supplierId, orderState, orderId, userId, afterSaleSubOrderIds, pagination)
|
||||
const userNickname = String(input.userNickname || '').trim()
|
||||
const matchingUserIds = userNickname ? (await suppliedUserDao.findVirtualIdsByNickname(userNickname)).map(user => user.virtualId) : undefined
|
||||
const { orders, totalCount } = await orderDao.findMerchantSubOrders(supplierId, orderState, orderId, userId, afterSaleSubOrderIds, pagination, matchingUserIds)
|
||||
const virtualIds = [...new Set(orders.map(order => order.userId).filter((id): id is string => Boolean(id)))]
|
||||
const users = await suppliedUserDao.findByVirtualIds(virtualIds)
|
||||
const nicknames = new Map(users.map(user => [user.virtualId, user.nickname]))
|
||||
@@ -224,13 +228,12 @@ export const merchantService = {
|
||||
const scene = String(input.scene || '').trim()
|
||||
logger.info('merchant after sale list query', { supplierId, scene })
|
||||
|
||||
if (!supplierId) {
|
||||
return serviceFail('供应商不能为空')
|
||||
}
|
||||
|
||||
const records = await orderDao.findMerchantAfterSales(supplierId, afterSaleStatesByScene(scene))
|
||||
const subOrderIds = records.map(item => item.subOrderId).filter(Boolean) as string[]
|
||||
const subOrders = subOrderIds.length ? await orderDao.findOpenApiSubOrders(supplierId, subOrderIds) : []
|
||||
const records = await orderDao.findMerchantAfterSales(supplierId, afterSaleStatesByScene(scene), String(input.serviceOrderId || '').trim())
|
||||
const supplierIds = [...new Set(records.map(item => item.supplierId))]
|
||||
const subOrders = (await Promise.all(supplierIds.map(recordSupplierId => {
|
||||
const subOrderIds = records.filter(item => item.supplierId === recordSupplierId).map(item => item.subOrderId).filter(Boolean) as string[]
|
||||
return subOrderIds.length ? orderDao.findOpenApiSubOrders(recordSupplierId, subOrderIds) : []
|
||||
}))).flat()
|
||||
const subOrderMap = new Map(subOrders.map(item => [item.orderId, item]))
|
||||
const afterSaleItems = records.map(record => {
|
||||
const subOrder = record.subOrderId ? subOrderMap.get(record.subOrderId) : null
|
||||
@@ -246,7 +249,7 @@ export const merchantService = {
|
||||
serviceState: record.serviceState,
|
||||
serviceStateText: serviceStateText(record.serviceState),
|
||||
logisticsState: record.logisticsState,
|
||||
isSigned: isOrderSigned(subOrder?.orderState),
|
||||
isSigned: Boolean(record.pickupMethod || record.pickupName || record.pickupMobile || record.pickupAddress) || isOrderSigned(subOrder?.orderState),
|
||||
reason: record.reason || '',
|
||||
problemDescription: record.problemDescription || '',
|
||||
pickupMethod: record.pickupMethod || '',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { prisma } from '../utils/prisma.js'
|
||||
import { serviceFail, serviceOk } from '../types/service-result.js'
|
||||
|
||||
export type UploadInput = {
|
||||
@@ -36,9 +37,9 @@ const signingKey = (date: string) => {
|
||||
return hmac(serviceKey, 'aws4_request')
|
||||
}
|
||||
|
||||
const objectUrl = (objectName: string) => {
|
||||
const objectUrl = (objectName: string, bucket = minioConfig.bucket) => {
|
||||
const endpoint = new URL(minioConfig.endPoint)
|
||||
endpoint.pathname = `${endpoint.pathname.replace(/\/$/, '')}/${minioConfig.bucket}/${encodePath(objectName)}`
|
||||
endpoint.pathname = `${endpoint.pathname.replace(/\/$/, '')}/${bucket}/${encodePath(objectName)}`
|
||||
return endpoint
|
||||
}
|
||||
|
||||
@@ -49,12 +50,12 @@ const extName = (fileName: string) => {
|
||||
|
||||
const safeFolder = (folder?: string) => String(folder || 'after-sale').replace(/[^a-zA-Z0-9/_-]/g, '').replace(/^\/+|\/+$/g, '') || 'after-sale'
|
||||
|
||||
const presign = (method: 'GET', objectName: string, expires = 3600) => {
|
||||
const presign = (method: 'GET', objectName: string, expires = 3600, bucket = minioConfig.bucket) => {
|
||||
const now = new Date()
|
||||
const date = shortDate(now)
|
||||
const fullDate = amzDate(now)
|
||||
const credentialScope = `${date}/${minioConfig.region}/s3/aws4_request`
|
||||
const endpoint = objectUrl(objectName)
|
||||
const endpoint = objectUrl(objectName, bucket)
|
||||
const params = new URLSearchParams({
|
||||
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
|
||||
'X-Amz-Credential': `${minioConfig.accessKey}/${credentialScope}`,
|
||||
@@ -147,8 +148,15 @@ export const minioUploadService = {
|
||||
return serviceFail(`图片上传失败:${response.status}`, 500, 'MINIO_UPLOAD_FAILED')
|
||||
}
|
||||
|
||||
const fileId = crypto.randomUUID()
|
||||
await prisma.supplierFile.create({
|
||||
data: { fileId, fileName, objectName, bucketName: minioConfig.bucket, contentType, fileSize: body.length }
|
||||
})
|
||||
const fileUrl = `/api/file?fid=${encodeURIComponent(fileId)}`
|
||||
const previewUrl = presign('GET', objectName)
|
||||
return serviceOk({
|
||||
fileId,
|
||||
fileUrl,
|
||||
objectName,
|
||||
fileName,
|
||||
contentType,
|
||||
@@ -158,6 +166,16 @@ export const minioUploadService = {
|
||||
})
|
||||
},
|
||||
|
||||
async imageUrl(fileId: string) {
|
||||
if (!ensureConfig()) return serviceFail('MinIO配置不完整', 500, 'MINIO_CONFIG_MISSING')
|
||||
if (!fileId) return serviceFail('文件ID不能为空', 400, 'FILE_ID_REQUIRED')
|
||||
const file = await prisma.supplierFile.findFirst({ where: { fileId, deleted: 0 } })
|
||||
if (!file || !file.contentType?.startsWith('image/')) {
|
||||
return serviceFail('图片不存在', 404, 'IMAGE_NOT_FOUND')
|
||||
}
|
||||
return serviceOk({ url: presign('GET', file.objectName, 3600, file.bucketName) })
|
||||
},
|
||||
|
||||
preview(input: PreviewInput) {
|
||||
if (!ensureConfig()) {
|
||||
return serviceFail('MinIO配置不完整', 500, 'MINIO_CONFIG_MISSING')
|
||||
|
||||
@@ -204,9 +204,9 @@ export const orderActionService = {
|
||||
}
|
||||
|
||||
const payTime = new Date()
|
||||
await orderDao.transaction(async tx => {
|
||||
await tx.supplierOrderMain.update({
|
||||
where: { orderId },
|
||||
const paid = await orderDao.transaction(async tx => {
|
||||
const changed = await tx.supplierOrderMain.updateMany({
|
||||
where: { orderId, supplierId, deleted: 0, orderState: ORDER_STATE.PENDING_PAY },
|
||||
data: {
|
||||
orderState: ORDER_STATE.FINISH_PAY,
|
||||
isSplit: ORDER_SPLIT_STATE.NOT_SPLIT,
|
||||
@@ -217,11 +217,38 @@ export const orderActionService = {
|
||||
updateTime: payTime
|
||||
}
|
||||
})
|
||||
if (changed.count !== 1) return false
|
||||
await tx.supplierOrderSub.updateMany({
|
||||
where: { mainOrderId: orderId, deleted: 0 },
|
||||
data: { orderState: ORDER_STATE.FINISH_PAY, payTime, updateTime: payTime }
|
||||
})
|
||||
const products = await tx.supplierOrderProduct.findMany({
|
||||
where: { deleted: 0, subOrder: { mainOrderId: orderId, supplierId, deleted: 0 } },
|
||||
select: { sku: true, number: true }
|
||||
})
|
||||
const quantities = new Map<string, number>()
|
||||
products.forEach(product => {
|
||||
if (product.number > 0) quantities.set(product.sku, (quantities.get(product.sku) || 0) + product.number)
|
||||
})
|
||||
// 锁定商品行后累加销量,与首次支付状态变更一起提交,避免并发丢失或重复累计。
|
||||
for (const sku of [...quantities.keys()].sort()) {
|
||||
const rows = await tx.$queryRaw<{ id: number; sales: string | null }[]>`
|
||||
SELECT id, sales FROM supplier_product WHERE sku = ${sku} FOR UPDATE
|
||||
`
|
||||
const product = rows[0]
|
||||
if (!product) continue
|
||||
const text = String(product.sales || '').trim().replace(/,/g, '')
|
||||
const match = text.match(/^(\d+(?:\.\d+)?)\s*([万千])?(?:件)?\+?$/)
|
||||
if (text && !match) throw new Error(`商品销量格式不正确:${sku}`)
|
||||
const sales = match ? Math.floor(Number(match[1]) * (match[2] === '万' ? 10000 : match[2] === '千' ? 1000 : 1)) : 0
|
||||
await tx.supplierProduct.update({
|
||||
where: { id: product.id },
|
||||
data: { sales: String(sales + quantities.get(sku)!) }
|
||||
})
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (!paid) return serviceFail('当前订单状态不可支付,请刷新订单状态', 400, 'ORDER_NOT_PAYABLE')
|
||||
|
||||
const confirm = await bridgeFetchWithAuth<BridgeOrderResponse>(supplierId, '/pointOrder/confirmPurchase', {
|
||||
orderId,
|
||||
|
||||
@@ -34,7 +34,7 @@ const toProductItem = (product: ProductRecord) => {
|
||||
categoryId: product.categoryId || '',
|
||||
category: product.category || '',
|
||||
tag: product.tag || '',
|
||||
sales: product.sales || '',
|
||||
sales: product.sales?.trim() || '0',
|
||||
stock: product.stock,
|
||||
sortOrder: product.sortOrder,
|
||||
enabled: product.enabled
|
||||
@@ -68,9 +68,11 @@ export const productService = {
|
||||
return serviceOk(toProductItem(product))
|
||||
},
|
||||
|
||||
async listManageProducts(keyword: string, categoryId: string) {
|
||||
const products = await productDao.findManageProducts(keyword, categoryId)
|
||||
return serviceOk({ categories, products: products.map(toProductItem) })
|
||||
async listManageProducts(keyword: string, categoryId: string, requestedPageNumber = 1, requestedPageSize = 10) {
|
||||
const pageNumber = Number.isInteger(Number(requestedPageNumber)) && Number(requestedPageNumber) > 0 ? Number(requestedPageNumber) : 1
|
||||
const pageSize = Number.isInteger(Number(requestedPageSize)) && Number(requestedPageSize) > 0 ? Math.min(Number(requestedPageSize), 100) : 10
|
||||
const { products, totalCount } = await productDao.findManageProducts(keyword, categoryId, pageNumber, pageSize)
|
||||
return serviceOk({ categories, products: products.map(product => ({ ...toProductItem(product), description: product.description || '', detail: product.detail || '' })), totalCount, pageNumber, pageSize })
|
||||
},
|
||||
|
||||
async saveManageProduct(input: {
|
||||
@@ -99,6 +101,13 @@ export const productService = {
|
||||
return serviceFail('商品SKU、名称和价格不能为空')
|
||||
}
|
||||
|
||||
let imageUrl = String(input.imageUrl || '').trim()
|
||||
if (imageUrl) {
|
||||
const image = new URL(imageUrl, 'http://supplier.local')
|
||||
if ((image.pathname.endsWith('/api/file') || image.pathname.endsWith('/api/upload-preview')) && image.searchParams.get('fid')) {
|
||||
imageUrl = `/api/file?fid=${encodeURIComponent(image.searchParams.get('fid')!)}`
|
||||
}
|
||||
}
|
||||
const data = {
|
||||
sku,
|
||||
name,
|
||||
@@ -108,17 +117,22 @@ export const productService = {
|
||||
taxRate: Number(input.taxRate ?? 0.13),
|
||||
categoryId: String(input.categoryId || '').trim() || null,
|
||||
category: String(input.category || '').trim() || null,
|
||||
imageUrl: String(input.imageUrl || '').trim() || null,
|
||||
imageUrl: imageUrl || null,
|
||||
description: String(input.description || '').trim() || null,
|
||||
detail: String(input.detail || '').trim() || null,
|
||||
originPrice: input.originPrice === undefined || input.originPrice === null ? null : Number(input.originPrice),
|
||||
tag: String(input.tag || '').trim() || null,
|
||||
sales: String(input.sales || '').trim() || null,
|
||||
sales: '0',
|
||||
sortOrder: Math.max(Number(input.sortOrder || 0), 0),
|
||||
enabled: input.enabled !== false
|
||||
}
|
||||
const id = Number(input.id || 0)
|
||||
const product = id > 0 ? await productDao.updateProduct(id, data) : await productDao.createProduct(data)
|
||||
const updateData: Partial<typeof data> = { ...data }
|
||||
for (const field of Object.keys(data) as Array<keyof typeof data>) {
|
||||
if (input[field] === undefined) delete updateData[field]
|
||||
}
|
||||
delete updateData.sales
|
||||
const product = id > 0 ? await productDao.updateProduct(id, updateData) : await productDao.createProduct(data)
|
||||
return serviceOk(toProductItem(product))
|
||||
},
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ export type ReconciliationResultCallbackInput = {
|
||||
}
|
||||
|
||||
export type ReconciliationResultListInput = {
|
||||
pageNumber?: number
|
||||
pageSize?: number
|
||||
supplierId?: string
|
||||
reconciliationStatus?: number
|
||||
orderId?: string
|
||||
@@ -189,26 +191,34 @@ export const reconciliationResultService = {
|
||||
|
||||
async list(input: ReconciliationResultListInput) {
|
||||
const supplierId = String(input.supplierId || '').trim()
|
||||
if (!supplierId) return serviceFail('供应商不能为空')
|
||||
const reconciliationStatus = input.reconciliationStatus === undefined ? undefined : Number(input.reconciliationStatus)
|
||||
if (reconciliationStatus !== undefined && ![0, 1, 2].includes(reconciliationStatus)) {
|
||||
return serviceFail('对账状态不正确')
|
||||
}
|
||||
|
||||
const orderId = String(input.orderId || '').trim()
|
||||
const pageNumber = Number.isInteger(Number(input.pageNumber)) && Number(input.pageNumber) > 0 ? Number(input.pageNumber) : 1
|
||||
const pageSize = Number.isInteger(Number(input.pageSize)) && Number(input.pageSize) > 0 ? Math.min(Number(input.pageSize), 100) : 10
|
||||
const condition = Prisma.sql`
|
||||
WHERE deleted = 0
|
||||
${supplierId ? Prisma.sql`AND supplierId = ${supplierId}` : Prisma.empty}
|
||||
${reconciliationStatus === undefined ? Prisma.empty : Prisma.sql`AND reconciliationStatus = ${reconciliationStatus}`}
|
||||
${orderId ? Prisma.sql`AND orderId LIKE ${`%${orderId}%`}` : Prisma.empty}
|
||||
`
|
||||
const totals = await prisma.$queryRaw<{ totalCount: bigint }[]>`
|
||||
SELECT COUNT(*) AS totalCount FROM supplier_reconciliation_order ${condition}
|
||||
`
|
||||
const records = await prisma.$queryRaw<SupplierReconciliationOrderRecord[]>`
|
||||
SELECT supplierId, orderId, mainOrderId, userId, reconciliationStatus, diffType, diffReason,
|
||||
platformTotalPrice, platformCashAmount, platformPointsPrice, platformRefundPrice,
|
||||
supplierTotalPrice, supplierCashAmount, supplierPointsPrice, supplierRefundPrice,
|
||||
reconciliationBatchNo, reconciliationTime, updateAt
|
||||
FROM supplier_reconciliation_order
|
||||
WHERE supplierId = ${supplierId}
|
||||
AND deleted = 0
|
||||
${reconciliationStatus === undefined ? Prisma.empty : Prisma.sql`AND reconciliationStatus = ${reconciliationStatus}`}
|
||||
${orderId ? Prisma.sql`AND orderId LIKE ${`%${orderId}%`}` : Prisma.empty}
|
||||
ORDER BY updateAt DESC
|
||||
${condition}
|
||||
ORDER BY updateAt DESC, supplierId, orderId
|
||||
LIMIT ${pageSize} OFFSET ${(pageNumber - 1) * pageSize}
|
||||
`
|
||||
return serviceOk({ reconciliationItems: records.map(resultItem) })
|
||||
return serviceOk({ reconciliationItems: records.map(resultItem), totalCount: Number(totals[0]?.totalCount || 0), pageNumber, pageSize })
|
||||
},
|
||||
|
||||
async update(input: ReconciliationResultUpdateInput) {
|
||||
|
||||
@@ -44,6 +44,15 @@ export const supplierFileService = {
|
||||
return { fileId, objectName }
|
||||
},
|
||||
|
||||
async pdfUrl(fileId: string, supplierId: string, download = false) {
|
||||
const file = await prisma.supplierFile.findFirst({ where: { fileId, supplierId, deleted: 0, contentType: 'application/pdf' } })
|
||||
if (!file) return null
|
||||
return client.presignedGetObject(file.bucketName, file.objectName, 3600, {
|
||||
'response-content-type': 'application/pdf',
|
||||
'response-content-disposition': `${download ? 'attachment' : 'inline'}; filename*=UTF-8''${encodeURIComponent(file.fileName)}`
|
||||
})
|
||||
},
|
||||
|
||||
async read(fileId: string, supplierId: string) {
|
||||
const file = await prisma.supplierFile.findFirst({ where: { fileId, supplierId, deleted: 0 } })
|
||||
if (!file) return null
|
||||
|
||||
@@ -9,6 +9,7 @@ import { logger } from '../utils/logger.js'
|
||||
import { sm4CbcDecrypt, verifySupplierOpenSignature } from '../utils/supplier-open-crypto.js'
|
||||
import { generateMockInvoicePdf } from '../../tools/mock-invoice-pdf.js'
|
||||
import { supplierFileService } from './supplier-file.service.js'
|
||||
import { minioUploadService } from './minio-upload.service.js'
|
||||
|
||||
export type SupplierOrderQueryRequest = {
|
||||
supplierId?: string
|
||||
@@ -123,7 +124,13 @@ const storedRequest = (value: unknown) => value && typeof value === 'object' &&
|
||||
: {}
|
||||
|
||||
const encodeImage = async (source?: string | null) => {
|
||||
const imageSource = String(source || '').trim()
|
||||
let imageSource = String(source || '').trim()
|
||||
if (/^\/api\/(?:file|upload-preview)\?/.test(imageSource)) {
|
||||
const fileId = new URL(imageSource, 'http://supplier.local').searchParams.get('fid') || ''
|
||||
const result = await minioUploadService.imageUrl(fileId)
|
||||
if (!result.success) return ''
|
||||
imageSource = result.data.url
|
||||
}
|
||||
if (!imageSource) return ''
|
||||
if (imageSource.startsWith('data:')) {
|
||||
return imageSource.split(',')[1] || ''
|
||||
|
||||
@@ -324,6 +324,32 @@ export const generateMockInvoicePdf = (invoiceInput, outputPath, _options = {})
|
||||
const invoiceDate = dateValue.replace(/^(\d{4})-(\d{2})-(\d{2}).*$/, '$1年$2月$3日')
|
||||
const title = Number(invoice.invoiceType) === 9 ? '电子发票(增值税专用发票)' : '电子发票(普通发票)'
|
||||
|
||||
const products = Array.isArray(invoice.orderProducts) && invoice.orderProducts.length
|
||||
? invoice.orderProducts
|
||||
: [{ name: invoice.invoiceContent, number: invoice.orderNum, price: invoice.total / invoice.orderNum, unit: '项' }]
|
||||
const weights = products.map(product => Math.max(Number(product.price || 0) * Number(product.number || 1), 0))
|
||||
const totalWeight = weights.reduce((sum, value) => sum + value, 0)
|
||||
const nakedCents = Math.round(invoice.naked * 100)
|
||||
const taxCents = Math.round(invoice.tax * 100)
|
||||
let accumulatedWeight = 0
|
||||
let allocatedNaked = 0
|
||||
let allocatedTax = 0
|
||||
// 按商品金额分摊票面金额,累计取整以消除逐行计算产生的分位尾差。
|
||||
const invoiceItems = products.map((product, index) => {
|
||||
accumulatedWeight += weights[index]
|
||||
const ratio = totalWeight > 0 ? accumulatedWeight / totalWeight : (index + 1) / products.length
|
||||
const nextNaked = index === products.length - 1 ? nakedCents : Math.round(nakedCents * ratio)
|
||||
const nextTax = index === products.length - 1 ? taxCents : Math.round(taxCents * ratio)
|
||||
const item = { ...product, number: Number(product.number || 1), naked: (nextNaked - allocatedNaked) / 100, tax: (nextTax - allocatedTax) / 100 }
|
||||
allocatedNaked = nextNaked
|
||||
allocatedTax = nextTax
|
||||
return item
|
||||
})
|
||||
const pages = []
|
||||
const itemsPerPage = 5
|
||||
for (let itemOffset = 0; itemOffset < invoiceItems.length; itemOffset += itemsPerPage) {
|
||||
commands = []
|
||||
const lastItemPage = itemOffset + itemsPerPage >= invoiceItems.length
|
||||
commands.push('1 1 1 rg 0 0 842 595 re f')
|
||||
text(title, (PAGE_WIDTH - title.length * 21) / 2, 546, 21, labelColor)
|
||||
line(245, 531, 590, 531, 0.8, labelColor)
|
||||
@@ -358,22 +384,30 @@ export const generateMockInvoicePdf = (invoiceInput, outputPath, _options = {})
|
||||
const x = (columns[index] + columns[index + 1] - label.length * 9) / 2
|
||||
text(label, x, 382, 9, labelColor)
|
||||
})
|
||||
wrappedText(`*商品*${String(invoice.invoiceContent).length > 50 ? '订单商品汇总(详见后附明细)' : invoice.invoiceContent}`, 50, 360, 20, 9, 14)
|
||||
text('项', 344, 360, 9)
|
||||
fitText(invoice.orderNum, 398, 360, 32, 9)
|
||||
fitText(money(invoice.naked / invoice.orderNum), 445, 360, 60, 9)
|
||||
fitText(money(invoice.naked), 526, 360, 73, 9)
|
||||
fitText(`${Number((invoice.taxRate * 100).toFixed(4))}%`, 627, 360, 60, 9)
|
||||
fitText(money(invoice.tax), 722, 360, 70, 9)
|
||||
invoiceItems.slice(itemOffset, itemOffset + itemsPerPage).forEach((item, index) => {
|
||||
const y = 360 - index * 23
|
||||
fitText(`*商品*${item.name || item.sku || '商品'}`, 50, y, 184, 9)
|
||||
fitText(item.spec || item.skuText || '', 247, y, 69, 8)
|
||||
fitText(item.unit || '件', 337, y, 40, 9)
|
||||
fitText(item.number, 391, y, 40, 9)
|
||||
fitText(Number((item.naked / item.number).toFixed(6)).toString(), 445, y, 60, 9)
|
||||
fitText(money(item.naked), 526, y, 73, 9)
|
||||
fitText(`${Number((invoice.taxRate * 100).toFixed(4))}%`, 627, y, 60, 9)
|
||||
fitText(money(item.tax), 722, y, 70, 9)
|
||||
})
|
||||
if (lastItemPage) {
|
||||
text('合 计', 92, 230, 10, labelColor)
|
||||
fitText(`¥${money(invoice.naked)}`, 526, 230, 73, 10)
|
||||
fitText(`¥${money(invoice.tax)}`, 722, 230, 70, 10)
|
||||
} else {
|
||||
text('项目明细续下页,合计见末页', 92, 230, 10, labelColor)
|
||||
}
|
||||
|
||||
line(235, 183, 235, 217, 0.7, labelColor)
|
||||
text('价税合计(大写)', 88, 196, 10, labelColor)
|
||||
fitText(chineseAmount(invoice.total), 245, 196, 340, 11)
|
||||
fitText(lastItemPage ? chineseAmount(invoice.total) : '详见项目明细末页', 245, 196, 340, 11)
|
||||
text('(小写)', 603, 196, 10, labelColor)
|
||||
fitText(`¥${money(invoice.total)}`, 657, 196, 133, 11)
|
||||
if (lastItemPage) fitText(`¥${money(invoice.total)}`, 657, 196, 133, 11)
|
||||
|
||||
line(70, 100, 70, 183, 0.7, labelColor)
|
||||
text('备', 51, 153, 10, labelColor)
|
||||
@@ -384,7 +418,9 @@ export const generateMockInvoicePdf = (invoiceInput, outputPath, _options = {})
|
||||
text('开票人:', 90, 73, 10, labelColor)
|
||||
fitText(invoice.biller || '模拟开票员', 140, 73, 250, 10)
|
||||
|
||||
const pages = [Buffer.from(commands.join('\n'), 'ascii')]
|
||||
text(`项目明细第 ${pages.length + 1} / ${Math.ceil(invoiceItems.length / itemsPerPage)} 页`, 620, 73, 9, labelColor)
|
||||
pages.push(Buffer.from(commands.join('\n'), 'ascii'))
|
||||
}
|
||||
const rows = (Array.isArray(invoice.orderProducts) ? invoice.orderProducts : []).flatMap(product => {
|
||||
const name = Array.from(String(product.name || product.sku || '商品'))
|
||||
const lines = []
|
||||
|
||||
Reference in New Issue
Block a user