From b4d2d1e2f57e819a7279047cc8fbf24a809780d3 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 9 Oct 2026 18:45:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(pickup):=20=E5=A2=9E=E5=8A=A0=E7=AC=AC?= =?UTF-8?q?=E4=B8=89=E6=96=B9=E5=8D=95=E5=8F=B7=E8=A1=A5=E5=BD=95=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=B9=B6=E4=BC=98=E5=8C=96=E5=88=97=E8=A1=A8=E6=8F=90?= =?UTF-8?q?=E5=8F=B7=E5=A4=87=E6=B3=A8=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端支持补录/修改第三方单号接口 PUT /api/v1/admin/pickups/:id/third-party-order-no,记录流转事件与审计日志 - 前端线下提号主列表在第三方单号列后展示提号备注列 - 前端列表操作列及提号详情页增加补录/修改第三方单号操作入口与弹窗 - 通过 scripts/check.sh 全量门禁校验 --- backend/internal/modules/pickup/dto.go | 6 ++ backend/internal/modules/pickup/handler.go | 28 ++++++ backend/internal/modules/pickup/repository.go | 55 +++++++++++ .../modules/pickup/repository_test.go | 58 ++++++++++++ backend/internal/modules/pickup/service.go | 13 +++ backend/internal/router/router.go | 1 + .../src/features/admin/api/adminPickup.ts | 15 +++ .../admin/views/AdminPickupDetailView.vue | 78 ++++++++++++++++ .../features/admin/views/AdminPickupView.vue | 92 ++++++++++++++++++- 9 files changed, 345 insertions(+), 1 deletion(-) diff --git a/backend/internal/modules/pickup/dto.go b/backend/internal/modules/pickup/dto.go index ad0672b..86ad29e 100644 --- a/backend/internal/modules/pickup/dto.go +++ b/backend/internal/modules/pickup/dto.go @@ -44,6 +44,8 @@ var ( ErrFinancialAdjustmentCannotSettle = errors.New("financial adjustment cannot be settled") ErrPickupShopNotFound = errors.New("pickup shop not found") ErrPickupShopNameExists = errors.New("pickup shop name already exists") + ErrPickupCancelled = errors.New("pickup is cancelled") + ErrInvalidThirdPartyOrderNo = errors.New("invalid third party order no") ) type PickupDTO struct { @@ -134,6 +136,10 @@ type UpdateProfitRequest struct { Reason string `json:"reason"` } +type UpdateThirdPartyOrderNoRequest struct { + ThirdPartyOrderNo string `json:"third_party_order_no" binding:"required,max=128"` +} + // FinancialAdjustmentRequest 按目标金额创建完成后的差额流水,至少提供一个金额。 type FinancialAdjustmentRequest struct { ProfitAmountCent *int64 `json:"profit_amount_cent"` diff --git a/backend/internal/modules/pickup/handler.go b/backend/internal/modules/pickup/handler.go index e740921..964f1d2 100644 --- a/backend/internal/modules/pickup/handler.go +++ b/backend/internal/modules/pickup/handler.go @@ -112,6 +112,30 @@ func (h *Handler) UpdateProfit(c *gin.Context) { response.OK(c, item) } +// UpdateThirdPartyOrderNo 补录或修改第三方单号(管理员) +func (h *Handler) UpdateThirdPartyOrderNo(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + var req UpdateThirdPartyOrderNoRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "第三方单号不能为空且长度不能超过128位") + return + } + item, err := h.service.UpdateThirdPartyOrderNo(c.Request.Context(), id, req, adminID, auditMeta(c)) + if err != nil { + writePickupError(c, err) + return + } + response.OK(c, item) +} + // CreateFinancialAdjustment 为已完成提号创建利润或打款调整。 func (h *Handler) CreateFinancialAdjustment(c *gin.Context) { adminID, ok := currentAdminID(c) @@ -305,6 +329,10 @@ func writePickupError(c *gin.Context, err error) { response.Error(c, http.StatusNotFound, "pickup_shop_not_found", "店铺不存在") case errors.Is(err, ErrPickupShopNameExists): response.Error(c, http.StatusConflict, "pickup_shop_name_exists", "店铺名称已存在") + case errors.Is(err, ErrPickupCancelled): + response.BadRequest(c, "提号订单已取消,不能补录单号") + case errors.Is(err, ErrInvalidThirdPartyOrderNo): + response.BadRequest(c, "第三方单号不能为空且长度不能超过128位") default: response.Error(c, http.StatusInternalServerError, "pickup_error", err.Error()) } diff --git a/backend/internal/modules/pickup/repository.go b/backend/internal/modules/pickup/repository.go index f61144b..0e05b53 100644 --- a/backend/internal/modules/pickup/repository.go +++ b/backend/internal/modules/pickup/repository.go @@ -465,6 +465,61 @@ func (r *Repository) UpdateProfit(ctx context.Context, pickupID uint64, req Upda return r.FindByID(ctx, pickupID) } +// UpdateThirdPartyOrderNo 补录或修改提号单第三方单号。 +func (r *Repository) UpdateThirdPartyOrderNo(ctx context.Context, pickupID uint64, req UpdateThirdPartyOrderNoRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + newThirdPartyOrderNo := strings.TrimSpace(req.ThirdPartyOrderNo) + if newThirdPartyOrderNo == "" || len([]rune(newThirdPartyOrderNo)) > 128 { + return nil, ErrInvalidThirdPartyOrderNo + } + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var pickup model.AdminPickup + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pickup, pickupID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrPickupNotFound + } + return err + } + if pickup.Status == StatusCancelled { + return ErrPickupCancelled + } + before := pickupState(pickup) + + oldThirdPartyOrderNo := pickup.ThirdPartyOrderNo + pickup.ThirdPartyOrderNo = newThirdPartyOrderNo + if err := tx.Model(&pickup).Update("third_party_order_no", pickup.ThirdPartyOrderNo).Error; err != nil { + return err + } + if err := appendPickupEvent(tx, pickup, "pickup_third_party_order_no_updated", adminID, "补录/修改第三方单号。", "", map[string]any{ + "old_third_party_order_no": oldThirdPartyOrderNo, + "third_party_order_no": pickup.ThirdPartyOrderNo, + }, before); err != nil { + return err + } + + bid := pickup.ID + return auditlog.Append(tx, auditlog.Entry{ + ActorType: "admin", + ActorID: adminID, + Action: "pickup_third_party_order_no_update", + BizType: "admin_pickup", + BizID: &bid, + Meta: meta, + Detail: map[string]any{ + "pickup_no": pickup.PickupNo, + "old_third_party_order_no": oldThirdPartyOrderNo, + "new_third_party_order_no": pickup.ThirdPartyOrderNo, + }, + }) + }) + if err != nil { + return nil, err + } + return r.FindByID(ctx, pickupID) +} + // CreateFinancialAdjustment 为已完成提号追加财务差额,不改写完成时的原始金额。 func (r *Repository) CreateFinancialAdjustment(ctx context.Context, pickupID uint64, req FinancialAdjustmentRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) { if r == nil || r.db == nil { diff --git a/backend/internal/modules/pickup/repository_test.go b/backend/internal/modules/pickup/repository_test.go index ba47caf..4a31239 100644 --- a/backend/internal/modules/pickup/repository_test.go +++ b/backend/internal/modules/pickup/repository_test.go @@ -1019,3 +1019,61 @@ func TestRepositoryHistoricalShopStatsAndMapping(t *testing.T) { } } +func TestRepositoryUpdateThirdPartyOrderNo(t *testing.T) { + t.Run("成功补录第三方单号并记录审计", func(t *testing.T) { + repo, db := newPickupTestRepo(t) + pickup := seedPickup(t, db, StatusPickingUp) + + item, err := repo.UpdateThirdPartyOrderNo(t.Context(), pickup.ID, UpdateThirdPartyOrderNoRequest{ + ThirdPartyOrderNo: "TB-20261009-8888", + }, 99, auditlog.Meta{RequestID: "req-third-party-order-no"}) + if err != nil { + t.Fatalf("UpdateThirdPartyOrderNo() error = %v", err) + } + if item.ThirdPartyOrderNo != "TB-20261009-8888" { + t.Fatalf("item.ThirdPartyOrderNo = %q, want TB-20261009-8888", item.ThirdPartyOrderNo) + } + + var stored model.AdminPickup + if err := db.First(&stored, pickup.ID).Error; err != nil { + t.Fatalf("查询提号失败: %v", err) + } + if stored.ThirdPartyOrderNo != "TB-20261009-8888" { + t.Fatalf("stored.ThirdPartyOrderNo = %q, want TB-20261009-8888", stored.ThirdPartyOrderNo) + } + + // 验证审计日志 + var audit model.AuditLog + if err := db.Where("action = ?", "pickup_third_party_order_no_update").First(&audit).Error; err != nil { + t.Fatalf("查询审计日志失败: %v", err) + } + var detail map[string]any + if err := json.Unmarshal(audit.Detail, &detail); err != nil { + t.Fatalf("解析审计日志失败: %v", err) + } + if detail["new_third_party_order_no"] != "TB-20261009-8888" { + t.Fatalf("detail[new_third_party_order_no] = %v, want TB-20261009-8888", detail["new_third_party_order_no"]) + } + + // 空单号拒绝 + _, err = repo.UpdateThirdPartyOrderNo(t.Context(), pickup.ID, UpdateThirdPartyOrderNoRequest{ + ThirdPartyOrderNo: " ", + }, 99, auditlog.Meta{}) + if !errors.Is(err, ErrInvalidThirdPartyOrderNo) { + t.Fatalf("UpdateThirdPartyOrderNo() with blank error = %v, want ErrInvalidThirdPartyOrderNo", err) + } + }) + + t.Run("已取消提号单拒绝补录", func(t *testing.T) { + repo, db := newPickupTestRepo(t) + cancelledPickup := seedPickup(t, db, StatusCancelled) + + _, err := repo.UpdateThirdPartyOrderNo(t.Context(), cancelledPickup.ID, UpdateThirdPartyOrderNoRequest{ + ThirdPartyOrderNo: "TB-CANCELLED-999", + }, 99, auditlog.Meta{}) + if !errors.Is(err, ErrPickupCancelled) { + t.Fatalf("UpdateThirdPartyOrderNo() on cancelled error = %v, want ErrPickupCancelled", err) + } + }) +} + diff --git a/backend/internal/modules/pickup/service.go b/backend/internal/modules/pickup/service.go index 9dc14f3..eb56674 100644 --- a/backend/internal/modules/pickup/service.go +++ b/backend/internal/modules/pickup/service.go @@ -64,6 +64,19 @@ func (s *Service) UpdateProfit(ctx context.Context, pickupID uint64, req UpdateP return s.repo.UpdateProfit(ctx, pickupID, req, adminID, meta) } +func (s *Service) UpdateThirdPartyOrderNo(ctx context.Context, pickupID uint64, req UpdateThirdPartyOrderNoRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if pickupID == 0 { + return nil, ErrPickupNotFound + } + if strings.TrimSpace(req.ThirdPartyOrderNo) == "" { + return nil, ErrInvalidThirdPartyOrderNo + } + return s.repo.UpdateThirdPartyOrderNo(ctx, pickupID, req, adminID, meta) +} + func (s *Service) CreateFinancialAdjustment(ctx context.Context, pickupID uint64, req FinancialAdjustmentRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index b775d54..07ed5c8 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -774,6 +774,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/pickups/:id/complete", requirePerm("order:pickup"), pickupHandler.Complete) adminRoutes.POST("/pickups/:id/offline-settlement", requirePerm("order:pickup"), pickupHandler.MarkOfflineSettlement) adminRoutes.PUT("/pickups/:id/profit", requirePerm("order:pickup"), pickupHandler.UpdateProfit) + adminRoutes.PUT("/pickups/:id/third-party-order-no", requirePerm("order:pickup"), pickupHandler.UpdateThirdPartyOrderNo) adminRoutes.POST("/pickups/:id/cancel", requirePerm("order:pickup"), pickupHandler.Cancel) adminRoutes.POST("/pickup-financial-adjustments/:id/settle", requirePerm("order:pickup"), pickupHandler.SettleFinancialAdjustment) diff --git a/frontend/src/features/admin/api/adminPickup.ts b/frontend/src/features/admin/api/adminPickup.ts index 3a350d5..48bc70c 100644 --- a/frontend/src/features/admin/api/adminPickup.ts +++ b/frontend/src/features/admin/api/adminPickup.ts @@ -134,6 +134,10 @@ export interface AdminPickupProfitUpdateRequest { reason?: string } +export interface AdminPickupThirdPartyOrderNoUpdateRequest { + third_party_order_no: string +} + export interface AdminPickupOfflineSettlementRequest { remark?: string } @@ -308,6 +312,17 @@ export async function updateAdminPickupProfit(id: number, req: AdminPickupProfit return data.data } +export async function updateAdminPickupThirdPartyOrderNo( + id: number, + req: AdminPickupThirdPartyOrderNoUpdateRequest +) { + const { data } = await apiClient.put>( + `/admin/pickups/${id}/third-party-order-no`, + req + ) + return data.data +} + export async function createAdminPickupFinancialAdjustment( id: number, req: AdminPickupFinancialAdjustmentRequest diff --git a/frontend/src/features/admin/views/AdminPickupDetailView.vue b/frontend/src/features/admin/views/AdminPickupDetailView.vue index a68cfb0..054f7da 100644 --- a/frontend/src/features/admin/views/AdminPickupDetailView.vue +++ b/frontend/src/features/admin/views/AdminPickupDetailView.vue @@ -11,6 +11,7 @@ import { createAdminPickupFinancialAdjustment, settleAdminPickupFinancialAdjustment, updateAdminPickupProfit, + updateAdminPickupThirdPartyOrderNo, type AdminPickup, type AdminPickupFinancialAdjustment, type AdminPickupProcessEvent, @@ -44,16 +45,20 @@ const financialAdjustments = ref([]) const processEvents = ref([]) const profitDialogVisible = ref(false) const financialAdjustmentDialogVisible = ref(false) +const thirdPartyOrderNoDialogVisible = ref(false) const profitSaving = ref(false) const financialAdjustmentSaving = ref(false) +const thirdPartyOrderNoSaving = ref(false) const profitForm = reactive({ profit_amount: 0, reason: '' }) const financialAdjustmentForm = reactive({ profit_amount: 0, settle_amount: 0, reason: '' }) +const thirdPartyOrderNoForm = reactive({ third_party_order_no: '' }) const listingCode = computed(() => pickup.value ? formatListingNo(pickup.value.listing_no, pickup.value.listing_id) : '-' ) const canEditProfit = computed(() => pickup.value?.status === 'picking_up') const canCreateFinancialAdjustment = computed(() => pickup.value?.status === 'completed') +const canUpdateThirdPartyOrderNo = computed(() => pickup.value?.status !== 'cancelled') const snapshot = computed(() => normalizeRecord(pickup.value?.account_snapshot)) const assetSummary = computed(() => normalizeRecord(snapshot.value?.asset_summary)) const priceBreakdown = computed(() => normalizeRecord(assetSummary.value?.price_breakdown)) @@ -221,6 +226,35 @@ async function handleUpdateProfit() { } } +function openThirdPartyOrderNoDialog() { + if (!pickup.value) return + thirdPartyOrderNoForm.third_party_order_no = pickup.value.third_party_order_no || '' + thirdPartyOrderNoDialogVisible.value = true +} + +async function handleSaveThirdPartyOrderNo() { + if (!pickup.value) return + const orderNo = thirdPartyOrderNoForm.third_party_order_no.trim() + if (!orderNo) { + ElMessage.warning('请输入第三方单号') + return + } + thirdPartyOrderNoSaving.value = true + try { + const updated = await updateAdminPickupThirdPartyOrderNo(pickup.value.id, { + third_party_order_no: orderNo, + }) + pickup.value = updated + processEvents.value = await fetchAdminPickupProcessEvents(pickup.value.id) + thirdPartyOrderNoDialogVisible.value = false + ElMessage.success('第三方单号已更新') + } catch (e: unknown) { + ElMessage.error(errorMessage(e) || '保存失败') + } finally { + thirdPartyOrderNoSaving.value = false + } +} + function statusType(status: string) { if (status === 'picking_up') return 'warning' if (status === 'completed') return 'success' @@ -359,6 +393,15 @@ function readSnapshotResources(summary: Record | null): Snapsho

+ + {{ pickup.third_party_order_no ? '修改单号' : '补录单号' }} + | null): Snapsho + + + + + + {{ pickup?.pickup_no }} + + + {{ pickup?.third_party_order_no || '(未录入)' }} + + + + + + + diff --git a/frontend/src/features/admin/views/AdminPickupView.vue b/frontend/src/features/admin/views/AdminPickupView.vue index eb223a6..e7ac93b 100644 --- a/frontend/src/features/admin/views/AdminPickupView.vue +++ b/frontend/src/features/admin/views/AdminPickupView.vue @@ -12,6 +12,7 @@ import { fetchAdminPickupShopOptions, fetchAvailableListings, updateAdminPickupProfit, + updateAdminPickupThirdPartyOrderNo, type AdminPickup, type AvailableListing, type AvailableListingSourceType, @@ -48,11 +49,14 @@ const createDialogVisible = ref(false) const completeDialogVisible = ref(false) const profitDialogVisible = ref(false) const financialAdjustmentDialogVisible = ref(false) +const thirdPartyOrderNoDialogVisible = ref(false) const shopMgrVisible = ref(false) const activePickupId = ref(0) const activePickup = ref(null) const profitSaving = ref(false) const financialAdjustmentSaving = ref(false) +const thirdPartyOrderNoSaving = ref(false) +const thirdPartyOrderNoForm = reactive({ third_party_order_no: '' }) const createForm = reactive({ listing_id: null as number | null, @@ -280,6 +284,41 @@ async function handleConfirmOfflineSettlement(row: AdminPickup) { } } +function openThirdPartyOrderNoDialog(row: AdminPickup) { + activePickupId.value = row.id + activePickup.value = row + thirdPartyOrderNoForm.third_party_order_no = row.third_party_order_no || '' + thirdPartyOrderNoDialogVisible.value = true +} + +async function handleSaveThirdPartyOrderNo() { + const orderNo = thirdPartyOrderNoForm.third_party_order_no.trim() + if (!orderNo) { + ElMessage.warning('请输入第三方单号') + return + } + if (!activePickupId.value) return + thirdPartyOrderNoSaving.value = true + try { + const updated = await updateAdminPickupThirdPartyOrderNo(activePickupId.value, { + third_party_order_no: orderNo, + }) + ElMessage.success('第三方单号已更新') + thirdPartyOrderNoDialogVisible.value = false + if (activePickup.value) { + activePickup.value.third_party_order_no = updated.third_party_order_no + } + const target = items.value.find(item => item.id === activePickupId.value) + if (target) { + target.third_party_order_no = updated.third_party_order_no + } + } catch (e: unknown) { + ElMessage.error(errorMessage(e) || '保存失败') + } finally { + thirdPartyOrderNoSaving.value = false + } +} + function openProfitDialog(row: AdminPickup) { activePickupId.value = row.id profitForm.profit_amount = centToYuan(row.profit_amount_cent) @@ -605,6 +644,12 @@ function listingOptionLabel(item: AvailableListing) { - + + + @@ -652,12 +697,22 @@ function listingOptionLabel(item: AvailableListing) { - + + + + + + + {{ activePickup?.pickup_no }} + + + {{ activePickup?.third_party_order_no || '(未录入)' }} + + + + + + +