shinnosuke
2026-08-31 cb5a42c97fe9ae23b891bdc3231dc8eb100de218
fix: route hotel free voucher scans to dedicated verification page
3个文件已添加
17个文件已修改
839 ■■■■■ 已修改文件
rencaika/pages/benefit-search/benefit-search.uvue 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/benefits/benefits.uvue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/transport-benefits/transport-benefits.uvue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/services/portal.uts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/utils/merchant-navigation.uts 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages.json 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/free-stay-confirm/free-stay-confirm.uvue 121 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/scan/scan.uvue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/verification-confirm/verification-confirm.uvue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/services/verification.uts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantVerificationService.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalController.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/MerchantSettlementStatisticsMapper.java 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/StatisticsQuerySql.java 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/MerchantSettlementStatisticsService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationFreeStayTests.java 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/portal/PortalControllerTests.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/portal/PortalServiceTests.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/mapper/MerchantSettlementStatisticsMapperTests.java 160 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/benefit-search/benefit-search.uvue
@@ -61,6 +61,7 @@
    import { resolveApiUrl } from '../../services/http.uts'
    import { getSession } from '../../services/session.uts'
    import { safeBack } from '../../utils/navigation.uts'
    import { openMerchantDetail } from '../../utils/merchant-navigation.uts'
    const navTop = ref(28)
    const navHeight = ref(32)
@@ -298,43 +299,7 @@
        keyword.value = value
        performSearch()
    }
    const openMerchant = (item: any) => {
        const action = item.targetAction != null ? item.targetAction as string : 'MERCHANT'
        const detailBusinessId = item.detailBusinessId != null ? item.detailBusinessId as string : item.id as string
        if (action == 'CINEMA') {
            uni.navigateTo({ url: '/pages/cinema-merchant/cinema-merchant?merchantId=' + encodeURIComponent(detailBusinessId) + '&name=' + encodeURIComponent(item.name) })
            return
        }
        if (action == 'SHOPPING') {
            uni.navigateTo({ url: '/pages/shopping-merchant/shopping-merchant?name=' + encodeURIComponent(item.name) + '&discount=' + encodeURIComponent(item.discount) + '&address=' + encodeURIComponent(item.address) })
            return
        }
        if (action == 'MEDICAL') {
            uni.navigateTo({ url: '/pages/merchant/merchant?type=medical&name=' + encodeURIComponent(item.name) })
            return
        }
        if (action == 'FINANCE') {
            uni.navigateTo({ url: '/pages/finance-merchant/finance-merchant?name=' + encodeURIComponent(item.name) + '&discount=' + encodeURIComponent(item.discount) + '&address=' + encodeURIComponent(item.address) + '&image=' + encodeURIComponent(item.image) })
            return
        }
        if (action == 'CAR_WASH') {
            uni.navigateTo({ url: '/pages/car-wash-merchant/car-wash-merchant?merchantId=' + encodeURIComponent(detailBusinessId) + '&name=' + encodeURIComponent(item.name) })
            return
        }
        if (action == 'FUEL' || action == 'PARKING') {
            const transportAction = action == 'PARKING' ? 'plate' : 'mobile'
            uni.navigateTo({ url: '/pages/merchant/merchant?type=transport&name=' + encodeURIComponent(item.name) + '&action=' + transportAction + '&plateMode=withParking' })
            return
        }
        uni.navigateTo({
            url: '/pages/merchant/merchant?merchantType=' + encodeURIComponent(item.merchantType)
                + '&merchantId=' + encodeURIComponent(detailBusinessId)
                + '&name=' + encodeURIComponent(item.name)
                + '&image=' + encodeURIComponent(item.image)
                + '&address=' + encodeURIComponent(item.address)
                + '&discount=' + encodeURIComponent(item.discount)
        })
    }
    const openMerchant = (item: any) => openMerchantDetail(item)
    const loadMoreMerchants = () => {
        if (initialLoading.value || loadingMore.value || !hasMore.value) return
        const requestVersion = listRequestVersion.value
rencaika/pages/benefits/benefits.uvue
@@ -69,6 +69,7 @@
    import { getMerchants } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { getSession } from '../../services/session.uts'
    import { openMerchantDetail } from '../../utils/merchant-navigation.uts'
    const navTop = ref(28)
    const navHeight = ref(32)
@@ -157,6 +158,9 @@
                id: '' + item.id,
                name: item.merchantName != null ? item.merchantName as string : '人才权益商户',
                merchantType: merchantType,
                detailType: item.detailType != null ? item.detailType as string : 'MERCHANT',
                detailBusinessId: item.detailBusinessId != null ? '' + item.detailBusinessId : '' + item.id,
                targetAction: item.targetAction != null ? item.targetAction as string : 'MERCHANT',
                hasCoupon: hasCoupon,
                discount: discount,
                tags: tags,
@@ -242,13 +246,7 @@
        }
        uni.showToast({ title: '已选择' + name, icon: 'none' })
    }
    const openMerchant = (item: any) => uni.navigateTo({
        url: '/pages/merchant/merchant?merchantType=' + encodeURIComponent(item.merchantType != null ? item.merchantType : 'food') + '&merchantId=' + encodeURIComponent(item.id)
            + '&name=' + encodeURIComponent(item.name)
            + '&image=' + encodeURIComponent(item.image)
            + '&address=' + encodeURIComponent(item.address)
            + '&discount=' + encodeURIComponent(item.discount)
    })
    const openMerchant = (item: any) => openMerchantDetail(item)
    const loadMoreBenefits = () => {
        if (initialLoading.value || loadingMore.value || !hasMoreBenefits.value) return
        loadMoreError.value = false
rencaika/pages/transport-benefits/transport-benefits.uvue
@@ -42,7 +42,7 @@
                        <image class="service-image" :src="item.image" mode="aspectFill" @error="replaceServiceImage(item)"></image>
                        <view class="service-info">
                            <text class="service-name">{{item.name}}</text>
                            <text class="service-discount">{{item.available ? item.discount : '已下架'}}</text>
                            <text v-if="!item.available || item.benefitTagVisible" class="service-discount">{{item.available ? item.discount : '已下架'}}</text>
                            <view class="address-row"><image src="/static/benefits-assets/map-pin.svg"></image><text>{{item.address}}</text></view>
                        </view>
                    </view>
@@ -60,6 +60,7 @@
    import { isApiConfigured } from '../../config/api.uts'
    import { getTransportTopic, getTransportTopicMerchants } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { getSession } from '../../services/session.uts'
    import { safeBack } from '../../utils/navigation.uts'
    const navTop = ref(28); const navHeight = ref(32); const navWidth = ref(88); const navRight = ref(10)
@@ -68,6 +69,7 @@
    const topicTitle = ref('行--畅行无忧'); const topicDescription = ref('整合加油、停车、洗车等出行服务资源,为人才提供专属折扣、停车优惠和便捷洗车服务,全方位保障人才出行便捷。'); const topicError = ref(false)
    const activeIndex = ref(0); const sceneScrollLeft = ref(0); const sceneTrackWidth = ref(644); const scenes = ref<any[]>(fallbackScenes); const currentScene = ref({ id: '', name: '', image: '' })
    const serviceTabs = [{ key: 'fuel', label: '加油服务' }, { key: 'parking', label: '停车服务' }, { key: 'carWash', label: '自助洗车服务' }]
    const talentId = ref('')
    const activeTab = ref('fuel'); const visibleServices = ref<any[]>([]); const initialLoading = ref(true); const initialError = ref(false); const loadingMore = ref(false); const loadMoreError = ref(false); const hasLoaded = ref(false); const hasMore = ref(false); const currentPage = ref(0); const listRequestVersion = ref(0); const failInitialOnce = ref(false); const failLoadMoreOnce = ref(false)
    onLoad((options: any) => {
@@ -75,6 +77,7 @@
        const capsule = uni.getMenuButtonBoundingClientRect(); const info = uni.getSystemInfoSync(); windowWidth.value = info.windowWidth; navTop.value = capsule.top; navHeight.value = capsule.height; navWidth.value = capsule.width; navRight.value = info.windowWidth - capsule.right
        // #endif
        if (options != null && options['previewState'] != null) { const state = options['previewState'] as string; failInitialOnce.value = state == 'initialError'; failLoadMoreOnce.value = state == 'loadMoreError' }
        const session = getSession(); if (session != null && session.talentId != null) talentId.value = session.talentId as string
        selectScene(0); loadTransportTopic(); loadInitialServices()
    })
    const loadTransportTopic = () => {
@@ -99,10 +102,10 @@
        const requestVersion = listRequestVersion.value
        if (!append && failInitialOnce.value) { failInitialOnce.value = false; initialLoading.value = false; initialError.value = true; return }
        if (append && failLoadMoreOnce.value) { failLoadMoreOnce.value = false; loadingMore.value = false; loadMoreError.value = true; return }
        getTransportTopicMerchants(activeTab.value, currentPage.value + 1, 4, (data: any) => {
        getTransportTopicMerchants(activeTab.value, talentId.value, currentPage.value + 1, 4, (data: any) => {
            if (requestVersion != listRequestVersion.value) return
            const records: any[] = data != null && data.records instanceof Array ? data.records as any[] : []; const next = append ? visibleServices.value.slice() : []
            for (let index = 0; index < records.length; index++) { const item = records[index]; next.push({ id: '' + item.id, merchantType: item.merchantType != null ? item.merchantType as string : activeTab.value, name: item.merchantName != null ? item.merchantName as string : '行服务', discount: item.discountText != null ? item.discountText as string : '人才专享', address: item.address != null ? item.address as string : '', image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackImage, available: item.available !== false, externalLinkType: item.externalLinkType != null ? item.externalLinkType as string : '' }) }
            for (let index = 0; index < records.length; index++) { const item = records[index]; const merchantType = item.merchantType != null ? item.merchantType as string : activeTab.value; const discount = item.discountText != null ? item.discountText as string : ''; next.push({ id: '' + item.id, merchantType: merchantType, name: item.merchantName != null ? item.merchantName as string : '行服务', discount: discount, benefitTagVisible: merchantType == 'carWash' ? item.benefitTagVisible === true : discount != '', address: item.address != null ? item.address as string : '', image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackImage, available: item.available !== false, externalLinkType: item.externalLinkType != null ? item.externalLinkType as string : '' }) }
            visibleServices.value = next; currentPage.value++; hasMore.value = data != null && data.total != null ? next.length < data.total : false; initialLoading.value = false; loadingMore.value = false; hasLoaded.value = true
        }, () => { if (requestVersion != listRequestVersion.value) return; initialLoading.value = false; loadingMore.value = false; if (append) loadMoreError.value = true; else initialError.value = true })
    }
rencaika/services/portal.uts
@@ -49,7 +49,11 @@
export const getEntertainmentTopic = (onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appEntertainmentTopic'), method: 'GET', skipAuth: true }, onSuccess, onError)
export const getEntertainmentTopicMerchants = (page: number, size: number, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appEntertainmentTopicMerchants'), method: 'GET', skipAuth: true, data: { page: page, size: size } }, onSuccess, onError)
export const getTransportTopic = (onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appTransportTopic'), method: 'GET', skipAuth: true }, onSuccess, onError)
export const getTransportTopicMerchants = (type: string, page: number, size: number, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appTransportTopicMerchants'), method: 'GET', skipAuth: true, data: { type: type, page: page, size: size } }, onSuccess, onError)
export const getTransportTopicMerchants = (type: string, talentId: string, page: number, size: number, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    const data: any = { type: type, page: page, size: size }
    if (talentId != '') data.talentId = Number(talentId)
    return request<any>({ path: getApiEndpoint('appTransportTopicMerchants'), method: 'GET', data: data }, onSuccess, onError)
}
export const getShoppingTopic = (onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appShoppingTopic'), method: 'GET', skipAuth: true }, onSuccess, onError)
export const getShoppingTopicMerchants = (categoryId: string, talentId: string, page: number, size: number, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    const data: any = { categoryId: categoryId, page: page, size: size }
rencaika/utils/merchant-navigation.uts
New file
@@ -0,0 +1,92 @@
const textValue = (value: any, fallback: string = ''): string => {
    if (value == null) return fallback
    return String(value)
}
const encoded = (value: any, fallback: string = ''): string => encodeURIComponent(textValue(value, fallback))
const inferTargetAction = (merchantType: string): string => {
    if (merchantType == 'cinema') return 'CINEMA'
    if (merchantType == 'fuel') return 'FUEL'
    if (merchantType == 'parking') return 'PARKING'
    if (merchantType == 'carWash') return 'CAR_WASH'
    if (merchantType == 'medical') return 'MEDICAL'
    if (merchantType == 'employment') return 'SPOUSE_EMPLOYMENT'
    if (merchantType == 'finance') return 'FINANCE'
    if (merchantType == 'approval') return 'ADMIN_APPROVAL'
    if (merchantType == 'children') return 'CHILD_ENTRUSTMENT'
    if (merchantType == 'housing' || merchantType == 'car' || merchantType == 'appliance' || merchantType == 'digital' || merchantType == 'culture' || merchantType == 'telecom' || merchantType == 'shopping') return 'SHOPPING'
    return 'MERCHANT'
}
export const openMerchantDetail = (item: any) => {
    if (item == null) return
    if (item.available === false) {
        uni.showToast({ title: textValue(item.unavailableReason, '该服务当前不可用'), icon: 'none' })
        return
    }
    const merchantId = textValue(item.detailBusinessId, textValue(item.id))
    const merchantType = textValue(item.merchantType, 'food')
    const configuredAction = textValue(item.targetAction, textValue(item.detailType))
    const action = configuredAction == '' || configuredAction == 'MERCHANT' ? inferTargetAction(merchantType) : configuredAction
    const name = encoded(item.name)
    const image = encoded(item.image)
    const address = encoded(item.address)
    const discount = encoded(item.discount)
    if (action == 'CINEMA') {
        uni.navigateTo({ url: '/pages/cinema-merchant/cinema-merchant?merchantId=' + encoded(merchantId) + '&name=' + name + '&image=' + image + '&address=' + address + '&discount=' + discount })
        return
    }
    if (action == 'SHOPPING' && merchantType != 'culture') {
        uni.navigateTo({ url: '/pages/shopping-merchant/shopping-merchant?merchantId=' + encoded(merchantId) })
        return
    }
    if (action == 'MEDICAL') {
        uni.navigateTo({ url: '/pages/merchant/merchant?type=medical&merchantType=medical&merchantId=' + encoded(merchantId) + '&name=' + name })
        return
    }
    if (action == 'SPOUSE_EMPLOYMENT') {
        uni.navigateTo({ url: '/pages/spouse-employment/spouse-employment?serviceId=' + encoded(merchantId) })
        return
    }
    if (action == 'FINANCE') {
        uni.navigateTo({ url: '/pages/finance-merchant/finance-merchant?serviceId=' + encoded(merchantId) + '&merchantId=' + encoded(merchantId) + '&name=' + name + '&discount=' + discount + '&address=' + address + '&image=' + image })
        return
    }
    if (action == 'ADMIN_APPROVAL') {
        uni.navigateTo({ url: '/pages/admin-approval/admin-approval?serviceId=' + encoded(merchantId) })
        return
    }
    if (action == 'CHILD_ENTRUSTMENT') {
        uni.navigateTo({ url: '/pages/children-entrustment/children-entrustment?serviceId=' + encoded(merchantId) })
        return
    }
    if (action == 'CAR_WASH') {
        uni.navigateTo({ url: '/pages/car-wash-merchant/car-wash-merchant?merchantId=' + encoded(merchantId) + '&name=' + name })
        return
    }
    if (action == 'FUEL' || action == 'PARKING') {
        const transportAction = action == 'PARKING' && textValue(item.discount).indexOf('人才码') >= 0 ? 'talentCode' : ''
        uni.navigateTo({
            url: '/pages/merchant/merchant?type=transport&merchantType=' + encoded(merchantType)
                + '&merchantId=' + encoded(merchantId)
                + '&name=' + name
                + '&image=' + image
                + '&address=' + address
                + '&discount=' + discount
                + '&action=' + encoded(transportAction)
        })
        return
    }
    uni.navigateTo({
        url: '/pages/merchant/merchant?merchantType=' + encoded(merchantType)
            + '&merchantId=' + encoded(merchantId)
            + '&name=' + name
            + '&image=' + image
            + '&address=' + address
            + '&discount=' + discount
    })
}
rencaika_merchant/pages.json
@@ -193,6 +193,14 @@
                "backgroundColor": "#F7F7F7",
                "disableScroll": true
            }
        },
        {
            "path": "pages/free-stay-confirm/free-stay-confirm",
            "style": {
                "navigationStyle": "custom",
                "backgroundColor": "#F7F8FA",
                "disableScroll": true
            }
        }
    ],
    "globalStyle": {
rencaika_merchant/pages/free-stay-confirm/free-stay-confirm.uvue
New file
@@ -0,0 +1,121 @@
<template>
    <view class="page">
        <view class="navigation-bar">
            <view class="back-button" @tap="goBack"><image class="back-icon" src="/static/icons/lucide/back-left.svg" mode="aspectFit"></image></view>
            <text class="navigation-title">核销输入</text>
        </view>
        <scroll-view class="content" scroll-y="true" :show-scrollbar="false">
            <view class="talent-card">
                <view class="card-decoration"></view>
                <view class="talent-head"><text class="talent-name">{{customerName}}</text><text class="talent-level">{{cardLevel}}</text></view>
                <text class="talent-mobile">{{displayMobile(customerMobile)}}</text>
                <view class="current-benefit"><text class="benefit-label">当前权益</text><text class="benefit-text">免费入住{{totalCount}}天</text></view>
            </view>
            <view class="method-card">
                <text class="section-label">优惠方式</text>
                <view class="tag-list"><text class="benefit-tag">免费入住{{totalCount}}天</text><text v-if="discountText.length > 0" class="benefit-tag">{{discountText}}</text></view>
            </view>
            <view class="stay-ticket">
                <view class="ticket-left"><text class="ticket-vertical">入<br/>住<br/>券</text></view>
                <view class="ticket-divider"></view>
                <view class="ticket-right"><text class="ticket-count">一天</text><text class="ticket-free">免费</text></view>
                <view class="ticket-notch ticket-notch-left"></view><view class="ticket-notch ticket-notch-right"></view>
            </view>
            <view class="content-bottom-space"></view>
        </scroll-view>
        <view class="bottom-bar"><view class="confirm-button" :class="submitting || loadError.length > 0 ? 'button-disabled' : ''" @tap="confirm"><text>{{submitting ? '核销中...' : '确认核销'}}</text></view></view>
    </view>
</template>
<script setup lang="uts">
    import { confirmVerificationCode } from '../../services/verification.uts'
    const verificationId = ref('')
    const customerName = ref('持卡人')
    const customerMobile = ref('')
    const cardLevel = ref('人才卡')
    const totalCount = ref(5)
    const discountText = ref('')
    const loadError = ref('')
    const submitting = ref(false)
    const textOption = (options: any, name: string, fallback: string): string => options != null && options[name] != null && ('' + options[name]).length > 0 ? decodeURIComponent('' + options[name]) : fallback
    const displayMobile = (mobile: string): string => mobile.length < 7 ? mobile : mobile.substring(0, 3) + '****' + mobile.substring(mobile.length - 4)
    const goBack = () => uni.navigateBack()
    const parseDiscount = (value: string): string => {
        if (value.length == 0 || value.indexOf('折') < 0) return ''
        const end = value.indexOf('折') + 1
        const parts = value.substring(0, end).split(';')
        return parts[parts.length - 1].split(',')[0].trim()
    }
    const confirm = () => {
        if (submitting.value || verificationId.value.length == 0 || loadError.value.length > 0) return
        uni.showModal({
            title: '确认核销',
            content: '确认核销本次免费入住券吗?',
            confirmText: '确认核销',
            confirmColor: '#FF6B2C',
            success: (modal: any) => {
                if (!modal.confirm) return
                submitting.value = true
                confirmVerificationCode(verificationId.value, 'merchant-free-stay-' + verificationId.value + '-' + Date.now(), (_result: any, _traceId: string) => {
                    submitting.value = false
                    uni.redirectTo({ url: '/pages/verification-success/verification-success?orderNo=' + encodeURIComponent('免费入住券') })
                }, (error: any) => {
                    submitting.value = false
                    uni.showToast({ title: error != null && error.message != null ? '' + error.message : '核销失败,请重试', icon: 'none' })
                })
            }
        })
    }
    onLoad((options: any) => {
        verificationId.value = textOption(options, 'verificationId', '')
        customerName.value = textOption(options, 'customerName', '持卡人')
        customerMobile.value = textOption(options, 'customerMobile', '')
        cardLevel.value = textOption(options, 'cardLevel', '人才卡')
        totalCount.value = Math.max(Number(textOption(options, 'freeStayTotalCount', '5')), 1)
        discountText.value = parseDiscount(textOption(options, 'benefitText', ''))
        if (verificationId.value.length == 0) loadError.value = '核销凭证无效,请返回重新扫码'
    })
</script>
<style>
    page { background-color: #F7F8FA; }
    .page { display: flex; width: 100%; height: 100vh; flex-direction: column; overflow: hidden; background-color: #F7F8FA; }
    .navigation-bar { display: flex; height: 174rpx; flex: none; justify-content: center; align-items: flex-end; padding-bottom: 25rpx; box-sizing: border-box; position: relative; }
    .navigation-title { color: #1F2937; font-size: 35rpx; line-height: 49rpx; font-weight: 700; }
    .back-button { display: flex; width: 72rpx; height: 72rpx; justify-content: center; align-items: center; position: absolute; top: 82rpx; left: 28rpx; }
    .back-icon { width: 40rpx; height: 40rpx; }
    .content { height: 0; flex: 1; padding: 20rpx 34rpx 0; box-sizing: border-box; }
    .talent-card { min-height: 238rpx; padding: 34rpx 34rpx 28rpx; border-radius: 38rpx; background-image: linear-gradient(135deg, #C99D32 0%, #F7E69D 55%, #E5BD4E 100%); box-shadow: 0 18rpx 38rpx rgba(188, 139, 37, 0.2); box-sizing: border-box; position: relative; overflow: hidden; }
    .card-decoration { width: 220rpx; height: 220rpx; border: 1rpx solid rgba(255, 255, 255, 0.35); border-radius: 110rpx; position: absolute; top: -72rpx; right: -12rpx; }
    .talent-head { display: flex; flex-direction: row; justify-content: space-between; align-items: center; position: relative; }
    .talent-name { color: #FFFFFF; font-size: 39rpx; line-height: 55rpx; font-weight: 700; }
    .talent-level { padding: 9rpx 20rpx; border-radius: 24rpx; color: #FFFFFF; font-size: 22rpx; line-height: 31rpx; font-weight: 600; background-color: rgba(149, 104, 22, 0.46); }
    .talent-mobile { display: block; margin-top: 15rpx; color: #FFFFFF; font-size: 29rpx; line-height: 41rpx; font-weight: 600; position: relative; }
    .current-benefit { display: flex; margin-top: 22rpx; flex-direction: row; align-items: center; position: relative; }
    .benefit-label { color: rgba(255, 255, 255, 0.78); font-size: 23rpx; line-height: 33rpx; }
    .benefit-text { margin-left: 18rpx; color: #FFFFFF; font-size: 23rpx; line-height: 33rpx; font-weight: 600; }
    .method-card { padding: 31rpx 34rpx; margin-top: 22rpx; border-radius: 30rpx; background-color: #FFFFFF; box-shadow: 0 14rpx 30rpx rgba(57, 67, 80, 0.06); }
    .section-label { display: block; color: #8D99AA; font-size: 27rpx; line-height: 38rpx; }
    .tag-list { display: flex; margin-top: 20rpx; flex-direction: row; flex-wrap: wrap; }
    .benefit-tag { display: flex; min-height: 48rpx; padding: 7rpx 18rpx; margin-right: 12rpx; margin-bottom: 4rpx; justify-content: center; align-items: center; border-radius: 24rpx; color: #FF772C; font-size: 23rpx; line-height: 33rpx; background-color: #FFF0E5; box-sizing: border-box; }
    .stay-ticket { display: flex; height: 210rpx; margin: 40rpx 42rpx 0; flex-direction: row; align-items: center; border-radius: 32rpx; background-image: linear-gradient(100deg, #FF5D25 0%, #FF8A27 100%); box-shadow: 0 18rpx 34rpx rgba(255, 98, 35, 0.23); position: relative; overflow: hidden; }
    .ticket-left { display: flex; width: 134rpx; justify-content: center; align-items: center; }
    .ticket-vertical { color: #FFFFFF; font-size: 28rpx; line-height: 37rpx; font-weight: 700; text-align: center; }
    .ticket-divider { width: 2rpx; height: 144rpx; background-color: rgba(255, 255, 255, 0.3); }
    .ticket-right { display: flex; flex: 1; flex-direction: column; justify-content: center; align-items: center; }
    .ticket-count { color: #FFFFFF; font-size: 43rpx; line-height: 60rpx; font-weight: 700; }
    .ticket-free { color: #FFFFFF; font-size: 43rpx; line-height: 60rpx; font-weight: 700; }
    .ticket-notch { width: 34rpx; height: 34rpx; border-radius: 20rpx; background-color: #F7F8FA; position: absolute; top: 88rpx; }
    .ticket-notch-left { left: -17rpx; }
    .ticket-notch-right { right: -17rpx; }
    .remaining-hint { margin-top: 20rpx; color: #98A2B1; font-size: 23rpx; line-height: 33rpx; text-align: center; }
    .content-bottom-space { height: 180rpx; }
    .bottom-bar { display: flex; height: 154rpx; padding: 20rpx 96rpx 34rpx; background-color: #FFFFFF; box-sizing: border-box; position: fixed; right: 0; bottom: 0; left: 0; z-index: 10; }
    .confirm-button { display: flex; height: 94rpx; flex: 1; justify-content: center; align-items: center; border-radius: 48rpx; color: #FFFFFF; font-size: 29rpx; line-height: 41rpx; font-weight: 700; background-image: linear-gradient(90deg, #FF7625 0%, #FF962F 100%); box-shadow: 0 12rpx 24rpx rgba(255, 107, 36, 0.22); }
    .button-disabled { opacity: 0.6; box-shadow: none; }
</style>
rencaika_merchant/pages/scan/scan.uvue
@@ -111,6 +111,19 @@
                uni.navigateTo({ url: '/pages/consume-input/consume-input?verificationId=' + encodeURIComponent(result.verificationId) })
                return
            }
            if (result.credentialType == 'FREE_STAY_CODE') {
                openedConfirmation = true
                uni.navigateTo({
                    url: '/pages/free-stay-confirm/free-stay-confirm?verificationId=' + encodeURIComponent(result.verificationId)
                        + '&customerName=' + encodeURIComponent(result.customerName)
                        + '&customerMobile=' + encodeURIComponent(result.customerMobile)
                        + '&cardLevel=' + encodeURIComponent(result.cardLevel)
                        + '&freeStayTotalCount=' + encodeURIComponent('' + result.freeStayTotalCount)
                        + '&freeStayRemainingCount=' + encodeURIComponent('' + result.freeStayRemainingCount)
                        + '&benefitText=' + encodeURIComponent(result.benefitText)
                })
                return
            }
            openedConfirmation = true
            uni.navigateTo({
                url: '/pages/verification-confirm/verification-confirm?verificationId=' + encodeURIComponent(result.verificationId)
@@ -119,6 +132,7 @@
                    + '&customerMobile=' + encodeURIComponent(result.customerMobile)
                    + '&cardLevel=' + encodeURIComponent(result.cardLevel)
                    + '&orderNo=' + encodeURIComponent(result.orderNo)
                    + '&subsidyAmount=' + encodeURIComponent('' + result.subsidyAmount)
            })
        }, showVerificationError)
    }
rencaika_merchant/pages/verification-confirm/verification-confirm.uvue
@@ -113,6 +113,7 @@
        customerMobile.value = textOption(options, 'customerMobile', '')
        cardLevel.value = textOption(options, 'cardLevel', '人才卡')
        orderNo.value = textOption(options, 'orderNo', '')
        subsidyAmount.value = Number(textOption(options, 'subsidyAmount', '0'))
        loadOrder()
    })
</script>
rencaika_merchant/services/verification.uts
@@ -13,6 +13,10 @@
    customerName: string
    customerMobile: string
    cardLevel: string
    subsidyAmount: number
    freeStayTotalCount: number
    freeStayRemainingCount: number
    benefitText: string
}
export type VerificationConfirmResult = {
@@ -76,7 +80,11 @@
            orderNo: data.orderNo != null ? data.orderNo as string : '',
            customerName: data.customerName != null ? data.customerName as string : '',
            customerMobile: data.customerMobile != null ? data.customerMobile as string : '',
            cardLevel: data.cardLevel != null ? data.cardLevel as string : ''
            cardLevel: data.cardLevel != null ? data.cardLevel as string : '',
            subsidyAmount: data.subsidyAmount != null ? Number(data.subsidyAmount) : 0,
            freeStayTotalCount: data.freeStayTotalCount != null ? Number(data.freeStayTotalCount) : 0,
            freeStayRemainingCount: data.freeStayRemainingCount != null ? Number(data.freeStayRemainingCount) : 0,
            benefitText: data.benefitText != null ? '' + data.benefitText : ''
        }
        onSuccess(result, traceId)
    }, onError)
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantVerificationService.java
@@ -1,5 +1,7 @@
package com.talentcard.server.business.merchant;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
@@ -11,6 +13,7 @@
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -94,22 +97,42 @@
        }
        String verificationId = createAttempt(principal, TALENT_CODE, number(code.get("dynamicCodeId")), null, null, code.get("expiresAt"));
        return scanResult(verificationId, TALENT_CODE, "人才码有效,请录入消费金额并发送待支付订单", code.get("talentName"),
                code.get("talentMobile"), code.get("cardLevel"), null, code.get("expiresAt"));
                code.get("talentMobile"), code.get("cardLevel"), null, code.get("expiresAt"), 0L);
    }
    private Map<String, Object> createOrderAttempt(MerchantPrincipal principal, Map<String, Object> code) {
        String verificationId = createAttempt(principal, ORDER_CODE, null, number(code.get("orderVerificationCodeId")), null, code.get("expiresAt"));
        String title = value(code.get("productName"), "订单权益");
        return scanResult(verificationId, ORDER_CODE, "订单核销码有效,请确认后完成核销:" + title,
                code.get("talentName"), code.get("talentMobile"), code.get("cardLevel"), code.get("orderNo"), code.get("expiresAt"));
                code.get("talentName"), code.get("talentMobile"), code.get("cardLevel"), code.get("orderNo"), code.get("expiresAt"), 0L);
    }
    private Map<String, Object> createFreeStayAttempt(MerchantPrincipal principal, Map<String, Object> code) {
        Long remaining = number(code.get("totalCount")) - number(code.get("usedCount"));
        String verificationId = createAttempt(principal, FREE_STAY_CODE, null, null,
                number(code.get("freeStayVerificationCodeId")), code.get("expiresAt"));
        return scanResult(verificationId, FREE_STAY_CODE, "免费入住券有效,剩余 " + remaining + " 次,请确认后核销",
                code.get("talentName"), code.get("talentMobile"), code.get("cardLevel"), null, code.get("expiresAt"));
        Map<String, Object> result = scanResult(verificationId, FREE_STAY_CODE, "免费入住券有效,剩余 " + remaining + " 次,请确认后核销",
                code.get("talentName"), code.get("talentMobile"), code.get("cardLevel"), null, code.get("expiresAt"),
                hotelSubsidyAmountCents());
        result.put("freeStayTotalCount", number(code.get("totalCount")));
        result.put("freeStayRemainingCount", Math.max(remaining, 0L));
        result.put("benefitText", freeStayBenefitText(code, principal));
        return result;
    }
    private String freeStayBenefitText(Map<String, Object> code, MerchantPrincipal principal) {
        try {
            List<Map<String, Object>> benefits = rows("""
                    select benefit_text
                    from merchant_card_benefit
                    where merchant_id = ? and card_level = ? and status = 1
                    limit 1
                    """, principal.merchantId(), value(code.get("cardLevel"), ""));
            return benefits.isEmpty() ? "" : value(benefits.get(0).get("benefitText"), "");
        } catch (DataAccessException ignored) {
            // Older installations may not have the optional display table yet.
            return "";
        }
    }
    private String createAttempt(MerchantPrincipal principal, String type, Long dynamicCodeId, Long orderCodeId, Long freeStayCodeId, Object expiresAt) {
@@ -217,8 +240,9 @@
        if (codeUpdated == 0 || voucherUpdated == 0) {
            throw new BusinessException(ErrorCode.CONFLICT, "免费入住券已被使用或已失效");
        }
        Long subsidyAmount = hotelSubsidyAmountCents();
        String verifyNo = insertRecord(principal, null, number(code.get("talentId")), number(code.get("cardId")), null,
                "HOTEL_FREE", 0L, 0L, "免费入住券");
                "HOTEL_FREE", 0L, subsidyAmount, "免费入住券(平台线下补贴)");
        finishAttempt(attempt, verifyNo);
        return confirmed(verifyNo, code.get("cardLevel"));
    }
@@ -291,7 +315,8 @@
    }
    private Map<String, Object> scanResult(String verificationId, String credentialType, String message,
                                            Object talentName, Object talentMobile, Object cardLevel, Object orderNo, Object expiresAt) {
                                            Object talentName, Object talentMobile, Object cardLevel, Object orderNo,
                                            Object expiresAt, Long subsidyAmount) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("verificationId", verificationId);
        result.put("credentialType", credentialType);
@@ -303,10 +328,36 @@
        result.put("customerMobile", maskMobile(value(talentMobile, "")));
        result.put("cardLevel", cardLevel == null ? "人才卡" : String.valueOf(cardLevel));
        result.put("orderNo", orderNo == null ? "" : String.valueOf(orderNo));
        result.put("subsidyAmount", subsidyAmount == null ? 0L : subsidyAmount);
        result.put("expiresAt", expiresAt);
        return result;
    }
    private Long hotelSubsidyAmountCents() {
        List<Map<String, Object>> settings = rows("""
                select setting_value
                from admin_setting
                where setting_key = 'hotelSubsidy'
                limit 1
                """);
        if (settings.isEmpty()) {
            return 0L;
        }
        String configuredAmount = value(settings.get(0).get("settingValue"), "").trim();
        if (configuredAmount.isEmpty()) {
            return 0L;
        }
        try {
            BigDecimal yuan = new BigDecimal(configuredAmount).setScale(2, RoundingMode.UNNECESSARY);
            if (yuan.signum() < 0) {
                throw new ArithmeticException("negative subsidy");
            }
            return yuan.movePointRight(2).longValueExact();
        } catch (ArithmeticException | NumberFormatException exception) {
            throw new BusinessException(ErrorCode.CONFLICT, "酒店免费入住补贴金额配置不正确,请联系平台管理员");
        }
    }
    private Map<String, Object> confirmed(Object verifyNo, Object cardLevel) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("verified", true);
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalController.java
@@ -146,9 +146,11 @@
    public ApiResponse<PageResult<Map<String, Object>>> transportMerchants(
            @RequestParam(defaultValue = "fuel") String type,
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "4") int size
            @RequestParam(defaultValue = "4") int size,
            @RequestParam(required = false) Long talentId,
            @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization
    ) {
        return ApiResponse.success(portalService.transportMerchants(type, page, size));
        return ApiResponse.success(portalService.transportMerchants(type, page, size, sessionTalentId(authorization, talentId)));
    }
    @GetMapping("/topics/shopping")
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
@@ -525,7 +525,7 @@
    }
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> transportMerchants(String type, int page, int size) {
    public PageResult<Map<String, Object>> transportMerchants(String type, int page, int size, Long talentId) {
        String requestedType = MerchantTypeCatalog.canonicalCode(type);
        if (!List.of("fuel", "parking", "carWash").contains(requestedType)) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的行服务类型");
@@ -539,12 +539,21 @@
                from merchant
                """ + merchantWhere + " order by sort_no asc, id asc",
                "select count(*) from merchant" + merchantWhere, page, size);
        String cardLevel = "carWash".equals(requestedType) ? activeProductCardLevel(talentId) : null;
        String sourceAreaType = "carWash".equals(requestedType) ? activeTalentSourceAreaType(talentId) : "";
        List<Map<String, Object>> records = result.records().stream().map(row -> {
            Map<String, Object> item = enrichMerchantSearchRow(row);
            item.put("merchantType", requestedType);
            item.put("discountText", textOrDefault(item.get("serviceLabel"), textOrDefault(item.get("discountText"), "人才专享")));
            if ("carWash".equals(requestedType)) {
                item.put("externalLinkType", carWashExternalLinkType(item.get("merchantRightsConfig")));
                List<Map<String, Object>> rights = merchantRightsConfigs(item.get("merchantRightsConfig"));
                String externalLinkType = carWashExternalLinkType(rights);
                Map<String, Object> right = matchingMerchantRight(rights, cardLevel, sourceAreaType, true);
                String discountText = carWashListDiscountText(externalLinkType, right);
                item.put("externalLinkType", externalLinkType);
                item.put("discountText", discountText);
                item.put("benefitTagVisible", !discountText.isBlank());
            } else {
                item.put("discountText", textOrDefault(item.get("serviceLabel"), textOrDefault(item.get("discountText"), "人才专享")));
            }
            item.remove("merchantRightsConfig");
            return item;
@@ -920,8 +929,8 @@
        }
    }
    private String carWashExternalLinkType(Object rightsConfig) {
        for (Map<String, Object> right : merchantRightsConfigs(rightsConfig)) {
    private String carWashExternalLinkType(List<Map<String, Object>> rights) {
        for (Map<String, Object> right : rights) {
            if (right.containsKey("externalLinkType")
                    || right.containsKey("externalMiniProgramShortLink")
                    || right.containsKey("externalMiniProgramPath")
@@ -932,6 +941,11 @@
        return "";
    }
    private String carWashListDiscountText(String externalLinkType, Map<String, Object> right) {
        if ("miniProgram".equals(externalLinkType) || right == null) return "";
        return merchantRightDiscountText(right);
    }
    private boolean booleanValue(Object value) {
        if (value instanceof Boolean bool) return bool;
        if (value instanceof Number number) return number.intValue() != 0;
talent-card-server/src/main/java/com/talentcard/server/mapper/MerchantSettlementStatisticsMapper.java
@@ -27,27 +27,45 @@
            <script>
            select count(*)
            from (
                select case
                           when #{periodType} = 'MONTH' then date_format(coalesce(v.verify_time, o.updated_at, o.created_at), '%Y-%m')
                           else date_format(coalesce(v.verify_time, o.updated_at, o.created_at), '%Y-%m-%d')
                       end as period_key,
                       o.merchant_id
                from benefit_order o
                join merchant m on m.id = o.merchant_id and m.deleted = 0
                left join (
                    select order_id, max(verify_time) as verify_time
                    from verify_record
                    where status = 1
                    group by order_id
                ) v on v.order_id = o.id
                where o.deleted = 0
                  and o.order_status = 1
                  and o.verify_status = 1
                  <if test="merchantId != null">and o.merchant_id = #{merchantId}</if>
                  <if test="merchantType != null and merchantType != ''">and m.merchant_type_code = #{merchantType}</if>
                  <if test="startTime != null">and coalesce(v.verify_time, o.updated_at, o.created_at) &gt;= #{startTime}</if>
                  <if test="endTime != null">and coalesce(v.verify_time, o.updated_at, o.created_at) &lt; #{endTime}</if>
                group by period_key, o.merchant_id
                select base.period_key, base.merchant_id
                from (
                    select case
                               when #{periodType} = 'MONTH' then date_format(coalesce(v.verify_time, o.updated_at, o.created_at), '%Y-%m')
                               else date_format(coalesce(v.verify_time, o.updated_at, o.created_at), '%Y-%m-%d')
                           end as period_key,
                           o.merchant_id
                    from benefit_order o
                    join merchant m on m.id = o.merchant_id and m.deleted = 0
                    left join (
                        select order_id, max(verify_time) as verify_time
                        from verify_record
                        where status = 1
                        group by order_id
                    ) v on v.order_id = o.id
                    where o.deleted = 0
                      and o.order_status = 1
                      and o.verify_status = 1
                      <if test="merchantId != null">and o.merchant_id = #{merchantId}</if>
                      <if test="merchantType != null and merchantType != ''">and m.merchant_type_code = #{merchantType}</if>
                      <if test="startTime != null">and coalesce(v.verify_time, o.updated_at, o.created_at) &gt;= #{startTime}</if>
                      <if test="endTime != null">and coalesce(v.verify_time, o.updated_at, o.created_at) &lt; #{endTime}</if>
                    union all
                    select case
                               when #{periodType} = 'MONTH' then date_format(r.verify_time, '%Y-%m')
                               else date_format(r.verify_time, '%Y-%m-%d')
                           end as period_key,
                           r.merchant_id
                    from verify_record r
                    join merchant m on m.id = r.merchant_id and m.deleted = 0
                    where r.status = 1
                      and r.order_id is null
                      and upper(r.verify_type) = 'HOTEL_FREE'
                      <if test="merchantId != null">and r.merchant_id = #{merchantId}</if>
                      <if test="merchantType != null and merchantType != ''">and m.merchant_type_code = #{merchantType}</if>
                      <if test="startTime != null">and r.verify_time &gt;= #{startTime}</if>
                      <if test="endTime != null">and r.verify_time &lt; #{endTime}</if>
                ) base
                group by base.period_key, base.merchant_id
            ) grouped
            </script>
            """)
talent-card-server/src/main/java/com/talentcard/server/mapper/StatisticsQuerySql.java
@@ -185,6 +185,28 @@
                  <if test="merchantType != null and merchantType != ''">and m.merchant_type_code = #{merchantType}</if>
                  <if test="startTime != null">and coalesce(v.verify_time, o.updated_at, o.created_at) &gt;= #{startTime}</if>
                  <if test="endTime != null">and coalesce(v.verify_time, o.updated_at, o.created_at) &lt; #{endTime}</if>
                union all
                select case
                           when #{periodType} = 'MONTH' then date_format(r.verify_time, '%Y-%m')
                           else date_format(r.verify_time, '%Y-%m-%d')
                       end as period_key,
                       r.verify_time as business_time,
                       r.merchant_id,
                       m.merchant_name,
                       m.merchant_type,
                       0 as order_amount,
                       0 as pay_amount,
                       0 as coupon_amount,
                       coalesce(r.subsidy_amount, 0) as government_subsidy_amount
                from verify_record r
                join merchant m on m.id = r.merchant_id and m.deleted = 0
                where r.status = 1
                  and r.order_id is null
                  and upper(r.verify_type) = 'HOTEL_FREE'
                  <if test="merchantId != null">and r.merchant_id = #{merchantId}</if>
                  <if test="merchantType != null and merchantType != ''">and m.merchant_type_code = #{merchantType}</if>
                  <if test="startTime != null">and r.verify_time &gt;= #{startTime}</if>
                  <if test="endTime != null">and r.verify_time &lt; #{endTime}</if>
            ) base
            group by base.period_key, base.merchant_id, base.merchant_name, base.merchant_type
            order by base.period_key desc, base.merchant_id desc
@@ -234,6 +256,24 @@
                  <if test="merchantType != null and merchantType != ''">and m.merchant_type_code = #{merchantType}</if>
                  <if test="startTime != null">and coalesce(v.verify_time, o.updated_at, o.created_at) &gt;= #{startTime}</if>
                  <if test="endTime != null">and coalesce(v.verify_time, o.updated_at, o.created_at) &lt; #{endTime}</if>
                union all
                select r.verify_time as business_time,
                       r.merchant_id,
                       m.merchant_name,
                       m.merchant_type,
                       0 as order_amount,
                       0 as pay_amount,
                       0 as coupon_amount,
                       coalesce(r.subsidy_amount, 0) as government_subsidy_amount
                from verify_record r
                join merchant m on m.id = r.merchant_id and m.deleted = 0
                where r.status = 1
                  and r.order_id is null
                  and upper(r.verify_type) = 'HOTEL_FREE'
                  <if test="merchantId != null">and r.merchant_id = #{merchantId}</if>
                  <if test="merchantType != null and merchantType != ''">and m.merchant_type_code = #{merchantType}</if>
                  <if test="startTime != null">and r.verify_time &gt;= #{startTime}</if>
                  <if test="endTime != null">and r.verify_time &lt; #{endTime}</if>
            ) base
            group by base.merchant_id
            order by settlementAmountCents desc, base.merchant_id desc
@@ -244,6 +284,8 @@
    public String selectSettlementExportDetails() {
        return sql("""
            <script>
            select details.*
            from (
            select o.id as orderId,
                   o.order_no as orderNo,
                   o.order_type as orderType,
@@ -268,7 +310,8 @@
                   o.created_at as createdAt,
                   o.updated_at as paidAt,
                   v.verify_time as verifyTime,
                   v.verify_operator as verifyOperator
                   v.verify_operator as verifyOperator,
                   coalesce(v.verify_time, o.updated_at, o.created_at) as businessTime
            from benefit_order o
            join merchant m on m.id = o.merchant_id and m.deleted = 0
            left join merchant_store s on s.id = o.store_id and s.deleted = 0
@@ -288,7 +331,48 @@
              <if test="verifyStatus != null">and o.verify_status = #{verifyStatus}</if>
              <if test="startTime != null">and coalesce(v.verify_time, o.updated_at, o.created_at) &gt;= #{startTime}</if>
              <if test="endTime != null">and coalesce(v.verify_time, o.updated_at, o.created_at) &lt; #{endTime}</if>
            order by coalesce(v.verify_time, o.updated_at, o.created_at) desc, o.id desc
            union all
            select r.id as orderId,
                   r.verify_no as orderNo,
                   'HOTEL_FREE' as orderType,
                   r.merchant_id as merchantId,
                   m.merchant_name as merchantName,
                   m.merchant_type as merchantType,
                   r.store_id as storeId,
                   s.store_name as storeName,
                   r.talent_id as talentId,
                   t.name as talentName,
                   t.mobile as talentMobile,
                   tc.card_no as cardNo,
                   tc.card_level as cardLevel,
                   '酒店免费入住券' as productName,
                   0 as originalAmountCents,
                   0 as transactionAmountCents,
                   coalesce(r.subsidy_amount, 0) as subsidyAmountCents,
                   coalesce(r.subsidy_amount, 0) as settlementAmountCents,
                   0 as couponAmountCents,
                   1 as orderStatus,
                   1 as verifyStatus,
                   r.verify_time as createdAt,
                   null as paidAt,
                   r.verify_time as verifyTime,
                   r.staff_id as verifyOperator,
                   r.verify_time as businessTime
            from verify_record r
            join merchant m on m.id = r.merchant_id and m.deleted = 0
            left join merchant_store s on s.id = r.store_id and s.deleted = 0
            left join talent_user t on t.id = r.talent_id and t.deleted = 0
            left join talent_card tc on tc.id = r.card_id and tc.deleted = 0
            where r.status = 1
              and r.order_id is null
              and upper(r.verify_type) = 'HOTEL_FREE'
              and r.merchant_id = #{merchantId}
              <if test="orderStatus != null">and #{orderStatus} = 1</if>
              <if test="verifyStatus != null">and #{verifyStatus} = 1</if>
              <if test="startTime != null">and r.verify_time &gt;= #{startTime}</if>
              <if test="endTime != null">and r.verify_time &lt; #{endTime}</if>
            ) details
            order by details.businessTime desc, details.orderId desc
            limit #{limit}
            </script>
            """);
@@ -329,12 +413,24 @@
                    where o.deleted = 0
                      and o.order_status = 1
                      and o.merchant_id = #{merchantId}
                ), 0)
                +
                coalesce((
                    select sum(r.subsidy_amount)
                    from verify_record r
                    where r.status = 1
                      and r.order_id is null
                      and upper(r.verify_type) = 'HOTEL_FREE'
                      and r.merchant_id = #{merchantId}
                ), 0) as subsidyAmountCents
            """);
    }
    private static String sql(String template) {
        return template.replace("/*AMOUNT*/", GovernmentSubsidySql.AMOUNT_CENTS)
        String amount = template.contains("<script>")
                ? GovernmentSubsidySql.AMOUNT_CENTS.replace("<", "&lt;")
                : GovernmentSubsidySql.AMOUNT_CENTS;
        return template.replace("/*AMOUNT*/", amount)
                .replace("/*ORDER_TIME*/", GovernmentSubsidySql.ORDER_TIME);
    }
}
talent-card-server/src/main/java/com/talentcard/server/service/MerchantSettlementStatisticsService.java
@@ -130,7 +130,7 @@
        result.put("subsidyAmountCents", subsidyAmount);
        result.put("subsidyAmount", yuan(subsidyAmount));
        result.put("hasData", transactionAmount > 0 || todayVerificationCount > 0 || weekVerificationCount > 0 || subsidyAmount > 0);
        result.put("statisticsBasis", "交易总额统计已支付订单实付金额;补贴总额仅统计政府承担补贴的商品补贴和政府补贴券金额,商家自行让利不进入政府补贴结算");
        result.put("statisticsBasis", "交易总额统计已支付订单实付金额;补贴总额统计政府承担的订单补贴、政府补贴券及酒店免费入住线下补贴,商家自行让利不进入政府补贴结算");
        return result;
    }
@@ -278,7 +278,7 @@
        result.put("governmentSubsidyOrderCount", longValue(row, "governmentSubsidyOrderCount"));
        result.put("periodStartTime", value(row, "periodStartTime", "startTime"));
        result.put("periodEndTime", value(row, "periodEndTime", "endTime"));
        result.put("statisticsBasis", "已支付且已核销订单;补贴额仅统计政府承担补贴的商品补贴和政府补贴券金额,商家自行让利不进入政府补贴结算");
        result.put("statisticsBasis", "已支付且已核销订单及酒店免费入住核销记录;补贴额统计政府承担的订单补贴、政府补贴券及酒店免费入住线下补贴,商家自行让利不进入政府补贴结算");
        return result;
    }
@@ -422,6 +422,7 @@
            case "STORE_CONSUME" -> "到店消费";
            case "GROUP_BUY" -> "团购订单";
            case "HOTEL_RESERVATION" -> "酒店预约";
            case "HOTEL_FREE" -> "酒店免费入住";
            default -> clean(orderType);
        };
    }
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationFreeStayTests.java
@@ -59,15 +59,65 @@
                create table free_stay_verification_code (
                    id bigint primary key,
                    voucher_id bigint not null,
                    code_token varchar(128),
                    expires_at timestamp not null,
                    status tinyint not null
                )
                """);
        jdbc.execute("""
                create table talent_user (
                    id bigint primary key,
                    name varchar(64),
                    mobile varchar(32),
                    status tinyint not null default 1,
                    source_area_type varchar(32),
                    deleted tinyint not null default 0
                )
                """);
        jdbc.execute("""
                create table talent_card (
                    id bigint primary key,
                    card_level varchar(64),
                    card_no varchar(64),
                    status tinyint not null default 1,
                    valid_end date,
                    deleted tinyint not null default 0
                )
                """);
        jdbc.execute("""
                create table talent_dynamic_code (
                    id bigint primary key,
                    expected_merchant_id bigint,
                    expires_at timestamp,
                    card_id bigint,
                    talent_id bigint,
                    code_token varchar(128),
                    status tinyint not null default 0
                )
                """);
        jdbc.execute("""
                create table benefit_order (
                    id bigint primary key,
                    order_no varchar(64),
                    order_status tinyint,
                    verify_status tinyint,
                    merchant_id bigint,
                    store_id bigint,
                    product_name_snapshot varchar(255),
                    benefit_id bigint,
                    talent_id bigint,
                    card_id bigint,
                    deleted tinyint not null default 0
                )
                """);
        jdbc.execute("create table benefit (id bigint primary key, benefit_name varchar(255))");
        jdbc.execute("""
                create table order_dynamic_verification_code (
                    id bigint primary key,
                    order_id bigint,
                    code_token varchar(128),
                    expires_at timestamp,
                    status tinyint not null default 0
                )
                """);
        jdbc.execute("""
@@ -87,6 +137,9 @@
                    remark varchar(255)
                )
                """);
        jdbc.execute("create table admin_setting (setting_key varchar(64) primary key, setting_value varchar(1000))");
        jdbc.update("insert into admin_setting(setting_key, setting_value) values ('hotelSubsidy', '268.50')");
        jdbc.execute("create table payment_transaction (id bigint primary key)");
        service = new MerchantVerificationService(jdbc, new ManualConsumptionService(jdbc));
        principal = new MerchantPrincipal(9L, 88L, 7L, "operator");
    }
@@ -103,6 +156,28 @@
        assertThat(jdbc.queryForObject("select status from merchant_verification_attempt where verification_id = ?", Integer.class,
                "free-stay-expired-code")).isEqualTo(1);
        assertThat(jdbc.queryForObject("select count(*) from verify_record where verify_type = 'HOTEL_FREE'", Integer.class)).isEqualTo(1);
        assertThat(jdbc.queryForObject("select subsidy_amount from verify_record where verify_type = 'HOTEL_FREE'", Long.class)).isEqualTo(26850L);
        assertThat(jdbc.queryForObject("select count(*) from benefit_order", Integer.class)).isZero();
        assertThat(jdbc.queryForObject("select count(*) from payment_transaction", Integer.class)).isZero();
    }
    @Test
    void scanReturnsConfiguredHotelSubsidyAmount() {
        jdbc.update("insert into talent_card(id, card_level, deleted) values (10, '卓越人才卡', 0)");
        jdbc.update("insert into talent_user(id, name, mobile, deleted) values (1015, '测试人才', '13800000000', 0)");
        jdbc.update("""
                insert into talent_free_stay_voucher(id, talent_id, merchant_id, card_id, total_count, used_count, valid_end, status)
                values (20, 1015, 88, 10, 2, 0, ?, 1)
                """, LocalDate.now().plusDays(1));
        jdbc.update("""
                insert into free_stay_verification_code(id, voucher_id, code_token, expires_at, status)
                values (30, 20, 'free-stay-scan-code', ?, 0)
                """, Timestamp.valueOf(LocalDateTime.now().plusMinutes(1)));
        java.util.Map<String, Object> scanned = service.scan(principal, new VerificationScanRequest("free-stay-scan-code"));
        assertThat(scanned.get("credentialType")).isEqualTo("FREE_STAY_CODE");
        assertThat(scanned.get("subsidyAmount")).isEqualTo(26850L);
    }
    @Test
@@ -121,9 +196,9 @@
                values (20, 1015, 88, 10, 2, 0, ?, 1)
                """, voucherValidEnd);
        jdbc.update("""
                insert into free_stay_verification_code(id, voucher_id, expires_at, status)
                values (30, 20, ?, 2)
                """, Timestamp.valueOf(LocalDateTime.now().minusMinutes(1)));
                insert into free_stay_verification_code(id, voucher_id, code_token, expires_at, status)
                values (30, 20, ?, ?, 2)
                """, verificationId, Timestamp.valueOf(LocalDateTime.now().minusMinutes(1)));
        jdbc.update("""
                insert into merchant_verification_attempt(verification_id, credential_type, free_stay_verification_code_id,
                                                          merchant_id, store_id, operator_id, expires_at, status)
talent-card-server/src/test/java/com/talentcard/server/business/portal/PortalControllerTests.java
@@ -47,4 +47,16 @@
        verify(portalService).stayMerchants("hotel", 1, 4, 7L);
    }
    @Test
    void transportMerchantsUsesTalentIdFromAuthenticatedSession() {
        when(talentAuthService.authenticate("Bearer session-token"))
                .thenReturn(new TalentSessionPrincipal(42L));
        when(portalService.transportMerchants("carWash", 1, 4, 42L))
                .thenReturn(new PageResult<>(List.of(), 1, 4, 0));
        controller.transportMerchants("carWash", 1, 4, 7L, "Bearer session-token");
        verify(portalService).transportMerchants("carWash", 1, 4, 42L);
    }
}
talent-card-server/src/test/java/com/talentcard/server/business/portal/PortalServiceTests.java
@@ -59,6 +59,20 @@
    }
    @Test
    void carWashListHidesMiniProgramTagAndShowsNormalConfiguredDiscount() throws Exception {
        Method method = PortalService.class.getDeclaredMethod("carWashListDiscountText", String.class, Map.class);
        method.setAccessible(true);
        Map<String, Object> right = Map.of(
                "enabled", true,
                "storeConsume", true,
                "consumeEnabled", true,
                "discount", "8.50");
        assertThat(method.invoke(service, "miniProgram", right)).isEqualTo("");
        assertThat(method.invoke(service, "normal", right)).isEqualTo("8.5折");
    }
    @Test
    @SuppressWarnings("unchecked")
    void recommendedMerchantTagsFollowCategoryListRules() throws Exception {
        Method method = PortalService.class.getDeclaredMethod(
talent-card-server/src/test/java/com/talentcard/server/mapper/MerchantSettlementStatisticsMapperTests.java
New file
@@ -0,0 +1,160 @@
package com.talentcard.server.mapper;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import javax.sql.DataSource;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
public class MerchantSettlementStatisticsMapperTests {
    private SqlSessionFactory sessionFactory;
    @BeforeEach
    void setUp() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource(
                "jdbc:h2:mem:merchant-settlement-statistics-" + System.nanoTime()
                        + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1", "sa", "");
        createSchema(dataSource);
        insertFixture(dataSource);
        Environment environment = new Environment("test", new JdbcTransactionFactory(), dataSource);
        Configuration configuration = new Configuration(environment);
        configuration.addMapper(MerchantSettlementStatisticsMapper.class);
        sessionFactory = new SqlSessionFactoryBuilder().build(configuration);
    }
    @Test
    void includesHotelFreeOfflineSubsidyWithoutIncreasingTransactionAmount() {
        LocalDateTime start = LocalDate.now().atStartOfDay();
        LocalDateTime end = LocalDate.now().plusDays(1).atStartOfDay();
        try (SqlSession session = sessionFactory.openSession()) {
            MerchantSettlementStatisticsMapper mapper = session.getMapper(MerchantSettlementStatisticsMapper.class);
            List<Map<String, Object>> summaries = mapper.selectPeriodSummaries(
                    88L, "HOTEL", "DAY", start, end, 20, 0);
            assertThat(summaries).hasSize(1);
            assertTotals(summaries.get(0));
            assertThat(mapper.countPeriodSummaries(88L, "HOTEL", "DAY", start, end)).isEqualTo(1L);
            List<Map<String, Object>> overview = mapper.selectMerchantOverviews(88L, "HOTEL", start, end);
            assertThat(overview).hasSize(1);
            assertTotals(overview.get(0));
            Map<String, Object> cards = mapper.selectDashboardCards(88L, start, end, start, end);
            assertThat(longValue(cards, "transactionAmountCents")).isEqualTo(10_000L);
            assertThat(longValue(cards, "subsidyAmountCents")).isEqualTo(28_850L);
            assertThat(longValue(cards, "todayVerificationCount")).isEqualTo(2L);
            List<Map<String, Object>> details = mapper.selectSettlementExportDetails(88L, 1, 1, start, end, 20);
            assertThat(details).hasSize(2);
            Map<String, Object> freeStay = details.stream()
                    .filter(row -> "HOTEL_FREE".equals(String.valueOf(value(row, "orderType"))))
                    .findFirst().orElseThrow();
            assertThat(longValue(freeStay, "transactionAmountCents")).isZero();
            assertThat(longValue(freeStay, "subsidyAmountCents")).isEqualTo(26_850L);
            assertThat(longValue(freeStay, "settlementAmountCents")).isEqualTo(26_850L);
        }
    }
    private void assertTotals(Map<String, Object> row) {
        assertThat(longValue(row, "orderCount")).isEqualTo(2L);
        assertThat(longValue(row, "transactionAmountCents")).isEqualTo(10_000L);
        assertThat(longValue(row, "subsidyAmountCents")).isEqualTo(28_850L);
        assertThat(longValue(row, "settlementAmountCents")).isEqualTo(38_850L);
    }
    private long longValue(Map<String, Object> row, String key) {
        Object value = value(row, key);
        return value instanceof Number number ? number.longValue() : 0L;
    }
    private Object value(Map<String, Object> row, String key) {
        for (Map.Entry<String, Object> entry : row.entrySet()) {
            if (entry.getKey().equalsIgnoreCase(key)) {
                return entry.getValue();
            }
        }
        return null;
    }
    private void createSchema(DataSource dataSource) {
        JdbcTemplate jdbc = new JdbcTemplate(dataSource);
        jdbc.execute("create alias if not exists date_format for 'com.talentcard.server.mapper.MerchantSettlementStatisticsMapperTests.dateFormat'");
        jdbc.execute("""
                create table merchant (
                    id bigint primary key, merchant_name varchar(128), merchant_type varchar(64),
                    merchant_type_code varchar(64), deleted tinyint not null default 0
                )
                """);
        jdbc.execute("""
                create table benefit_order (
                    id bigint primary key, order_no varchar(64), order_type varchar(64), merchant_id bigint,
                    store_id bigint, talent_id bigint, card_id bigint, benefit_id bigint, product_id bigint,
                    coupon_id bigint, product_name_snapshot varchar(255), order_amount bigint, pay_amount bigint,
                    coupon_amount bigint, subsidy_amount bigint, order_status tinyint, verify_status tinyint,
                    created_at timestamp, updated_at timestamp, deleted tinyint not null default 0
                )
                """);
        jdbc.execute("""
                create table verify_record (
                    id bigint primary key, verify_no varchar(64), order_id bigint, talent_id bigint, card_id bigint,
                    merchant_id bigint, store_id bigint, staff_id bigint, verify_type varchar(64), verify_amount bigint,
                    subsidy_amount bigint, verify_time timestamp, status tinyint not null default 1
                )
                """);
        jdbc.execute("create table merchant_product (id bigint primary key, product_name varchar(255), is_government_subsidy tinyint, deleted tinyint)");
        jdbc.execute("create table merchant_coupon (id bigint primary key, government_subsidy tinyint, deleted tinyint)");
        jdbc.execute("create table merchant_store (id bigint primary key, store_name varchar(128), deleted tinyint)");
        jdbc.execute("create table talent_user (id bigint primary key, name varchar(64), mobile varchar(32), deleted tinyint)");
        jdbc.execute("create table talent_card (id bigint primary key, card_no varchar(64), card_level varchar(64), deleted tinyint)");
    }
    private void insertFixture(DataSource dataSource) {
        JdbcTemplate jdbc = new JdbcTemplate(dataSource);
        LocalDateTime now = LocalDateTime.now();
        jdbc.update("insert into merchant(id, merchant_name, merchant_type, merchant_type_code, deleted) values (88, '测试酒店', '酒店服务', 'HOTEL', 0)");
        jdbc.update("insert into merchant_store(id, store_name, deleted) values (7, '酒店门店', 0)");
        jdbc.update("insert into talent_user(id, name, mobile, deleted) values (1015, '测试人才', '13800000000', 0)");
        jdbc.update("insert into talent_card(id, card_no, card_level, deleted) values (10, 'TC001', '卓越人才卡', 0)");
        jdbc.update("""
                insert into benefit_order(id, order_no, order_type, merchant_id, store_id, talent_id, card_id,
                                          product_name_snapshot, order_amount, pay_amount, coupon_amount, subsidy_amount,
                                          order_status, verify_status, created_at, updated_at, deleted)
                values (1, 'O001', 'GROUP_BUY', 88, 7, 1015, 10, '测试团单', 12000, 10000, 0, 2000, 1, 1, ?, ?, 0)
                """, now, now);
        jdbc.update("""
                insert into verify_record(id, verify_no, order_id, talent_id, card_id, merchant_id, store_id, staff_id,
                                          verify_type, verify_amount, subsidy_amount, verify_time, status)
                values (1, 'V001', 1, 1015, 10, 88, 7, 9, 'ORDER_CODE', 10000, 2000, ?, 1)
                """, now);
        jdbc.update("""
                insert into verify_record(id, verify_no, order_id, talent_id, card_id, merchant_id, store_id, staff_id,
                                          verify_type, verify_amount, subsidy_amount, verify_time, status)
                values (2, 'V002', null, 1015, 10, 88, 7, 9, 'HOTEL_FREE', 0, 26850, ?, 1)
                """, now);
    }
    public static String dateFormat(java.sql.Timestamp value, String pattern) {
        DateTimeFormatter formatter = "%Y-%m".equals(pattern)
                ? DateTimeFormatter.ofPattern("yyyy-MM")
                : DateTimeFormatter.ofPattern("yyyy-MM-dd");
        return value.toLocalDateTime().format(formatter);
    }
}