Files
yxe-mp/pages/audit/components/AuditSelfReportItem.vue
T
weicw-dshsa 4556114b4d fix(审核): 阻断位置型 chainId 占位符写入 yxe_self_report
normalizeMerchantChain 的兜底链以 `chain_${index}` 结尾,该值是数组下标而非
连锁标识。审核员改门店时 saveMerchantEdit 会把 chain.id 直接写入 chainId,
落库后按连锁筛选(YxeSelfReportMapper.xml: a.chain_id = #{param.chainId}
等值匹配)就再也筛不出这条记录。

存量数据已出现 chain_3/chain_6 等脏值,导致 5 条已通过记录按连锁筛选时
永久漏查。本次修复:

- 兜底链结尾改用连锁名(与提交端 create.vue 的 `?? chainName` 对齐)
- saveMerchantEdit 写入前拦截 `chain_` 前缀,退回连锁名

后端 MerchantServiceImpl.groupByChain 已返回稳定 chainId,正常路径不再回退。
2026-10-11 16:34:30 +08:00

1424 lines
48 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view>
<view class="record">
<view>
<view class="record-meta" v-if="currentRecord.chainName && currentRecord.chainName !== '-'">连锁:{{ currentRecord.chainName }}</view>
<view class="record-meta-row">
<view class="record-meta">门店:{{ currentRecord.merchantName || '-' }}</view>
<view v-if="item.status === 0" class="record-meta-action" @tap="openMerchantEditPicker">修改</view>
</view>
<view class="record-meta-row">
<view class="record-meta">活动日期:{{ currentRecord.date || item.date || '-' }}</view>
<view v-if="item.status === 0" class="record-meta-action" @tap="openDateEditPicker">修改</view>
</view>
<!-- <view class="record-meta" style="color: red">奖金:¥{{ formatPrice(currentRecord.reward || 0) }}</view>-->
<view class="record-meta">评分:{{ currentRecord.rating || '-' }} 星</view>
<view class="record-meta">提交人:{{ currentRecord.owner || item.owner }}</view>
<view class="record-meta">小结:{{ currentRecord.summary || '-' }}</view>
<view class="record-meta text-red" v-if="currentRecord.rejectReason">驳回原因:{{ currentRecord.rejectReason }}</view>
<view class="record-toggle" @tap="toggleDetailExpanded">
<text>{{ detailExpanded ? '收起详情' : '展开详情' }}</text>
<u-icon :name="detailExpanded ? 'arrow-up' : 'arrow-down'" size="20" color="#2fc6a7"/>
</view>
<view v-if="detailExpanded" class="record-detail">
<view class="record-section">
<view class="record-section-title">上下班打卡</view>
<view v-if="clockImageList.length" class="media-images">
<image
v-for="(img, idx) in clockImageList"
:key="`clock-${idx}`"
class="media-image"
:src="img"
mode="aspectFill"
@click="emitPreview(img, clockImageList)"
/>
</view>
<view v-else class="detail-empty">暂无打卡图片</view>
</view>
<view class="record-section">
<view class="record-section-title">门头摆台照</view>
<view v-if="boothImageList.length" class="media-images">
<image
v-for="(img, idx) in boothImageList"
:key="`booth-${idx}`"
class="media-image"
:src="img"
mode="aspectFill"
@click="emitPreview(img, boothImageList)"
/>
</view>
<view v-else class="detail-empty">暂无门头摆台照</view>
</view>
<view class="record-section">
<view class="record-section-title">小票上传</view>
<view v-if="receiptImageList.length" class="media-images">
<image
v-for="(img, idx) in receiptImageList"
:key="`receipt-${idx}`"
class="media-image"
:src="img"
mode="aspectFill"
@click="emitPreview(img, receiptImageList)"
/>
</view>
<view v-else class="detail-empty">暂无小票</view>
</view>
<view class="record-section">
<view class="record-section-title-row">
<view class="record-section-title">销量填写</view>
<view v-if="item.status === 0" class="section-action" @tap="openEditPopup">修改销量</view>
</view>
<view v-if="salesRows.length" class="detail-list">
<view class="detail-item" v-for="(detail, idx) in salesRows" :key="`sales-${idx}`">
<view class="detail-head">
<view class="detail-name">{{ detail.name }}</view>
<view class="detail-value text-red">销量:{{ detail.qty }}{{ detail.unit }}</view>
</view>
<view class="detail-grid">
<view class="detail-kv">
<text class="detail-label">分组</text>
<text class="detail-value">{{ detail.groupTitle }}</text>
</view>
<view class="detail-kv">
<text class="detail-label">厂家</text>
<text class="detail-value">{{ detail.makerAbb || '-' }}</text>
</view>
<view class="detail-kv">
<text class="detail-label">规格</text>
<text class="detail-value">{{ detail.spec || '-' }}</text>
</view>
</view>
</view>
</view>
<view v-else class="detail-empty">暂无销量数据</view>
</view>
</view>
</view>
<view class="record-actions" v-if="item.status === 0">
<view class="flex-1 mx-15">
<view class="chip w-90p" @tap="handleApprove">通过</view>
</view>
<view class="flex-1 mx-15">
<view class="chip w-90p danger-chip" @tap="$emit('reject', item)">驳回</view>
</view>
<view class="flex-1 mx-15">
<view class="chip w-90p" @tap="openEditPopup">修改销量</view>
</view>
</view>
</view>
<view v-if="showEditPopup" class="self-report-popup-mask" @tap="closeEditPopup" @touchmove.stop.prevent></view>
<view v-if="showEditPopup" class="self-report-popup card" @tap.stop>
<view class="popup-head">
<view class="section-title">修改销量填写</view>
<view class="close-btn" @tap="closeEditPopup">关闭</view>
</view>
<!-- <view class="edit-summary">-->
<!-- <view class="edit-summary-label">奖金合计</view>-->
<!-- <view class="edit-summary-value">¥{{ editTotalRewardDisplay }}</view>-->
<!-- </view>-->
<view class="detail-actions">
<view class="ghost-btn" @tap="openProductPicker(-1)">新增商品</view>
</view>
<view v-if="editSalesItems.length === 0" class="detail-empty">暂无销量商品</view>
<view v-else class="edit-sales-list">
<view class="edit-sales-item" v-for="(detail, idx) in editSalesItems" :key="detail._key || idx">
<view class="edit-sales-head">
<view>
<view class="edit-sales-name">{{ detail.name || '商品' }}</view>
<view class="edit-sales-group">{{ detail.groupTitle || '自定义商品' }}</view>
</view>
<view class="edit-sales-remove" @tap="removeEditSalesItem(idx)">删除</view>
</view>
<view class="form-row">
<view class="label">商品</view>
<view class="picker-input" @tap="openProductPicker(idx)">
<view class="picker-input-inner">
<text :class="detail.name ? 'picker-text' : 'picker-placeholder'">{{ detail.name || '点击选择商品' }}</text>
<u-icon name="arrow-right" size="20" color="#9ca3af"/>
</view>
</view>
</view>
<view class="detail-grid edit-grid">
<view class="detail-kv">
<text class="detail-label">厂家</text>
<text class="detail-value">{{ detail.makerAbb || '-' }}</text>
</view>
<view class="detail-kv">
<text class="detail-label">编码</text>
<text class="detail-value">{{ detail.goodsCode || '-' }}</text>
</view>
</view>
<view class="form-row" v-if="detail.specOptions && detail.specOptions.length">
<view class="label">规格</view>
<picker class="picker-input" mode="selector" :range="detail.specOptions" @change="onSpecChange($event, detail)">
<view class="picker-input-inner">
<text class="picker-text">{{ detail.spec || detail.specOptions[0] || '规格' }}</text>
<u-icon name="arrow-right" size="20" color="#9ca3af"/>
</view>
</picker>
</view>
<view class="form-row" v-else>
<view class="label">规格</view>
<input v-model="detail.spec" class="input" placeholder="规格"/>
</view>
<!-- <view class="form-row">-->
<!-- <view class="label">单品奖金</view>-->
<!-- <view class="form-value bonus-text">¥{{ formatMoney(getTaskIncomeNumber(detail)) }}</view>-->
<!-- </view>-->
<view class="form-row">
<view class="label">数量</view>
<view class="qty-input-wrap">
<input :value="String(detail.qty === undefined || detail.qty === null ? '' : detail.qty)" class="input qty-input" type="digit" placeholder="0" @input="onEditQtyInput($event, idx)"/>
<text class="qty-unit">{{ detail.unit || '盒' }}</text>
</view>
</view>
</view>
</view>
<view class="popup-actions">
<view class="primary-btn submit" @tap="saveEdit">{{ editSaving ? '保存中...' : '保存' }}</view>
</view>
</view>
<u-popup :show="showMerchantEditPicker" mode="bottom" round="24rpx" @close="closeMerchantEditPicker">
<view class="merchant-edit-picker">
<view class="merchant-picker-head">
<view class="picker-action" @tap="closeMerchantEditPicker">取消</view>
<text class="picker-title">修改门店</text>
<view class="picker-action primary" @tap="saveMerchantEdit">{{ merchantEditSaving ? '保存中...' : '保存' }}</view>
</view>
<view v-if="merchantEditLoading" class="merchant-picker-loading">
<u-loading-icon mode="circle"></u-loading-icon>
</view>
<view v-else>
<view class="merchant-picker-search">
<input v-model="merchantEditKeyword" class="merchant-search-input" placeholder="搜索门店" placeholder-class="merchant-search-placeholder" @input="onMerchantEditSearchInput"/>
</view>
<picker-view class="merchant-picker-view" :value="merchantEditPickerValue" @change="onMerchantEditPickerChange">
<picker-view-column>
<view v-for="(chain, index) in merchantEditChains" :key="`edit-chain-${index}`" class="merchant-picker-item">
{{ chain.text }}
</view>
</picker-view-column>
<picker-view-column>
<view v-for="(merchant, index) in merchantEditStores" :key="`edit-merchant-${index}`" class="merchant-picker-item">
{{ merchant.text }}
</view>
</picker-view-column>
</picker-view>
</view>
</view>
</u-popup>
<u-datetime-picker
v-if="showDateEditPicker"
:key="dateEditPickerKey"
:show="showDateEditPicker"
:value="dateEditPickerValue"
mode="date"
@confirm="saveDateEdit"
@cancel="closeDateEditPicker"
@close="closeDateEditPicker"
/>
<u-popup :show="showProductPicker" mode="bottom" round="24rpx" @close="closeProductPicker">
<view class="product-picker">
<view class="merchant-picker-head">
<view class="picker-action" @tap="closeProductPicker">取消</view>
<text class="picker-title">选择商品</text>
<view class="picker-action primary" @tap="closeProductPicker">完成</view>
</view>
<view class="merchant-picker-search">
<input
v-model="productKeyword"
class="merchant-search-input"
placeholder="搜索商品名称/编码/厂家/规格"
placeholder-class="merchant-search-placeholder"
/>
</view>
<view v-if="productLoading" class="merchant-picker-loading">
<u-loading-icon mode="circle"></u-loading-icon>
</view>
<view v-else class="product-panel">
<scroll-view class="category-sidebar" scroll-y>
<view
v-for="group in filteredProductGroups"
:key="group.key"
class="category-item"
:class="currentProductGroupKey === group.key ? 'category-item-active' : ''"
@tap="selectProductGroup(group.key)"
>
{{ group.title }}
</view>
</scroll-view>
<scroll-view class="product-panel-main" scroll-y>
<view v-if="filteredProductGroups.length === 0" class="detail-empty">暂无可选商品</view>
<view v-else-if="displayedProductOptions.length === 0" class="detail-empty">该分类暂无商品</view>
<view v-else class="product-picker-list">
<view
v-for="product in displayedProductOptions"
:key="product._key"
class="product-picker-item"
@tap="selectProduct(product)"
>
<view class="product-picker-main">
<view class="product-picker-name">{{ product.name || '-' }}</view>
<view class="product-picker-sub">{{ product.makerAbb || '-' }} / {{ product.spec || '规格' }}</view>
<view class="product-picker-sub">编码:{{ product.goodsCode || '-' }}</view>
<!-- <view class="product-picker-income">奖金金额:¥{{ formatMoney(getTaskIncomeNumber(product)) }}</view>-->
</view>
<view class="product-picker-tag">{{ isProductSelected(product) ? '已添加' : '添加' }}</view>
</view>
</view>
</scroll-view>
</view>
</view>
</u-popup>
</view>
</template>
<script>
import dayjs from 'dayjs'
import {productGroupByCateReq} from "@/api/product";
import {merchantGroupByChainReq} from "@/api/shop";
import {selfReportAuditDetailReq, selfReportChangeReq} from "@/api/self-report";
export default {
name: 'AuditSelfReportItem',
props: {
item: {
type: Object,
required: true
}
},
data() {
return {
detailExpanded: false,
detailLoading: false,
detailLoaded: false,
detailData: null,
showEditPopup: false,
editSaving: false,
showProductPicker: false,
productLoading: false,
productGroups: [],
productLoadedChainName: '',
productKeyword: '',
activeProductGroupKey: '',
editSalesItems: [],
activeEditSalesIndex: -1,
showMerchantEditPicker: false,
merchantEditLoading: false,
merchantEditSaving: false,
merchantEditChains: [],
merchantEditPickerValue: [0, 0],
merchantEditKeyword: '',
showDateEditPicker: false,
dateEditPickerValue: Date.now(),
dateEditPickerKey: 0,
dateEditSaving: false
}
},
computed: {
currentRecord() {
return this.detailData || this.item
},
clockImageList() {
const source = this.currentRecord
return this.parseClockImageList(source.clockPhotos)
},
boothImageList() {
const source = this.currentRecord
return this.parseImageList(source.boothPhotos)
},
receiptImageList() {
const source = this.currentRecord
return this.parseImageList(source.receiptPhotos)
},
salesRows() {
const source = this.currentRecord
const groups = Array.isArray(source.salesGroups) ? source.salesGroups : []
const rows = []
groups.forEach((group, gIdx) => {
const items = Array.isArray(group && group.items) ? group.items : []
items.forEach((detail, iIdx) => {
rows.push({
key: `${gIdx}-${iIdx}`,
groupTitle: group && group.title ? group.title : `分组${gIdx + 1}`,
name: detail && detail.name ? detail.name : '-',
makerAbb: detail && detail.makerAbb ? detail.makerAbb : '',
spec: detail && detail.spec ? detail.spec : '',
qty: detail && detail.qty !== undefined && detail.qty !== null ? detail.qty : 0,
unit: detail && detail.unit ? detail.unit : ''
})
})
})
return rows
},
filteredProductGroups() {
const keyword = (this.productKeyword || '').trim().toLowerCase()
const groups = Array.isArray(this.productGroups) ? this.productGroups : []
if (!keyword) return groups
return groups
.map(group => {
const items = Array.isArray(group.items) ? group.items : []
const nextItems = items.filter(item => {
const name = String(item.name || '').toLowerCase()
const maker = String(item.makerAbb || '').toLowerCase()
const spec = String(item.spec || '').toLowerCase()
const code = String(item.goodsCode || '').toLowerCase()
return name.includes(keyword) || maker.includes(keyword) || spec.includes(keyword) || code.includes(keyword)
})
return Object.assign({}, group, {items: nextItems})
})
.filter(group => Array.isArray(group.items) && group.items.length)
},
currentProductGroupKey() {
const groups = Array.isArray(this.filteredProductGroups) ? this.filteredProductGroups : []
if (!groups.length) return ''
const exists = groups.some(group => group.key === this.activeProductGroupKey)
return exists ? this.activeProductGroupKey : groups[0].key
},
displayedProductOptions() {
const groups = Array.isArray(this.filteredProductGroups) ? this.filteredProductGroups : []
const current = groups.find(group => group.key === this.currentProductGroupKey)
return current && Array.isArray(current.items) ? current.items : []
},
editTotalRewardAmount() {
return (Array.isArray(this.editSalesItems) ? this.editSalesItems : []).reduce((sum, item) => {
return sum + (this.parseNumber(item && item.qty) * this.getTaskIncomeNumber(item))
}, 0)
},
editTotalRewardDisplay() {
return this.formatMoney(this.editTotalRewardAmount)
},
merchantEditStores() {
const chainIndex = Number(this.merchantEditPickerValue[0] || 0)
const chain = this.merchantEditChains[chainIndex] || this.merchantEditChains[0] || {}
const stores = Array.isArray(chain.merchants) ? chain.merchants : []
const keyword = this.merchantEditKeyword.trim()
const filteredStores = keyword ? stores.filter(store => String(store.text || '').includes(keyword)) : stores
return filteredStores.length ? filteredStores : [{id: '', text: keyword ? '无匹配门店' : '暂无门店'}]
}
},
methods: {
async toggleDetailExpanded() {
const nextExpanded = !this.detailExpanded
this.detailExpanded = nextExpanded
if (!nextExpanded || this.detailLoaded || this.detailLoading || !this.item.id) return
this.detailLoading = true
try {
const {data} = await selfReportAuditDetailReq(this.item.id)
this.detailData = this.normalizeDetailData(data)
this.detailLoaded = true
} catch (error) {
uni.showToast({
title: '详情加载失败',
icon: 'none'
})
} finally {
this.detailLoading = false
}
},
emitPreview(url, list) {
this.$emit('preview', url, list)
},
handleApprove() {
this.$emit('approve', this.currentRecord)
},
pickFirstText(source, keys = []) {
if (!source || typeof source !== 'object') return ''
for (let i = 0; i < keys.length; i += 1) {
const value = source[keys[i]]
if (value !== undefined && value !== null && String(value).trim()) {
return String(value).trim()
}
}
return ''
},
parseNumber(value) {
const text = String(value === null || value === undefined ? '' : value).trim()
if (!text) return 0
const num = Number(text)
return Number.isFinite(num) ? num : 0
},
formatMoney(value) {
const num = Number(value)
return Number.isFinite(num) ? num.toFixed(2) : '0.00'
},
getTaskIncomeNumber(item) {
if (!item || typeof item !== 'object') return 0
const num = Number(item.taskIncome !== undefined && item.taskIncome !== null ? item.taskIncome : item.income)
return Number.isFinite(num) ? num : 0
},
normalizeSalesGroupItem(detail = {}, groupTitle = '') {
const product = detail.product && typeof detail.product === 'object' ? detail.product : {}
const specOptions = Array.isArray(detail.specOptions) && detail.specOptions.length
? detail.specOptions
: Array.isArray(product.specOptions) && product.specOptions.length
? product.specOptions
: []
const productId = detail.productId || detail.goodsId || product.productId || product.goodsId || product.id || ''
const goodsCode = detail.goodsCode || detail.code || detail.productCode || product.goodsCode || product.code || product.productCode || ''
const name = detail.name || detail.goodsName || detail.title || product.goodsName || product.name || product.title || ''
const makerAbb = detail.makerAbb || detail.manufacturer || product.makerAbb || product.manufacturer || ''
const spec = detail.spec || detail.specification || product.spec || specOptions[0] || ''
const taskIncome = detail.taskIncome !== undefined && detail.taskIncome !== null
? detail.taskIncome
: product.income !== undefined && product.income !== null
? product.income
: detail.income || 0
return {
_key: detail._key || `${Date.now()}_${Math.random().toString(16).slice(2)}`,
groupTitle: groupTitle || detail.groupTitle || '自定义商品',
productId,
goodsCode,
name,
makerAbb,
spec,
specOptions,
unit: detail.unit || product.unit || '盒',
qty: detail.qty !== undefined && detail.qty !== null ? detail.qty : detail.num || 0,
taskIncome
}
},
flattenSalesGroups(groups) {
const source = Array.isArray(groups) ? groups : []
const rows = []
source.forEach((group, gIdx) => {
const items = Array.isArray(group && group.items) ? group.items : []
items.forEach((detail, iIdx) => {
rows.push(this.normalizeSalesGroupItem(Object.assign({}, detail, {
_key: detail && detail._key ? detail._key : `${gIdx}_${iIdx}_${Date.now()}`
}), group && group.title ? group.title : `分组${gIdx + 1}`))
})
})
return rows
},
buildSalesGroupsFromItems(items) {
const groupsMap = {}
;(Array.isArray(items) ? items : []).forEach(item => {
const groupTitle = item.groupTitle || '自定义商品'
if (!groupsMap[groupTitle]) {
groupsMap[groupTitle] = {
title: groupTitle,
items: []
}
}
groupsMap[groupTitle].items.push({
productId: item.productId || '',
goodsCode: item.goodsCode || '',
name: item.name || '',
makerAbb: item.makerAbb || '',
spec: item.spec || '',
taskIncome: this.getTaskIncomeNumber(item),
qty: this.parseNumber(item.qty),
unit: item.unit || '盒',
specOptions: Array.isArray(item.specOptions) ? item.specOptions : []
})
})
return Object.keys(groupsMap).map(key => groupsMap[key])
},
unwrapProductList(data) {
if (Array.isArray(data)) return data
if (!data || typeof data !== 'object') return []
const keys = ['list', 'records', 'rows', 'items', 'data', 'result', 'children', 'productGroup', 'productList', 'products']
for (let i = 0; i < keys.length; i += 1) {
if (Array.isArray(data[keys[i]])) return data[keys[i]]
}
return [data]
},
isProductNode(node) {
if (!node || typeof node !== 'object') return false
const product = node.product && typeof node.product === 'object' ? node.product : {}
const hasChildrenOnly = Array.isArray(node.children)
&& !this.pickFirstText(node, ['goodsName', 'goods_name', 'goodsCode', 'code', 'productCode', 'batchCode'])
&& !node.product
if (hasChildrenOnly) return false
return !!(
this.pickFirstText(node, ['goodsName', 'goods_name', 'goodsCode', 'code', 'productCode', 'batchCode']) ||
this.pickFirstText(product, ['goodsName', 'goods_name', 'goodsCode', 'code', 'productCode', 'batchCode'])
)
},
collectProductNodes(node, depth = 0) {
if (!node || depth > 4) return []
if (Array.isArray(node)) {
return node.reduce((acc, item) => acc.concat(this.collectProductNodes(item, depth + 1)), [])
}
if (typeof node !== 'object') return []
if (this.isProductNode(node)) return [node]
const keys = ['productGroup', 'productList', 'products', 'list', 'records', 'rows', 'items', 'children', 'data']
return keys.reduce((acc, key) => {
const value = node[key]
if (Array.isArray(value) || (value && typeof value === 'object')) {
return acc.concat(this.collectProductNodes(value, depth + 1))
}
return acc
}, [])
},
normalizeSpecOptions(item) {
const raw = item.specOptions || item.specList || item.specs || []
if (Array.isArray(raw)) {
return raw.map(value => `${value}`.trim()).filter(Boolean)
}
if (typeof raw === 'string') {
return raw.split(/[、,,]/).map(value => value.trim()).filter(Boolean)
}
const spec = item.spec || item.specification || item.standard || ''
return spec ? [spec] : []
},
normalizeProductNode(item = {}) {
const product = item && item.product ? item.product : {}
const goodsCode = item.goodsCode || item.code || item.productCode || item.batchCode || product.code || product.goodsCode || product.batchCode || ''
const productId = item.productId || item.id || item.goodsId || item.skuId || product.productId || product.id || product.goodsId || product.skuId || ''
const name = item.goodsName || item.goods_name || item.title || item.name || product.title || product.goodsName || product.name || ''
const makerAbb = item.makerAbb || item.maker || item.manufacturer || product.makerAbb || product.maker || product.manufacturer || ''
const spec = product.spec || item.spec || item.specification || ''
return {
productId,
goodsCode,
name,
makerAbb,
spec,
unit: item.unit || product.unit || '盒',
taskIncome: item.taskIncome !== undefined && item.taskIncome !== null ? item.taskIncome : (product.income || item.income || 0),
specOptions: this.normalizeSpecOptions(Object.assign({}, product, item))
}
},
buildProductGroupsFromData(rawData) {
const rootList = this.unwrapProductList(rawData)
const tabs = []
rootList.forEach((categoryNode, categoryIndex) => {
if (!categoryNode || typeof categoryNode !== 'object') return
const label = categoryNode.cate || categoryNode.title || categoryNode.name
const products = Array.isArray(categoryNode.children) ? categoryNode.children : []
if (!label || !products.length) return
tabs.push({
key: `cate_children_${categoryIndex}`,
label,
rawProducts: products
})
})
if (!tabs.length) {
const allProducts = this.collectProductNodes(rootList)
if (allProducts.length) {
tabs.push({
key: 'all',
label: '全部',
rawProducts: allProducts
})
}
}
let rowSeed = 0
return tabs.map((tab, tabIndex) => {
const items = (Array.isArray(tab.rawProducts) ? tab.rawProducts : [])
.map((rawItem, itemIndex) => {
const product = this.normalizeProductNode(rawItem)
if (!product.name) return null
const specOptions = Array.isArray(product.specOptions) && product.specOptions.length
? product.specOptions
: [product.spec || '规格']
return {
...product,
groupTitle: tab.label || `分类${tabIndex + 1}`,
spec: product.spec || specOptions[0] || '规格',
specOptions,
_key: `audit_product_${tabIndex}_${itemIndex}_${rowSeed++}`
}
})
.filter(Boolean)
return {
key: tab.key || `group_${tabIndex}`,
title: tab.label || `分类${tabIndex + 1}`,
items
}
}).filter(group => group.items.length)
},
async loadProductGroups() {
const chainName = String(this.currentRecord.chainName || '').trim()
if (!chainName || chainName === '-') {
this.productGroups = []
this.productLoadedChainName = ''
this.activeProductGroupKey = ''
return
}
if (this.productLoadedChainName === chainName && this.productGroups.length) return
this.productLoading = true
try {
const {data} = await productGroupByCateReq({chain: chainName, type: 0})
this.productGroups = this.buildProductGroupsFromData(data)
this.productLoadedChainName = chainName
this.activeProductGroupKey = this.productGroups[0] ? this.productGroups[0].key : ''
} catch (error) {
this.productGroups = []
this.productLoadedChainName = ''
this.activeProductGroupKey = ''
uni.showToast({
title: '商品数据获取失败',
icon: 'none'
})
} finally {
this.productLoading = false
}
},
async ensureDetailLoaded() {
if (this.detailLoaded || this.detailLoading || !this.item.id) return
this.detailLoading = true
try {
const {data} = await selfReportAuditDetailReq(this.item.id)
this.detailData = this.normalizeDetailData(data)
this.detailLoaded = true
} catch (error) {
uni.showToast({
title: '详情加载失败',
icon: 'none'
})
} finally {
this.detailLoading = false
}
},
unwrapMerchantList(data) {
if (Array.isArray(data)) return data
if (!data || typeof data !== 'object') return []
const keys = ['list', 'records', 'rows', 'items', 'data', 'result', 'chainList']
for (let i = 0; i < keys.length; i += 1) {
const value = data[keys[i]]
if (Array.isArray(value)) return value
if (value && typeof value === 'object') {
const nested = this.unwrapMerchantList(value)
if (nested.length) return nested
}
}
return [data]
},
normalizeMerchant(item = {}) {
return {
id: item.id ?? item.merchantId ?? item.merchant_id ?? '',
text: item.name || item.merchantName || item.merchant_name || item.title || '未命名门店'
}
},
normalizeMerchantChain(item = {}, index) {
const text = item.chainName || item.chain || item.chainAbb || item.name || item.title || `连锁${index + 1}`
// 兜底必须用连锁名,不能用 `chain_${index}` 这类位置型占位符:
// 该 id 会被 saveMerchantEdit 写进 yxe_self_report.chain_id,而按连锁筛选走的是
// 等值匹配(YxeSelfReportMapper.xml: a.chain_id = #{param.chainId}),
// 占位符一旦落库,这条记录就再也筛不出来(后端已返回稳定 chainId,此处为兜底防线)。
const id = item.chainId ?? item.chain_id ?? item.id ?? item.chainCode ?? item.chain_code ?? text
const childKeys = ['children', 'merchantList', 'merchants', 'shopList', 'list', 'records']
let children = []
for (let i = 0; i < childKeys.length; i += 1) {
if (Array.isArray(item[childKeys[i]]) && item[childKeys[i]].length) {
children = item[childKeys[i]]
break
}
}
let merchants = children
.map(child => this.normalizeMerchant(child))
.filter(merchant => merchant.id !== '' && merchant.id !== null && merchant.id !== undefined)
if (!merchants.length) {
const merchant = this.normalizeMerchant(item)
if (merchant.id !== '' && merchant.id !== null && merchant.id !== undefined) {
merchants = [merchant]
}
}
return {id, text, merchants}
},
async loadMerchantEditChains() {
if (this.merchantEditChains.length) return
try {
const {data} = await merchantGroupByChainReq({})
this.merchantEditChains = this.unwrapMerchantList(data)
.map((item, index) => this.normalizeMerchantChain(item, index))
.filter(chain => chain.merchants.length)
} catch (error) {
this.merchantEditChains = []
uni.showToast({title: '门店列表获取失败', icon: 'none'})
}
},
async openMerchantEditPicker() {
if (this.item.status !== 0) return
this.showMerchantEditPicker = true
this.merchantEditKeyword = ''
this.merchantEditLoading = true
try {
await this.ensureDetailLoaded()
await this.loadMerchantEditChains()
if (!this.merchantEditChains.length) {
this.showMerchantEditPicker = false
return
}
const record = this.currentRecord
const chainIndex = this.merchantEditChains.findIndex(chain => String(chain.id) === String(record.chainId))
const safeChainIndex = chainIndex > -1 ? chainIndex : 0
const stores = this.merchantEditChains[safeChainIndex].merchants
const merchantIndex = stores.findIndex(merchant => String(merchant.id) === String(record.merchantId))
this.merchantEditPickerValue = [safeChainIndex, merchantIndex > -1 ? merchantIndex : 0]
} finally {
this.merchantEditLoading = false
}
},
closeMerchantEditPicker() {
if (!this.merchantEditSaving) {
this.showMerchantEditPicker = false
this.merchantEditKeyword = ''
}
},
onMerchantEditPickerChange(event) {
const value = Array.isArray(event.detail.value) ? event.detail.value : [0, 0]
const chainIndex = Number(value[0] || 0)
const previousChainIndex = Number(this.merchantEditPickerValue[0] || 0)
this.merchantEditPickerValue = [chainIndex, chainIndex === previousChainIndex ? Number(value[1] || 0) : 0]
},
onMerchantEditSearchInput() {
this.merchantEditPickerValue = [Number(this.merchantEditPickerValue[0] || 0), 0]
},
async saveMerchantEdit() {
if (this.merchantEditSaving) return
const chainIndex = Number(this.merchantEditPickerValue[0] || 0)
const merchantIndex = Number(this.merchantEditPickerValue[1] || 0)
const chain = this.merchantEditChains[chainIndex]
const merchant = this.merchantEditStores[merchantIndex]
if (!chain || !merchant || !merchant.id) {
uni.showToast({title: '请选择门店', icon: 'none'})
return
}
this.merchantEditSaving = true
// 写入前兜底:`chain_${index}` 这类位置型占位符不能落库(详见 normalizeMerchantChain 注释),
// 否则该记录会被按连锁等值筛选永久漏掉。这里退回用连锁名作为标识。
const chainIdText = String(chain.id ?? '').trim()
const safeChainId = !chainIdText || chainIdText.startsWith('chain_')
? String(chain.text || '').trim()
: chainIdText
try {
await selfReportChangeReq({
id: this.currentRecord.id,
chainId: safeChainId,
chainName: chain.text,
merchantId: merchant.id,
merchantName: merchant.text
})
this.detailData = Object.assign({}, this.currentRecord, {
chainId: safeChainId,
chainName: chain.text,
merchantId: merchant.id,
merchantName: merchant.text
})
this.detailLoaded = true
this.showMerchantEditPicker = false
this.$emit('updated', this.detailData)
uni.showToast({title: '保存成功', icon: 'success'})
} catch (error) {
uni.showToast({title: error && error.message ? error.message : '保存失败', icon: 'none'})
} finally {
this.merchantEditSaving = false
}
},
async openDateEditPicker() {
if (this.item.status !== 0) return
await this.ensureDetailLoaded()
const value = dayjs(this.currentRecord.date || this.item.date)
this.dateEditPickerValue = value.isValid() ? value.valueOf() : Date.now()
this.dateEditPickerKey += 1
await this.$nextTick()
this.showDateEditPicker = true
},
closeDateEditPicker() {
if (!this.dateEditSaving) this.showDateEditPicker = false
},
async saveDateEdit(event) {
if (this.dateEditSaving) return
const date = dayjs(event.value).format('YYYY-MM-DD')
this.dateEditSaving = true
try {
await selfReportChangeReq({id: this.currentRecord.id, date})
this.detailData = Object.assign({}, this.currentRecord, {date})
this.detailLoaded = true
this.showDateEditPicker = false
this.$emit('updated', this.detailData)
uni.showToast({title: '保存成功', icon: 'success'})
} catch (error) {
uni.showToast({title: error && error.message ? error.message : '保存失败', icon: 'none'})
} finally {
this.dateEditSaving = false
}
},
async openEditPopup() {
if (this.item.status !== 0) return
await this.ensureDetailLoaded()
const salesGroups = this.parseMaybeJson(this.currentRecord.salesGroups, [])
this.editSalesItems = this.flattenSalesGroups(salesGroups)
this.productKeyword = ''
this.activeEditSalesIndex = -1
this.showEditPopup = true
await this.loadProductGroups()
},
closeEditPopup() {
this.showEditPopup = false
this.showProductPicker = false
this.activeEditSalesIndex = -1
this.productKeyword = ''
},
async openProductPicker(index) {
await this.loadProductGroups()
if (!this.productGroups.length) {
uni.showToast({
title: '当前连锁暂无可选商品',
icon: 'none'
})
return
}
this.activeEditSalesIndex = Number.isInteger(index) ? index : -1
this.productKeyword = ''
this.showProductPicker = true
if (!this.activeProductGroupKey && this.filteredProductGroups.length) {
this.activeProductGroupKey = this.filteredProductGroups[0].key
}
},
closeProductPicker() {
this.showProductPicker = false
this.activeEditSalesIndex = -1
},
selectProductGroup(key) {
this.activeProductGroupKey = key
},
isProductSelected(product) {
return (Array.isArray(this.editSalesItems) ? this.editSalesItems : []).some((item, index) => {
if (index === this.activeEditSalesIndex) return false
if (product.productId && item.productId) {
return String(item.productId) === String(product.productId)
}
return item.name === product.name
})
},
selectProduct(product) {
if (this.isProductSelected(product)) {
uni.showToast({
title: '该商品已添加',
icon: 'none'
})
return
}
const next = this.normalizeSalesGroupItem(product, product.groupTitle || '自定义商品')
if (this.activeEditSalesIndex >= 0 && this.editSalesItems[this.activeEditSalesIndex]) {
const current = this.editSalesItems[this.activeEditSalesIndex]
this.$set(this.editSalesItems, this.activeEditSalesIndex, Object.assign({}, current, next, {
qty: current.qty,
groupTitle: next.groupTitle || current.groupTitle
}))
} else {
next.qty = ''
this.editSalesItems.push(next)
}
this.closeProductPicker()
},
removeEditSalesItem(index) {
this.editSalesItems.splice(index, 1)
},
onEditQtyInput(event, index) {
const current = this.editSalesItems[index]
if (!current) return
const qty = event && event.detail ? event.detail.value : ''
this.$set(this.editSalesItems, index, Object.assign({}, current, {qty}))
},
onSpecChange(event, item) {
const value = Number(event.detail.value || 0)
item.spec = item.specOptions[value] || item.spec
},
parseImageList(list) {
const source = Array.isArray(list) ? list : []
return source
.map(item => {
if (!item) return ''
if (typeof item === 'string') return item
return item.url || item.path || item.image || ''
})
.filter(Boolean)
},
parseClockImageList(list) {
const source = Array.isArray(list) ? list : []
return source
.map(item => {
if (!item) return ''
if (typeof item === 'string') return item
return item.url || item.path || item.image || ''
})
.filter(Boolean)
},
parseMaybeJson(value, fallback) {
if (value === null || value === undefined || value === '') return fallback
if (typeof value === 'string') {
try {
return JSON.parse(value)
} catch (error) {
return fallback
}
}
return value
},
normalizeDetailData(data) {
const detail = data && typeof data === 'object' ? {...data} : {}
detail.clockPhotos = this.parseMaybeJson(detail.clockPhotos, [])
detail.boothPhotos = this.parseMaybeJson(detail.boothPhotos, [])
detail.receiptPhotos = this.parseImageList(this.parseMaybeJson(detail.receiptPhoto, []))
detail.salesGroups = this.parseMaybeJson(detail.salesGroups, [])
return detail
},
async saveEdit() {
if (this.editSaving) return
const items = Array.isArray(this.editSalesItems) ? this.editSalesItems : []
if (!items.length) {
uni.showToast({
title: '请至少保留一项销量商品',
icon: 'none'
})
return
}
for (let i = 0; i < items.length; i += 1) {
const item = items[i]
if (!String(item.name || '').trim()) {
uni.showToast({
title: '请选择商品',
icon: 'none'
})
return
}
if (this.parseNumber(item.qty) < 0) {
uni.showToast({
title: '数量不能小于0',
icon: 'none'
})
return
}
}
this.editSaving = true
try {
const salesGroups = this.buildSalesGroupsFromItems(items)
const reward = this.editTotalRewardAmount
await selfReportChangeReq({
id: this.currentRecord.id,
salesGroups: JSON.stringify(salesGroups),
reward
})
const nextRecord = Object.assign({}, this.currentRecord, {
salesGroups,
reward
})
this.detailData = nextRecord
this.detailLoaded = true
this.showEditPopup = false
this.showProductPicker = false
this.activeEditSalesIndex = -1
this.$emit('updated', nextRecord)
uni.showToast({
title: '保存成功',
icon: 'success'
})
} catch (error) {
uni.showToast({
title: error && error.message ? error.message : '保存失败',
icon: 'none'
})
} finally {
this.editSaving = false
}
},
formatPrice(value) {
const num = Number(value || 0)
return num.toFixed(2)
}
}
}
</script>
<style lang="scss" scoped>
@import './record.scss';
.record-section {
margin-top: 18rpx;
}
.record-meta-row {
display: flex;
align-items: center;
gap: 14rpx;
}
.record-meta-action {
margin-top: 6rpx;
color: #2fc6a7;
font-size: 24rpx;
white-space: nowrap;
}
.record-section-title {
font-size: 26rpx;
font-weight: 700;
color: #1f2937;
margin-bottom: 12rpx;
}
.record-toggle {
margin-top: 12rpx;
display: inline-flex;
align-items: center;
gap: 8rpx;
color: #2fc6a7;
font-size: 24rpx;
}
.detail-empty {
color: #94a3b8;
font-size: 24rpx;
}
.record-section-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
margin-bottom: 12rpx;
}
.record-section-title-row .record-section-title {
margin-bottom: 0;
}
.section-action {
color: #2fc6a7;
font-size: 24rpx;
white-space: nowrap;
}
.self-report-popup-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 50;
background: rgba(15, 23, 42, 0.42);
}
.self-report-popup {
position: fixed;
left: 20rpx;
right: 20rpx;
bottom: 20rpx;
z-index: 60;
max-height: 86vh;
overflow-y: auto;
padding: 20rpx;
border-radius: 18rpx;
background: #ffffff;
}
.popup-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
}
.section-title {
font-size: 30rpx;
color: #1f2937;
font-weight: 700;
}
.close-btn {
color: #64748b;
font-size: 24rpx;
}
.edit-summary {
margin-top: 12rpx;
display: flex;
justify-content: space-between;
align-items: center;
padding: 12rpx 16rpx;
border-radius: 12rpx;
background: #f7f9fc;
}
.edit-summary-label {
color: #64748b;
font-size: 24rpx;
}
.edit-summary-value {
color: #f5576c;
font-size: 30rpx;
font-weight: 700;
}
.detail-actions {
margin-top: 12rpx;
}
.ghost-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10rpx 16rpx;
border-radius: 999rpx;
color: #2563eb;
background: #eff6ff;
font-size: 24rpx;
}
.edit-sales-list {
margin-top: 12rpx;
}
.edit-sales-item {
margin-bottom: 16rpx;
padding: 16rpx;
border-radius: 12rpx;
background: #f7f9fc;
}
.edit-sales-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12rpx;
margin-bottom: 10rpx;
}
.edit-sales-name {
font-size: 28rpx;
font-weight: 700;
color: #1f2937;
}
.edit-sales-group {
margin-top: 4rpx;
font-size: 22rpx;
color: #64748b;
}
.edit-sales-remove {
color: #f5576c;
font-size: 24rpx;
white-space: nowrap;
}
.form-row {
display: flex;
align-items: center;
gap: 12rpx;
margin-top: 10rpx;
}
.label {
width: 120rpx;
color: #64748b;
font-size: 24rpx;
flex-shrink: 0;
}
.picker-input,
.input {
flex: 1;
min-width: 0;
}
.input {
min-height: 72rpx;
padding: 0 20rpx;
border-radius: 12rpx;
background: #ffffff;
border: 1rpx solid #e5e7eb;
box-sizing: border-box;
}
.picker-input-inner {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 72rpx;
padding: 0 20rpx;
border-radius: 12rpx;
background: #ffffff;
border: 1rpx solid #e5e7eb;
box-sizing: border-box;
}
.picker-text {
color: #1f2937;
}
.picker-placeholder {
color: #94a3b8;
}
.bonus-text {
color: #f5576c;
font-weight: 700;
}
.qty-input-wrap {
flex: 1;
display: flex;
align-items: center;
gap: 12rpx;
min-width: 0;
}
.qty-input {
min-width: 0;
}
.qty-unit {
color: #64748b;
font-size: 24rpx;
white-space: nowrap;
}
.popup-actions {
margin-top: 16rpx;
}
.product-picker {
padding: 0;
}
.merchant-picker-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22rpx 24rpx;
}
.picker-title {
font-size: 30rpx;
color: #1f2937;
font-weight: 700;
}
.picker-action {
color: #64748b;
font-size: 26rpx;
}
.picker-action.primary {
color: #2563eb;
}
.merchant-picker-search {
padding: 0 24rpx 18rpx;
}
.merchant-edit-picker {
background: #ffffff;
padding-bottom: calc(env(safe-area-inset-bottom) + 12rpx);
}
.merchant-picker-view {
width: 100%;
height: 420rpx;
}
.merchant-picker-item {
display: flex;
align-items: center;
justify-content: center;
height: 64px;
padding: 0 24rpx;
text-align: center;
font-size: 26rpx;
color: #1f2937;
}
.merchant-search-input {
height: 72rpx;
padding: 0 24rpx;
border-radius: 999rpx;
background: #f7f9fc;
font-size: 26rpx;
}
.merchant-picker-loading {
min-height: 360rpx;
display: flex;
align-items: center;
justify-content: center;
}
.product-panel {
display: flex;
min-height: 540rpx;
}
.category-sidebar {
width: 180rpx;
max-height: 540rpx;
background: #f8fafc;
}
.category-item {
padding: 18rpx 14rpx;
font-size: 24rpx;
color: #475569;
}
.category-item-active {
color: #2563eb;
font-weight: 700;
background: #ffffff;
}
.product-panel-main {
flex: 1;
max-height: 540rpx;
padding: 0 18rpx 20rpx 12rpx;
}
.product-picker-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.product-picker-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 14rpx 12rpx;
border-radius: 12rpx;
background: #f8fafc;
}
.product-picker-main {
flex: 1;
min-width: 0;
}
.product-picker-name {
font-size: 26rpx;
font-weight: 700;
color: #1f2937;
}
.product-picker-sub,
.product-picker-income {
margin-top: 4rpx;
font-size: 22rpx;
color: #64748b;
}
.product-picker-income {
color: #f5576c;
}
.product-picker-tag {
color: #2563eb;
font-size: 24rpx;
white-space: nowrap;
}
</style>