fix: route hotel free voucher scans to dedicated verification page
| | |
| | | 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) |
| | |
| | | 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 |
| | |
| | | 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) |
| | |
| | | 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, |
| | |
| | | } |
| | | 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 |
| | |
| | | <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> |
| | |
| | | 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) |
| | |
| | | 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) => { |
| | |
| | | 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 = () => { |
| | |
| | | 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 }) |
| | | } |
| | |
| | | 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 } |
| New file |
| | |
| | | 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 |
| | | }) |
| | | } |
| | |
| | | "backgroundColor": "#F7F7F7", |
| | | "disableScroll": true |
| | | } |
| | | }, |
| | | { |
| | | "path": "pages/free-stay-confirm/free-stay-confirm", |
| | | "style": { |
| | | "navigationStyle": "custom", |
| | | "backgroundColor": "#F7F8FA", |
| | | "disableScroll": true |
| | | } |
| | | } |
| | | ], |
| | | "globalStyle": { |
| New file |
| | |
| | | <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> |
| | |
| | | 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) |
| | |
| | | + '&customerMobile=' + encodeURIComponent(result.customerMobile) |
| | | + '&cardLevel=' + encodeURIComponent(result.cardLevel) |
| | | + '&orderNo=' + encodeURIComponent(result.orderNo) |
| | | + '&subsidyAmount=' + encodeURIComponent('' + result.subsidyAmount) |
| | | }) |
| | | }, showVerificationError) |
| | | } |
| | |
| | | customerMobile.value = textOption(options, 'customerMobile', '') |
| | | cardLevel.value = textOption(options, 'cardLevel', '人才卡') |
| | | orderNo.value = textOption(options, 'orderNo', '') |
| | | subsidyAmount.value = Number(textOption(options, 'subsidyAmount', '0')) |
| | | loadOrder() |
| | | }) |
| | | </script> |
| | |
| | | customerName: string |
| | | customerMobile: string |
| | | cardLevel: string |
| | | subsidyAmount: number |
| | | freeStayTotalCount: number |
| | | freeStayRemainingCount: number |
| | | benefitText: string |
| | | } |
| | | |
| | | export type VerificationConfirmResult = { |
| | |
| | | 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) |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | } |
| | | 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) { |
| | |
| | | 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")); |
| | | } |
| | |
| | | } |
| | | |
| | | 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); |
| | |
| | | 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); |
| | |
| | | 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") |
| | |
| | | } |
| | | |
| | | @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, "不支持的行服务类型"); |
| | |
| | | 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; |
| | |
| | | } |
| | | } |
| | | |
| | | 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") |
| | |
| | | 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; |
| | |
| | | <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) >= #{startTime}</if> |
| | | <if test="endTime != null">and coalesce(v.verify_time, o.updated_at, o.created_at) < #{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) >= #{startTime}</if> |
| | | <if test="endTime != null">and coalesce(v.verify_time, o.updated_at, o.created_at) < #{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 >= #{startTime}</if> |
| | | <if test="endTime != null">and r.verify_time < #{endTime}</if> |
| | | ) base |
| | | group by base.period_key, base.merchant_id |
| | | ) grouped |
| | | </script> |
| | | """) |
| | |
| | | <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) >= #{startTime}</if> |
| | | <if test="endTime != null">and coalesce(v.verify_time, o.updated_at, o.created_at) < #{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 >= #{startTime}</if> |
| | | <if test="endTime != null">and r.verify_time < #{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 |
| | |
| | | <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) >= #{startTime}</if> |
| | | <if test="endTime != null">and coalesce(v.verify_time, o.updated_at, o.created_at) < #{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 >= #{startTime}</if> |
| | | <if test="endTime != null">and r.verify_time < #{endTime}</if> |
| | | ) base |
| | | group by base.merchant_id |
| | | order by settlementAmountCents desc, base.merchant_id desc |
| | |
| | | public String selectSettlementExportDetails() { |
| | | return sql(""" |
| | | <script> |
| | | select details.* |
| | | from ( |
| | | select o.id as orderId, |
| | | o.order_no as orderNo, |
| | | o.order_type as orderType, |
| | |
| | | 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 |
| | |
| | | <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) >= #{startTime}</if> |
| | | <if test="endTime != null">and coalesce(v.verify_time, o.updated_at, o.created_at) < #{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 >= #{startTime}</if> |
| | | <if test="endTime != null">and r.verify_time < #{endTime}</if> |
| | | ) details |
| | | order by details.businessTime desc, details.orderId desc |
| | | limit #{limit} |
| | | </script> |
| | | """); |
| | |
| | | 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("<", "<") |
| | | : GovernmentSubsidySql.AMOUNT_CENTS; |
| | | return template.replace("/*AMOUNT*/", amount) |
| | | .replace("/*ORDER_TIME*/", GovernmentSubsidySql.ORDER_TIME); |
| | | } |
| | | } |
| | |
| | | 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; |
| | | } |
| | | |
| | |
| | | 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; |
| | | } |
| | | |
| | |
| | | case "STORE_CONSUME" -> "到店消费"; |
| | | case "GROUP_BUY" -> "团购订单"; |
| | | case "HOTEL_RESERVATION" -> "酒店预约"; |
| | | case "HOTEL_FREE" -> "酒店免费入住"; |
| | | default -> clean(orderType); |
| | | }; |
| | | } |
| | |
| | | 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(""" |
| | |
| | | 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"); |
| | | } |
| | |
| | | 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 |
| | |
| | | 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) |
| | |
| | | |
| | | 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); |
| | | } |
| | | } |
| | |
| | | } |
| | | |
| | | @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( |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |