feat: 去掉商品取货码,管理员可直接发货完成

已付款订单不再生成或核验取货码,管理员确认后即可完成订单。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
richarjiang
2026-09-25 11:22:15 +08:00
parent a76afb19de
commit 21e3ece21c
15 changed files with 35 additions and 68 deletions

View File

@@ -45,21 +45,9 @@
<view class="divider" />
<text class="muted">{{ order.contactName }} · {{ order.contactPhone }}</text>
<text class="order-no" @tap="copy(order.orderNo)">订单号 {{ order.orderNo }} ⧉</text>
<template v-if="order.status === 'PAID'">
<view v-if="!admin" class="notice">
到馆出示提货码
<text class="code" @tap="copy(order.pickupCode)">{{ order.pickupCode }}</text>
请在领取商品时向工作人员出示。
</view>
<view v-else class="field">
<input
v-model="codes[order.id]"
class="input"
maxlength="12"
placeholder="输入顾客的 12 位提货码"
/>
</view>
</template>
<text v-if="order.status === 'PAID' && !admin" class="notice">
已付款,到馆即可领取。工作人员确认交付后,订单即完成。
</text>
<text v-if="order.status === 'PENDING'" class="muted">
付款截止 {{ date(order.expiresAt) }},超时后自动取消。
</text>
@@ -77,7 +65,7 @@
:disabled="!!busy"
@tap="complete(order)"
>
确认已交付商品
确认发货并完成
</button>
</view>
</view>
@@ -105,8 +93,7 @@ const items = ref<ShopOrder[]>([]),
total = ref(0),
loading = ref(false),
error = ref(''),
busy = ref(''),
codes = ref<Record<string, string>>({})
busy = ref('')
const tabs = [
{ value: '', label: '全部' },
...Object.entries(shopStatus).map(([value, label]) => ({ value, label })),
@@ -199,22 +186,17 @@ async function cancel(order: ShopOrder) {
}
}
async function complete(order: ShopOrder) {
const code = (codes.value[order.id] || '').trim().toUpperCase()
if (!/^[A-F0-9]{12}$/.test(code)) {
shopError(new Error('请输入完整提货码'))
return
}
const answer = await uni.showModal({
title: '确认商品已交付',
content: `${order.title} × ${order.quantity},取货人 ${order.contactName}。核销后订单将完成。`,
title: '确认发货并完成',
content: `${order.title} × ${order.quantity},取货人 ${order.contactName}。确认后订单将完成。`,
})
if (!answer.confirm || busy.value) return
busy.value = order.id
try {
await post(`/admin/shop/orders/${order.id}/complete`, { pickupCode: code })
await post(`/admin/shop/orders/${order.id}/complete`)
await load(true)
emit('changed')
uni.showToast({ title: '提货完成' })
uni.showToast({ title: '订单已完成' })
} catch (e) {
shopError(e)
} finally {

View File

@@ -43,7 +43,7 @@
<view class="row">
<view>
<text class="label">上架展示</text>
<text class="muted">下架不影响已有订单的提货</text>
<text class="muted">下架不影响已付款订单</text>
</view>
<switch :checked="form.isActive" color="#647c6b" @change="toggleActive" />
</view>

View File

@@ -14,7 +14,7 @@
<text class="muted">{{ summary.orders }} 笔已付款 · {{ summary.quantity }} 件商品</text>
</view>
<view class="stats">
<text>{{ summary.pendingPickup }} 待取货</text>
<text>{{ summary.pendingPickup }} 待发货</text>
<text>{{ summary.activeProducts }} 已上架</text>
<text>低库存 {{ summary.lowStock }} 款</text>
</view>
@@ -23,7 +23,7 @@
<text class="tab" :class="{ active: tab === 'products' }" @tap="tab = 'products'">
商品与库存
</text>
<text class="tab" :class="{ active: tab === 'orders' }" @tap="showOrders">订单与提货</text>
<text class="tab" :class="{ active: tab === 'orders' }" @tap="showOrders">销售订单</text>
</view>
<view v-show="tab === 'products'">
<input

View File

@@ -15,7 +15,7 @@
<text class="muted">可售 {{ product.stock }} 件 · 已售 {{ product.sold }} 件</text>
</view>
<view class="notice">
到馆自提 · 付款后凭订单提货码领取
到馆自提 · 付款后由工作人员确认交付
<text></text>
{{ studio.studioInfo?.address || '到馆领取前可联系场馆确认营业时间' }}
<text v-if="studio.studioInfo?.phone" @tap="call">· 联系场馆 ›</text>

View File

@@ -4,7 +4,7 @@
<view class="content">
<text class="eyebrow">YOUR STUDIO FINDS</text>
<text class="headline">把喜欢带回家</text>
<text class="muted">付款后到馆领取,提货码在订单内查看。</text>
<text class="muted">付款后到馆领取,工作人员确认交付后订单完成。</text>
<ShopOrders v-if="user.loggedIn" ref="orders" />
<view v-else class="state">
<text>登录后查看你的商品订单</text>

View File

@@ -156,14 +156,6 @@ button::after {
color: #a09487;
word-break: break-all;
}
.code {
font-family: monospace;
font-size: 38rpx;
letter-spacing: 4rpx;
color: #547060;
margin: 18rpx 0;
display: block;
}
.load-more {
margin: 24rpx auto;
}

View File

@@ -3,8 +3,8 @@ import { post } from './request'
export const shopMoney = (fen: number) => (fen / 100).toFixed(2)
export const shopStatus: Record<ShopOrderStatus, string> = {
PENDING: '待付款',
PAID: '待到馆取货',
COMPLETED: '已取货',
PAID: '待发货',
COMPLETED: '已完成',
CANCELLED: '已取消',
}
export const shopError = (error: unknown) =>

View File

@@ -0,0 +1,3 @@
-- Paid product orders are fulfilled directly by an administrator.
-- The pickup code is no longer generated, shown, or required.
ALTER TABLE `product_orders` DROP COLUMN `pickup_code`;

View File

@@ -663,7 +663,6 @@ model ProductOrder {
contactName String @map("contact_name") @db.VarChar(40)
contactPhone String @map("contact_phone") @db.VarChar(20)
status ProductOrderStatus @default(PENDING)
pickupCode String @map("pickup_code") @db.VarChar(12)
wxTransactionId String? @unique @map("wx_transaction_id")
expiresAt DateTime @map("expires_at")
paidAt DateTime? @map("paid_at")

View File

@@ -15,7 +15,6 @@ const order = () => ({
contactName: '顾客',
contactPhone: '13800138000',
status: 'PENDING',
pickupCode: 'ABCDEF123456',
expiresAt: new Date(Date.now() + 900000),
createdAt: new Date(),
updatedAt: new Date(),
@@ -78,7 +77,6 @@ describe('ShopService inventory and payment', () => {
create: jest.fn(async ({ data }) => ({ ...current, ...data })),
updateMany: jest.fn(async ({ where, data }) => {
if (where.status && where.status !== current.status) return { count: 0 }
if (where.pickupCode && where.pickupCode !== current.pickupCode) return { count: 0 }
Object.assign(current, data)
return { count: 1 }
}),
@@ -246,14 +244,13 @@ describe('ShopService inventory and payment', () => {
data: { productId: 'p1', delta: 2, reason: '到货', operatorId: 'admin' },
})
})
it('requires correct pickup code and paid state', async () => {
await expect(service.complete('o1', current.pickupCode, 'admin')).rejects.toThrow('不可核销')
it('completes a paid order directly and only once', async () => {
await expect(service.complete('o1', 'admin')).rejects.toThrow('仅已付款')
current.status = 'PAID'
await expect(service.complete('o1', 'WRONG', 'admin')).rejects.toThrow('不可核销')
await service.complete('o1', current.pickupCode, 'admin')
await service.complete('o1', 'admin')
expect(current.status).toBe('COMPLETED')
expect(current.completedBy).toBe('admin')
await expect(service.complete('o1', current.pickupCode, 'admin')).rejects.toThrow('不可核销')
await expect(service.complete('o1', 'admin')).rejects.toThrow('仅已付款')
})
it('scopes user lists and hides inactive products publicly', async () => {
await service.orders(new ShopQueryDto(), 'u1')

View File

@@ -62,6 +62,3 @@ export class ShopQueryDto {
@IsOptional() @IsString() @MaxLength(80) search?: string
@IsOptional() @IsString() @MaxLength(50) productId?: string
}
export class PickupDto {
@IsString() @Matches(/^[A-F0-9]{12}$/) pickupCode!: string
}

View File

@@ -17,7 +17,7 @@ import { RolesGuard } from '../auth/roles.guard'
import { Roles } from '../auth/roles.decorator'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { ShopService } from './shop.service'
import { BuyProductDto, PickupDto, ProductDto, ShopQueryDto, StockDto } from './dto/shop.dto'
import { BuyProductDto, ProductDto, ShopQueryDto, StockDto } from './dto/shop.dto'
@Controller('shop')
export class ShopController {
@@ -101,9 +101,8 @@ export class ShopAdminController {
}
@Post('orders/:id/complete') complete(
@Param('id') id: string,
@Body() dto: PickupDto,
@CurrentUser('sub') userId: string,
) {
return this.shop.complete(id, dto.pickupCode, userId)
return this.shop.complete(id, userId)
}
}

View File

@@ -7,7 +7,7 @@ import {
} from '@nestjs/common'
import { Cron } from '@nestjs/schedule'
import { Prisma, ProductOrder } from '@prisma/client'
import { randomBytes, randomUUID } from 'crypto'
import { randomUUID } from 'crypto'
import { PrismaService } from '../prisma/prisma.service'
import { WechatPayService } from '../payment/wechat-pay.service'
import { BuyProductDto, ProductDto, ShopQueryDto, StockDto } from './dto/shop.dto'
@@ -139,7 +139,6 @@ export class ShopService {
coverUrl: product.coverUrl,
unitPrice: product.price,
amount: product.price * dto.quantity,
pickupCode: randomBytes(6).toString('hex').toUpperCase(),
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
},
})
@@ -273,12 +272,12 @@ export class ShopService {
return { items, total, page: query.page, limit: query.limit }
}
async complete(id: string, pickupCode: string, operatorId: string) {
async complete(id: string, operatorId: string) {
const result = await this.prisma.productOrder.updateMany({
where: { id, pickupCode, status: 'PAID' },
where: { id, status: 'PAID' },
data: { status: 'COMPLETED', completedAt: new Date(), completedBy: operatorId },
})
if (!result.count) throw new BadRequestException('提货码不正确,或订单不可核销')
if (!result.count) throw new BadRequestException('仅已付款订单可以发货完成')
return this.prisma.productOrder.findUniqueOrThrow({ where: { id } })
}

View File

@@ -24,7 +24,6 @@ export interface ShopOrder {
contactName: string
contactPhone: string
status: ShopOrderStatus
pickupCode: string
expiresAt: string
createdAt: string
paidAt: string | null