diff --git a/app/Http/Controllers/Mini/BillController.php b/app/Http/Controllers/Mini/BillController.php index dba827e..3910c3a 100644 --- a/app/Http/Controllers/Mini/BillController.php +++ b/app/Http/Controllers/Mini/BillController.php @@ -8,7 +8,6 @@ use App\Services\BillDetailService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Modules\AnnoRoute\Attribute\GetRoute; -use Modules\AnnoRoute\Attribute\PostRoute; use Modules\AnnoRoute\Attribute\RequestAttribute; /** @@ -17,17 +16,21 @@ use Modules\AnnoRoute\Attribute\RequestAttribute; #[RequestAttribute('/mini', 'mini', authGuard: 'users')] class BillController extends BaseMiniController { - /** 账单列表:当前门店强制过滤,?page=&pageSize= */ + /** 账单列表:当前门店强制过滤,?status= 按支付状态筛选(0未支付 1已支付) */ #[GetRoute('/bill', authorize: true)] public function index(Request $request): JsonResponse { $user = $this->currentUser($request); $store = $this->ensureStoreBound($user); - $data = BillModel::query() + $query = BillModel::query() ->where('store_id', $store->id) - ->with('purchase:id,purchase_no,purchase_date') - ->orderBy('bill_date', 'desc') + ->with('purchase:id,purchase_no,purchase_date'); + if ($request->filled('status')) { + $query->where('status', (int) $request->input('status')); + } + + $data = $query->orderBy('bill_date', 'desc') ->orderBy('id', 'desc') ->paginate((int) $request->input('pageSize', 10)) ->toArray(); @@ -60,13 +63,4 @@ class BillController extends BaseMiniController 'orders' => $orders, ]); } - - /** - * 在线支付(预留接口,本次不实现;当前为线下收款,由后台手动登记) - */ - #[PostRoute('/bill/{id}/pay', authorize: true, where: ['id' => '[0-9]+'])] - public function pay(int $id, Request $request): JsonResponse - { - throw new RepositoryException('在线支付暂未开通,请线下付款后由商家登记收款'); - } } diff --git a/app/Http/Controllers/Mini/PaymentController.php b/app/Http/Controllers/Mini/PaymentController.php new file mode 100644 index 0000000..9535df6 --- /dev/null +++ b/app/Http/Controllers/Mini/PaymentController.php @@ -0,0 +1,170 @@ +find((int) $value)?->preview_url ?? ''); + } + return $value; + }; + + return $this->success([ + 'wechat_qrcode' => $resolve(site_config('pay.wechat_qrcode', '')), + 'alipay_qrcode' => $resolve(site_config('pay.alipay_qrcode', '')), + 'bank_info' => (string) site_config('pay.bank_info', ''), + ]); + } + + /** 支付记录列表:当前门店强制过滤,?status=&page=&pageSize= */ + #[GetRoute('/payment', authorize: true)] + public function index(Request $request): JsonResponse + { + $user = $this->currentUser($request); + $store = $this->ensureStoreBound($user); + + $query = PaymentModel::query() + ->where('store_id', $store->id) + ->withCount('bills'); + if ($request->filled('status')) { + $query->where('status', (int) $request->input('status')); + } + + $data = $query->orderBy('id', 'desc') + ->paginate((int) $request->input('pageSize', 10)) + ->toArray(); + + return $this->success($data); + } + + /** + * 发起付款:合并选择本店未支付账单,提交支付方式与汇款凭证(后台审核) + * @throws Throwable + */ + #[PostRoute('/payment', authorize: true)] + public function create(Request $request): JsonResponse + { + $data = $request->validate([ + 'bill_ids' => 'required|array|min:1', + 'bill_ids.*' => 'integer|distinct', + 'pay_method' => 'required|integer|in:1,2,3', + 'voucher_ids' => 'required|array|min:1', + 'voucher_ids.*' => 'integer|distinct', + 'remark' => 'nullable|string|max:255', + ], [ + 'bill_ids.required' => '请选择要付款的账单', + 'bill_ids.min' => '请选择要付款的账单', + 'pay_method.required' => '请选择支付方式', + 'pay_method.in' => '支付方式不正确', + 'voucher_ids.required' => '请上传汇款凭证', + 'voucher_ids.min' => '请上传汇款凭证', + 'remark.max' => '备注超过最大长度', + ]); + + $user = $this->currentUser($request); + $store = $this->ensureStoreBound($user); + $billIds = array_map('intval', $data['bill_ids']); + + $payment = DB::transaction(function () use ($store, $user, $data, $billIds) { + $bills = BillModel::query() + ->where('store_id', $store->id) + ->whereIn('id', $billIds) + ->lockForUpdate() + ->get(); + if ($bills->count() !== count($billIds)) { + throw new RepositoryException('包含不属于本店的账单,请刷新后重试'); + } + foreach ($bills as $bill) { + if ($bill->status === BillModel::STATUS_PAID) { + throw new RepositoryException('账单 ' . $bill->bill_no . ' 已支付,请刷新后重试'); + } + if ((int) $bill->payment_id !== 0) { + throw new RepositoryException('账单 ' . $bill->bill_no . ' 已在支付审核中,请勿重复提交'); + } + } + + $amount = $bills->reduce( + static fn (string $carry, BillModel $bill): string => bcadd($carry, (string) $bill->total_amount, 2), + '0' + ); + + $payment = PaymentModel::create([ + 'payment_no' => app(BillNumberService::class)->make('ZF'), + 'store_id' => $store->id, + 'user_id' => $user->id, + 'amount' => $amount, + 'pay_method' => (int) $data['pay_method'], + 'voucher_ids' => array_map('intval', $data['voucher_ids']), + 'status' => PaymentModel::STATUS_PENDING, + 'remark' => (string) ($data['remark'] ?? ''), + ]); + + // 锁定账单到本支付记录(审核拒绝后释放,可重新付款) + BillModel::query()->whereIn('id', $bills->pluck('id'))->update(['payment_id' => $payment->id]); + + return $payment; + }); + + return $this->success([ + 'id' => $payment->id, + 'payment_no' => $payment->payment_no, + 'amount' => $payment->amount, + ], '付款申请已提交,请等待商家审核'); + } + + /** 支付记录详情(校验归属;含合并账单与凭证图片) */ + #[GetRoute('/payment/{id}', authorize: true, where: ['id' => '[0-9]+'])] + public function detail(int $id, Request $request): JsonResponse + { + $user = $this->currentUser($request); + $store = $this->ensureStoreBound($user); + + $payment = PaymentModel::query() + ->where('store_id', $store->id) + ->find($id); + if ($payment === null) { + throw new RepositoryException('支付记录不存在'); + } + + $bills = $payment->bills() + ->orderBy('id') + ->get(['id', 'bill_no', 'bill_date', 'product_amount', 'delivery_fee', 'added_amount', 'total_amount', 'status']) + ->toArray(); + + $data = $payment->toArray(); + $data['voucher_urls'] = $payment->voucherUrls(); + + return $this->success([ + 'payment' => $data, + 'bills' => $bills, + ]); + } +} diff --git a/app/Http/Controllers/Mini/UploadController.php b/app/Http/Controllers/Mini/UploadController.php new file mode 100644 index 0000000..aa9409f --- /dev/null +++ b/app/Http/Controllers/Mini/UploadController.php @@ -0,0 +1,38 @@ +validate([ + 'file' => 'required|image|max:5120', + ], [ + 'file.required' => '请选择要上传的图片', + 'file.image' => '仅支持图片文件', + 'file.max' => '图片不能超过 5MB', + ]); + + $user = $this->currentUser($request); + // 分组 4=用户上传,渠道 20=APP用户 + $result = app(SysFileService::class)->upload($data['file'], 4, 20, $user->id); + + return $this->success([ + 'id' => $result['id'], + 'url' => $result['preview_url'] ?? '', + ], '上传成功'); + } +} diff --git a/app/Http/Controllers/Recon/PaymentController.php b/app/Http/Controllers/Recon/PaymentController.php new file mode 100644 index 0000000..4b23aee --- /dev/null +++ b/app/Http/Controllers/Recon/PaymentController.php @@ -0,0 +1,135 @@ + '=', + 'status' => '=', + 'pay_method' => '=', + 'payment_no' => 'like', + ]; + + /** 支付记录列表(待审核优先) */ + #[GetRoute(authorize: 'query')] + public function query(Request $request): JsonResponse + { + $params = $request->all(); + $pageSize = $params['pageSize'] ?? 10; + $data = $this->buildSearch($params, PaymentModel::query() + ->with(['store:id,name', 'user:id,nickname', 'auditor:id,nickname']) + ->withCount('bills')) + ->orderBy('status') + ->orderBy('id', 'desc') + ->paginate($pageSize) + ->toArray(); + return $this->success($data); + } + + /** 支付记录详情:支付信息 + 凭证图片 + 合并付款的账单 */ + #[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])] + public function detail(int $id): JsonResponse + { + $payment = PaymentModel::with(['store:id,name,contact,phone', 'user:id,nickname', 'auditor:id,nickname'])->find($id); + if (empty($payment)) { + throw new RepositoryException('支付记录不存在'); + } + + $bills = $payment->bills() + ->orderBy('id') + ->get(['id', 'bill_no', 'bill_date', 'product_amount', 'delivery_fee', 'added_amount', 'total_amount', 'status']) + ->toArray(); + + $data = $payment->toArray(); + $data['voucher_urls'] = $payment->voucherUrls(); + + return $this->success([ + 'payment' => $data, + 'bills' => $bills, + ]); + } + + /** + * 审核支付记录:通过 → 关联账单全部置已支付;拒绝 → 释放账单(可重新发起付款) + * @throws Throwable + */ + #[PutRoute(route: '/{id}/audit', authorize: 'audit', where: ['id' => '[0-9]+'])] + public function audit(int $id, Request $request): JsonResponse + { + $data = $request->validate([ + 'result' => 'required|string|in:pass,reject', + 'audit_remark' => 'nullable|string|max:255|required_if:result,reject', + ], [ + 'result.required' => '请选择审核结果', + 'result.in' => '审核结果不正确', + 'audit_remark.required_if' => '拒绝时请填写原因', + 'audit_remark.max' => '审核备注超过最大长度', + ]); + + return DB::transaction(function () use ($id, $data, $request) { + $payment = PaymentModel::query()->lockForUpdate()->find($id); + if (empty($payment)) { + throw new RepositoryException('支付记录不存在'); + } + if ($payment->status !== PaymentModel::STATUS_PENDING) { + throw new RepositoryException('该支付记录已审核,请勿重复操作'); + } + + $bills = $payment->bills()->lockForUpdate()->get(); + $auditorId = (int) $request->user()->id; + $now = now(); + + if ($data['result'] === 'pass') { + // 任一账单已通过其他方式收款(如线下登记)则整批中止,避免重复收款 + $paid = $bills->where('status', BillModel::STATUS_PAID); + if ($paid->isNotEmpty()) { + throw new RepositoryException( + '账单 ' . $paid->pluck('bill_no')->implode('、') . ' 已收款,请核实后再审核' + ); + } + + $methodName = PaymentModel::METHOD_NAMES[$payment->pay_method] ?? '线上支付'; + BillModel::query()->whereIn('id', $bills->pluck('id'))->update([ + 'status' => BillModel::STATUS_PAID, + 'paid_at' => $now, + 'paid_operator_id' => $auditorId, + 'pay_remark' => $methodName . '(支付单号 ' . $payment->payment_no . ')', + ]); + $payment->status = PaymentModel::STATUS_APPROVED; + } else { + // 拒绝:释放账单,门店可重新发起付款 + BillModel::query()->whereIn('id', $bills->pluck('id'))->update(['payment_id' => 0]); + $payment->status = PaymentModel::STATUS_REJECTED; + } + + $payment->audited_at = $now; + $payment->auditor_id = $auditorId; + $payment->audit_remark = (string) ($data['audit_remark'] ?? ''); + $payment->save(); + + return $this->success( + [], + $payment->status === PaymentModel::STATUS_APPROVED + ? '审核通过,' . $bills->count() . ' 张账单已置为已支付' + : '已拒绝,账单已释放可重新付款' + ); + }); + } +} diff --git a/app/Models/BillModel.php b/app/Models/BillModel.php index 10db8b7..0439261 100644 --- a/app/Models/BillModel.php +++ b/app/Models/BillModel.php @@ -43,6 +43,7 @@ class BillModel extends Model 'added_amount', 'total_amount', 'status', + 'payment_id', 'paid_at', 'pay_remark', 'paid_operator_id', @@ -63,6 +64,7 @@ class BillModel extends Model 'added_amount' => 'decimal:2', 'total_amount' => 'decimal:2', 'status' => 'integer', + 'payment_id' => 'integer', 'paid_at' => 'datetime:Y-m-d H:i:s', 'paid_operator_id' => 'integer', 'operator_id' => 'integer', @@ -101,6 +103,14 @@ class BillModel extends Model return $this->belongsTo(SysUserModel::class, 'paid_operator_id', 'id'); } + /** + * 关联支付记录(小程序合并付款) + */ + public function payment(): BelongsTo + { + return $this->belongsTo(PaymentModel::class, 'payment_id', 'id'); + } + /** * 本账单关联的门店订单 */ diff --git a/app/Models/PaymentModel.php b/app/Models/PaymentModel.php new file mode 100644 index 0000000..face02a --- /dev/null +++ b/app/Models/PaymentModel.php @@ -0,0 +1,139 @@ + '微信支付', + self::METHOD_ALIPAY => '支付宝', + self::METHOD_BANK => '对公汇款', + ]; + + /** 状态:待审核 */ + public const int STATUS_PENDING = 0; + /** 状态:已通过 */ + public const int STATUS_APPROVED = 1; + /** 状态:已拒绝 */ + public const int STATUS_REJECTED = 2; + + /** 状态中文名 */ + public const array STATUS_NAMES = [ + self::STATUS_PENDING => '待审核', + self::STATUS_APPROVED => '已通过', + self::STATUS_REJECTED => '已拒绝', + ]; + + protected $table = 'payment'; + protected $primaryKey = 'id'; + + protected $fillable = [ + 'payment_no', + 'store_id', + 'user_id', + 'amount', + 'pay_method', + 'voucher_ids', + 'status', + 'remark', + 'audited_at', + 'auditor_id', + 'audit_remark', + ]; + + protected $casts = [ + 'store_id' => 'integer', + 'user_id' => 'integer', + 'amount' => 'decimal:2', + 'pay_method' => 'integer', + 'status' => 'integer', + 'audited_at' => 'datetime:Y-m-d H:i:s', + 'auditor_id' => 'integer', + 'created_at' => 'datetime:Y-m-d H:i:s', + ]; + + /** + * 汇款凭证图片ID(逗号分隔字符串 ↔ 数组) + */ + public function voucherIds(): Attribute + { + return Attribute::make( + get: fn ($value) => $value === '' || $value === null ? [] : explode(',', (string) $value), + set: fn ($value) => is_array($value) ? implode(',', $value) : $value, + ); + } + + /** + * 汇款凭证图片URL列表(保持提交顺序) + * + * @return string[] + */ + public function voucherUrls(): array + { + $ids = array_map('intval', $this->voucher_ids); + if ($ids === []) { + return []; + } + $urls = SysFileModel::query()->whereIn('id', $ids)->pluck('preview_url', 'id'); + $result = []; + foreach ($ids as $id) { + if (isset($urls[$id])) { + $result[] = $urls[$id]; + } + } + return $result; + } + + /** + * 所属门店(含软删除门店,保证历史记录可见) + */ + public function store(): BelongsTo + { + return $this->belongsTo(StoreModel::class, 'store_id', 'id')->withTrashed(); + } + + /** + * 本支付记录合并付款的账单 + */ + public function bills(): HasMany + { + return $this->hasMany(BillModel::class, 'payment_id', 'id'); + } + + /** + * 提交人(小程序用户) + */ + public function user(): BelongsTo + { + return $this->belongsTo(UserModel::class, 'user_id', 'id'); + } + + /** + * 审核人(后台系统用户) + */ + public function auditor(): BelongsTo + { + return $this->belongsTo(SysUserModel::class, 'auditor_id', 'id'); + } +} diff --git a/app/Services/BillNumberService.php b/app/Services/BillNumberService.php index 7b0c30b..3501a10 100644 --- a/app/Services/BillNumberService.php +++ b/app/Services/BillNumberService.php @@ -27,12 +27,13 @@ class BillNumberService 'RC' => ['reconciliation', 'recon_no'], 'JS' => ['settlement', 'settlement_no'], 'ZD' => ['bill', 'bill_no'], + 'ZF' => ['payment', 'payment_no'], ]; /** * 生成业务单号 * - * @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / RC 对账 / JS 结算 / ZD 账单 + * @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / RC 对账 / JS 结算 / ZD 账单 / ZF 支付 * @return string 如 PO202607230001 */ public function make(string $prefix): string diff --git a/database/migrations/2026_08_13_000001_create_bill_table.php b/database/migrations/2026_08_13_000001_create_bill_table.php index f17dcc8..de3b23b 100644 --- a/database/migrations/2026_08_13_000001_create_bill_table.php +++ b/database/migrations/2026_08_13_000001_create_bill_table.php @@ -30,6 +30,7 @@ return new class extends Migration $table->decimal('added_amount', 10, 2)->default(0)->comment('附加金额(周转筐/托盘金额)'); $table->decimal('total_amount', 10, 2)->default(0)->comment('账单总金额 = 商品金额 + 配送费 + 附加金额'); $table->integer('status')->default(0)->comment('支付状态(0未支付 1已支付)'); + $table->integer('payment_id')->default(0)->comment('关联支付记录ID(0=未发起支付)'); $table->timestamp('paid_at')->nullable()->comment('付款时间(线下收款手动登记)'); $table->string('pay_remark', 255)->default('')->comment('付款备注(线下收款信息)'); $table->integer('paid_operator_id')->default(0)->comment('收款操作人(后台系统用户ID)'); @@ -39,6 +40,7 @@ return new class extends Migration $table->unique(['purchase_id', 'store_id'], 'bill_purchase_store_unique'); $table->index(['store_id', 'bill_date'], 'bill_store_date_index'); $table->index(['status'], 'bill_status_index'); + $table->index(['payment_id'], 'bill_payment_index'); $table->comment('门店账单表(采购单完成后按门店生成)'); }); } diff --git a/database/migrations/2026_08_14_000002_create_payment_table.php b/database/migrations/2026_08_14_000002_create_payment_table.php new file mode 100644 index 0000000..2ba31a0 --- /dev/null +++ b/database/migrations/2026_08_14_000002_create_payment_table.php @@ -0,0 +1,43 @@ +increments('id')->comment('支付记录ID'); + $table->string('payment_no', 32)->unique()->comment('支付单号'); + $table->integer('store_id')->comment('门店ID'); + $table->integer('user_id')->default(0)->comment('提交人(小程序用户ID)'); + $table->decimal('amount', 10, 2)->default(0)->comment('支付金额(= 关联账单总金额合计,提交时快照)'); + $table->integer('pay_method')->comment('支付方式(1微信 2支付宝 3对公汇款)'); + $table->string('voucher_ids', 255)->default('')->comment('汇款凭证图片ID(逗号分隔)'); + $table->integer('status')->default(0)->comment('状态(0待审核 1已通过 2已拒绝)'); + $table->string('remark', 255)->default('')->comment('门店备注'); + $table->timestamp('audited_at')->nullable()->comment('审核时间'); + $table->integer('auditor_id')->default(0)->comment('审核人(后台系统用户ID)'); + $table->string('audit_remark', 255)->default('')->comment('审核备注(拒绝原因)'); + $table->timestamps(); + $table->index(['store_id', 'status'], 'payment_store_status_index'); + $table->comment('支付记录表(小程序合并付款,后台审核汇款凭证)'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('payment'); + } +}; diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index 27028ba..49f0833 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -242,6 +242,16 @@ class PermissionSeeder extends Seeder ['type' => 'rule', 'key' => 'recon.containerReturn.delete', 'name' => '删除'], ], ], + [ + 'type' => 'route', + 'key' => 'recon.payment', + 'name' => '支付记录', + 'path' => '/recon/payment', + 'children' => [ + ['type' => 'rule', 'key' => 'recon.payment.query', 'name' => '查询'], + ['type' => 'rule', 'key' => 'recon.payment.audit', 'name' => '审核'], + ], + ], [ 'type' => 'route', 'key' => 'recon.settlement', diff --git a/database/seeders/SysDataSeeder.php b/database/seeders/SysDataSeeder.php index 030749c..7acacb9 100644 --- a/database/seeders/SysDataSeeder.php +++ b/database/seeders/SysDataSeeder.php @@ -17,7 +17,8 @@ class SysDataSeeder extends Seeder DB::table('sys_site_config_group')->insert([ ['id' => 1, 'title' => '网站设置', 'key' => 'web', 'remark' => '网站基础设置', 'created_at' => $date, 'updated_at' => $date], ['id' => 2, 'title' => '小程序设置', 'key' => 'wechatMini', 'remark' => '小程序设置', 'created_at' => $date, 'updated_at' => $date], - ['id' => 3, 'title' => '支付配置', 'key' => 'pay', 'remark' => '网站的支付配置', 'created_at' => $date, 'updated_at' => $date], + ['id' => 3, 'title' => '业务配置', 'key' => 'services', 'remark' => '小程序设置', 'created_at' => $date, 'updated_at' => $date], + ['id' => 4, 'title' => '支付配置', 'key' => 'pay', 'remark' => '网站的支付配置', 'created_at' => $date, 'updated_at' => $date], ]); DB::table('sys_site_config_items')->insert([ ['id' => 1, 'group_id' => 1, 'key' => 'title', 'title' => '网站标题', 'describe' => '网站标题,用于展示在网站logo旁边和登录页面以及网页title中', 'values' => 'Xin Admin', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date,], @@ -26,6 +27,11 @@ class SysDataSeeder extends Seeder ['id' => 4, 'group_id' => 1, 'key' => 'describe', 'title' => '网站描述', 'describe' => '网站的基本描述', 'values' => '没有描述', 'type' => 'TextArea','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date], ['id' => 5, 'group_id' => 2, 'key' => 'appid', 'title' => 'APPID', 'describe' => '小程序的APPID', 'values' => '没有描述', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date], ['id' => 6, 'group_id' => 2, 'key' => 'secret', 'title' => 'SecretKey', 'describe' => '小程序的SecretKey', 'values' => '没有描述', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date], + ['id' => 7, 'group_id' => 3, 'key' => 'box_amount', 'title' => '周转筐金额', 'describe' => '周转筐的金额,用于附加业务金额的计算', 'values' => '', 'type' => 'InputNumber','props' => "min=0\nmax=10000\nstep=0.01", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date], + ['id' => 8, 'group_id' => 3, 'key' => 'tray_amount', 'title' => '周转托盘金额', 'describe' => '周转托盘的金额,用于附加金额的计算', 'values' => '', 'type' => 'InputNumber','props' => "min=0\nmax=10000\nstep=0.01", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date], + ['id' => 9, 'group_id' => 4, 'key' => 'wechat_qrcode', 'title' => '微信收款码', 'describe' => '微信收款码图片地址(在文件管理中上传收款码后复制图片链接,或直接填写文件ID),小程序付款页展示', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date], + ['id' => 10, 'group_id' => 4, 'key' => 'alipay_qrcode', 'title' => '支付宝收款码', 'describe' => '支付宝收款码图片地址(在文件管理中上传收款码后复制图片链接,或直接填写文件ID),小程序付款页展示', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date], + ['id' => 11, 'group_id' => 4, 'key' => 'bank_info', 'title' => '对公汇款信息', 'describe' => '对公账户汇款信息(户名、账号、开户行等),小程序付款页展示', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date], ]); // 字典类型初始数据 DB::table('sys_dict')->insert([ diff --git a/web/api/recon/payment.ts b/web/api/recon/payment.ts new file mode 100644 index 0000000..e65e860 --- /dev/null +++ b/web/api/recon/payment.ts @@ -0,0 +1,19 @@ +import createAxios from '@/utils/request'; +import type { IPaymentDetail } from '@/domain/iPayment.ts'; + +/** 支付记录详情(支付信息 + 凭证图片 + 合并账单) */ +export async function getPaymentDetail(id: number) { + return createAxios({ + url: `/recon/payment/${id}`, + method: 'get', + }); +} + +/** 审核支付记录:pass 通过(账单批量置已支付)/ reject 拒绝(释放账单,需填原因) */ +export async function auditPayment(id: number, data: { result: 'pass' | 'reject'; audit_remark?: string }) { + return createAxios({ + url: `/recon/payment/${id}/audit`, + method: 'put', + data, + }); +} diff --git a/web/domain/iPayment.ts b/web/domain/iPayment.ts new file mode 100644 index 0000000..c6b02b6 --- /dev/null +++ b/web/domain/iPayment.ts @@ -0,0 +1,61 @@ +/** 支付记录(小程序合并付款提交汇款凭证,后台审核) */ +export default interface IPayment { + id?: number; + payment_no?: string; + store_id?: number; + user_id?: number; + /** 支付金额(= 关联账单总金额合计) */ + amount?: string; + /** 支付方式:1微信 2支付宝 3对公汇款 */ + pay_method?: number; + /** 汇款凭证图片ID列表 */ + voucher_ids?: number[]; + /** 凭证图片URL列表(详情接口解析) */ + voucher_urls?: string[]; + /** 状态:0待审核 1已通过 2已拒绝 */ + status?: number; + /** 门店备注 */ + remark?: string; + audited_at?: string | null; + auditor_id?: number; + audit_remark?: string; + created_at?: string; + /** 列表/详情接口附带 */ + store?: { id: number; name: string; contact?: string; phone?: string } | null; + user?: { id: number; nickname: string } | null; + auditor?: { id: number; nickname: string } | null; + bills_count?: number; +} + +/** 支付记录关联账单(合并付款) */ +export interface IPaymentBill { + id: number; + bill_no: string; + bill_date: string; + product_amount: string; + delivery_fee: string; + added_amount: string; + total_amount: string; + /** 0未支付 1已支付 */ + status: number; +} + +/** 支付记录详情 */ +export interface IPaymentDetail { + payment: IPayment; + bills: IPaymentBill[]; +} + +/** 支付方式映射 */ +export const PAY_METHOD_MAP: Record = { + 1: { text: '微信支付', color: 'green' }, + 2: { text: '支付宝', color: 'blue' }, + 3: { text: '对公汇款', color: 'purple' }, +}; + +/** 支付记录状态映射 */ +export const PAYMENT_STATUS_MAP: Record = { + 0: { text: '待审核', color: 'warning' }, + 1: { text: '已通过', color: 'success' }, + 2: { text: '已拒绝', color: 'error' }, +}; diff --git a/web/domain/iPurchaseOrder.ts b/web/domain/iPurchaseOrder.ts index 515698b..47fa4bf 100644 --- a/web/domain/iPurchaseOrder.ts +++ b/web/domain/iPurchaseOrder.ts @@ -76,6 +76,8 @@ export interface IBill { total_amount: string; /** 支付状态:0未支付 1已支付 */ status?: number; + /** 关联支付记录ID(0=未发起支付) */ + payment_id?: number; /** 付款时间(线下收款手动登记) */ paid_at?: string | null; /** 付款备注(线下收款信息) */ diff --git a/web/pages/recon/payment.tsx b/web/pages/recon/payment.tsx new file mode 100644 index 0000000..e8c4883 --- /dev/null +++ b/web/pages/recon/payment.tsx @@ -0,0 +1,398 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + Button, + Descriptions, + Drawer, + Form, + Image, + Input, + message, + Modal, + Radio, + Space, + Table, + Tag, + Typography, +} from 'antd'; +import type { TableProps } from 'antd'; +import { UnorderedListOutlined } from '@ant-design/icons'; +import XinTable from '@/components/XinTable'; +import type { + XinTableColumn, + XinTableInstance, + XinTableProps, +} from '@/components/XinTable/typings.ts'; +import type IPayment from '@/domain/iPayment.ts'; +import type { IPaymentBill, IPaymentDetail } from '@/domain/iPayment.ts'; +import { PAY_METHOD_MAP, PAYMENT_STATUS_MAP } from '@/domain/iPayment.ts'; +import { BILL_STATUS_MAP } from '@/domain/iPurchaseOrder.ts'; +import { getPaymentDetail, auditPayment } from '@/api/recon/payment.ts'; +import { getStoreOptions } from '@/api/customer/store.ts'; +import type IStore from '@/domain/iStore.ts'; +import AuthButton from '@/components/AuthButton'; + +const { Title, Text } = Typography; + +/** 审核表单 */ +interface AuditFormValues { + result: 'pass' | 'reject'; + audit_remark?: string; +} + +/** + * 支付记录(小程序合并付款提交汇款凭证;审核通过后关联账单全部置已支付,拒绝则释放账单) + */ +const PaymentPage: React.FC = () => { + const tableRef = useRef>(null); + const [stores, setStores] = useState([]); + + const [detailOpen, setDetailOpen] = useState(false); + const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + + // 审核弹窗 + const [auditTarget, setAuditTarget] = useState(null); + const [auditSaving, setAuditSaving] = useState(false); + const [auditForm] = Form.useForm(); + const watchAuditResult = Form.useWatch('result', auditForm); + + useEffect(() => { + getStoreOptions().then((res) => setStores(res.data.data ?? [])); + }, []); + + const openDetail = async (id: number) => { + setDetailOpen(true); + setDetailLoading(true); + try { + const res = await getPaymentDetail(id); + setDetail(res.data.data ?? null); + } finally { + setDetailLoading(false); + } + }; + + /** 打开审核弹窗 */ + const openAudit = (record: IPayment) => { + setAuditTarget(record); + auditForm.setFieldsValue({ result: 'pass', audit_remark: '' }); + }; + + /** 提交审核:通过 → 账单批量置已支付;拒绝 → 释放账单 */ + const handleAuditSave = async (values: AuditFormValues) => { + if (!auditTarget?.id) { + return; + } + setAuditSaving(true); + try { + const res = await auditPayment(auditTarget.id, values); + message.success(res.data.msg ?? '审核完成'); + setAuditTarget(null); + await tableRef.current?.reload(); + if (detail && detail.payment.id === auditTarget.id) { + await openDetail(auditTarget.id); + } + } finally { + setAuditSaving(false); + } + }; + + /** 合并账单列 */ + const billColumns: TableProps['columns'] = [ + { + title: '账单号', + dataIndex: 'bill_no', + align: 'center', + render: (v) => {v}, + }, + { title: '账单日期', dataIndex: 'bill_date', align: 'center' }, + { + title: '商品金额', + dataIndex: 'product_amount', + align: 'center', + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '配送费', + dataIndex: 'delivery_fee', + align: 'center', + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '附加金额', + dataIndex: 'added_amount', + align: 'center', + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '账单总金额', + dataIndex: 'total_amount', + align: 'center', + render: (v) => ¥{Number(v).toFixed(2)}, + }, + { + title: '支付状态', + dataIndex: 'status', + align: 'center', + render: (v) => { + const item = BILL_STATUS_MAP[Number(v ?? 0)]; + return {item?.text}; + }, + }, + ]; + + const columns: XinTableColumn[] = [ + { + title: '支付单号', + dataIndex: 'payment_no', + valueType: 'text', + hideInForm: true, + width: 210, + render: (_, record) => {record.payment_no}, + }, + { + title: '门店', + dataIndex: 'store_id', + valueType: 'select', + hideInForm: true, + fieldProps: { + options: stores.map((s) => ({ label: s.name, value: s.id })), + showSearch: true, + optionFilterProp: 'label', + }, + render: (_, record) => record.store?.name ?? `门店#${record.store_id}`, + }, + { + title: '支付金额', + dataIndex: 'amount', + hideInForm: true, + hideInSearch: true, + align: 'center', + render: (_, record) => ¥{record.amount}, + }, + { + title: '支付方式', + dataIndex: 'pay_method', + valueType: 'select', + hideInForm: true, + align: 'center', + fieldProps: { + options: Object.entries(PAY_METHOD_MAP).map(([value, item]) => ({ + value: Number(value), + label: item.text, + })), + }, + render: (_, record) => { + const item = PAY_METHOD_MAP[record.pay_method ?? 0]; + return {item?.text ?? '-'}; + }, + }, + { + title: '合并账单', + dataIndex: 'bills_count', + hideInForm: true, + hideInSearch: true, + align: 'center', + render: (_, record) => `${record.bills_count ?? 0} 张`, + }, + { + title: '状态', + dataIndex: 'status', + valueType: 'select', + hideInForm: true, + align: 'center', + fieldProps: { + options: Object.entries(PAYMENT_STATUS_MAP).map(([value, item]) => ({ + value: Number(value), + label: item.text, + })), + }, + render: (_, record) => { + const item = PAYMENT_STATUS_MAP[record.status ?? 0]; + return {item?.text}; + }, + }, + { + title: '提交人', + dataIndex: 'user', + hideInForm: true, + hideInSearch: true, + align: 'center', + render: (_, record) => record.user?.nickname ?? '-', + }, + { + title: '提交时间', + dataIndex: 'created_at', + hideInForm: true, + hideInSearch: true, + align: 'center', + }, + { + title: '审核人', + dataIndex: 'auditor', + hideInForm: true, + hideInSearch: true, + align: 'center', + render: (_, record) => record.auditor?.nickname ?? '-', + }, + ]; + + const operateRender: XinTableProps['operateRender'] = (record) => [ + + + ) : null, + ]; + + const tableProps: XinTableProps = { + api: '/recon/payment', + columns, + rowKey: 'id', + accessName: 'recon.payment', + tableRef, + operateRender, + formProps: false, + actionBarRender: (dom) => [dom.search, dom.keywordSearch], + }; + + return ( + <> +
+ 支付记录 + + 门店在小程序端选择账单合并付款并上传汇款凭证;审核通过后关联账单全部置为已支付,拒绝则释放账单可重新付款。 + +
+ {...tableProps} /> + + {/* 支付详情:支付信息 + 凭证 + 合并账单 */} + setDetailOpen(false)} + size={1000} + loading={detailLoading} + footer={ + detail && detail.payment.status === 0 ? ( + + + + + + ) : null + } + > + {detail ? ( + <> + + {detail.payment.store?.name ?? `门店#${detail.payment.store_id}`} + + ¥{detail.payment.amount} + + + + {PAY_METHOD_MAP[detail.payment.pay_method ?? 0]?.text ?? '-'} + + + + + {PAYMENT_STATUS_MAP[detail.payment.status ?? 0]?.text} + + + {detail.payment.user?.nickname ?? '-'} + {detail.payment.created_at} + {detail.payment.auditor?.nickname ?? '-'} + {detail.payment.audited_at ?? '-'} + {detail.payment.audit_remark || '-'} + {detail.payment.remark ? ( + {detail.payment.remark} + ) : null} + + + + 汇款凭证 + + {(detail.payment.voucher_urls ?? []).length > 0 ? ( + + + {(detail.payment.voucher_urls ?? []).map((url, index) => ( + + ))} + + + ) : ( + 无凭证 + )} + + + 合并付款账单({detail.bills.length} 张) + + + rowKey="id" + size="small" + bordered + columns={billColumns} + dataSource={detail.bills} + pagination={false} + /> + + ) : null} + + + {/* 审核弹窗 */} + setAuditTarget(null)} + onOk={() => auditForm.submit()} + confirmLoading={auditSaving} + okText="提交审核" + destroyOnHidden + > +
+ 支付金额 ¥{auditTarget?.amount ?? '0.00'}({auditTarget?.bills_count ?? 0} 张账单); + 通过后关联账单全部置为「已支付」,拒绝则释放账单,门店可重新发起付款。 +
+
+ + + + + + +
+
+ + ); +}; + +export default PaymentPage;