'新购', 'renewal' => '续费', 'upgrade' => '升级', 'downgrade' => '降级', ]; $type = (string) ($this->order_type ?? ''); return (string) ($labels[$type] ?? $type); } public function receiptTotal(): float { // 优先读扁平字段 payment_summary.total_amount(更稳定、避免遍历 receipts) $total = (float) (data_get($this->meta, 'payment_summary.total_amount') ?? 0); if ($total > 0) { return $total; } // 回退:遍历 payment_receipts[].amount $receipts = (array) (data_get($this->meta, 'payment_receipts', []) ?? []); $sum = 0.0; foreach ($receipts as $r) { $sum += (float) (data_get($r, 'amount') ?? 0); } return $sum; } public function refundTotal(): float { // 优先读扁平字段 refund_summary.total_amount $total = data_get($this->meta, 'refund_summary.total_amount'); if ($total !== null) { return (float) $total; } // 回退:遍历 refund_receipts[].amount $refunds = (array) (data_get($this->meta, 'refund_receipts', []) ?? []); $sum = 0.0; foreach ($refunds as $r) { $sum += (float) (data_get($r, 'amount') ?? 0); } return $sum; } public function isRefundInconsistent(): bool { // 口径与平台订单列表 refund_inconsistent 保持一致:按分取整 + 容差(config('saasshop.amounts.tolerance')) $refundTotal = (float) $this->refundTotal(); $paidAmount = (float) ($this->paid_amount ?? 0); $refundCents = (int) round($refundTotal * 100); $paidCents = (int) round($paidAmount * 100); $tol = (float) config('saasshop.amounts.tolerance', 0.01); $tolCents = (int) round($tol * 100); $tolCents = max(1, $tolCents); if ((string) $this->payment_status === 'refunded') { // 已退款但退款总额不足:refund_total + tol < paid return ($refundCents + $tolCents) < $paidCents; } // 非已退款但退款总额已达到/超过已付:refund_total >= paid + tol return $paidCents > 0 && $refundCents >= ($paidCents + $tolCents); } public function isReconcileMismatch(): bool { // 口径与平台订单列表 reconcile_mismatch 保持一致:支付回执总额 与订单 paid_amount 不一致(按分取整,差额>=容差) // // 重要:若该订单尚无任何“回执证据”(payment_summary/payment_receipts 都为空),则不判定为对账不一致。 // 原因:测试/人工补数据场景下,订单可能已标记 paid,但尚未沉淀回执;此时应通过 receipt_status=none 暴露问题, // 而不是把它强制归入 reconcile_mismatch 并阻断正常 SOP。 $hasSummary = data_get($this->meta, 'payment_summary.total_amount') !== null; $hasReceipt = data_get($this->meta, 'payment_receipts.0.amount') !== null; if (! $hasSummary && ! $hasReceipt) { return false; } $receiptCents = (int) round(((float) $this->receiptTotal()) * 100); $paidCents = (int) round(((float) ($this->paid_amount ?? 0)) * 100); $tol = (float) config('saasshop.amounts.tolerance', 0.01); $tolCents = (int) round($tol * 100); // 以“分”为最小粒度,至少 1 分(若配置 0,则视为要求严格一致) $tolCents = max(1, $tolCents); return abs($receiptCents - $paidCents) >= $tolCents; } protected $fillable = [ 'merchant_id', 'plan_id', 'site_subscription_id', 'created_by_admin_id', 'order_no', 'order_type', 'status', 'payment_status', 'payment_channel', 'plan_name', 'billing_cycle', 'period_months', 'quantity', 'list_amount', 'discount_amount', 'payable_amount', 'paid_amount', 'placed_at', 'paid_at', 'activated_at', 'cancelled_at', 'refunded_at', 'plan_snapshot', 'meta', 'remark', ]; protected $casts = [ 'list_amount' => 'decimal:2', 'discount_amount' => 'decimal:2', 'payable_amount' => 'decimal:2', 'paid_amount' => 'decimal:2', 'placed_at' => 'datetime', 'paid_at' => 'datetime', 'activated_at' => 'datetime', 'cancelled_at' => 'datetime', 'refunded_at' => 'datetime', 'plan_snapshot' => 'array', 'meta' => 'array', ]; public function merchant(): BelongsTo { return $this->belongsTo(Merchant::class, 'merchant_id'); } public function plan(): BelongsTo { return $this->belongsTo(Plan::class); } public function siteSubscription(): BelongsTo { return $this->belongsTo(SiteSubscription::class); } public function creator(): BelongsTo { return $this->belongsTo(Admin::class, 'created_by_admin_id'); } }