shinnosuke
2026-08-20 bacaa6e8f87ba4167901095ddb82c4a2ac2e74ca
fix: repair benefit submissions and parking review flow
1个文件已添加
6个文件已修改
175 ■■■■■ 已修改文件
rencaika/pages/medical-booking/medical-booking.uvue 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantCreateView.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/ParkingPlateDetailView.vue 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/ParkingPlateView.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/ServiceApplicationMapper.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V117__backfill_fuel_benefits_for_admin_merchants.sql 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/medical-booking/medical-booking.uvue
@@ -39,7 +39,9 @@
<script setup lang="uts">
    import { safeBack } from '../../utils/navigation.uts'
    import { submitBusiness } from '../../services/business.uts'
    import { createIdempotencyKey } from '../../services/business.uts'
    import { getTalentCardProfile, submitServiceAppointment } from '../../services/portal.uts'
    import { getSession } from '../../services/session.uts'
    const navTop = ref(28)
    const navHeight = ref(32)
@@ -53,11 +55,15 @@
        return date.getFullYear() + '-' + (month < 10 ? '0' + month : '' + month) + '-' + (day < 10 ? '0' + day : '' + day)
    }
    const todayDate = getDateText(0)
    // 姓名、手机号、身份证号由认证信息默认回填,可编辑修改,真实数据以后台返回为准。
    // 预约人信息可编辑,姓名在已登录人才卡资料可用时自动带入。
    const userName = ref('')
    const userMobile = ref('')
    const userIdCard = ref('')
    const hospitalName = ref('攀枝花市中心医院')
    const hospitalId = ref('')
    const packageId = ref('')
    const talentId = ref('')
    const cardId = ref('')
    // 预约套餐名称随入口参数带入,当前表单不展示,预留给预约提交接口。
    const packageName = ref('')
    const expectDate = ref(getDateText(1))
@@ -75,6 +81,17 @@
        if (options != null) {
            if (options['hospital'] != null) hospitalName.value = decodeURIComponent(options['hospital'] as string)
            if (options['package'] != null) packageName.value = decodeURIComponent(options['package'] as string)
            if (options['hospitalId'] != null) hospitalId.value = options['hospitalId'] as string
            if (options['packageId'] != null) packageId.value = options['packageId'] as string
        }
        const session = getSession()
        if (session != null && session.talentId != null) talentId.value = session.talentId as string
        if (talentId.value != '') {
            getTalentCardProfile(talentId.value, (data: any) => {
                const card = data != null ? data.card : null
                cardId.value = card != null && card.id != null ? '' + card.id : ''
                if (userName.value == '' && card != null && card.name != null) userName.value = '' + card.name
            }, () => {})
        }
    })
@@ -102,8 +119,29 @@
            uni.showToast({ title: '预约日期不能早于今天', icon: 'none' })
            return
        }
        if (!/^[1-9][0-9]*$/.test(hospitalId.value) || !/^[1-9][0-9]*$/.test(packageId.value)) {
            uni.showToast({ title: '体检服务信息异常,请返回重新选择套餐', icon: 'none' })
            return
        }
        if (talentId.value == '' && cardId.value == '') {
            uni.showToast({ title: '请先登录人才账号', icon: 'none' })
            return
        }
        isSubmitting.value = true
        submitBusiness('medicalBooking', { hospitalName: hospitalName.value, packageName: packageName.value, userName: userName.value.trim(), userMobile: userMobile.value.trim(), userIdCard: userIdCard.value.trim(), expectDate: expectDate.value }, () => {
        const payload: any = {
            applicationType: 'MEDICAL_RESERVATION',
            merchantId: Number(hospitalId.value),
            packageId: Number(packageId.value),
            packageName: packageName.value,
            talentId: talentId.value != '' ? Number(talentId.value) : null,
            cardId: cardId.value != '' ? Number(cardId.value) : null,
            applicantName: userName.value.trim(),
            applicantMobile: userMobile.value.trim(),
            applicantIdCardNo: userIdCard.value.trim(),
            hospitalName: hospitalName.value,
            expectedDate: expectDate.value
        }
        submitServiceAppointment(payload, createIdempotencyKey('medical-booking'), () => {
            uni.redirectTo({ url: '/pages/medical-booking-success/medical-booking-success?hospital=' + encodeURIComponent(hospitalName.value) })
        }, (error) => {
            isSubmitting.value = false
talent-card-admin/src/views/MerchantCreateView.vue
@@ -1602,8 +1602,8 @@
  productImages.value = Array.isArray(savedImages)
    ? savedImages.filter((image): image is string => typeof image === 'string' && image.length > 0)
    : []
  productForm.salePrice = row?.salePrice
  productForm.originalPrice = row?.originalPrice
  productForm.salePrice = isHealthCheckMerchant.value ? undefined : row?.salePrice
  productForm.originalPrice = isHealthCheckMerchant.value ? undefined : row?.originalPrice
  productForm.stock = row?.stock === -1 ? undefined : row?.stock
  productForm.detailText = row?.detailText || ''
  productForm.benefitDesc = row?.benefitDesc || ''
@@ -1737,8 +1737,8 @@
      ...productForm,
      merchantId: currentMerchantId(),
      detailText: isHealthCheckMerchant.value ? JSON.stringify(healthCheckItems.value) : productForm.detailText,
      salePrice: productForm.salePrice === undefined ? undefined : Number(productForm.salePrice),
      originalPrice: productForm.originalPrice === undefined ? undefined : Number(productForm.originalPrice),
      salePrice: isHealthCheckMerchant.value || productForm.salePrice === undefined ? undefined : Number(productForm.salePrice),
      originalPrice: isHealthCheckMerchant.value || productForm.originalPrice === undefined ? undefined : Number(productForm.originalPrice),
      stock: productForm.stock === undefined ? undefined : Number(productForm.stock),
      sortNo: productForm.sortNo === undefined ? 0 : Number(productForm.sortNo),
      purchaseNotice: {
talent-card-admin/src/views/ParkingPlateDetailView.vue
@@ -1,65 +1,42 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getParkingPlateSubmissionDetail, getParkingPlateOperationLogs, updateParkingPlateSubmissionStatus } from '../api/admin'
import { getParkingPlateOperationLogs, getParkingPlateSubmissionDetail, updateParkingPlateSubmissionStatus } from '../api/admin'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const detail = ref<Record<string, any>>()
const logs = ref<Record<string, any>[]>([])
const processing = ref(false)
const detail = ref<Record<string, any>>({})
const logs = ref<Record<string, any>[]>([])
function text(value: unknown) { return value === undefined || value === null || value === '' ? '-' : String(value) }
const overviewFields = computed(() => [
  ['submissionNo', '????'], ['talentName', '???'], ['talentMobile', '???'], ['talentLevel', '????'], ['plateNumber', '???'], ['parkingLotName', '???'], ['submissionType', '????'], ['status', '??'], ['submittedAt', '????'], ['handledAt', '????'], ['handlerName', '???'],
function field(...keys: string[]) { const groups = [detail.value, detail.value.talentInfo || {}, detail.value.submissionInfo || {}, detail.value.statusInfo || {}]; for (const group of groups) { for (const key of keys) { if (group[key] !== undefined && group[key] !== null && group[key] !== '') return group[key] } } return '' }
function formatDate(value: unknown) { return text(value).replace('T', ' ') }
const overview = computed(() => [
  ['提交单号', field('submissionNo', 'applicationNo')], ['持卡人姓名', field('talentName')], ['手机号', field('talentMobile')], ['人才等级', field('talentLevel', 'cardLevel')],
  ['车牌号', field('plateNumber', 'plateNo')], ['停车场', field('parkingLotName', 'parkingLot', 'serviceName')], ['提交类型', field('submissionType')], ['提交时间', formatDate(field('submittedAt', 'createdAt'))],
  ['状态', field('status')], ['处理时间', formatDate(field('handledAt', 'processedAt'))], ['处理人', field('handlerName', 'processedBy')],
])
async function loadData() {
  loading.value = true
  try {
    const id = String(route.params.id)
    const [info, logResult] = await Promise.all([getParkingPlateSubmissionDetail(id), getParkingPlateOperationLogs(id)])
    detail.value = info
    logs.value = logResult || []
  } finally {
    loading.value = false
  }
}
async function mark(status: string) {
  if (!detail.value?.id) return
  processing.value = true
  try {
    detail.value = await updateParkingPlateSubmissionStatus(detail.value.id, status)
    ElMessage.success('?????')
  } finally {
    processing.value = false
  }
}
async function loadData() { loading.value = true; try { const id = String(route.params.id); const [info, operationLogs] = await Promise.all([getParkingPlateSubmissionDetail(id), getParkingPlateOperationLogs(id)]); detail.value = info || {}; logs.value = operationLogs || [] } finally { loading.value = false } }
async function mark(status: string) { const id = detail.value.id || detail.value.registrationId; if (!id || processing.value) return; processing.value = true; try { detail.value = await updateParkingPlateSubmissionStatus(id, status); logs.value = await getParkingPlateOperationLogs(String(id)); ElMessage.success(status === '已驳回' ? '已驳回该车牌申请' : '已处理该车牌申请') } finally { processing.value = false } }
onMounted(loadData)
</script>
<template>
  <div v-loading="loading" class="page-stack">
    <div class="toolbar"><div><h2>??????</h2><p>??????????????</p></div><el-button @click="$router.back()">??</el-button></div>
    <section class="panel">
      <el-descriptions :column="2" border>
        <el-descriptions-item v-for="[key, label] in overviewFields" :key="key" :label="label">{{ text(detail?.[key]) }}</el-descriptions-item>
      </el-descriptions>
  <div v-loading="loading" class="page-stack parking-plate-detail">
    <div class="toolbar"><div><h2>停车车牌详情</h2><p>查看人才提交的车牌信息,并在详情页完成处理。</p></div><el-button @click="router.back()">返回</el-button></div>
    <section class="panel detail-panel">
      <h3>持卡人信息</h3><div class="info-grid"><div v-for="[label, value] in overview.slice(0, 4)" :key="label" class="info-item"><span>{{ label }}</span><strong>{{ value }}</strong></div></div>
      <h3>提交信息</h3><div class="info-grid"><div v-for="[label, value] in overview.slice(4, 8)" :key="label" class="info-item"><span>{{ label }}</span><strong>{{ value }}</strong></div></div>
      <h3>状态信息</h3><div class="info-grid"><div v-for="[label, value] in overview.slice(8)" :key="label" class="info-item"><span>{{ label }}</span><strong>{{ value }}</strong></div></div>
      <div class="detail-actions"><el-button type="primary" :loading="processing" :disabled="field('status') === '已处理'" @click="mark('已处理')">已处理</el-button><el-button type="danger" :loading="processing" :disabled="field('status') === '已驳回'" @click="mark('已驳回')">驳回</el-button></div>
    </section>
    <section class="panel">
      <div class="toolbar"><div><h3>????</h3></div><div><el-button type="primary" :loading="processing" @click="mark('???')">??</el-button><el-button type="danger" :loading="processing" @click="mark('???')">??</el-button></div></div>
      <el-table :data="logs" border empty-text="????">
        <el-table-column prop="operationType" label="????" min-width="120" />
        <el-table-column prop="operatorName" label="???" min-width="120" />
        <el-table-column prop="beforeStatus" label="???" min-width="110" />
        <el-table-column prop="afterStatus" label="???" min-width="110" />
        <el-table-column prop="remark" label="??" min-width="180" show-overflow-tooltip />
        <el-table-column prop="createdAt" label="??" min-width="170" />
      </el-table>
    </section>
    <section class="panel"><div class="section-heading"><h3>操作记录</h3></div><el-table :data="logs" border empty-text="暂无操作记录"><el-table-column prop="operationType" label="操作类型" min-width="120" /><el-table-column prop="operatorName" label="操作人" min-width="120" /><el-table-column prop="beforeStatus" label="处理前状态" min-width="110" /><el-table-column prop="afterStatus" label="处理后状态" min-width="110" /><el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip /><el-table-column label="操作时间" min-width="170"><template #default="{ row }">{{ formatDate(row.createdAt) }}</template></el-table-column></el-table></section>
  </div>
</template>
<style scoped>
.detail-panel { padding: 24px 28px 28px; }.detail-panel h3 { margin: 0 0 14px; color: #1f2937; font-size: 16px; }.detail-panel h3:not(:first-child) { margin-top: 28px; }.info-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; overflow: hidden; border: 1px solid #e7ebf0; background: #e7ebf0; }.info-item { display: flex; min-height: 66px; padding: 14px 16px; box-sizing: border-box; background: #f8fafc; flex-direction: column; justify-content: center; gap: 7px; }.info-item span { color: #7b8794; font-size: 13px; }.info-item strong { color: #263445; font-size: 14px; font-weight: 500; word-break: break-all; }.detail-actions { display: flex; margin-top: 28px; gap: 12px; }.section-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }.section-heading h3 { margin: 0; color: #1f2937; font-size: 16px; }@media (max-width: 1000px) { .info-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
</style>
talent-card-admin/src/views/ParkingPlateView.vue
@@ -1,12 +1,12 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Download, Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getParkingPlateSubmissions, updateParkingPlateSubmissionStatus } from '../api/admin'
import { getParkingPlateSubmissions } from '../api/admin'
import { downloadExcel } from '../utils/excel'
const loading = ref(false)
const processingId = ref<number | string | null>(null)
const router = useRouter()
const rows = ref<Record<string, any>[]>([])
const filters = reactive({ keyword: '', serviceKeyword: '', plateNumber: '', cardLevel: '', status: '', submissionType: '', submittedTime: [] as string[] })
const pagination = reactive({ page: 1, size: 10, total: 0 })
@@ -18,7 +18,7 @@
async function loadData() { loading.value = true; try { const result = await getParkingPlateSubmissions(queryParams()); rows.value = result.records || []; pagination.total = result.total || 0 } finally { loading.value = false } }
function search() { pagination.page = 1; loadData() }
function resetFilters() { Object.assign(filters, { keyword: '', serviceKeyword: '', plateNumber: '', cardLevel: '', status: '', submissionType: '', submittedTime: [] }); search() }
async function handle(row: Record<string, any>, status = '已处理') { processingId.value = row.id; try { await updateParkingPlateSubmissionStatus(row.id, status); ElMessage.success('状态已更新'); await loadData() } finally { processingId.value = null } }
function viewDetail(row: Record<string, any>) { router.push(`/parking-plates/${row.id}`) }
async function download() { const result = await getParkingPlateSubmissions({ ...queryParams(1000), page: 1 }); downloadExcel('停车车牌管理', [{ title: '提交单号', value: row => text(row.submissionNo || row.applicationNo) }, { title: '持卡人', value: row => text(row.talentName) }, { title: '手机号', value: row => text(row.talentMobile) }, { title: '车牌号', value: row => text(row.plateNumber || row.plateNo) }, { title: '停车场', value: row => text(row.parkingLotName || row.parkingLot || row.serviceName) }, { title: '提交类型', value: row => text(row.submissionType) }, { title: '提交时间', value: row => text(row.submittedAt || row.createdAt) }, { title: '状态', value: row => text(row.status) }], result.records || []) }
onMounted(loadData)
</script>
@@ -45,7 +45,7 @@
        <el-table-column prop="submissionType" label="提交类型" min-width="100" />
        <el-table-column label="提交时间" min-width="170"><template #default="{ row }">{{ text(row.submittedAt || row.createdAt) }}</template></el-table-column>
        <el-table-column prop="status" label="状态" min-width="110" />
        <el-table-column label="操作" width="170" fixed="right"><template #default="{ row }"><el-button link type="primary" :loading="processingId === row.id" @click="handle(row)">处理</el-button><el-button link type="danger" :loading="processingId === row.id" @click="handle(row, '已驳回')">驳回</el-button></template></el-table-column>
        <el-table-column label="操作" width="100" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="viewDetail(row)">查看</el-button></template></el-table-column>
      </el-table>
      <div class="pagination-row"><el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.size" background layout="total, prev, pager, next" :total="pagination.total" @change="loadData" /></div>
    </section>
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java
@@ -390,6 +390,7 @@
                request.sortNo() == null ? 0 : request.sortNo());
        Long id = jdbcTemplate.queryForObject("select last_insert_id()", Long.class);
        ensureParkingBenefit(id, request.merchantType());
        ensureFuelBenefit(id, request.merchantType(), request.serviceLabel());
        Long storeId = createDefaultStore(id, request.merchantName(), request.address());
        syncMerchantBusinessHours(id, storeId, request.businessHours());
        if (hasMerchantAppAccess(request)) {
@@ -510,6 +511,7 @@
                id);
        ensureUpdated(updated);
        ensureParkingBenefit(id, request.merchantType());
        ensureFuelBenefit(id, request.merchantType(), request.serviceLabel());
        Long storeId = findOrCreateDefaultStore(id, request.merchantName(), request.address());
        syncMerchantBusinessHours(id, storeId, request.businessHours());
        if (request.loginPassword() != null && !request.loginPassword().isBlank()) {
@@ -557,6 +559,21 @@
                """, merchantId, merchantId);
    }
    private void ensureFuelBenefit(Long merchantId, String merchantType, String serviceLabel) {
        if (!"加油服务".equals(merchantType) && !"fuel".equalsIgnoreCase(merchantType)) return;
        String discountText = serviceLabel == null || serviceLabel.isBlank() ? "请咨询商户" : serviceLabel.trim();
        jdbcTemplate.update("""
                insert into transport_fuel_benefit(
                    merchant_id, talent_levels, discount_text, oil_types, single_limit_text, annual_limit_text,
                    available_time, usage_instructions, rule_text, status, deleted
                )
                select ?, '卓越人才卡、杰出人才卡、菁英人才卡', ?, '请咨询商户', '请咨询商户', '请咨询商户',
                       '请咨询商户', '提交手机号后,工作人员将根据加油权益与您联系。',
                       '具体适用油品、优惠额度、可用时段和活动规则以加油站说明为准。', 1, 0
                where not exists (select 1 from transport_fuel_benefit where merchant_id = ? and deleted = 0)
                """, merchantId, discountText, merchantId);
    }
    @Transactional
    public Map<String, Object> updateMerchantStatus(Long id, Byte status) {
        int updated = jdbcTemplate.update("update merchant set status = ? where deleted = 0 and id = ?", status, id);
talent-card-server/src/main/java/com/talentcard/server/mapper/ServiceApplicationMapper.java
@@ -57,11 +57,14 @@
                   m.longitude,
                   m.latitude
            from merchant m
            join premium_topic_merchant mapping on mapping.merchant_id = m.id and mapping.deleted = 0
            where m.id = #{merchantId}
              and mapping.category_key = #{categoryKey}
              and m.deleted = 0
              and m.status = 1
              and (exists (select 1 from premium_topic_merchant mapping
                           where mapping.merchant_id = m.id
                             and mapping.category_key = #{categoryKey}
                             and mapping.deleted = 0)
                   or (#{categoryKey} = 'medical' and m.merchant_type in ('medical', '免费体检', 'healthCheck')))
            limit 1
            """)
    Map<String, Object> selectPremiumMerchantSnapshot(@Param("merchantId") Long merchantId, @Param("categoryKey") String categoryKey);
talent-card-server/src/main/resources/db/migration/V117__backfill_fuel_benefits_for_admin_merchants.sql
New file
@@ -0,0 +1,16 @@
insert into transport_fuel_benefit(
    merchant_id, talent_levels, discount_text, oil_types, single_limit_text, annual_limit_text,
    available_time, stock, valid_end, usage_instructions, rule_text, status, deleted
)
select m.id, '卓越人才卡、杰出人才卡、菁英人才卡',
       case when m.service_label is null or m.service_label = '' then '请咨询商户' else m.service_label end,
       '请咨询商户', '请咨询商户', '请咨询商户', '请咨询商户', null, null,
       '提交手机号后,工作人员将根据加油权益与您联系。',
       '具体适用油品、优惠额度、可用时段和活动规则以加油站说明为准。', 1, 0
from merchant m
where m.merchant_type in ('fuel', '加油服务')
  and m.deleted = 0
  and not exists (
      select 1 from transport_fuel_benefit b
      where b.merchant_id = m.id and b.deleted = 0
  );