修复财务支付记录展示
This commit is contained in:
@@ -5,15 +5,152 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.apinb.com/bsm-sdk/core/errcode"
|
||||
"git.apinb.com/bsm-sdk/core/infra"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// finPaymentProductSnapshot 是商城下单时固化的商品成交快照最小结构。
|
||||
type finPaymentProductSnapshot struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// ListFinPayment 返回财务支付记录列表,并批量附加成交商品摘要。
|
||||
func ListFinPayment(ctx *gin.Context) {
|
||||
page, size := common.PageSize(ctx)
|
||||
var payments []models.FinPayment
|
||||
var total int64
|
||||
query := common.ApplyKeywordFilter(
|
||||
ctx,
|
||||
common.ActiveRecords(impl.DBService.Model(&models.FinPayment{})),
|
||||
&models.FinPayment{},
|
||||
)
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
if err := query.Order("created_at desc").Offset((page - 1) * size).Limit(size).Find(&payments).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
summaries, err := loadFinPaymentProductSummaries(payments)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
response, err := common.PublicResourceResponse(payments)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
rows, ok := response.([]any)
|
||||
if !ok || len(rows) != len(payments) {
|
||||
infra.Response.Error(ctx, errors.New("invalid finance payment list response"))
|
||||
return
|
||||
}
|
||||
for index, payment := range payments {
|
||||
row, valid := rows[index].(map[string]any)
|
||||
if !valid {
|
||||
infra.Response.Error(ctx, errors.New("invalid finance payment row response"))
|
||||
return
|
||||
}
|
||||
row["product_summary"] = summaries[payment.EcOrderID]
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{
|
||||
"total": total,
|
||||
"list": common.ProtectPreciseLocation(ctx, &models.FinPayment{}, response),
|
||||
})
|
||||
}
|
||||
|
||||
// loadFinPaymentProductSummaries 一次查询当前页全部订单明细,避免列表逐行查询。
|
||||
func loadFinPaymentProductSummaries(payments []models.FinPayment) (map[uint64]string, error) {
|
||||
orderIDs := make([]uint64, 0, len(payments))
|
||||
seen := make(map[uint64]struct{}, len(payments))
|
||||
for _, payment := range payments {
|
||||
if _, exists := seen[payment.EcOrderID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[payment.EcOrderID] = struct{}{}
|
||||
orderIDs = append(orderIDs, payment.EcOrderID)
|
||||
}
|
||||
itemsByOrder := make(map[uint64][]models.EcOrderItem, len(orderIDs))
|
||||
if len(orderIDs) > 0 {
|
||||
var items []models.EcOrderItem
|
||||
if err := common.ActiveRecords(impl.DBService).
|
||||
Where("ec_order_id IN ?", orderIDs).
|
||||
Order("ec_order_id asc, id asc").Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range items {
|
||||
itemsByOrder[item.EcOrderID] = append(itemsByOrder[item.EcOrderID], item)
|
||||
}
|
||||
}
|
||||
summaries := make(map[uint64]string, len(orderIDs))
|
||||
for _, orderID := range orderIDs {
|
||||
summaries[orderID] = finPaymentProductSummary(itemsByOrder[orderID])
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
// GetFinPayment 返回支付详情,并从订单商品快照生成财务可读的商品摘要。
|
||||
func GetFinPayment(ctx *gin.Context) {
|
||||
var payment models.FinPayment
|
||||
if err := common.ActiveRecords(impl.DBService).
|
||||
Where("identity = ?", ctx.Param("identity")).First(&payment).Error; err != nil {
|
||||
common.RespondRecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
var items []models.EcOrderItem
|
||||
if err := common.ActiveRecords(impl.DBService).
|
||||
Where("ec_order_id = ?", payment.EcOrderID).
|
||||
Order("id asc").Find(&items).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
response, err := common.PublicResourceResponse(payment)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
data, ok := response.(map[string]any)
|
||||
if !ok {
|
||||
infra.Response.Error(ctx, errors.New("invalid finance payment response"))
|
||||
return
|
||||
}
|
||||
data["product_summary"] = finPaymentProductSummary(items)
|
||||
infra.Response.Success(ctx, data)
|
||||
}
|
||||
|
||||
// finPaymentProductSummary 优先使用不可变成交快照,避免商品改名后财务详情发生漂移。
|
||||
func finPaymentProductSummary(items []models.EcOrderItem) string {
|
||||
summaries := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
var snapshot finPaymentProductSnapshot
|
||||
if err := json.Unmarshal([]byte(item.ProductSnapshot), &snapshot); err != nil {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(snapshot.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if item.Quantity > 1 {
|
||||
name += " × " + strconv.Itoa(item.Quantity)
|
||||
}
|
||||
summaries = append(summaries, name)
|
||||
}
|
||||
if len(summaries) == 0 {
|
||||
return "商品信息缺失"
|
||||
}
|
||||
return strings.Join(summaries, "、")
|
||||
}
|
||||
|
||||
func FinSettlementHandlers() (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) {
|
||||
fields := []string{"settlement_no", "subject_type", "subject_id", "period_start", "period_end"}
|
||||
return func(ctx *gin.Context) { common.ListResource(ctx, &models.FinSettlement{}) },
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package fin
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
)
|
||||
|
||||
func TestSettlementPeriodMustMoveForward(t *testing.T) {
|
||||
if !validSettlementPeriod("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z") {
|
||||
@@ -10,3 +14,17 @@ func TestSettlementPeriodMustMoveForward(t *testing.T) {
|
||||
t.Fatal("reversed settlement period was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFinPaymentProductSummary 验证财务详情使用成交快照并正确汇总商品数量。
|
||||
func TestFinPaymentProductSummary(t *testing.T) {
|
||||
items := []models.EcOrderItem{
|
||||
{ProductSnapshot: `{"name":"15kg 液化气配送服务"}`, Quantity: 1},
|
||||
{ProductSnapshot: `{"name":"智能瓶阀安装服务"}`, Quantity: 2},
|
||||
}
|
||||
if got, want := finPaymentProductSummary(items), "15kg 液化气配送服务、智能瓶阀安装服务 × 2"; got != want {
|
||||
t.Fatalf("商品摘要不一致:got %q want %q", got, want)
|
||||
}
|
||||
if got := finPaymentProductSummary([]models.EcOrderItem{{ProductSnapshot: `{}`, Quantity: 1}}); got != "商品信息缺失" {
|
||||
t.Fatalf("缺失商品快照应返回稳定文案,got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ func registerFinanceRoute(group *gin.RouterGroup) {
|
||||
refund.GET("/:identity", paymentlogic.GetRefund)
|
||||
refund.POST("/:identity/approve", paymentlogic.ApproveRefund)
|
||||
refund.POST("/:identity/reject", paymentlogic.RejectRefund)
|
||||
registerReadOnlyResource(group, "/fin_payment", &models.FinPayment{})
|
||||
registerReadOnlyHandlers(group, "/fin_payment", fin.ListFinPayment, fin.GetFinPayment)
|
||||
settlementList, settlementCreate, settlementGet, settlementUpdate := fin.FinSettlementHandlers()
|
||||
registerWritableResource(group, "/fin_settlement", settlementList, settlementCreate, settlementGet, settlementUpdate, &models.FinSettlement{})
|
||||
registerReadOnlyResource(group, "/fin_reconciliation", &models.FinReconciliation{})
|
||||
|
||||
57
docs/操作日志_财务支付记录展示修复_20260816.md
Normal file
57
docs/操作日志_财务支付记录展示修复_20260816.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# 财务支付记录展示修复操作日志
|
||||
|
||||
操作时间:2026-08-16
|
||||
|
||||
操作类型:修改
|
||||
|
||||
影响模块:平台总后台、财务管理、支付记录
|
||||
|
||||
## 操作前状态
|
||||
|
||||
- 商城订单关系直接显示截断 UUID,没有展示订单号。
|
||||
- 财务支付记录使用通用支付订单状态格式化器,无法识别财务领域的 `35=已支付`,显示“未知支付状态(35)”。
|
||||
- 支付渠道直接显示 `wallet` 等接口编码。
|
||||
|
||||
## 具体操作
|
||||
|
||||
1. 商城订单关系改为以订单号作为主文案,保留订单唯一标识复制和详情跳转。
|
||||
2. 为财务支付记录单独声明 `10=待支付`、`35=已支付` 状态,不修改其他支付资源的状态口径。
|
||||
3. 按业务文档将渠道编码映射为“余额支付”“支付宝”“微信支付”,未知编码继续明确显示原值。
|
||||
4. 增加静态展示契约断言,防止跨领域支付状态再次混用。
|
||||
5. 财务支付详情从商城订单明细的成交快照生成“支付商品”,多商品以顿号分隔,数量大于 1 时显示“× 数量”。
|
||||
6. 财务支付列表按当前页订单主键批量查询订单明细并附加相同商品摘要,避免逐行查询和列表显示“商品信息缺失”。
|
||||
|
||||
## 行为变化
|
||||
|
||||
- 修改前:商城订单显示 UUID,支付状态显示未知状态,渠道显示英文编码。
|
||||
- 修改后:商城订单显示订单号,状态显示“已支付”,渠道显示“余额支付”。
|
||||
- 详情同时显示成交商品名称;当前 Mock 记录显示“15kg 液化气配送服务”。
|
||||
- 支付金额、支付时间、接口、数据库数据和业务状态均未修改。
|
||||
|
||||
## 代码变更
|
||||
|
||||
- `frontend/platform_admin/src/api/resources.ts`:补齐财务支付记录的关系、状态和渠道展示配置。
|
||||
- `frontend/platform_admin/src/api/resource-detail-contract.ts`:将支付商品加入财务支付详情优先字段。
|
||||
- `frontend/platform_admin/scripts/check-resource-display-contracts.mjs`:增加财务支付展示回归断言。
|
||||
- `backend/api/internal/logic/platform/fin/fin.go`:增加财务支付详情和成交商品快照汇总。
|
||||
- `backend/api/internal/logic/platform/fin/fin_test.go`:覆盖单商品、多数量和快照缺失场景。
|
||||
- `backend/api/internal/routers/platform.go`:财务支付路由切换为专属只读处理器,路径与权限保持不变。
|
||||
- `docs/项目文档_平台资源中文展示统一_v1.0.md`:记录 v1.8 变更。
|
||||
|
||||
## 验证结果
|
||||
|
||||
- `pnpm resource-display-contracts:check`:通过。
|
||||
- `pnpm contract:check`:通过,48 个资源契约一致。
|
||||
- 修改文件定向 Biome lint:通过。
|
||||
- `pnpm build`:通过,TypeScript 类型检查及 Vite 生产构建完成,共转换 2621 个模块。
|
||||
- `go test ./internal/logic/platform/fin ./internal/routers`:通过。
|
||||
- `go test ./...`:通过。
|
||||
- `go vet ./...`:通过。
|
||||
- `go build ./cmd/main/main.go`:通过。
|
||||
- 列表批量商品摘要补充后再次执行财务模块、路由测试、定向 `go vet` 和主程序构建:通过。
|
||||
|
||||
## 风险评估
|
||||
|
||||
- 本次仅修改 `fin_payment` 的前端展示契约,不改变支付订单、钱包支付或商城订单的业务状态。
|
||||
- 未知支付状态和未知渠道仍保留原值提示,避免历史数据被错误归类。
|
||||
- 商品名称只读取下单时的成交快照,不读取当前商品名称,避免历史财务记录随商品资料修改而漂移。
|
||||
@@ -70,3 +70,5 @@ frontend/platform_admin/
|
||||
- v1.6:商品分类的技术标识列与其他电商列表统一为“系统唯一标识”,显示截断值并保留复制能力。
|
||||
- v1.7:提现详情的钱包关系改为可读归属类型摘要,审核人合并展示姓名快照与可复制标识,移除“名称加载失败”和重复裸 UUID。
|
||||
- v1.7.1:提现列表的审核人改为直接使用审核姓名快照,并保留审核人标识复制与详情跳转,避免历史账户关系加载失败影响列表展示。
|
||||
- v1.8:财务支付记录的商城订单关系统一显示订单号,支付状态使用财务领域的 `35=已支付`,渠道按业务文档统一显示“余额支付”“支付宝”“微信支付”。
|
||||
- v1.9:财务支付详情新增“支付商品”,由后端按商城订单明细的成交快照汇总商品名称和数量,确保商品改名后仍可按交易事实核对。
|
||||
|
||||
@@ -112,6 +112,25 @@ assertIncludes(
|
||||
'钱包关系必须提供按归属类型生成的可读摘要',
|
||||
);
|
||||
|
||||
// 财务支付记录必须使用本领域的支付状态与渠道,不得复用支付订单状态。
|
||||
const financePaymentDefinition = resources.match(
|
||||
/define\('fin_payment',[\s\S]*?\n\s*\]\),/,
|
||||
)?.[0];
|
||||
if (!financePaymentDefinition) throw new Error('无法读取财务支付记录资源定义');
|
||||
for (const contract of [
|
||||
"relation('ec_order_identity', '/ec_order'",
|
||||
"listRelationNameOnly: true",
|
||||
"f('product_summary', { emptyText: '商品信息缺失' })",
|
||||
"{ label: '已支付', value: 35 }",
|
||||
"{ label: '余额支付', value: 'wallet' }",
|
||||
"{ label: '支付宝', value: 'alipay' }",
|
||||
"{ label: '微信支付', value: 'wechat' }",
|
||||
]) {
|
||||
if (!financePaymentDefinition.includes(contract)) {
|
||||
throw new Error(`财务支付记录缺少中文展示契约:${contract}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 购物车关联列必须展示用户和商品名称,UUID仅作为复制与排障信息保留。
|
||||
const cartDefinition = resources.match(
|
||||
/define\('ec_cart',[\s\S]*?\n\s*\]\),/,
|
||||
|
||||
@@ -126,6 +126,12 @@ const contracts: Record<string, ResourceDetailContract> = {
|
||||
'business_identity', 'user_identity', 'amount', 'identity',
|
||||
],
|
||||
},
|
||||
fin_payment: {
|
||||
leadingKeys: [
|
||||
'ec_order_identity', 'product_summary', 'payment_status', 'channel',
|
||||
'amount', 'paid_at', 'identity', 'status',
|
||||
],
|
||||
},
|
||||
wallet_record: {
|
||||
leadingKeys: [
|
||||
'record_no', 'request_no', 'direction', 'trade_type', 'amount',
|
||||
|
||||
@@ -274,6 +274,7 @@ const fieldLabels: Record<string, string> = {
|
||||
quantity: '数量',
|
||||
selected: '是否选中',
|
||||
product_snapshot: '商品快照',
|
||||
product_summary: '支付商品',
|
||||
sale_amount: '成交金额(元)',
|
||||
score: '评分',
|
||||
paid_at: '支付时间',
|
||||
@@ -818,7 +819,31 @@ export const resources: ResourceUiDefinition[] = [
|
||||
]),
|
||||
|
||||
define('fin_payment', '财务支付记录', 'readonly', [
|
||||
relation('ec_order_identity', '/ec_order'), f('payment_status'), f('channel'), f('amount'), f('paid_at'),
|
||||
relation('ec_order_identity', '/ec_order', false, {
|
||||
label: '商城订单',
|
||||
listRelationNameOnly: true,
|
||||
displayRelationLabel: true,
|
||||
showIdentityCopy: true,
|
||||
}),
|
||||
f('product_summary', { emptyText: '商品信息缺失' }),
|
||||
f('payment_status', {
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '待支付', value: 10 },
|
||||
{ label: '已支付', value: 35 },
|
||||
],
|
||||
unknownValueLabel: '未知支付状态',
|
||||
}),
|
||||
f('channel', {
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '余额支付', value: 'wallet' },
|
||||
{ label: '支付宝', value: 'alipay' },
|
||||
{ label: '微信支付', value: 'wechat' },
|
||||
],
|
||||
unknownValueLabel: '未知支付渠道',
|
||||
}),
|
||||
f('amount'), f('paid_at'),
|
||||
]),
|
||||
define('fin_settlement', '财务结算', 'writable', [f('settlement_no', { required: true }), f('subject_type', { required: true }), f('subject_identity', {
|
||||
required: true, type: 'identity', label: '结算主体', listRelationNameOnly: true,
|
||||
|
||||
Reference in New Issue
Block a user