Compare commits
3 Commits
61a70319a0
...
889f987f17
| Author | SHA1 | Date | |
|---|---|---|---|
| 889f987f17 | |||
| bf8ac68391 | |||
| e35c951a59 |
@@ -1,67 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Models\StatementModel;
|
||||
use Illuminate\Support\Collection;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
/**
|
||||
* 门店对账单导出
|
||||
*/
|
||||
class StatementExport implements FromCollection, WithHeadings, WithMapping, WithStyles
|
||||
{
|
||||
public function __construct(private readonly StatementModel $statement)
|
||||
{
|
||||
}
|
||||
|
||||
public function collection(): Collection
|
||||
{
|
||||
return $this->statement->items()->orderBy('id')->get();
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return ['品名', '单价', '数量', '重量', '金额', '对账状态', '备注'];
|
||||
}
|
||||
|
||||
public function map($item): array
|
||||
{
|
||||
return [
|
||||
$item->product_name,
|
||||
(float) $item->price,
|
||||
(float) $item->quantity,
|
||||
(float) $item->weight,
|
||||
(float) $item->amount,
|
||||
$item->is_reconciled ? '已对账' : '未对账',
|
||||
$item->store_remark,
|
||||
];
|
||||
}
|
||||
|
||||
public function styles(Worksheet $sheet): array
|
||||
{
|
||||
$sheet->freezePane('A2');
|
||||
|
||||
return [
|
||||
1 => ['font' => ['bold' => true]],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF 模板视图数据
|
||||
*
|
||||
* @return array{statement: StatementModel, storeName: string, items: Collection}
|
||||
*/
|
||||
public function viewData(): array
|
||||
{
|
||||
return [
|
||||
'statement' => $this->statement,
|
||||
'storeName' => $this->statement->store?->name ?? '',
|
||||
'items' => $this->collection(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Mini;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\BillModel;
|
||||
use App\Services\BillDetailService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
|
||||
/**
|
||||
* 小程序门店账单(采购单完成后由后台生成,门店端只读)
|
||||
*/
|
||||
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
||||
class BillController extends BaseMiniController
|
||||
{
|
||||
/** 账单列表:当前门店强制过滤,?status= 按支付状态筛选(0未支付 1已支付) */
|
||||
#[GetRoute('/bill', authorize: true)]
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->ensureStoreBound($user);
|
||||
|
||||
$query = BillModel::query()
|
||||
->where('store_id', $store->id)
|
||||
->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();
|
||||
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/** 账单详情(校验归属:仅能查看本店账单;含合并后的商品明细与关联订单) */
|
||||
#[GetRoute('/bill/{id}', authorize: true, where: ['id' => '[0-9]+'])]
|
||||
public function detail(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->ensureStoreBound($user);
|
||||
|
||||
$bill = BillModel::with('purchase:id,purchase_no,purchase_date')
|
||||
->where('store_id', $store->id)
|
||||
->find($id);
|
||||
if ($bill === null) {
|
||||
throw new RepositoryException('账单不存在');
|
||||
}
|
||||
|
||||
$orders = $bill->orders()
|
||||
->orderBy('id')
|
||||
->get(['id', 'order_no', 'order_date', 'total_quantity', 'total_weight', 'total_amount', 'status'])
|
||||
->toArray();
|
||||
|
||||
return $this->success([
|
||||
'bill' => $bill->toArray(),
|
||||
'items' => app(BillDetailService::class)->mergedItems($bill),
|
||||
'orders' => $orders,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,6 @@ class OrderController extends BaseMiniController
|
||||
'order_date' => $now->toDateString(),
|
||||
'total_quantity' => $totalQuantity,
|
||||
'total_weight' => 0,
|
||||
'product_amount' => $totalAmount,
|
||||
'total_amount' => $totalAmount,
|
||||
'status' => StoreOrderModel::STATUS_PENDING,
|
||||
'remark' => $remark,
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Mini;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\BillModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Services\BillNumberService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\PostRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Modules\SystemTool\Models\SysFileModel;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 小程序门店支付(选择本店账单合并付款,提交汇款凭证,后台审核通过后账单置已支付)
|
||||
*/
|
||||
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
||||
class PaymentController extends BaseMiniController
|
||||
{
|
||||
/** 支付配置:收款码图片与对公汇款信息(付款页展示) */
|
||||
#[GetRoute('/payment/config', authorize: true)]
|
||||
public function config(): JsonResponse
|
||||
{
|
||||
// 配置值支持图片URL或文件ID(文件ID解析为预览地址)
|
||||
$resolve = static function (mixed $value): string {
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
if (is_numeric($value)) {
|
||||
return (string) (SysFileModel::query()->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Mini;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\StatementModel;
|
||||
use App\Services\ExportService;
|
||||
use App\Services\StatementGenerateService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\PostRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* 小程序门店对账单(自助生成 / 查看 / 导出)
|
||||
*/
|
||||
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
||||
class StatementController extends BaseMiniController
|
||||
{
|
||||
/** 对账单列表(当前门店) */
|
||||
#[GetRoute('/statement', authorize: true)]
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->ensureStoreBound($user);
|
||||
|
||||
$data = StatementModel::query()
|
||||
->where('store_id', $store->id)
|
||||
->orderBy('id', 'desc')
|
||||
->paginate((int) $request->input('pageSize', 10))
|
||||
->toArray();
|
||||
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/** 生成对账单:快照当前回款周期,settlement_date = period_end + cycle 天 */
|
||||
#[PostRoute('/statement/generate', authorize: true)]
|
||||
public function generate(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'period_start' => 'required|date_format:Y-m-d',
|
||||
'period_end' => 'required|date_format:Y-m-d|after_or_equal:period_start',
|
||||
], [
|
||||
'period_start.required' => '请选择对账周期开始日期',
|
||||
'period_start.date_format' => '开始日期格式为 Y-m-d',
|
||||
'period_end.required' => '请选择对账周期结束日期',
|
||||
'period_end.date_format' => '结束日期格式为 Y-m-d',
|
||||
'period_end.after_or_equal' => '结束日期不能早于开始日期',
|
||||
]);
|
||||
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->ensureStoreBound($user);
|
||||
|
||||
$statement = app(StatementGenerateService::class)->generate(
|
||||
$store,
|
||||
$data['period_start'],
|
||||
$data['period_end'],
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'id' => $statement->id,
|
||||
'statement_no' => $statement->statement_no,
|
||||
'total_amount' => $statement->total_amount,
|
||||
'settlement_date' => $statement->settlement_date?->toDateString(),
|
||||
], '对账单已生成');
|
||||
}
|
||||
|
||||
/** 对账单详情(校验归属,含单品对账状态标识) */
|
||||
#[GetRoute('/statement/{id}', authorize: true, where: ['id' => '[0-9]+'])]
|
||||
public function detail(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->ensureStoreBound($user);
|
||||
|
||||
$statement = StatementModel::with('items')
|
||||
->where('store_id', $store->id)
|
||||
->find($id);
|
||||
if ($statement === null) {
|
||||
throw new RepositoryException('对账单不存在');
|
||||
}
|
||||
|
||||
return $this->success($statement->toArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Mini;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\AnnoRoute\Attribute\PostRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Modules\SystemTool\Services\SysFileService;
|
||||
|
||||
/**
|
||||
* 小程序文件上传(汇款凭证等图片)
|
||||
*/
|
||||
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
||||
class UploadController extends BaseMiniController
|
||||
{
|
||||
/** 上传图片,返回文件ID与预览地址(5MB 内) */
|
||||
#[PostRoute('/upload', authorize: true)]
|
||||
public function upload(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->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'] ?? '',
|
||||
], '上传成功');
|
||||
}
|
||||
}
|
||||
@@ -68,14 +68,6 @@ class StoreOrderController extends BaseController
|
||||
}
|
||||
unset($order);
|
||||
|
||||
$box_amount = site_config('services.box_amount');
|
||||
$tray_amount = site_config('services.tray_amount');
|
||||
foreach ($data['data'] as &$item) {
|
||||
$item['box_price'] = number_format($box_amount, 2);
|
||||
$item['tray_price'] = number_format($tray_amount, 2);
|
||||
$item['box_amount'] = number_format($box_amount * $item['box_num'], 2);
|
||||
$item['tray_amount'] = number_format($tray_amount * $item['tray_num'], 2);
|
||||
}
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
@@ -96,14 +88,8 @@ class StoreOrderController extends BaseController
|
||||
|
||||
$data = $order->toArray();
|
||||
|
||||
// 明细首图 + 附加金额(与列表接口一致)
|
||||
// 明细首图
|
||||
app(ItemImageResolver::class)->resolve($data['items']);
|
||||
$boxAmount = site_config('services.box_amount');
|
||||
$trayAmount = site_config('services.tray_amount');
|
||||
$data['box_price'] = number_format($boxAmount, 2);
|
||||
$data['tray_price'] = number_format($trayAmount, 2);
|
||||
$data['box_amount'] = number_format($boxAmount * $data['box_num'], 2);
|
||||
$data['tray_amount'] = number_format($trayAmount * $data['tray_num'], 2);
|
||||
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
@@ -4,17 +4,18 @@ namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Exports\PurchaseOrderExport;
|
||||
use App\Http\Requests\Purchase\PurchaseBillGenerateRequest;
|
||||
use App\Http\Requests\Purchase\PurchaseCellUpdateRequest;
|
||||
use App\Http\Requests\Purchase\PurchaseContainerUpdateRequest;
|
||||
use App\Http\Requests\Purchase\PurchaseRowUpdateRequest;
|
||||
use App\Models\BillModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Services\BillGenerateService;
|
||||
use App\Services\ItemImageResolver;
|
||||
use App\Services\PurchaseGenerateService;
|
||||
use App\Services\StoreOrderContainerService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -135,44 +136,21 @@ class PurchaseOrderController extends BaseController
|
||||
[$a['category_sort'], $a['product_sort'], $a['product_id']]
|
||||
<=> [$b['category_sort'], $b['product_sort'], $b['product_id']]);
|
||||
|
||||
// 周转框/托盘合并记录:按门店聚合全部订单(附底层订单明细,供采购单完成前修改)
|
||||
$storeOrders = StoreOrderModel::query()
|
||||
// 门店账单:采购单完成后按门店生成(含软删除门店,保证历史单据可见)
|
||||
$bills = BillModel::query()
|
||||
->where('purchase_id', $purchase->id)
|
||||
->whereNull('deleted_at')
|
||||
->with('store:id,name')
|
||||
->orderBy('id')
|
||||
->get(['id', 'order_no', 'store_id', 'box_num', 'tray_num']);
|
||||
|
||||
$boxPrice = (float) site_config('services.box_amount', 0);
|
||||
$trayPrice = (float) site_config('services.tray_amount', 0);
|
||||
$storeSort = $stores->pluck('id')->flip();
|
||||
$containers = [];
|
||||
foreach ($storeOrders->groupBy('store_id') as $storeId => $orders) {
|
||||
$boxNum = (int) $orders->sum('box_num');
|
||||
$trayNum = (int) $orders->sum('tray_num');
|
||||
$containers[] = [
|
||||
'store_id' => (int) $storeId,
|
||||
'store_name' => $stores->firstWhere('id', (int) $storeId)['name'] ?? '门店#' . $storeId,
|
||||
'box_num' => $boxNum,
|
||||
'tray_num' => $trayNum,
|
||||
'box_price' => number_format($boxPrice, 2),
|
||||
'tray_price' => number_format($trayPrice, 2),
|
||||
'added_amount' => number_format($boxNum * $boxPrice + $trayNum * $trayPrice, 2),
|
||||
'order_count' => $orders->count(),
|
||||
'orders' => $orders->map(static fn (StoreOrderModel $order) => [
|
||||
'order_id' => $order->id,
|
||||
'order_no' => $order->order_no,
|
||||
'box_num' => (int) $order->box_num,
|
||||
'tray_num' => (int) $order->tray_num,
|
||||
])->values()->toArray(),
|
||||
'store_sort' => (int) ($storeSort[$storeId] ?? 9999),
|
||||
];
|
||||
}
|
||||
usort($containers, static fn (array $a, array $b): int =>
|
||||
[$a['store_sort'], $a['store_id']] <=> [$b['store_sort'], $b['store_id']]);
|
||||
$containers = array_map(static function (array $row): array {
|
||||
unset($row['store_sort']);
|
||||
return $row;
|
||||
}, $containers);
|
||||
->get()
|
||||
->map(static function (BillModel $bill): array {
|
||||
$row = $bill->toArray();
|
||||
$row['store_name'] = $bill->store->name ?? ('门店#' . $bill->store_id);
|
||||
$row['order_count'] = $bill->orders()->count();
|
||||
unset($row['store']);
|
||||
return $row;
|
||||
})
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
return $this->success([
|
||||
'purchase' => $purchase->toArray(),
|
||||
@@ -181,7 +159,7 @@ class PurchaseOrderController extends BaseController
|
||||
unset($row['category_sort'], $row['product_sort']);
|
||||
return $row;
|
||||
}, $rows),
|
||||
'containers' => $containers,
|
||||
'bills' => $bills,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -232,7 +210,6 @@ class PurchaseOrderController extends BaseController
|
||||
StoreOrderModel::where('purchase_id', $purchase->id)->update([
|
||||
'status' => StoreOrderModel::STATUS_DISTRIBUTION
|
||||
]);
|
||||
// 生成并发送账单
|
||||
|
||||
return $this->success();
|
||||
});
|
||||
@@ -438,71 +415,81 @@ class PurchaseOrderController extends BaseController
|
||||
}
|
||||
|
||||
/**
|
||||
* 周转框/托盘合并记录修改:按门店覆盖全部订单逐笔更新(仅采购单进行中可改)
|
||||
* @throws Throwable
|
||||
* 账单生成预览
|
||||
*/
|
||||
#[PutRoute(route: '/{id}/container/{storeId}', authorize: 'update', where: ['id' => '[0-9]+', 'storeId' => '[0-9]+'])]
|
||||
public function updateContainer(int $id, int $storeId, PurchaseContainerUpdateRequest $request): JsonResponse
|
||||
#[GetRoute(route: '/{id}/bill/prepare', authorize: 'query', where: ['id' => '[0-9]+'])]
|
||||
public function billPrepare(int $id): JsonResponse
|
||||
{
|
||||
$purchase = PurchaseOrderModel::find($id);
|
||||
if (empty($purchase)) {
|
||||
throw new RepositoryException('采购单不存在');
|
||||
}
|
||||
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
|
||||
throw new RepositoryException('采购单已完成,不允许修改周转框/托盘');
|
||||
|
||||
$orders = StoreOrderModel::query()
|
||||
->where('purchase_id', $purchase->id)
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id')
|
||||
->get(['id', 'store_id', 'total_amount']);
|
||||
|
||||
$stores = StoreModel::withTrashed()
|
||||
->whereIn('id', $orders->pluck('store_id')->unique())
|
||||
->get(['id', 'name'])
|
||||
->keyBy('id');
|
||||
|
||||
$bills = BillModel::query()
|
||||
->where('purchase_id', $purchase->id)
|
||||
->get()
|
||||
->keyBy('store_id');
|
||||
|
||||
$boxPrice = number_format((float) site_config('services.box_amount', 0), 2);
|
||||
$trayPrice = number_format((float) site_config('services.tray_amount', 0), 2);
|
||||
|
||||
$rows = [];
|
||||
foreach ($orders->groupBy('store_id') as $storeId => $storeOrders) {
|
||||
$productAmount = $storeOrders->reduce(
|
||||
static fn (string $carry, StoreOrderModel $order): string => bcadd($carry, (string) $order->total_amount, 2),
|
||||
'0'
|
||||
);
|
||||
$bill = $bills->get((int) $storeId);
|
||||
$rows[] = [
|
||||
'store_id' => (int) $storeId,
|
||||
'store_name' => $stores->get((int) $storeId)->name ?? ('门店#' . $storeId),
|
||||
'order_count' => $storeOrders->count(),
|
||||
'product_amount' => $productAmount,
|
||||
'box_price' => $boxPrice,
|
||||
'tray_price' => $trayPrice,
|
||||
'bill' => $bill?->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
$submitted = $request->validated()['orders'];
|
||||
return $this->success([
|
||||
'purchase' => $purchase->only(['id', 'purchase_no', 'status']),
|
||||
'stores' => $rows,
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($purchase, $storeId, $submitted) {
|
||||
$orders = StoreOrderModel::query()
|
||||
->where('purchase_id', $purchase->id)
|
||||
->where('store_id', $storeId)
|
||||
->whereNull('deleted_at')
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
/**
|
||||
* 生成账单
|
||||
* @throws Throwable
|
||||
*/
|
||||
#[PostRoute(route: '/{id}/bill', authorize: 'bill', where: ['id' => '[0-9]+'])]
|
||||
public function generateBill(int $id, PurchaseBillGenerateRequest $request): JsonResponse
|
||||
{
|
||||
$purchase = PurchaseOrderModel::find($id);
|
||||
if (empty($purchase)) {
|
||||
throw new RepositoryException('采购单不存在');
|
||||
}
|
||||
if ($purchase->status !== PurchaseOrderModel::STATUS_COMPLETED) {
|
||||
throw new RepositoryException('采购单未完成,不允许生成账单');
|
||||
}
|
||||
|
||||
if ($orders->isEmpty()) {
|
||||
throw new RepositoryException('该采购单下无此门店的订单');
|
||||
}
|
||||
$bills = app(BillGenerateService::class)->generate(
|
||||
$purchase,
|
||||
$request->validated()['stores'],
|
||||
(int) $request->user()->id,
|
||||
);
|
||||
|
||||
// 合并记录修改必须覆盖该门店全部订单,避免只改部分造成汇总偏差
|
||||
$actualIds = $orders->pluck('id')->map(static fn ($orderId) => (int) $orderId)->all();
|
||||
$submittedIds = array_map(static fn (array $row) => (int) $row['order_id'], $submitted);
|
||||
$missing = array_diff($actualIds, $submittedIds);
|
||||
if ($missing !== []) {
|
||||
throw new RepositoryException('提交不完整,缺少订单:' . implode('、', $missing));
|
||||
}
|
||||
if (array_diff($submittedIds, $actualIds) !== []) {
|
||||
throw new RepositoryException('包含不属于该采购单该门店的订单');
|
||||
}
|
||||
|
||||
foreach ($orders as $order) {
|
||||
if (! in_array($order->status, [
|
||||
StoreOrderModel::STATUS_SUMMARIZED,
|
||||
StoreOrderModel::STATUS_DELIVERING,
|
||||
StoreOrderModel::STATUS_DISTRIBUTION,
|
||||
], true)) {
|
||||
throw new RepositoryException(
|
||||
'订单 ' . $order->order_no . ' 当前状态为「'
|
||||
. (StoreOrderModel::STATUS_NAMES[$order->status] ?? $order->status)
|
||||
. '」,不允许修改周转框/托盘数量'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$ordersById = $orders->keyBy('id');
|
||||
$containerService = app(StoreOrderContainerService::class);
|
||||
foreach ($submitted as $row) {
|
||||
$containerService->update(
|
||||
$ordersById->get($row['order_id']),
|
||||
(int) $row['box_num'],
|
||||
(int) $row['tray_num'],
|
||||
);
|
||||
}
|
||||
|
||||
return $this->success();
|
||||
});
|
||||
return $this->success(['count' => count($bills)], '已生成 ' . count($bills) . ' 张门店账单');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -575,9 +562,8 @@ class PurchaseOrderController extends BaseController
|
||||
|
||||
// 重算订单金额
|
||||
$order = StoreOrderModel::query()->lockForUpdate()->find($item->order_id);
|
||||
$order->product_amount = $order->items()->sum('amount');
|
||||
$order->total_weight = $order->items()->sum('weight');
|
||||
$order->total_amount = bcadd($order->product_amount, $order->added_amount, 2);
|
||||
$order->total_amount = $order->items()->sum('amount');
|
||||
$order->save();
|
||||
|
||||
// 重算采购单重量
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Recon;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\BillModel;
|
||||
use App\Services\BillDetailService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\PutRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Modules\Common\Http\Controllers\BaseController;
|
||||
|
||||
/**
|
||||
* 门店账单管理(采购单完成后按门店生成,后台查看 + 线下收款登记)
|
||||
*/
|
||||
#[RequestAttribute('/recon/bill', 'recon.bill')]
|
||||
class BillController extends BaseController
|
||||
{
|
||||
protected array $searchField = [
|
||||
'store_id' => '=',
|
||||
'status' => '=',
|
||||
'bill_no' => 'like',
|
||||
'bill_date' => 'betweenDate',
|
||||
];
|
||||
|
||||
/** 账单列表 */
|
||||
#[GetRoute(authorize: 'query')]
|
||||
public function query(Request $request): JsonResponse
|
||||
{
|
||||
$params = $request->all();
|
||||
$pageSize = $params['pageSize'] ?? 10;
|
||||
$query = BillModel::query()->with([
|
||||
'store:id,name',
|
||||
'purchase:id,purchase_no,purchase_date',
|
||||
'operator:id,nickname',
|
||||
])->withCount('orders');
|
||||
|
||||
// 按采购单号搜索
|
||||
$purchaseNo = trim((string) ($params['purchase_no'] ?? ''));
|
||||
if ($purchaseNo !== '') {
|
||||
$keyword = '%' . str_replace('%', '\%', $purchaseNo) . '%';
|
||||
$query->whereHas('purchase', static function ($purchaseQuery) use ($keyword) {
|
||||
$purchaseQuery->where('purchase_no', 'like', $keyword);
|
||||
});
|
||||
}
|
||||
|
||||
$data = $this->buildSearch($params, $query)
|
||||
->orderBy('bill_date', 'desc')
|
||||
->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
|
||||
{
|
||||
$bill = BillModel::with([
|
||||
'store:id,name,address,contact,phone',
|
||||
'purchase:id,purchase_no,purchase_date,status',
|
||||
'operator:id,nickname',
|
||||
'paidOperator:id,nickname',
|
||||
])->find($id);
|
||||
if (empty($bill)) {
|
||||
throw new RepositoryException('账单不存在');
|
||||
}
|
||||
|
||||
$orders = $bill->orders()
|
||||
->orderBy('id')
|
||||
->get(['id', 'order_no', 'order_date', 'total_quantity', 'total_weight', 'total_amount', 'status'])
|
||||
->toArray();
|
||||
|
||||
return $this->success([
|
||||
'bill' => $bill->toArray(),
|
||||
'items' => app(BillDetailService::class)->mergedItems($bill),
|
||||
'orders' => $orders,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认收款:线下收款后手动登记付款信息,支付状态置为已支付
|
||||
*/
|
||||
#[PutRoute(route: '/{id}/pay', authorize: 'pay', where: ['id' => '[0-9]+'])]
|
||||
public function pay(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'paid_at' => 'sometimes|date_format:Y-m-d H:i:s',
|
||||
'pay_remark' => 'nullable|string|max:255',
|
||||
], [
|
||||
'paid_at.date_format' => '付款时间格式为 Y-m-d H:i:s',
|
||||
'pay_remark.max' => '付款备注超过最大长度',
|
||||
]);
|
||||
|
||||
$bill = BillModel::find($id);
|
||||
if (empty($bill)) {
|
||||
throw new RepositoryException('账单不存在');
|
||||
}
|
||||
if ($bill->status === BillModel::STATUS_PAID) {
|
||||
throw new RepositoryException('账单已支付,请勿重复收款');
|
||||
}
|
||||
|
||||
$bill->status = BillModel::STATUS_PAID;
|
||||
$bill->paid_at = $data['paid_at'] ?? now();
|
||||
$bill->pay_remark = (string) ($data['pay_remark'] ?? '');
|
||||
$bill->paid_operator_id = (int) $request->user()->id;
|
||||
$bill->save();
|
||||
|
||||
return $this->success([], '收款已登记');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Recon;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Http\Requests\Recon\ContainerReturnFormRequest;
|
||||
use App\Models\ContainerReturnModel;
|
||||
use App\Models\StoreModel;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\AnnoRoute\Attribute\DeleteRoute;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\PostRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Modules\Common\Http\Controllers\BaseController;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 回筐记录(压筐=生成账单自动写入只读;回筐=门店退回手动登记,扣减门店待回数量)
|
||||
*/
|
||||
#[RequestAttribute('/recon/container-return', 'recon.containerReturn')]
|
||||
class ContainerReturnController extends BaseController
|
||||
{
|
||||
protected array $searchField = [
|
||||
'store_id' => '=',
|
||||
'type' => '=',
|
||||
'return_date' => 'betweenDate',
|
||||
];
|
||||
|
||||
/** 回筐记录列表 */
|
||||
#[GetRoute(authorize: 'query')]
|
||||
public function query(Request $request): JsonResponse
|
||||
{
|
||||
$params = $request->all();
|
||||
$pageSize = $params['pageSize'] ?? 10;
|
||||
$data = $this->buildSearch($params, ContainerReturnModel::query()->with([
|
||||
'store:id,name,pending_box_num,pending_tray_num',
|
||||
'bill:id,bill_no',
|
||||
'operator:id,nickname',
|
||||
]))
|
||||
->orderBy('return_date', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->paginate($pageSize)
|
||||
->toArray();
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回筐登记:门店退回周转筐/托盘,扣减门店待回数量(超回拒绝)
|
||||
* @throws Throwable
|
||||
*/
|
||||
#[PostRoute(authorize: 'create')]
|
||||
public function create(ContainerReturnFormRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$boxNum = (int) $data['box_num'];
|
||||
$trayNum = (int) $data['tray_num'];
|
||||
if ($boxNum === 0 && $trayNum === 0) {
|
||||
throw new RepositoryException('周转筐与周转托盘数量不能同时为 0');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($data, $boxNum, $trayNum, $request) {
|
||||
$store = StoreModel::query()->lockForUpdate()->find((int) $data['store_id']);
|
||||
if (empty($store)) {
|
||||
throw new RepositoryException('门店不存在');
|
||||
}
|
||||
if ($boxNum > (int) $store->pending_box_num) {
|
||||
throw new RepositoryException(
|
||||
'回筐数量超过该门店待回筐数量(当前待回 ' . (int) $store->pending_box_num . ' 个)'
|
||||
);
|
||||
}
|
||||
if ($trayNum > (int) $store->pending_tray_num) {
|
||||
throw new RepositoryException(
|
||||
'回托盘数量超过该门店待回托盘数量(当前待回 ' . (int) $store->pending_tray_num . ' 个)'
|
||||
);
|
||||
}
|
||||
|
||||
$store->pending_box_num = (int) $store->pending_box_num - $boxNum;
|
||||
$store->pending_tray_num = (int) $store->pending_tray_num - $trayNum;
|
||||
$store->save();
|
||||
|
||||
ContainerReturnModel::create([
|
||||
'store_id' => $store->id,
|
||||
'bill_id' => 0,
|
||||
'type' => ContainerReturnModel::TYPE_RETURN,
|
||||
'box_num' => $boxNum,
|
||||
'tray_num' => $trayNum,
|
||||
'return_date' => $data['return_date'],
|
||||
'operator_id' => (int) $request->user()->id,
|
||||
'remark' => (string) ($data['remark'] ?? ''),
|
||||
]);
|
||||
|
||||
return $this->success([], '回筐已登记');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除回筐记录(仅手动登记的回筐记录可删,删除后恢复门店待回数量;压筐记录随账单生成不允许删除)
|
||||
* @throws Throwable
|
||||
*/
|
||||
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
|
||||
public function delete(int $id): JsonResponse
|
||||
{
|
||||
$record = ContainerReturnModel::find($id);
|
||||
if (empty($record)) {
|
||||
throw new RepositoryException('回筐记录不存在');
|
||||
}
|
||||
if ($record->type !== ContainerReturnModel::TYPE_RETURN) {
|
||||
throw new RepositoryException('压筐记录由账单生成,不允许删除');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($record) {
|
||||
$store = StoreModel::query()->lockForUpdate()->find($record->store_id);
|
||||
if ($store !== null) {
|
||||
$store->pending_box_num = (int) $store->pending_box_num + (int) $record->box_num;
|
||||
$store->pending_tray_num = (int) $store->pending_tray_num + (int) $record->tray_num;
|
||||
$store->save();
|
||||
}
|
||||
$record->delete();
|
||||
|
||||
return $this->success([], '回筐记录已删除,门店待回数量已恢复');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Recon;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\BillModel;
|
||||
use App\Models\PaymentModel;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\PutRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Modules\Common\Http\Controllers\BaseController;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 支付记录(小程序合并付款提交汇款凭证;后台审核:通过后关联账单批量置已支付,拒绝释放账单)
|
||||
*/
|
||||
#[RequestAttribute('/recon/payment', 'recon.payment')]
|
||||
class PaymentController extends BaseController
|
||||
{
|
||||
protected array $searchField = [
|
||||
'store_id' => '=',
|
||||
'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() . ' 张账单已置为已支付'
|
||||
: '已拒绝,账单已释放可重新付款'
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Recon;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\StatementModel;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Modules\Common\Http\Controllers\BaseController;
|
||||
|
||||
/**
|
||||
* 门店对账单管理(后台只读视角;生成/导出在小程序端)
|
||||
*/
|
||||
#[RequestAttribute('/recon/statement', 'recon.statement')]
|
||||
class StatementController extends BaseController
|
||||
{
|
||||
protected array $searchField = [
|
||||
'statement_no' => 'like',
|
||||
'store_id' => '=',
|
||||
'status' => '=',
|
||||
'period_start' => 'betweenDate',
|
||||
];
|
||||
|
||||
/** 对账单列表 */
|
||||
#[GetRoute(authorize: 'query')]
|
||||
public function query(Request $request): JsonResponse
|
||||
{
|
||||
$params = $request->all();
|
||||
$pageSize = $params['pageSize'] ?? 10;
|
||||
$data = $this->buildSearch($params, StatementModel::query()->with('store:id,name'))
|
||||
->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
|
||||
{
|
||||
$statement = StatementModel::with(['store:id,name', 'items'])->find($id);
|
||||
if (empty($statement)) {
|
||||
throw new RepositoryException('对账单不存在');
|
||||
}
|
||||
return $this->success($statement->toArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Purchase;
|
||||
|
||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
|
||||
/**
|
||||
* 采购单生成账单 验证(按门店提交配送费/周转筐/托盘数量,金额由系统汇总不可修改)
|
||||
*/
|
||||
class PurchaseBillGenerateRequest extends BaseFormRequest
|
||||
{
|
||||
protected $stopOnFirstFailure = true;
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'stores' => 'required|array|min:1',
|
||||
'stores.*.store_id' => 'required|integer|distinct',
|
||||
'stores.*.delivery_fee' => 'required|numeric|min:0',
|
||||
'stores.*.box_num' => 'required|integer|min:0',
|
||||
'stores.*.tray_num' => 'required|integer|min:0',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'stores.required' => '请提交门店账单信息',
|
||||
'stores.array' => '门店账单数据格式错误',
|
||||
'stores.min' => '请提交门店账单信息',
|
||||
'stores.*.store_id.required' => '门店ID不能为空',
|
||||
'stores.*.store_id.integer' => '门店ID格式错误',
|
||||
'stores.*.store_id.distinct' => '存在重复门店',
|
||||
'stores.*.delivery_fee.required' => '配送费不能为空',
|
||||
'stores.*.delivery_fee.numeric' => '配送费格式错误',
|
||||
'stores.*.delivery_fee.min' => '配送费不能小于 0',
|
||||
'stores.*.box_num.required' => '周转筐数量不能为空',
|
||||
'stores.*.box_num.integer' => '周转筐数量必须为整数',
|
||||
'stores.*.box_num.min' => '周转筐数量不能小于 0',
|
||||
'stores.*.tray_num.required' => '周转托盘数量不能为空',
|
||||
'stores.*.tray_num.integer' => '周转托盘数量必须为整数',
|
||||
'stores.*.tray_num.min' => '周转托盘数量不能小于 0',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Purchase;
|
||||
|
||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
|
||||
/**
|
||||
* 采购单周转框/托盘合并记录修改 验证(按门店覆盖全部订单,逐笔重算附加金额)
|
||||
*/
|
||||
class PurchaseContainerUpdateRequest extends BaseFormRequest
|
||||
{
|
||||
protected $stopOnFirstFailure = true;
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'orders' => 'required|array|min:1',
|
||||
'orders.*.order_id' => 'required|integer|distinct',
|
||||
'orders.*.box_num' => 'required|integer|min:0',
|
||||
'orders.*.tray_num' => 'required|integer|min:0',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'orders.required' => '请提交门店订单周转框/托盘数量',
|
||||
'orders.array' => '订单数据格式错误',
|
||||
'orders.min' => '请提交门店订单周转框/托盘数量',
|
||||
'orders.*.order_id.required' => '订单ID不能为空',
|
||||
'orders.*.order_id.integer' => '订单ID格式错误',
|
||||
'orders.*.order_id.distinct' => '存在重复订单',
|
||||
'orders.*.box_num.required' => '周转框数量不能为空',
|
||||
'orders.*.box_num.integer' => '周转框数量必须为整数',
|
||||
'orders.*.box_num.min' => '周转框数量不能小于 0',
|
||||
'orders.*.tray_num.required' => '周转托盘数量不能为空',
|
||||
'orders.*.tray_num.integer' => '周转托盘数量必须为整数',
|
||||
'orders.*.tray_num.min' => '周转托盘数量不能小于 0',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Recon;
|
||||
|
||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
|
||||
/**
|
||||
* 回筐登记 验证(门店退回周转筐/托盘,扣减门店待回数量)
|
||||
*/
|
||||
class ContainerReturnFormRequest extends BaseFormRequest
|
||||
{
|
||||
protected $stopOnFirstFailure = true;
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'store_id' => 'required|integer|exists:store,id',
|
||||
'box_num' => 'required|integer|min:0',
|
||||
'tray_num' => 'required|integer|min:0',
|
||||
'return_date' => 'required|date_format:Y-m-d',
|
||||
'remark' => 'nullable|string|max:255',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'store_id.required' => '请选择门店',
|
||||
'store_id.exists' => '门店不存在',
|
||||
'box_num.required' => '周转筐数量不能为空',
|
||||
'box_num.integer' => '周转筐数量必须为整数',
|
||||
'box_num.min' => '周转筐数量不能小于 0',
|
||||
'tray_num.required' => '周转托盘数量不能为空',
|
||||
'tray_num.integer' => '周转托盘数量必须为整数',
|
||||
'tray_num.min' => '周转托盘数量不能小于 0',
|
||||
'return_date.required' => '请选择退回日期',
|
||||
'return_date.date_format' => '退回日期格式为 Y-m-d',
|
||||
'remark.max' => '备注超过最大长度',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Modules\SystemUser\Models\SysUserModel;
|
||||
|
||||
/**
|
||||
* 门店账单模型(采购单完成后按门店生成;商品金额由订单汇总快照,生成后不可修改)
|
||||
*/
|
||||
class BillModel extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/** 支付状态:未支付 */
|
||||
public const int STATUS_UNPAID = 0;
|
||||
/** 支付状态:已支付 */
|
||||
public const int STATUS_PAID = 1;
|
||||
|
||||
/** 支付状态中文名 */
|
||||
public const array STATUS_NAMES = [
|
||||
self::STATUS_UNPAID => '未支付',
|
||||
self::STATUS_PAID => '已支付',
|
||||
];
|
||||
|
||||
protected $table = 'bill';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $fillable = [
|
||||
'bill_no',
|
||||
'purchase_id',
|
||||
'store_id',
|
||||
'bill_date',
|
||||
'product_amount',
|
||||
'delivery_fee',
|
||||
'box_num',
|
||||
'tray_num',
|
||||
'box_price',
|
||||
'tray_price',
|
||||
'added_amount',
|
||||
'total_amount',
|
||||
'status',
|
||||
'payment_id',
|
||||
'paid_at',
|
||||
'pay_remark',
|
||||
'paid_operator_id',
|
||||
'operator_id',
|
||||
'remark',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'purchase_id' => 'integer',
|
||||
'store_id' => 'integer',
|
||||
'bill_date' => 'date:Y-m-d',
|
||||
'product_amount' => 'decimal:2',
|
||||
'delivery_fee' => 'decimal:2',
|
||||
'box_num' => 'integer',
|
||||
'tray_num' => 'integer',
|
||||
'box_price' => 'decimal:2',
|
||||
'tray_price' => 'decimal:2',
|
||||
'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',
|
||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||
];
|
||||
|
||||
/**
|
||||
* 所属门店(含软删除门店,保证历史账单可见)
|
||||
*/
|
||||
public function store(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StoreModel::class, 'store_id', 'id')->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联采购单
|
||||
*/
|
||||
public function purchase(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成人(后台系统用户)
|
||||
*/
|
||||
public function operator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SysUserModel::class, 'operator_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 收款操作人(后台系统用户,线下收款登记)
|
||||
*/
|
||||
public function paidOperator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SysUserModel::class, 'paid_operator_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联支付记录(小程序合并付款)
|
||||
*/
|
||||
public function payment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PaymentModel::class, 'payment_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 本账单关联的门店订单
|
||||
*/
|
||||
public function orders(): HasMany
|
||||
{
|
||||
return $this->hasMany(StoreOrderModel::class, 'bill_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 本账单关联的门店订单明细
|
||||
*/
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(StoreOrderItemModel::class, 'bill_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\SystemUser\Models\SysUserModel;
|
||||
|
||||
/**
|
||||
* 门店回筐记录模型(压筐=生成账单时自动写入并累加门店待回;回筐=门店退回手动登记并扣减待回)
|
||||
*/
|
||||
class ContainerReturnModel extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/** 类型:压筐(账单生成压出,系统写入只读) */
|
||||
public const int TYPE_PRESS = 1;
|
||||
/** 类型:回筐(门店退回,后台手动登记) */
|
||||
public const int TYPE_RETURN = 2;
|
||||
|
||||
/** 类型中文名 */
|
||||
public const array TYPE_NAMES = [
|
||||
self::TYPE_PRESS => '压筐',
|
||||
self::TYPE_RETURN => '回筐',
|
||||
];
|
||||
|
||||
protected $table = 'container_return';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $fillable = [
|
||||
'store_id',
|
||||
'bill_id',
|
||||
'type',
|
||||
'box_num',
|
||||
'tray_num',
|
||||
'return_date',
|
||||
'operator_id',
|
||||
'remark',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'store_id' => 'integer',
|
||||
'bill_id' => 'integer',
|
||||
'type' => 'integer',
|
||||
'box_num' => 'integer',
|
||||
'tray_num' => 'integer',
|
||||
'return_date' => 'date:Y-m-d',
|
||||
'operator_id' => 'integer',
|
||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||
];
|
||||
|
||||
/**
|
||||
* 所属门店(含软删除门店,保证历史记录可见)
|
||||
*/
|
||||
public function store(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StoreModel::class, 'store_id', 'id')->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联账单(压筐记录)
|
||||
*/
|
||||
public function bill(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BillModel::class, 'bill_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 操作人(后台系统用户)
|
||||
*/
|
||||
public function operator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SysUserModel::class, 'operator_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Modules\SystemTool\Models\SysFileModel;
|
||||
use Modules\SystemUser\Models\SysUserModel;
|
||||
|
||||
/**
|
||||
* 支付记录模型(小程序选择门店账单合并付款,提交汇款凭证;后台审核通过后关联账单批量置已支付)
|
||||
*/
|
||||
class PaymentModel extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/** 支付方式:微信 */
|
||||
public const int METHOD_WECHAT = 1;
|
||||
/** 支付方式:支付宝 */
|
||||
public const int METHOD_ALIPAY = 2;
|
||||
/** 支付方式:对公汇款(银行卡) */
|
||||
public const int METHOD_BANK = 3;
|
||||
|
||||
/** 支付方式中文名 */
|
||||
public const array METHOD_NAMES = [
|
||||
self::METHOD_WECHAT => '微信支付',
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 门店对账单明细模型(快照商品名/单价/数量/金额)
|
||||
*/
|
||||
class StatementItemModel extends Model
|
||||
{
|
||||
/** 未对账 */
|
||||
public const NOT_RECONCILED = 0;
|
||||
/** 已对账 */
|
||||
public const RECONCILED = 1;
|
||||
|
||||
protected $table = 'statement_item';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $fillable = [
|
||||
'statement_id',
|
||||
'order_id',
|
||||
'order_item_id',
|
||||
'product_id',
|
||||
'product_name',
|
||||
'price',
|
||||
'quantity',
|
||||
'weight',
|
||||
'amount',
|
||||
'is_reconciled',
|
||||
'store_remark',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'statement_id' => 'integer',
|
||||
'order_id' => 'integer',
|
||||
'order_item_id' => 'integer',
|
||||
'product_id' => 'integer',
|
||||
'price' => 'decimal:2',
|
||||
'quantity' => 'decimal:2',
|
||||
'weight' => 'decimal:3',
|
||||
'amount' => 'decimal:2',
|
||||
'is_reconciled' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 所属对账单
|
||||
*/
|
||||
public function statement(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StatementModel::class, 'statement_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 来源订单
|
||||
*/
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StoreOrderModel::class, 'order_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 来源订单明细
|
||||
*/
|
||||
public function orderItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StoreOrderItemModel::class, 'order_item_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 对账商品
|
||||
*/
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductModel::class, 'product_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* 门店对账单模型(门店自助生成,快照回款周期:settlement_date = period_end + payment_cycle_days)
|
||||
*/
|
||||
class StatementModel extends Model
|
||||
{
|
||||
/** 状态:待对账 */
|
||||
public const STATUS_PENDING = 0;
|
||||
/** 状态:已对账 */
|
||||
public const STATUS_RECONCILED = 1;
|
||||
/** 状态:已结算 */
|
||||
public const STATUS_SETTLED = 2;
|
||||
|
||||
protected $table = 'statement';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $fillable = [
|
||||
'statement_no',
|
||||
'store_id',
|
||||
'period_start',
|
||||
'period_end',
|
||||
'total_amount',
|
||||
'payment_cycle_days',
|
||||
'settlement_date',
|
||||
'status',
|
||||
'reconciled_at',
|
||||
'settled_at',
|
||||
'remark',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'store_id' => 'integer',
|
||||
'period_start' => 'date:Y-m-d',
|
||||
'period_end' => 'date:Y-m-d',
|
||||
'total_amount' => 'decimal:2',
|
||||
'payment_cycle_days' => 'integer',
|
||||
'settlement_date' => 'date:Y-m-d',
|
||||
'status' => 'integer',
|
||||
'reconciled_at' => 'datetime',
|
||||
'settled_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 所属门店
|
||||
*/
|
||||
public function store(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StoreModel::class, 'store_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 对账单明细
|
||||
*/
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(StatementItemModel::class, 'statement_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,8 @@ class StoreModel extends Model
|
||||
protected $casts = [
|
||||
'level_id' => 'integer',
|
||||
'payment_cycle_days' => 'integer',
|
||||
'pending_box_num' => 'integer',
|
||||
'pending_tray_num' => 'integer',
|
||||
'status' => 'integer',
|
||||
];
|
||||
|
||||
@@ -66,10 +68,10 @@ class StoreModel extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* 门店对账单
|
||||
* 门店账单(采购单完成后按门店生成)
|
||||
*/
|
||||
public function statements(): HasMany
|
||||
public function bills(): HasMany
|
||||
{
|
||||
return $this->hasMany(StatementModel::class, 'store_id', 'id');
|
||||
return $this->hasMany(BillModel::class, 'store_id', 'id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ class StoreOrderItemModel extends Model
|
||||
protected $fillable = [
|
||||
'order_id',
|
||||
'purchase_id',
|
||||
'bill_id',
|
||||
'store_id',
|
||||
'product_id',
|
||||
'category_id',
|
||||
@@ -42,6 +43,7 @@ class StoreOrderItemModel extends Model
|
||||
protected $casts = [
|
||||
'order_id' => 'integer',
|
||||
'purchase_id' => 'integer',
|
||||
'bill_id' => 'integer',
|
||||
'store_id' => 'integer',
|
||||
'product_id' => 'integer',
|
||||
'category_id' => 'integer',
|
||||
|
||||
@@ -48,27 +48,20 @@ class StoreOrderModel extends Model
|
||||
'total_quantity',
|
||||
'total_weight',
|
||||
'total_amount',
|
||||
'product_amount',
|
||||
'added_amount',
|
||||
'box_num',
|
||||
'tray_num',
|
||||
'status',
|
||||
'remark',
|
||||
'purchase_id',
|
||||
'bill_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'store_id' => 'integer',
|
||||
'purchase_id' => 'integer',
|
||||
'statement_id' => 'integer',
|
||||
'bill_id' => 'integer',
|
||||
'order_date' => 'date:Y-m-d',
|
||||
'total_quantity' => 'integer',
|
||||
'total_weight' => 'decimal:3',
|
||||
'total_amount' => 'decimal:2',
|
||||
'product_amount' => 'decimal:2',
|
||||
'added_amount' => 'decimal:2',
|
||||
'box_num' => 'integer',
|
||||
'tray_num' => 'integer',
|
||||
'status' => 'integer',
|
||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||
];
|
||||
@@ -96,4 +89,12 @@ class StoreOrderModel extends Model
|
||||
{
|
||||
return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联账单(采购单完成后按门店生成)
|
||||
*/
|
||||
public function bill(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BillModel::class, 'bill_id', 'id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\BillModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
|
||||
/**
|
||||
* 账单详情数据组装(后台门店账单页与小程序账单详情共用)
|
||||
*/
|
||||
class BillDetailService
|
||||
{
|
||||
/**
|
||||
* 合并后的商品明细:按商品聚合账单关联的全部订单明细
|
||||
* 单价为加权平均口径(Σ金额÷Σ数量),保证 单价×数量=金额
|
||||
*
|
||||
* @return array<int, array{product_id: int, product_name: string, product_spec: string, unit: string, price: string, quantity: int, weight: string, amount: string}>
|
||||
*/
|
||||
public function mergedItems(BillModel $bill): array
|
||||
{
|
||||
$items = StoreOrderItemModel::query()
|
||||
->where('bill_id', $bill->id)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
// 排序键:分类 sort → 商品 sort(与采购单明细矩阵同序)
|
||||
$products = ProductModel::withTrashed()
|
||||
->with('category:id,sort')
|
||||
->whereIn('id', $items->pluck('product_id')->unique())
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$rows = [];
|
||||
foreach ($items->groupBy('product_id') as $productId => $group) {
|
||||
$product = $products->get((int) $productId);
|
||||
$first = $group->first();
|
||||
$quantity = 0;
|
||||
$weight = '0';
|
||||
$amount = '0';
|
||||
foreach ($group as $item) {
|
||||
$quantity += (int) $item->quantity;
|
||||
$weight = bcadd($weight, (string) $item->weight, 3);
|
||||
$amount = bcadd($amount, (string) $item->amount, 2);
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'product_id' => (int) $productId,
|
||||
'product_name' => $first->product_name,
|
||||
'product_spec' => $first->product_spec,
|
||||
'unit' => $first->unit,
|
||||
'price' => $quantity > 0
|
||||
? bcdiv($amount, (string) $quantity, 2)
|
||||
: (string) $first->price,
|
||||
'quantity' => $quantity,
|
||||
'weight' => $weight,
|
||||
'amount' => $amount,
|
||||
'category_sort' => (int) ($product->category->sort ?? 9999),
|
||||
'product_sort' => (int) ($product->sort ?? 9999),
|
||||
];
|
||||
}
|
||||
usort($rows, static fn (array $a, array $b): int =>
|
||||
[$a['category_sort'], $a['product_sort'], $a['product_id']]
|
||||
<=> [$b['category_sort'], $b['product_sort'], $b['product_id']]);
|
||||
|
||||
return array_map(static function (array $row): array {
|
||||
unset($row['category_sort'], $row['product_sort']);
|
||||
return $row;
|
||||
}, $rows);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\BillModel;
|
||||
use App\Models\ContainerReturnModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 门店账单生成(采购单完成后,按门店各生成一张账单)
|
||||
*
|
||||
* 流程(事务内):
|
||||
* 1. 锁定采购单全部有效订单,按门店分组,校验提交门店完整覆盖
|
||||
* 2. 商品金额 = 门店订单商品金额汇总(快照,生成后不可修改)
|
||||
* 3. 附加金额 = 周转筐数量×筐单价 + 托盘数量×托盘单价(单价取站点配置快照)
|
||||
* 4. 总金额 = 商品金额 + 配送费 + 附加金额;回写门店订单 bill_id 完成关联
|
||||
*/
|
||||
readonly class BillGenerateService
|
||||
{
|
||||
public function __construct(private BillNumberService $billNumberService)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PurchaseOrderModel $purchase 已完成采购单
|
||||
* @param array<int, array{store_id: int, delivery_fee: string, box_num: int, tray_num: int}> $stores 按门店提交的配送费/周转筐/托盘数量
|
||||
* @param int $operatorId 生成人(后台系统用户ID)
|
||||
* @return BillModel[] 生成的账单列表
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function generate(PurchaseOrderModel $purchase, array $stores, int $operatorId): array
|
||||
{
|
||||
return DB::transaction(function () use ($purchase, $stores, $operatorId) {
|
||||
// 1. 行锁采购单全部有效订单(并发防护),按门店分组
|
||||
$orders = StoreOrderModel::query()
|
||||
->where('purchase_id', $purchase->id)
|
||||
->whereNull('deleted_at')
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
if ($orders->isEmpty()) {
|
||||
throw new RepositoryException('采购单下无门店订单,无法生成账单');
|
||||
}
|
||||
$ordersByStore = $orders->groupBy('store_id');
|
||||
|
||||
// 提交的门店必须完整覆盖采购单门店,避免漏门店造成订单未入账
|
||||
$submitted = [];
|
||||
foreach ($stores as $row) {
|
||||
$submitted[(int) $row['store_id']] = $row;
|
||||
}
|
||||
$missing = array_diff(array_map('intval', $ordersByStore->keys()->all()), array_keys($submitted));
|
||||
if ($missing !== []) {
|
||||
throw new RepositoryException('提交不完整,缺少门店账单信息:' . implode('、', $missing));
|
||||
}
|
||||
if (array_diff(array_keys($submitted), array_map('intval', $ordersByStore->keys()->all())) !== []) {
|
||||
throw new RepositoryException('包含不属于该采购单的门店');
|
||||
}
|
||||
|
||||
// 2. 防重复生成:任一门店已出账则整批拒绝
|
||||
$billedStoreIds = BillModel::query()
|
||||
->where('purchase_id', $purchase->id)
|
||||
->pluck('store_id')
|
||||
->map(static fn ($id) => (int) $id)
|
||||
->all();
|
||||
if ($billedStoreIds !== []) {
|
||||
$names = StoreModel::withTrashed()
|
||||
->whereIn('id', $billedStoreIds)
|
||||
->pluck('name')
|
||||
->implode('、');
|
||||
throw new RepositoryException('以下门店已生成账单,不允许重复生成:' . $names);
|
||||
}
|
||||
|
||||
// 3. 逐门店生成账单并关联订单
|
||||
$boxPrice = (string) site_config('services.box_amount', 0);
|
||||
$trayPrice = (string) site_config('services.tray_amount', 0);
|
||||
$billDate = now()->toDateString();
|
||||
$bills = [];
|
||||
foreach ($ordersByStore as $storeId => $storeOrders) {
|
||||
$row = $submitted[(int) $storeId];
|
||||
|
||||
$productAmount = $storeOrders->reduce(
|
||||
static fn (string $carry, StoreOrderModel $order): string => bcadd($carry, (string) $order->total_amount, 2),
|
||||
'0'
|
||||
);
|
||||
$deliveryFee = bcadd((string) $row['delivery_fee'], '0', 2);
|
||||
$addedAmount = bcadd(
|
||||
bcmul((string) (int) $row['box_num'], $boxPrice, 2),
|
||||
bcmul((string) (int) $row['tray_num'], $trayPrice, 2),
|
||||
2
|
||||
);
|
||||
$totalAmount = bcadd(bcadd($productAmount, $deliveryFee, 2), $addedAmount, 2);
|
||||
|
||||
$bill = BillModel::create([
|
||||
'bill_no' => $this->billNumberService->make('ZD'),
|
||||
'purchase_id' => $purchase->id,
|
||||
'store_id' => (int) $storeId,
|
||||
'bill_date' => $billDate,
|
||||
'product_amount' => $productAmount,
|
||||
'delivery_fee' => $deliveryFee,
|
||||
'box_num' => (int) $row['box_num'],
|
||||
'tray_num' => (int) $row['tray_num'],
|
||||
'box_price' => bcadd($boxPrice, '0', 2),
|
||||
'tray_price' => bcadd($trayPrice, '0', 2),
|
||||
'added_amount' => $addedAmount,
|
||||
'total_amount' => $totalAmount,
|
||||
'operator_id' => $operatorId,
|
||||
]);
|
||||
|
||||
// 关联该门店在采购单中的全部订单与订单明细到账单
|
||||
StoreOrderModel::query()
|
||||
->whereIn('id', $storeOrders->pluck('id'))
|
||||
->update(['bill_id' => $bill->id]);
|
||||
StoreOrderItemModel::query()
|
||||
->whereIn('order_id', $storeOrders->pluck('id'))
|
||||
->update(['bill_id' => $bill->id]);
|
||||
|
||||
// 压筐:累加门店待回筐/托盘(行锁防并发),并写入压筐记录
|
||||
$store = StoreModel::query()->lockForUpdate()->find((int) $storeId);
|
||||
if ($store !== null) {
|
||||
$store->pending_box_num = (int) $store->pending_box_num + (int) $row['box_num'];
|
||||
$store->pending_tray_num = (int) $store->pending_tray_num + (int) $row['tray_num'];
|
||||
$store->save();
|
||||
}
|
||||
ContainerReturnModel::create([
|
||||
'store_id' => (int) $storeId,
|
||||
'bill_id' => $bill->id,
|
||||
'type' => ContainerReturnModel::TYPE_PRESS,
|
||||
'box_num' => (int) $row['box_num'],
|
||||
'tray_num' => (int) $row['tray_num'],
|
||||
'return_date' => $billDate,
|
||||
'operator_id' => $operatorId,
|
||||
]);
|
||||
|
||||
$bills[] = $bill;
|
||||
}
|
||||
|
||||
return $bills;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -25,14 +25,15 @@ class BillNumberService
|
||||
'PO' => ['purchase_order', 'purchase_no'],
|
||||
'SO' => ['store_order', 'order_no'],
|
||||
'RC' => ['reconciliation', 'recon_no'],
|
||||
'ST' => ['statement', 'statement_no'],
|
||||
'JS' => ['settlement', 'settlement_no'],
|
||||
'ZD' => ['bill', 'bill_no'],
|
||||
'ZF' => ['payment', 'payment_no'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 生成业务单号
|
||||
*
|
||||
* @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / RC 对账 / ST 对账单 / JS 结算
|
||||
* @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / RC 对账 / JS 结算 / ZD 账单 / ZF 支付
|
||||
* @return string 如 PO202607230001
|
||||
*/
|
||||
public function make(string $prefix): string
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\StatementItemModel;
|
||||
use App\Models\StatementModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 门店对账单生成(小程序端自助生成)
|
||||
*
|
||||
* 流程(事务内):
|
||||
* 1. 拉取门店周期内的订单明细(排除已取消订单,按 order_item 去重防止重复入账)
|
||||
* 2. 快照当前 payment_cycle_days,settlement_date = period_end + cycle 天
|
||||
* 3. 明细快照商品名/单价/数量/重量/金额,statement_no = ST…
|
||||
*/
|
||||
class StatementGenerateService
|
||||
{
|
||||
public function __construct(private readonly BillNumberService $billNumberService)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param StoreModel $store 门店(回款周期从此快照)
|
||||
* @param string $periodStart 周期开始(Y-m-d)
|
||||
* @param string $periodEnd 周期结束(Y-m-d)
|
||||
*/
|
||||
public function generate(StoreModel $store, string $periodStart, string $periodEnd): StatementModel
|
||||
{
|
||||
return DB::transaction(function () use ($store, $periodStart, $periodEnd) {
|
||||
$orderItems = StoreOrderItemModel::query()
|
||||
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
|
||||
->where('store_order_item.store_id', $store->id)
|
||||
->whereDate('store_order.order_date', '>=', $periodStart)
|
||||
->whereDate('store_order.order_date', '<=', $periodEnd)
|
||||
->where('store_order.status', '<>', StoreOrderModel::STATUS_CANCELLED)
|
||||
->whereNull('store_order.deleted_at')
|
||||
->select('store_order_item.*')
|
||||
->get();
|
||||
|
||||
if ($orderItems->isEmpty()) {
|
||||
throw new RepositoryException('周期内本店无订单数据,无法生成对账单');
|
||||
}
|
||||
|
||||
// 防重复入账:剔除已计入过对账单的订单明细
|
||||
$usedItemIds = StatementItemModel::query()
|
||||
->whereIn('statement_id', StatementModel::where('store_id', $store->id)->pluck('id'))
|
||||
->pluck('order_item_id');
|
||||
$orderItems = $orderItems->reject(fn ($item) => $usedItemIds->contains($item->id));
|
||||
if ($orderItems->isEmpty()) {
|
||||
throw new RepositoryException('周期内的订单明细均已生成过对账单');
|
||||
}
|
||||
|
||||
// 快照回款周期 → 应结算日期
|
||||
$cycleDays = (int) $store->payment_cycle_days;
|
||||
$totalAmount = $orderItems->reduce(
|
||||
static fn (string $carry, $item): string => bcadd($carry, (string) $item->amount, 2),
|
||||
'0'
|
||||
);
|
||||
|
||||
$statement = StatementModel::create([
|
||||
'statement_no' => $this->billNumberService->make('ST'),
|
||||
'store_id' => $store->id,
|
||||
'period_start' => $periodStart,
|
||||
'period_end' => $periodEnd,
|
||||
'total_amount' => $totalAmount,
|
||||
'payment_cycle_days' => $cycleDays,
|
||||
'settlement_date' => Carbon::parse($periodEnd)->addDays($cycleDays)->toDateString(),
|
||||
'status' => StatementModel::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
$rows = [];
|
||||
$now = now();
|
||||
foreach ($orderItems as $item) {
|
||||
$rows[] = [
|
||||
'statement_id' => $statement->id,
|
||||
'order_id' => $item->order_id,
|
||||
'order_item_id' => $item->id,
|
||||
'product_id' => $item->product_id,
|
||||
'product_name' => $item->product_name,
|
||||
'price' => $item->price,
|
||||
'quantity' => $item->quantity,
|
||||
'weight' => $item->weight,
|
||||
'amount' => $item->amount,
|
||||
'is_reconciled' => StatementItemModel::NOT_RECONCILED,
|
||||
'store_remark' => '',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
StatementItemModel::insert($rows);
|
||||
|
||||
return $statement;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\StoreOrderModel;
|
||||
|
||||
/**
|
||||
* 周转框/周转托盘数量修改(订单附加金额重算)
|
||||
*/
|
||||
readonly class StoreOrderContainerService
|
||||
{
|
||||
/**
|
||||
* 更新订单周转框/托盘数量并重算附加金额与订单总金额。
|
||||
* 历史订单未写商品金额:按「总额 - 附加」反推并回写,保证 总额 = 商品 + 附加 恒成立。
|
||||
*/
|
||||
public function update(StoreOrderModel $order, int $boxNum, int $trayNum): void
|
||||
{
|
||||
$boxPrice = (float) site_config('services.box_amount', 0);
|
||||
$trayPrice = (float) site_config('services.tray_amount', 0);
|
||||
$addedAmount = round($boxNum * $boxPrice + $trayNum * $trayPrice, 2);
|
||||
|
||||
$productAmount = (float) $order->product_amount;
|
||||
if ($productAmount <= 0 && (float) $order->total_amount > 0) {
|
||||
$productAmount = round((float) $order->total_amount - (float) $order->added_amount, 2);
|
||||
}
|
||||
|
||||
$order->box_num = $boxNum;
|
||||
$order->tray_num = $trayNum;
|
||||
$order->product_amount = $productAmount;
|
||||
$order->added_amount = $addedAmount;
|
||||
$order->total_amount = round($productAmount + $addedAmount, 2);
|
||||
$order->save();
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,8 @@ return new class extends Migration
|
||||
$table->string('phone', 20)->default('')->comment('联系电话');
|
||||
$table->string('address', 255)->default('')->comment('门店地址');
|
||||
$table->integer('payment_cycle_days')->default(0)->comment('回款周期(天)');
|
||||
$table->integer('pending_box_num')->default(0)->comment('待回筐数量(生成账单压筐累加,回筐登记扣减)');
|
||||
$table->integer('pending_tray_num')->default(0)->comment('待回托盘数量');
|
||||
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
||||
$table->string('remark', 255)->nullable()->default('')->comment('备注');
|
||||
$table->timestamps();
|
||||
|
||||
@@ -19,15 +19,11 @@ return new class extends Migration
|
||||
$table->string('order_no', 32)->unique()->comment('订单编号');
|
||||
$table->integer('store_id')->comment('门店ID');
|
||||
$table->integer('purchase_id')->nullable()->comment('关联采购单ID');
|
||||
$table->integer('statement_id')->nullable()->comment('关联账单ID');
|
||||
$table->integer('bill_id')->nullable()->comment('关联账单ID');
|
||||
$table->date('order_date')->comment('订货日期');
|
||||
$table->integer('total_quantity')->default(0)->comment('订货总量');
|
||||
$table->decimal('total_weight', 10, 3)->default(0)->comment('总重量');
|
||||
$table->decimal('total_amount', 10, 2)->default(0)->comment('订单总金额');
|
||||
$table->decimal('product_amount', 10, 2)->default(0)->comment('商品总金额');
|
||||
$table->decimal('added_amount', 10, 2)->default(0)->comment('附加金额');
|
||||
$table->decimal('box_num', 10, 2)->default(0)->comment('周转框数量');
|
||||
$table->decimal('tray_num', 10, 2)->default(0)->comment('周转托盘数量');
|
||||
$table->integer('status')->default(0)->comment('订单状态(0待接单 1已接单 2采购中 3配送中 4已完成 9已取消)');
|
||||
$table->string('remark', 255)->default('')->comment('订单备注');
|
||||
$table->softDeletes();
|
||||
@@ -45,6 +41,7 @@ return new class extends Migration
|
||||
$table->integer('order_id')->comment('订单ID');
|
||||
$table->integer('store_id')->comment('门店ID');
|
||||
$table->integer('purchase_id')->default(0)->comment('归属采购单ID(0=未归集)');
|
||||
$table->integer('bill_id')->default(0)->comment('归属账单ID(0=未出账)');
|
||||
$table->integer('product_id')->comment('商品ID');
|
||||
$table->integer('category_id')->default(0)->comment('分类ID');
|
||||
$table->integer('supplier_id')->default(0)->comment('供应商ID');
|
||||
@@ -63,6 +60,7 @@ return new class extends Migration
|
||||
$table->timestamps();
|
||||
$table->index(['order_id'], 'store_order_item_order_index');
|
||||
$table->index(['purchase_id'], 'store_order_item_purchase_index');
|
||||
$table->index(['bill_id'], 'store_order_item_bill_index');
|
||||
$table->index(['store_id', 'product_id'], 'store_order_item_store_product_index');
|
||||
$table->comment('门店订货明细表');
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
* 对账管理(D1-D10):财务对账、门店对账单、结算表
|
||||
* 财务管理(D1-D10):财务对账、结算表(门店对账单已下线,由采购单账单 bill 表替代)
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
@@ -58,48 +58,6 @@ return new class extends Migration
|
||||
});
|
||||
}
|
||||
|
||||
// 门店对账单表(门店在小程序端自助生成,回款周期快照决定应结算日期)
|
||||
if (! Schema::hasTable('statement')) {
|
||||
Schema::create('statement', function (Blueprint $table) {
|
||||
$table->increments('id')->comment('对账单ID');
|
||||
$table->string('statement_no', 32)->unique()->comment('对账单编号');
|
||||
$table->integer('store_id')->comment('门店ID');
|
||||
$table->date('period_start')->comment('对账周期开始');
|
||||
$table->date('period_end')->comment('对账周期结束');
|
||||
$table->decimal('total_amount', 10, 2)->default(0)->comment('对账总金额');
|
||||
$table->integer('payment_cycle_days')->default(0)->comment('回款周期(天),生成时从门店快照');
|
||||
$table->date('settlement_date')->nullable()->comment('应结算日期(按回款周期计算)');
|
||||
$table->integer('status')->default(0)->comment('状态(0未对账 1已对账 2已结算)');
|
||||
$table->timestamp('reconciled_at')->nullable()->comment('对账完成时间');
|
||||
$table->timestamp('settled_at')->nullable()->comment('结算时间');
|
||||
$table->string('remark', 255)->default('')->comment('备注');
|
||||
$table->timestamps();
|
||||
$table->index(['store_id', 'period_start'], 'statement_store_period_index');
|
||||
$table->comment('门店对账单表');
|
||||
});
|
||||
}
|
||||
|
||||
// 门店对账单明细表(每个单品/订单的对账状态标识)
|
||||
if (! Schema::hasTable('statement_item')) {
|
||||
Schema::create('statement_item', function (Blueprint $table) {
|
||||
$table->increments('id')->comment('明细ID');
|
||||
$table->integer('statement_id')->comment('对账单ID');
|
||||
$table->integer('order_id')->comment('订单ID');
|
||||
$table->integer('order_item_id')->comment('订货明细ID');
|
||||
$table->integer('product_id')->comment('商品ID');
|
||||
$table->string('product_name', 100)->comment('品名(快照)');
|
||||
$table->decimal('price', 10, 2)->default(0)->comment('单价');
|
||||
$table->decimal('quantity', 10, 2)->default(0)->comment('订货量');
|
||||
$table->decimal('weight', 10, 3)->default(0)->comment('重量');
|
||||
$table->decimal('amount', 10, 2)->default(0)->comment('单品金额');
|
||||
$table->integer('is_reconciled')->default(0)->comment('对账状态(1已对账 0未对账)');
|
||||
$table->string('store_remark', 255)->default('')->comment('门店备注');
|
||||
$table->timestamps();
|
||||
$table->index(['statement_id'], 'statement_item_statement_index');
|
||||
$table->comment('门店对账单明细表');
|
||||
});
|
||||
}
|
||||
|
||||
// 结算表(D9 对账结束后生成结算表/回框统计表,D10 下载存档)
|
||||
if (! Schema::hasTable('settlement')) {
|
||||
Schema::create('settlement', function (Blueprint $table) {
|
||||
@@ -131,8 +89,6 @@ return new class extends Migration
|
||||
{
|
||||
Schema::dropIfExists('reconciliation');
|
||||
Schema::dropIfExists('reconciliation_item');
|
||||
Schema::dropIfExists('statement');
|
||||
Schema::dropIfExists('statement_item');
|
||||
Schema::dropIfExists('settlement');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
* 门店账单:采购单完成后按门店生成;周转筐/托盘与附加金额从门店订单迁入账单
|
||||
* (store_order 的列变动已按约定折进 2026_07_23_030610 原迁移文件,本文件仅建账单表)
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 门店账单表(每个客户单独一张,商品金额由订单汇总不可修改)
|
||||
if (! Schema::hasTable('bill')) {
|
||||
Schema::create('bill', function (Blueprint $table) {
|
||||
$table->increments('id')->comment('账单ID');
|
||||
$table->string('bill_no', 32)->unique()->comment('账单编号');
|
||||
$table->integer('purchase_id')->comment('关联采购单ID');
|
||||
$table->integer('store_id')->comment('门店ID');
|
||||
$table->date('bill_date')->comment('账单日期');
|
||||
$table->decimal('product_amount', 10, 2)->default(0)->comment('商品金额(订单商品金额汇总,不可修改)');
|
||||
$table->decimal('delivery_fee', 10, 2)->default(0)->comment('配送费(生成账单时填写)');
|
||||
$table->integer('box_num')->default(0)->comment('周转筐数量');
|
||||
$table->integer('tray_num')->default(0)->comment('周转托盘数量');
|
||||
$table->decimal('box_price', 10, 2)->default(0)->comment('周转筐单价(生成时快照)');
|
||||
$table->decimal('tray_price', 10, 2)->default(0)->comment('周转托盘单价(生成时快照)');
|
||||
$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)');
|
||||
$table->integer('operator_id')->default(0)->comment('生成人(后台系统用户ID)');
|
||||
$table->string('remark', 255)->default('')->comment('备注');
|
||||
$table->timestamps();
|
||||
$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('门店账单表(采购单完成后按门店生成)');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('bill');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
* 门店回筐台账:压筐(生成账单时自动写入)+ 回筐(门店退回,后台手动登记)
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('container_return')) {
|
||||
Schema::create('container_return', function (Blueprint $table) {
|
||||
$table->increments('id')->comment('记录ID');
|
||||
$table->integer('store_id')->comment('门店ID');
|
||||
$table->integer('bill_id')->default(0)->comment('关联账单ID(0=手动回筐登记)');
|
||||
$table->integer('type')->comment('类型(1压筐-账单生成 2回筐-退回登记)');
|
||||
$table->integer('box_num')->default(0)->comment('周转筐数量');
|
||||
$table->integer('tray_num')->default(0)->comment('周转托盘数量');
|
||||
$table->date('return_date')->comment('记录日期(压筐=账单日期,回筐=退回日期)');
|
||||
$table->integer('operator_id')->default(0)->comment('操作人(后台系统用户ID)');
|
||||
$table->string('remark', 255)->default('')->comment('备注');
|
||||
$table->timestamps();
|
||||
$table->index(['store_id', 'type'], 'container_return_store_type_index');
|
||||
$table->index(['bill_id'], 'container_return_bill_index');
|
||||
$table->comment('门店回筐记录表(压筐=生成账单压出,回筐=门店退回登记)');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('container_return');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
* 支付记录:小程序端选择门店账单合并付款,提交汇款凭证,后台审核通过后账单批量置已支付
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('payment')) {
|
||||
Schema::create('payment', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -91,6 +91,18 @@ class PermissionSeeder extends Seeder
|
||||
['type' => 'rule', 'key' => 'product.goods.batchPrice', 'name' => '批量调价'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'route',
|
||||
'key' => 'customer.supplier',
|
||||
'name' => '供应商',
|
||||
'path' => '/customer/supplier',
|
||||
'children' => [
|
||||
['type' => 'rule', 'key' => 'customer.supplier.query', 'name' => '查询'],
|
||||
['type' => 'rule', 'key' => 'customer.supplier.create', 'name' => '新增'],
|
||||
['type' => 'rule', 'key' => 'customer.supplier.update', 'name' => '编辑'],
|
||||
['type' => 'rule', 'key' => 'customer.supplier.delete', 'name' => '删除'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
@@ -123,18 +135,6 @@ class PermissionSeeder extends Seeder
|
||||
['type' => 'rule', 'key' => 'customer.level.delete', 'name' => '删除'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'route',
|
||||
'key' => 'customer.supplier',
|
||||
'name' => '供应商',
|
||||
'path' => '/customer/supplier',
|
||||
'children' => [
|
||||
['type' => 'rule', 'key' => 'customer.supplier.query', 'name' => '查询'],
|
||||
['type' => 'rule', 'key' => 'customer.supplier.create', 'name' => '新增'],
|
||||
['type' => 'rule', 'key' => 'customer.supplier.update', 'name' => '编辑'],
|
||||
['type' => 'rule', 'key' => 'customer.supplier.delete', 'name' => '删除'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'route',
|
||||
'key' => 'customer.miniUser',
|
||||
@@ -193,6 +193,7 @@ class PermissionSeeder extends Seeder
|
||||
['type' => 'rule', 'key' => 'purchase.order.query', 'name' => '查询'],
|
||||
['type' => 'rule', 'key' => 'purchase.order.update', 'name' => '修改'],
|
||||
['type' => 'rule', 'key' => 'purchase.order.generate', 'name' => '生成采购单'],
|
||||
['type' => 'rule', 'key' => 'purchase.order.bill', 'name' => '生成账单'],
|
||||
['type' => 'rule', 'key' => 'purchase.order.export', 'name' => '导出'],
|
||||
],
|
||||
],
|
||||
@@ -201,7 +202,7 @@ class PermissionSeeder extends Seeder
|
||||
[
|
||||
'type' => 'menu',
|
||||
'key' => 'procurement.recon',
|
||||
'name' => '对账管理',
|
||||
'name' => '财务管理',
|
||||
'icon' => 'AccountBookOutlined',
|
||||
'children' => [
|
||||
[
|
||||
@@ -222,11 +223,33 @@ class PermissionSeeder extends Seeder
|
||||
],
|
||||
[
|
||||
'type' => 'route',
|
||||
'key' => 'recon.statement',
|
||||
'name' => '门店对账单',
|
||||
'path' => '/recon/statement',
|
||||
'key' => 'recon.bill',
|
||||
'name' => '门店账单',
|
||||
'path' => '/recon/bill',
|
||||
'children' => [
|
||||
['type' => 'rule', 'key' => 'recon.statement.query', 'name' => '查询'],
|
||||
['type' => 'rule', 'key' => 'recon.bill.query', 'name' => '查询'],
|
||||
['type' => 'rule', 'key' => 'recon.bill.pay', 'name' => '确认收款'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'route',
|
||||
'key' => 'recon.containerReturn',
|
||||
'name' => '回筐记录',
|
||||
'path' => '/recon/container-return',
|
||||
'children' => [
|
||||
['type' => 'rule', 'key' => 'recon.containerReturn.query', 'name' => '查询'],
|
||||
['type' => 'rule', 'key' => 'recon.containerReturn.create', 'name' => '回筐登记'],
|
||||
['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' => '审核'],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: SimHei, sans-serif; font-size: 12px; color: #333; }
|
||||
h2 { text-align: center; margin: 0 0 12px; }
|
||||
.meta { margin-bottom: 10px; line-height: 1.8; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { border: 1px solid #999; padding: 4px 6px; }
|
||||
th { background: #f0f0f0; }
|
||||
.text-right { text-align: right; }
|
||||
tfoot td { font-weight: bold; background: #fafafa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>门店对账单</h2>
|
||||
<div class="meta">
|
||||
对账单号:{{ $statement->statement_no }} 门店:{{ $storeName }}<br>
|
||||
对账周期:{{ $statement->period_start?->format('Y-m-d') }} 至 {{ $statement->period_end?->format('Y-m-d') }}
|
||||
回款周期:{{ $statement->payment_cycle_days }} 天
|
||||
应结算日期:{{ $statement->settlement_date?->format('Y-m-d') }}
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>品名</th>
|
||||
<th>单价</th>
|
||||
<th>数量</th>
|
||||
<th>重量</th>
|
||||
<th>金额</th>
|
||||
<th>对账状态</th>
|
||||
<th>备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($items as $item)
|
||||
<tr>
|
||||
<td>{{ $item->product_name }}</td>
|
||||
<td class="text-right">{{ $item->price }}</td>
|
||||
<td class="text-right">{{ $item->quantity }}</td>
|
||||
<td class="text-right">{{ $item->weight }}</td>
|
||||
<td class="text-right">{{ $item->amount }}</td>
|
||||
<td>{{ $item->is_reconciled ? '已对账' : '未对账' }}</td>
|
||||
<td>{{ $item->store_remark }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="4" class="text-right">合计金额</td>
|
||||
<td class="text-right">¥{{ $statement->total_amount }}</td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,9 +11,6 @@ use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\SupplierModel;
|
||||
use App\Models\UserModel;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
|
||||
use Modules\SystemTool\Models\SysSiteConfigItemsModel;
|
||||
|
||||
/**
|
||||
* C4 采购单数据修改:详情矩阵(商品行 × 门店列)、门店单元格下钻编辑/同步、行级成本,
|
||||
@@ -55,23 +52,6 @@ class PurchaseEditTest extends ProcurementTestCase
|
||||
return [PurchaseOrderModel::first(), $product, [$storeA, $storeB]];
|
||||
}
|
||||
|
||||
/** 播种站点配置:周转框单价 2.00、周转托盘单价 10.00 */
|
||||
private function seedContainerConfig(): void
|
||||
{
|
||||
$group = SysSiteConfigGroupModel::create(['title' => '业务配置', 'key' => 'services']);
|
||||
foreach ([['box_amount', '周转框单价', '2.00'], ['tray_amount', '周转托盘单价', '10.00']] as [$key, $title, $value]) {
|
||||
SysSiteConfigItemsModel::create([
|
||||
'group_id' => $group->id,
|
||||
'key' => $key,
|
||||
'title' => $title,
|
||||
'type' => 'InputNumber',
|
||||
'values' => $value,
|
||||
'sort' => 0,
|
||||
]);
|
||||
}
|
||||
Cache::forget('site_config');
|
||||
}
|
||||
|
||||
/** 详情返回「商品行 × 门店列」矩阵:cells 按门店聚合数量 */
|
||||
public function test_detail_returns_store_matrix(): void
|
||||
{
|
||||
@@ -460,148 +440,4 @@ class PurchaseEditTest extends ProcurementTestCase
|
||||
->assertOk()
|
||||
->assertJsonPath('data.items.0.editable', false);
|
||||
}
|
||||
|
||||
/** 详情返回周转框/托盘合并记录:按门店聚合全部订单(含底层订单明细与单价) */
|
||||
public function test_detail_returns_container_summary(): void
|
||||
{
|
||||
$this->seedContainerConfig();
|
||||
[$purchase, , $stores] = $this->buildPurchase();
|
||||
|
||||
StoreOrderModel::where('store_id', $stores[0]->id)->update(['box_num' => 2, 'tray_num' => 1]);
|
||||
StoreOrderModel::where('store_id', $stores[1]->id)->update(['box_num' => 3, 'tray_num' => 0]);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$containers = $this->getJson("/purchase/order/{$purchase->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true)
|
||||
->json('data.containers');
|
||||
$this->assertCount(2, $containers);
|
||||
$this->assertSame($stores[0]->id, $containers[0]['store_id'], '与门店列同序');
|
||||
|
||||
$row = $containers[0];
|
||||
$this->assertSame($stores[0]->name, $row['store_name']);
|
||||
$this->assertSame(2, $row['box_num']);
|
||||
$this->assertSame(1, $row['tray_num']);
|
||||
$this->assertSame('2.00', $row['box_price']);
|
||||
$this->assertSame('10.00', $row['tray_price']);
|
||||
$this->assertSame('14.00', $row['added_amount'], '2×2.00 + 1×10.00');
|
||||
$this->assertSame(1, $row['order_count']);
|
||||
$this->assertCount(1, $row['orders']);
|
||||
|
||||
$order = $row['orders'][0];
|
||||
$this->assertStringStartsWith('SO', $order['order_no']);
|
||||
$this->assertSame(2, $order['box_num']);
|
||||
$this->assertSame(1, $order['tray_num']);
|
||||
|
||||
$this->assertSame(3, $containers[1]['box_num']);
|
||||
$this->assertSame('6.00', $containers[1]['added_amount']);
|
||||
}
|
||||
|
||||
/** 合并记录修改:逐笔更新订单数量并重算附加金额与订单总金额,其他门店不受影响 */
|
||||
public function test_update_container_syncs_order_amounts(): void
|
||||
{
|
||||
$this->seedContainerConfig();
|
||||
[$purchase, , $stores] = $this->buildPurchase();
|
||||
$order = StoreOrderModel::where('store_id', $stores[0]->id)->first();
|
||||
$this->assertSame('20.00', (string) $order->product_amount, '下单时应写入商品金额');
|
||||
|
||||
$this->putJson("/purchase/order/{$purchase->id}/container/{$stores[0]->id}", [
|
||||
'orders' => [['order_id' => $order->id, 'box_num' => 3, 'tray_num' => 1]],
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$order->refresh();
|
||||
$this->assertSame(3, $order->box_num);
|
||||
$this->assertSame(1, $order->tray_num);
|
||||
$this->assertSame('16.00', (string) $order->added_amount, '3×2.00 + 1×10.00');
|
||||
$this->assertSame('20.00', (string) $order->product_amount);
|
||||
$this->assertSame('36.00', (string) $order->total_amount, '20.00 + 16.00');
|
||||
|
||||
$other = StoreOrderModel::where('store_id', $stores[1]->id)->first();
|
||||
$this->assertSame(0, $other->box_num, '其他门店订单不受影响');
|
||||
$this->assertSame('30.00', (string) $other->total_amount);
|
||||
}
|
||||
|
||||
/** 采购单已完成:合并记录修改拒绝,数量与金额不变 */
|
||||
public function test_update_container_rejected_when_purchase_completed(): void
|
||||
{
|
||||
$this->seedContainerConfig();
|
||||
[$purchase, , $stores] = $this->buildPurchase();
|
||||
$order = StoreOrderModel::where('store_id', $stores[0]->id)->first();
|
||||
$purchase->update(['status' => PurchaseOrderModel::STATUS_COMPLETED]);
|
||||
|
||||
$this->putJson("/purchase/order/{$purchase->id}/container/{$stores[0]->id}", [
|
||||
'orders' => [['order_id' => $order->id, 'box_num' => 5, 'tray_num' => 5]],
|
||||
])->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '采购单已完成,不允许修改周转框/托盘');
|
||||
|
||||
$this->assertSame(0, $order->fresh()->box_num, '被拒绝后数量不变');
|
||||
}
|
||||
|
||||
/** 提交必须覆盖该门店全部订单;跨门店/跨采购单订单拒绝 */
|
||||
public function test_update_container_rejects_incomplete_or_foreign_orders(): void
|
||||
{
|
||||
$this->seedContainerConfig();
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 10.00]);
|
||||
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
foreach ([1, 2] as $qty) {
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]])
|
||||
->assertJsonPath('success', true);
|
||||
}
|
||||
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', true);
|
||||
$purchase = PurchaseOrderModel::first();
|
||||
$orders = StoreOrderModel::where('purchase_id', $purchase->id)->orderBy('id')->get();
|
||||
$this->assertCount(2, $orders, '同一门店两笔订单');
|
||||
|
||||
// 只提交一笔 → 缺少另一笔,整批拒绝
|
||||
$this->putJson("/purchase/order/{$purchase->id}/container/{$store->id}", [
|
||||
'orders' => [['order_id' => $orders[0]->id, 'box_num' => 1, 'tray_num' => 0]],
|
||||
])->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '提交不完整,缺少订单:' . $orders[1]->id);
|
||||
|
||||
// 混入其他门店订单(同一采购单另一门店)→ 拒绝
|
||||
$otherStore = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($otherStore->id)->create());
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
|
||||
->assertJsonPath('success', true);
|
||||
$foreign = StoreOrderModel::where('store_id', $otherStore->id)->first();
|
||||
$this->actingAsSysUser();
|
||||
$this->putJson("/purchase/order/{$purchase->id}/container/{$store->id}", [
|
||||
'orders' => [
|
||||
['order_id' => $orders[0]->id, 'box_num' => 1, 'tray_num' => 0],
|
||||
['order_id' => $orders[1]->id, 'box_num' => 1, 'tray_num' => 0],
|
||||
['order_id' => $foreign->id, 'box_num' => 1, 'tray_num' => 0],
|
||||
],
|
||||
])->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '包含不属于该采购单该门店的订单');
|
||||
|
||||
$this->assertSame(0, $orders[0]->fresh()->box_num, '被拒绝后数量不变');
|
||||
}
|
||||
|
||||
/** 数量为负/重复订单时校验失败 */
|
||||
public function test_update_container_validates_negative_and_duplicate_orders(): void
|
||||
{
|
||||
$this->seedContainerConfig();
|
||||
[$purchase, , $stores] = $this->buildPurchase();
|
||||
$order = StoreOrderModel::where('store_id', $stores[0]->id)->first();
|
||||
|
||||
$this->putJson("/purchase/order/{$purchase->id}/container/{$stores[0]->id}", [
|
||||
'orders' => [['order_id' => $order->id, 'box_num' => -1, 'tray_num' => 0]],
|
||||
])->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '周转框数量不能小于 0');
|
||||
|
||||
$this->putJson("/purchase/order/{$purchase->id}/container/{$stores[0]->id}", [
|
||||
'orders' => [
|
||||
['order_id' => $order->id, 'box_num' => 1, 'tray_num' => 0],
|
||||
['order_id' => $order->id, 'box_num' => 1, 'tray_num' => 0],
|
||||
],
|
||||
])->assertJsonPath('success', false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\StatementModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* 门店对账单:回款周期快照(settlement_date = period_end + cycle)、门店数据隔离、防重复入账
|
||||
*/
|
||||
class StatementTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* @return array{0: StoreModel, 1: UserModel, 2: ProductModel}
|
||||
*/
|
||||
private function makeStoreWithOrder(string $qty = '2.00', int $cycleDays = 7): array
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create([
|
||||
'level_id' => $level->id,
|
||||
'payment_cycle_days' => $cycleDays,
|
||||
]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => '10.00']);
|
||||
|
||||
$user = UserModel::factory()->forStore($store->id)->create();
|
||||
$this->actingAsMiniUser($user);
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
return [$store, $user, $product];
|
||||
}
|
||||
|
||||
/** 回款周期快照:settlement_date = period_end + cycle 天 */
|
||||
public function test_generate_snapshots_payment_cycle(): void
|
||||
{
|
||||
[$store] = $this->makeStoreWithOrder('2.00', 7);
|
||||
$today = now()->toDateString();
|
||||
|
||||
$response = $this->postJson('/mini/statement/generate', [
|
||||
'period_start' => $today,
|
||||
'period_end' => $today,
|
||||
]);
|
||||
$response->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$statement = StatementModel::where('store_id', $store->id)->first();
|
||||
$this->assertNotNull($statement);
|
||||
$this->assertStringStartsWith('ST', $statement->statement_no);
|
||||
$this->assertSame(7, $statement->payment_cycle_days, '快照生成时的回款周期');
|
||||
$this->assertSame(now()->addDays(7)->toDateString(), $statement->settlement_date->toDateString());
|
||||
$this->assertSame('20.00', (string) $statement->total_amount);
|
||||
$this->assertSame(1, $statement->items()->count());
|
||||
|
||||
// 生成后门店修改回款周期,不影响已生成的对账单(快照语义)
|
||||
$store->update(['payment_cycle_days' => 30]);
|
||||
$this->assertSame(7, $statement->fresh()->payment_cycle_days);
|
||||
}
|
||||
|
||||
/** 门店隔离:只能查看与生成本店对账单 */
|
||||
public function test_statement_isolated_between_stores(): void
|
||||
{
|
||||
[$storeA] = $this->makeStoreWithOrder();
|
||||
$today = now()->toDateString();
|
||||
$this->postJson('/mini/statement/generate', ['period_start' => $today, 'period_end' => $today])
|
||||
->assertJsonPath('success', true);
|
||||
$statementOfA = StatementModel::where('store_id', $storeA->id)->first();
|
||||
|
||||
// 门店 B 用户
|
||||
$storeB = StoreModel::factory()->create(['level_id' => $storeA->level_id]);
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($storeB->id)->create());
|
||||
|
||||
$this->getJson('/mini/statement')->assertJsonPath('data.total', 0);
|
||||
$this->getJson("/mini/statement/{$statementOfA->id}")->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
/** 已取消订单不计入;重复生成时已入账明细被排除 */
|
||||
public function test_generate_excludes_cancelled_and_used_items(): void
|
||||
{
|
||||
[$store] = $this->makeStoreWithOrder('2.00');
|
||||
// 再下一单并取消
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => ProductModel::first()->id, 'quantity' => 5]]]);
|
||||
$cancelledOrder = StoreOrderModel::where('store_id', $store->id)
|
||||
->orderBy('id', 'desc')
|
||||
->first();
|
||||
$this->putJson("/mini/order/{$cancelledOrder->id}/cancel")->assertJsonPath('success', true);
|
||||
|
||||
$today = now()->toDateString();
|
||||
$this->postJson('/mini/statement/generate', ['period_start' => $today, 'period_end' => $today])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$statement = StatementModel::where('store_id', $store->id)->first();
|
||||
$this->assertSame('20.00', (string) $statement->total_amount, '已取消订单不计入');
|
||||
$this->assertSame(1, $statement->items()->count());
|
||||
|
||||
// 同周期重复生成 → 明细已全部入账,拒绝
|
||||
$this->postJson('/mini/statement/generate', ['period_start' => $today, 'period_end' => $today])
|
||||
->assertJsonPath('success', false);
|
||||
$this->assertSame(1, StatementModel::where('store_id', $store->id)->count());
|
||||
}
|
||||
}
|
||||
@@ -122,12 +122,11 @@ class StoreOrderItemTest extends ProcurementTestCase
|
||||
->assertJsonPath('data.items.0.supplier.name', $supplier->name);
|
||||
}
|
||||
|
||||
/** 修改明细:重算单品金额与订单总量/总额(总额 = 商品 + 附加) */
|
||||
/** 修改明细:重算单品金额与订单总量/总额(总额 = 商品金额,附加金额已迁入账单) */
|
||||
public function test_update_item_recalculates_order_totals(): void
|
||||
{
|
||||
[$store, $product, $user] = $this->makeStoreWithProduct('5.00');
|
||||
$order = $this->placeOrder($product, $user, 2, StoreOrderModel::STATUS_SUMMARIZED);
|
||||
$order->update(['added_amount' => '6.00']); // 已有附加金额
|
||||
$item = $order->items->first();
|
||||
$supplier = SupplierModel::factory()->create();
|
||||
|
||||
@@ -158,7 +157,7 @@ class StoreOrderItemTest extends ProcurementTestCase
|
||||
$this->assertSame(5, $order->total_quantity, '订货总量 = 明细合计');
|
||||
$this->assertSame('2.500', (string) $order->total_weight, '总重量 = 明细合计');
|
||||
$this->assertSame('30.00', (string) $order->product_amount, '商品总金额 = 明细金额合计');
|
||||
$this->assertSame('36.00', (string) $order->total_amount, '订单总金额 = 商品 30 + 附加 6');
|
||||
$this->assertSame('30.00', (string) $order->total_amount, '订单总金额 = 商品金额');
|
||||
}
|
||||
|
||||
/** 已完成/已取消订单不允许修改明细 */
|
||||
|
||||
+19
-13
@@ -1,6 +1,7 @@
|
||||
import createAxios from '@/utils/request';
|
||||
import { downloadBlob } from '@/api/common/download.ts';
|
||||
import type {
|
||||
IBillPrepare,
|
||||
IPurchaseCell,
|
||||
IPurchaseDetail,
|
||||
IPurchaseStoreSummary,
|
||||
@@ -23,9 +24,10 @@ export interface PurchaseCellUpdateParams {
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
/** 采购单周转框/托盘合并记录修改:单笔订单的框/托盘数量 */
|
||||
export interface PurchaseContainerOrderParams {
|
||||
order_id: number;
|
||||
/** 生成账单:单个门店的配送费/周转筐/托盘数量 */
|
||||
export interface BillGenerateStoreParams {
|
||||
store_id: number;
|
||||
delivery_fee: number;
|
||||
box_num: number;
|
||||
tray_num: number;
|
||||
}
|
||||
@@ -84,16 +86,20 @@ export async function getPurchaseStoreSummary(purchaseId: number, storeId: numbe
|
||||
});
|
||||
}
|
||||
|
||||
/** 采购单周转框/托盘合并记录修改:按门店覆盖全部订单逐笔更新(自动重算附加金额与订单总金额) */
|
||||
export async function updatePurchaseContainer(
|
||||
purchaseId: number,
|
||||
storeId: number,
|
||||
orders: PurchaseContainerOrderParams[],
|
||||
) {
|
||||
return createAxios({
|
||||
url: `/purchase/order/${purchaseId}/container/${storeId}`,
|
||||
method: 'put',
|
||||
data: { orders },
|
||||
/** 账单生成预览:按门店汇总商品金额(金额只读) */
|
||||
export async function getBillPrepare(purchaseId: number) {
|
||||
return createAxios<IBillPrepare>({
|
||||
url: `/purchase/order/${purchaseId}/bill/prepare`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成账单:已完成采购单按门店各生成一张(关联采购单全部订单,金额由系统汇总不可修改) */
|
||||
export async function generateBill(purchaseId: number, stores: BillGenerateStoreParams[]) {
|
||||
return createAxios<{ count: number }>({
|
||||
url: `/purchase/order/${purchaseId}/bill`,
|
||||
method: 'post',
|
||||
data: { stores },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import createAxios from '@/utils/request';
|
||||
import type { IBillDetail } from '@/domain/iBill.ts';
|
||||
|
||||
/** 账单详情(账单信息 + 合并商品明细 + 关联订单) */
|
||||
export async function getBillDetail(id: number) {
|
||||
return createAxios<IBillDetail>({
|
||||
url: `/recon/bill/${id}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** 确认收款:线下收款后手动登记付款信息,支付状态置为已支付 */
|
||||
export async function payBill(id: number, data: { paid_at: string; pay_remark?: string }) {
|
||||
return createAxios({
|
||||
url: `/recon/bill/${id}/pay`,
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import createAxios from '@/utils/request';
|
||||
import type { IPaymentDetail } from '@/domain/iPayment.ts';
|
||||
|
||||
/** 支付记录详情(支付信息 + 凭证图片 + 合并账单) */
|
||||
export async function getPaymentDetail(id: number) {
|
||||
return createAxios<IPaymentDetail>({
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { IBill } from '@/domain/iPurchaseOrder.ts';
|
||||
|
||||
/** 门店账单(采购单完成后按门店生成),类型定义在 iPurchaseOrder.ts */
|
||||
export type { IBill };
|
||||
export { BILL_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
|
||||
|
||||
/** 账单合并商品明细行(按商品聚合账单关联的全部订单明细) */
|
||||
export interface IBillGoodsItem {
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
/** 规格/包规 */
|
||||
product_spec: string;
|
||||
unit: string;
|
||||
/** 单价(加权平均:Σ金额÷Σ数量) */
|
||||
price: string;
|
||||
/** 数量合计 */
|
||||
quantity: number;
|
||||
/** 重量合计 */
|
||||
weight: string;
|
||||
/** 金额合计 = Σ 明细金额 */
|
||||
amount: string;
|
||||
}
|
||||
|
||||
/** 账单关联的门店订单 */
|
||||
export interface IBillOrder {
|
||||
id: number;
|
||||
order_no: string;
|
||||
order_date: string;
|
||||
total_quantity: number;
|
||||
total_weight: string;
|
||||
total_amount: string;
|
||||
/** 0待接单 1已接单 2采购中 3配送中 4已完成 9已取消 */
|
||||
status: number;
|
||||
}
|
||||
|
||||
/** 账单详情(账单 + 合并商品明细 + 关联订单) */
|
||||
export interface IBillDetail {
|
||||
bill: IBill;
|
||||
items: IBillGoodsItem[];
|
||||
orders: IBillOrder[];
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** 门店回筐记录(压筐=生成账单自动写入只读;回筐=门店退回手动登记) */
|
||||
export default interface IContainerReturn {
|
||||
id?: number;
|
||||
store_id?: number;
|
||||
/** 关联账单ID(0=手动回筐登记) */
|
||||
bill_id?: number;
|
||||
/** 类型:1压筐 2回筐 */
|
||||
type?: number;
|
||||
box_num?: number;
|
||||
tray_num?: number;
|
||||
/** 记录日期(压筐=账单日期,回筐=退回日期) */
|
||||
return_date?: string;
|
||||
operator_id?: number;
|
||||
remark?: string;
|
||||
created_at?: string;
|
||||
/** 列表接口附带 */
|
||||
store?: { id: number; name: string; pending_box_num?: number; pending_tray_num?: number } | null;
|
||||
bill?: { id: number; bill_no: string } | null;
|
||||
operator?: { id: number; nickname: string } | null;
|
||||
}
|
||||
|
||||
/** 回筐记录类型映射 */
|
||||
export const CONTAINER_TYPE_MAP: Record<number, { text: string; color: string }> = {
|
||||
1: { text: '压筐', color: 'processing' },
|
||||
2: { text: '回筐', color: 'success' },
|
||||
};
|
||||
@@ -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<number, { text: string; color: string }> = {
|
||||
1: { text: '微信支付', color: 'green' },
|
||||
2: { text: '支付宝', color: 'blue' },
|
||||
3: { text: '对公汇款', color: 'purple' },
|
||||
};
|
||||
|
||||
/** 支付记录状态映射 */
|
||||
export const PAYMENT_STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||
0: { text: '待审核', color: 'warning' },
|
||||
1: { text: '已通过', color: 'success' },
|
||||
2: { text: '已拒绝', color: 'error' },
|
||||
};
|
||||
@@ -48,34 +48,77 @@ export interface IPurchaseDetail {
|
||||
purchase: IPurchaseOrder;
|
||||
stores: { id: number; name: string }[];
|
||||
items: IPurchaseDetailRow[];
|
||||
/** 周转框/托盘合并记录(按门店聚合) */
|
||||
containers: IPurchaseContainerStore[];
|
||||
/** 门店账单(采购单完成后按门店生成) */
|
||||
bills: IBill[];
|
||||
}
|
||||
|
||||
/** 合并记录中的底层门店订单(订单号 + 框/托盘数量) */
|
||||
export interface IPurchaseContainerOrder {
|
||||
order_id: number;
|
||||
order_no: string;
|
||||
/** 门店账单(采购单完成后按门店生成,商品金额为订单汇总快照不可修改) */
|
||||
export interface IBill {
|
||||
id: number;
|
||||
bill_no: string;
|
||||
purchase_id: number;
|
||||
store_id: number;
|
||||
store_name?: string;
|
||||
bill_date: string;
|
||||
/** 商品金额(订单商品金额汇总) */
|
||||
product_amount: string;
|
||||
/** 配送费(生成账单时填写) */
|
||||
delivery_fee: string;
|
||||
box_num: number;
|
||||
tray_num: number;
|
||||
/** 周转筐单价(生成时快照) */
|
||||
box_price: string;
|
||||
/** 周转托盘单价(生成时快照) */
|
||||
tray_price: string;
|
||||
/** 附加金额 = 周转筐×筐单价 + 托盘×托盘单价 */
|
||||
added_amount: string;
|
||||
/** 账单总金额 = 商品金额 + 配送费 + 附加金额 */
|
||||
total_amount: string;
|
||||
/** 支付状态:0未支付 1已支付 */
|
||||
status?: number;
|
||||
/** 关联支付记录ID(0=未发起支付) */
|
||||
payment_id?: number;
|
||||
/** 付款时间(线下收款手动登记) */
|
||||
paid_at?: string | null;
|
||||
/** 付款备注(线下收款信息) */
|
||||
pay_remark?: string;
|
||||
paid_operator_id?: number;
|
||||
order_count?: number;
|
||||
remark?: string;
|
||||
created_at?: string;
|
||||
/** 门店账单列表/详情接口附带 */
|
||||
store?: { id: number; name: string; address?: string; contact?: string; phone?: string } | null;
|
||||
purchase?: { id: number; purchase_no: string; purchase_date: string; status?: number } | null;
|
||||
operator?: { id: number; nickname: string } | null;
|
||||
paid_operator?: { id: number; nickname: string } | null;
|
||||
orders_count?: number;
|
||||
}
|
||||
|
||||
/** 门店周转框/托盘合并记录(该门店在本采购单下全部订单的合计) */
|
||||
export interface IPurchaseContainerStore {
|
||||
/** 账单支付状态映射 */
|
||||
export const BILL_STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||
0: { text: '未支付', color: 'warning' },
|
||||
1: { text: '已支付', color: 'success' },
|
||||
};
|
||||
|
||||
/** 账单生成预览行(按门店汇总,金额只读) */
|
||||
export interface IBillPrepareStore {
|
||||
store_id: number;
|
||||
store_name: string;
|
||||
/** 周转框合计 */
|
||||
box_num: number;
|
||||
/** 周转托盘合计 */
|
||||
tray_num: number;
|
||||
/** 周转框单价(站点配置) */
|
||||
order_count: number;
|
||||
/** 商品金额(订单汇总,不可修改) */
|
||||
product_amount: string;
|
||||
/** 周转筐单价(站点配置) */
|
||||
box_price: string;
|
||||
/** 周转托盘单价(站点配置) */
|
||||
tray_price: string;
|
||||
/** 附加金额合计 = 框合计×框单价 + 托盘合计×托盘单价 */
|
||||
added_amount: string;
|
||||
order_count: number;
|
||||
orders: IPurchaseContainerOrder[];
|
||||
/** 已生成的账单(未生成为 null) */
|
||||
bill: IBill | null;
|
||||
}
|
||||
|
||||
/** 账单生成预览(采购单 + 门店行) */
|
||||
export interface IBillPrepare {
|
||||
purchase: { id: number; purchase_no: string; status: number };
|
||||
stores: IBillPrepareStore[];
|
||||
}
|
||||
|
||||
/** 单元格下钻明细行(溯源订货单明细,附订单号/状态/可编辑标记) */
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
/** 门店对账单明细 */
|
||||
export interface IStatementItem {
|
||||
id?: number;
|
||||
statement_id?: number;
|
||||
order_id?: number;
|
||||
order_item_id?: number;
|
||||
product_id?: number;
|
||||
product_name?: string;
|
||||
price?: string;
|
||||
quantity?: string;
|
||||
weight?: string;
|
||||
amount?: string;
|
||||
is_reconciled?: number;
|
||||
store_remark?: string;
|
||||
}
|
||||
|
||||
/** 门店对账单 */
|
||||
export default interface IStatement {
|
||||
id?: number;
|
||||
statement_no?: string;
|
||||
store_id?: number;
|
||||
store?: { id: number; name: string };
|
||||
period_start?: string;
|
||||
period_end?: string;
|
||||
total_amount?: string;
|
||||
/** 回款周期快照(天) */
|
||||
payment_cycle_days?: number;
|
||||
/** 应结算日期 = period_end + 回款周期 */
|
||||
settlement_date?: string;
|
||||
/** 0待对账 1已对账 2已结算 */
|
||||
status?: number;
|
||||
reconciled_at?: string;
|
||||
settled_at?: string;
|
||||
remark?: string;
|
||||
items?: IStatementItem[];
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export const STATEMENT_STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||
0: { text: '待对账', color: 'default' },
|
||||
1: { text: '已对账', color: 'processing' },
|
||||
2: { text: '已结算', color: 'success' },
|
||||
};
|
||||
@@ -14,6 +14,10 @@ export default interface IStore {
|
||||
address?: string;
|
||||
/** 回款周期(天) */
|
||||
payment_cycle_days?: number;
|
||||
/** 待回筐数量(生成账单压筐累加,回筐登记扣减) */
|
||||
pending_box_num?: number;
|
||||
/** 待回托盘数量 */
|
||||
pending_tray_num?: number;
|
||||
status?: number;
|
||||
remark?: string;
|
||||
created_at?: string;
|
||||
|
||||
@@ -44,7 +44,8 @@ export default interface IStoreOrder {
|
||||
order_no?: string;
|
||||
store_id?: number;
|
||||
purchase_id?: number;
|
||||
statement_id?: number;
|
||||
/** 关联账单ID(采购单完成后按门店生成账单时回写) */
|
||||
bill_id?: number;
|
||||
store?: {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -56,18 +57,6 @@ export default interface IStoreOrder {
|
||||
total_quantity?: number;
|
||||
total_weight?: string;
|
||||
total_amount?: string;
|
||||
product_amount: string;
|
||||
added_amount: string;
|
||||
box_num: number;
|
||||
tray_num: number;
|
||||
/** 周转框单价(列表接口附加) */
|
||||
box_price?: string;
|
||||
/** 周转托盘单价(列表接口附加) */
|
||||
tray_price?: string;
|
||||
/** 周转框金额(列表接口附加) */
|
||||
box_amount?: string;
|
||||
/** 周转托盘金额(列表接口附加) */
|
||||
tray_amount?: string;
|
||||
/** 0待接单 1已接单 2采购中 3配送中 4已完成 9已取消 */
|
||||
status?: number;
|
||||
remark?: string;
|
||||
|
||||
@@ -86,6 +86,30 @@ const StorePage: React.FC = () => {
|
||||
fieldProps: { min: 0, precision: 0 },
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '待回筐数量',
|
||||
dataIndex: 'pending_box_num',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
render: (_, record) => (
|
||||
<Text strong type={Number(record.pending_box_num) > 0 ? 'warning' : undefined}>
|
||||
{record.pending_box_num ?? 0}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '待回托盘数量',
|
||||
dataIndex: 'pending_tray_num',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
render: (_, record) => (
|
||||
<Text strong type={Number(record.pending_tray_num) > 0 ? 'warning' : undefined}>
|
||||
{record.pending_tray_num ?? 0}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
|
||||
+16
-53
@@ -261,12 +261,8 @@ const StoreOrderPage: React.FC = () => {
|
||||
{record.total_quantity}
|
||||
</div>
|
||||
<div>
|
||||
<Text type={'secondary'}>周转框数量:</Text>
|
||||
{record.box_num}
|
||||
</div>
|
||||
<div>
|
||||
<Text type={'secondary'}>周转托盘数量:</Text>
|
||||
{record.tray_num}
|
||||
<Text type={'secondary'}>总重量:</Text>
|
||||
{record.total_weight}斤
|
||||
</div>
|
||||
</Space>
|
||||
)
|
||||
@@ -274,24 +270,11 @@ const StoreOrderPage: React.FC = () => {
|
||||
{
|
||||
title: '订单金额',
|
||||
hideInForm: true,
|
||||
dataIndex: 'box_num',
|
||||
dataIndex: 'total_amount',
|
||||
hideInSearch: true,
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<Space orientation={'vertical'}>
|
||||
<div>
|
||||
<Text type={'secondary'}>商品总金额:</Text>
|
||||
<span className={'text-[red]'}>{record.product_amount} ¥</span>
|
||||
</div>
|
||||
<div>
|
||||
<Text type={'secondary'}>附加总金额:</Text>
|
||||
<span className={'text-[red]'}>{record.added_amount} ¥</span>
|
||||
</div>
|
||||
<div>
|
||||
<Text type={'secondary'}>订单总金额:</Text>
|
||||
<span className={'text-[red] text-[16px]'}>{record.total_amount} ¥</span>
|
||||
</div>
|
||||
</Space>
|
||||
render: (text) => (
|
||||
<span className={'text-[red] text-[16px]'}>{text} ¥</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -334,7 +317,7 @@ const StoreOrderPage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: '账单ID',
|
||||
dataIndex: 'statement_id',
|
||||
dataIndex: 'bill_id',
|
||||
valueType: 'digit',
|
||||
hideInForm: true,
|
||||
},
|
||||
@@ -507,8 +490,8 @@ const StoreOrderPage: React.FC = () => {
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="订货总量">{detail.total_quantity}</Descriptions.Item>
|
||||
<Descriptions.Item label="周转框数量">{detail.box_num}</Descriptions.Item>
|
||||
<Descriptions.Item label="周转托盘数量">{detail.tray_num}</Descriptions.Item>
|
||||
<Descriptions.Item label="总重量">{detail.total_weight}斤</Descriptions.Item>
|
||||
<Descriptions.Item label="账单ID">{detail.bill_id ?? '-'}</Descriptions.Item>
|
||||
{detail.remark ? (
|
||||
<Descriptions.Item label="备注" span={3}>
|
||||
{detail.remark}
|
||||
@@ -568,6 +551,14 @@ const StoreOrderPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex bg-gray-50 px-4 py-3 text-sm">
|
||||
<div className="flex-1">总计</div>
|
||||
<div className="w-30 shrink-0 text-center"></div>
|
||||
<div className="w-30 shrink-0 text-center"></div>
|
||||
<div className="w-26 shrink-0 text-center">{detail.total_quantity}</div>
|
||||
<div className="w-33 shrink-0 text-center text-[red] text-[16px]">¥{detail.total_amount}</div>
|
||||
<div className="w-33 shrink-0 text-center">{detail.total_weight}斤</div>
|
||||
</div>
|
||||
{(detail.items ?? []).length === 0 && (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
@@ -576,34 +567,6 @@ const StoreOrderPage: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 附加信息 */}
|
||||
<div className="mt-3! flex justify-end">
|
||||
<div className="w-full rounded bg-gray-50 p-4 text-sm">
|
||||
<div className="flex justify-between py-1">
|
||||
<Text type="secondary">
|
||||
周转框金额({detail.box_num} × ¥{detail.box_price ?? '0.00'})
|
||||
</Text>
|
||||
<span>¥{detail.box_amount ?? '0.00'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-1">
|
||||
<Text type="secondary">
|
||||
周转托盘金额({detail.tray_num} × ¥{detail.tray_price ?? '0.00'})
|
||||
</Text>
|
||||
<span>¥{detail.tray_amount ?? '0.00'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-1">
|
||||
<Text type="secondary">商品总金额</Text>
|
||||
<span>¥{detail.product_amount}</span>
|
||||
</div>
|
||||
<div className="mt-1! flex justify-between border-t border-gray-200 pt-2">
|
||||
<Text strong>订单总金额</Text>
|
||||
<Text strong type="danger">
|
||||
¥{detail.total_amount}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Drawer>
|
||||
|
||||
+265
-170
@@ -20,7 +20,7 @@ import {
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import {DownloadOutlined, EditOutlined, UnorderedListOutlined} from '@ant-design/icons';
|
||||
import {AccountBookOutlined, DownloadOutlined, EditOutlined, UnorderedListOutlined} from '@ant-design/icons';
|
||||
import type { TableProps } from 'antd';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type {
|
||||
@@ -30,24 +30,28 @@ import type {
|
||||
} from '@/components/XinTable/typings.ts';
|
||||
import type IPurchaseOrder from '@/domain/iPurchaseOrder.ts';
|
||||
import type {
|
||||
IBill,
|
||||
IBillPrepare,
|
||||
IPurchaseCell,
|
||||
IPurchaseCellItem,
|
||||
IPurchaseContainerStore,
|
||||
IPurchaseDetail,
|
||||
IPurchaseDetailRow,
|
||||
IPurchaseStoreItem,
|
||||
IPurchaseStoreSummary,
|
||||
} from '@/domain/iPurchaseOrder.ts';
|
||||
import { PURCHASE_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
|
||||
import { PURCHASE_STATUS_MAP, BILL_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
|
||||
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
|
||||
import {
|
||||
exportPurchase,
|
||||
generateBill,
|
||||
getBillPrepare,
|
||||
getPurchaseCell,
|
||||
getPurchaseDetail,
|
||||
getPurchaseStoreSummary,
|
||||
type PurchaseCellUpdateParams, type PurchaseContainerOrderParams, type PurchaseRowUpdateParams,
|
||||
type BillGenerateStoreParams,
|
||||
type PurchaseCellUpdateParams,
|
||||
type PurchaseRowUpdateParams,
|
||||
updatePurchaseCellItem,
|
||||
updatePurchaseContainer,
|
||||
updatePurchaseRow,
|
||||
} from '@/api/purchase/order.ts';
|
||||
import { Update } from '@/api/common/table.ts';
|
||||
@@ -100,25 +104,28 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
const [storeSummary, setStoreSummary] = useState<IPurchaseStoreSummary | null>(null);
|
||||
const [storeLoading, setStoreLoading] = useState(false);
|
||||
|
||||
// 周转框/托盘合并记录修改(按门店覆盖全部订单逐笔修改)
|
||||
const [containerOpen, setContainerOpen] = useState(false);
|
||||
const [containerStore, setContainerStore] = useState<IPurchaseContainerStore | null>(null);
|
||||
const [containerSaving, setContainerSaving] = useState(false);
|
||||
const [containerForm] = Form.useForm<{ orders: PurchaseContainerOrderParams[] }>();
|
||||
// 生成账单(已完成采购单按门店生成:填写配送费/周转筐/托盘数量,金额只读)
|
||||
const [billOpen, setBillOpen] = useState(false);
|
||||
const [billPrepare, setBillPrepare] = useState<IBillPrepare | null>(null);
|
||||
const [billLoading, setBillLoading] = useState(false);
|
||||
const [billSaving, setBillSaving] = useState(false);
|
||||
const [billForm] = Form.useForm<{ stores: BillGenerateStoreParams[] }>();
|
||||
|
||||
// 弹窗内实时预览:合并合计 = Σ 各订单框/托盘数量,附加金额 = 合计 × 单价
|
||||
const watchContainerOrders = Form.useWatch('orders', containerForm) ?? [];
|
||||
const previewContainerBox = watchContainerOrders.reduce(
|
||||
(sum, order) => sum + Number(order?.box_num ?? 0),
|
||||
0,
|
||||
);
|
||||
const previewContainerTray = watchContainerOrders.reduce(
|
||||
(sum, order) => sum + Number(order?.tray_num ?? 0),
|
||||
0,
|
||||
);
|
||||
const previewContainerAdded =
|
||||
previewContainerBox * Number(containerStore?.box_price ?? 0) +
|
||||
previewContainerTray * Number(containerStore?.tray_price ?? 0);
|
||||
// 弹窗内实时预览:附加金额 = 筐×筐单价 + 托盘×托盘单价;总额 = 商品 + 配送费 + 附加
|
||||
const watchBillStores = Form.useWatch('stores', billForm) ?? [];
|
||||
const billPreview = (billPrepare?.stores ?? []).map((row, index) => {
|
||||
const input = watchBillStores[index] ?? {};
|
||||
const deliveryFee = Number(input.delivery_fee ?? 0);
|
||||
const added =
|
||||
Number(input.box_num ?? 0) * Number(row.box_price) +
|
||||
Number(input.tray_num ?? 0) * Number(row.tray_price);
|
||||
return {
|
||||
added,
|
||||
total: Number(row.product_amount) + deliveryFee + added,
|
||||
};
|
||||
});
|
||||
const billAllGenerated =
|
||||
(billPrepare?.stores ?? []).length > 0 && billPrepare!.stores.every((row) => row.bill !== null);
|
||||
|
||||
useEffect(() => {
|
||||
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
|
||||
@@ -264,34 +271,45 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/** 打开周转框/托盘合并记录修改(初始化该门店全部订单的当前数量) */
|
||||
const openContainerEdit = (store: IPurchaseContainerStore) => {
|
||||
setContainerStore(store);
|
||||
containerForm.setFieldsValue({
|
||||
orders: store.orders.map((order) => ({
|
||||
order_id: order.order_id,
|
||||
box_num: order.box_num,
|
||||
tray_num: order.tray_num,
|
||||
})),
|
||||
});
|
||||
setContainerOpen(true);
|
||||
/** 打开生成账单弹窗:拉取按门店汇总的商品金额(只读),初始化配送费/周转筐/托盘数量 */
|
||||
const openBillGenerate = async (id: number) => {
|
||||
setBillOpen(true);
|
||||
setBillLoading(true);
|
||||
setBillPrepare(null);
|
||||
try {
|
||||
const res = await getBillPrepare(id);
|
||||
const data = res.data.data ?? null;
|
||||
setBillPrepare(data);
|
||||
billForm.setFieldsValue({
|
||||
stores: (data?.stores ?? []).map((row) => ({
|
||||
store_id: row.store_id,
|
||||
delivery_fee: row.bill ? Number(row.bill.delivery_fee) : 0,
|
||||
box_num: row.bill ? row.bill.box_num : 0,
|
||||
tray_num: row.bill ? row.bill.tray_num : 0,
|
||||
})),
|
||||
});
|
||||
} finally {
|
||||
setBillLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 提交合并记录修改:逐笔更新订单,自动重算附加金额与订单总金额 */
|
||||
const handleContainerSave = async (values: { orders: PurchaseContainerOrderParams[] }) => {
|
||||
if (!detail || !containerStore) {
|
||||
/** 提交生成账单:按门店各生成一张并关联采购单全部订单,金额由系统汇总不可修改 */
|
||||
const handleBillSave = async (values: { stores: BillGenerateStoreParams[] }) => {
|
||||
if (!billPrepare) {
|
||||
return;
|
||||
}
|
||||
setContainerSaving(true);
|
||||
setBillSaving(true);
|
||||
try {
|
||||
await updatePurchaseContainer(detail.purchase.id!, containerStore.store_id, values.orders);
|
||||
message.success('周转框/托盘已更新,订单金额已重算');
|
||||
setContainerOpen(false);
|
||||
setContainerStore(null);
|
||||
await loadDetail(detail.purchase.id!);
|
||||
const res = await generateBill(billPrepare.purchase.id, values.stores);
|
||||
message.success(`已生成 ${res.data.data?.count ?? 0} 张门店账单,采购单订单已关联`);
|
||||
setBillOpen(false);
|
||||
setBillPrepare(null);
|
||||
await tableRef.current?.reload();
|
||||
if (detail && detail.purchase.id === billPrepare.purchase.id) {
|
||||
await loadDetail(detail.purchase.id!);
|
||||
}
|
||||
} finally {
|
||||
setContainerSaving(false);
|
||||
setBillSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -473,56 +491,100 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
/** 周转框/托盘合并记录列:门店/订单数/框合计/托盘合计/附加金额(合计可点击下钻逐订单修改) */
|
||||
const containerColumns: TableProps<IPurchaseContainerStore>['columns'] = [
|
||||
{ title: '门店', dataIndex: 'store_name', width: 180, align: 'center' },
|
||||
{ title: '订单数', dataIndex: 'order_count', width: 90, align: 'center' },
|
||||
/** 门店账单列:门店/账单号/账单日期/商品金额/配送费/周转筐/托盘/附加金额/总金额 */
|
||||
const billColumns: TableProps<IBill>['columns'] = [
|
||||
{ title: '门店', dataIndex: 'store_name', width: 140, align: 'center' },
|
||||
{
|
||||
title: '周转框合计',
|
||||
dataIndex: 'box_num',
|
||||
title: '账单号',
|
||||
dataIndex: 'bill_no',
|
||||
width: 180,
|
||||
align: 'center',
|
||||
render: (v) => <Text copyable={{ text: v }}>{v}</Text>,
|
||||
},
|
||||
{ title: '账单日期', dataIndex: 'bill_date', width: 110, align: 'center' },
|
||||
{
|
||||
title: '商品金额',
|
||||
dataIndex: 'product_amount',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
render: (_, row) => (
|
||||
<Typography.Link onClick={() => openContainerEdit(row)}>{row.box_num}</Typography.Link>
|
||||
),
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '周转托盘合计',
|
||||
dataIndex: 'tray_num',
|
||||
width: 120,
|
||||
title: '配送费',
|
||||
dataIndex: 'delivery_fee',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (_, row) => (
|
||||
<Typography.Link onClick={() => openContainerEdit(row)}>{row.tray_num}</Typography.Link>
|
||||
),
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '周转筐',
|
||||
key: 'box',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
render: (_, row) => `${row.box_num} × ¥${Number(row.box_price).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '周转托盘',
|
||||
key: 'tray',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
render: (_, row) => `${row.tray_num} × ¥${Number(row.tray_price).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '附加金额',
|
||||
dataIndex: 'added_amount',
|
||||
width: 110,
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (_, row) => <Text strong type="danger">¥{row.added_amount}</Text>,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '账单总金额',
|
||||
dataIndex: 'total_amount',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
render: (v) => <Text strong type="danger">¥{Number(v).toFixed(2)}</Text>,
|
||||
},
|
||||
{
|
||||
title: '支付状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (v) => {
|
||||
const item = BILL_STATUS_MAP[Number(v ?? 0)];
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** 周转框/托盘合并记录合计行 */
|
||||
const renderContainerSummary = () => {
|
||||
const containers = detail?.containers ?? [];
|
||||
const totalBox = containers.reduce((sum, row) => sum + row.box_num, 0);
|
||||
const totalTray = containers.reduce((sum, row) => sum + row.tray_num, 0);
|
||||
const totalAdded = containers.reduce((sum, row) => sum + Number(row.added_amount), 0);
|
||||
/** 门店账单合计行 */
|
||||
const renderBillSummary = () => {
|
||||
const bills = detail?.bills ?? [];
|
||||
const totalProduct = bills.reduce((sum, row) => sum + Number(row.product_amount), 0);
|
||||
const totalDelivery = bills.reduce((sum, row) => sum + Number(row.delivery_fee), 0);
|
||||
const totalAdded = bills.reduce((sum, row) => sum + Number(row.added_amount), 0);
|
||||
const totalAmount = bills.reduce((sum, row) => sum + Number(row.total_amount), 0);
|
||||
return (
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0} colSpan={2} align="center">
|
||||
<Table.Summary.Cell index={0} colSpan={3} align="center">
|
||||
<Text strong>合计</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={2} align="center">
|
||||
<Text strong>{totalBox}</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={3} align="center">
|
||||
<Text strong>{totalTray}</Text>
|
||||
<Text strong>¥{totalProduct.toFixed(2)}</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={4} align="center">
|
||||
<Text strong type="danger">¥{totalAdded.toFixed(2)}</Text>
|
||||
<Text strong>¥{totalDelivery.toFixed(2)}</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={5} colSpan={2} align="center">
|
||||
<Text strong>-</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={7} align="center">
|
||||
<Text strong>¥{totalAdded.toFixed(2)}</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={8} align="center">
|
||||
<Text strong type="danger">¥{totalAmount.toFixed(2)}</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={9} align="center">
|
||||
<Text strong>-</Text>
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
);
|
||||
@@ -632,7 +694,20 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
) : null
|
||||
) : (
|
||||
<AuthButton auth="purchase.order.bill">
|
||||
<Button
|
||||
type={'primary'}
|
||||
size="small"
|
||||
variant={'solid'}
|
||||
color={'orange'}
|
||||
icon={<AccountBookOutlined />}
|
||||
onClick={() => openBillGenerate(record.id!)}
|
||||
>
|
||||
生成账单
|
||||
</Button>
|
||||
</AuthButton>
|
||||
)
|
||||
];
|
||||
|
||||
const tableProps: XinTableProps<IPurchaseOrder> = {
|
||||
@@ -696,7 +771,7 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
items={[
|
||||
{ key: 'items', label: '商品明细' },
|
||||
{ key: 'stores', label: '门店购买详情' },
|
||||
{ key: 'containers', label: '周转框/托盘' },
|
||||
{ key: 'bills', label: '门店账单' },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -736,21 +811,21 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
)}
|
||||
</Spin>
|
||||
</>
|
||||
) : detailTab === 'containers' ? (
|
||||
detail.containers.length > 0 ? (
|
||||
<Table<IPurchaseContainerStore>
|
||||
rowKey="store_id"
|
||||
) : detailTab === 'bills' ? (
|
||||
detail.bills.length > 0 ? (
|
||||
<Table<IBill>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
bordered
|
||||
columns={containerColumns}
|
||||
dataSource={detail.containers}
|
||||
columns={billColumns}
|
||||
dataSource={detail.bills}
|
||||
pagination={false}
|
||||
summary={renderContainerSummary}
|
||||
summary={renderBillSummary}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="该采购单暂无门店订单"
|
||||
description={detail.purchase.status === 0 ? '采购单完成后可在列表操作列生成账单' : '该采购单暂未生成账单'}
|
||||
className="py-8!"
|
||||
/>
|
||||
)
|
||||
@@ -1005,99 +1080,119 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 周转框/托盘合并记录下钻:按门店展示全部订单逐笔修改(采购单已完成则只读) */}
|
||||
{/* 生成账单:按门店填写配送费/周转筐/托盘数量(商品金额只读,由订单汇总) */}
|
||||
<Modal
|
||||
title={
|
||||
containerStore
|
||||
? `${containerStore.store_name} · 周转框/托盘`
|
||||
: '周转框/托盘'
|
||||
}
|
||||
open={containerOpen}
|
||||
title={billPrepare ? `生成账单 · ${billPrepare.purchase.purchase_no}` : '生成账单'}
|
||||
open={billOpen}
|
||||
onCancel={() => {
|
||||
setContainerOpen(false);
|
||||
setContainerStore(null);
|
||||
setBillOpen(false);
|
||||
setBillPrepare(null);
|
||||
}}
|
||||
onOk={() => containerForm.submit()}
|
||||
confirmLoading={containerSaving}
|
||||
okText="保存"
|
||||
okButtonProps={{ disabled: detail?.purchase.status !== 0 }}
|
||||
width={760}
|
||||
onOk={() => billForm.submit()}
|
||||
confirmLoading={billSaving}
|
||||
okText="确认生成"
|
||||
okButtonProps={{ disabled: billAllGenerated }}
|
||||
width={960}
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="py-2 text-gray-500">
|
||||
该门店在本采购单下的全部订单({containerStore?.order_count ?? 0} 笔):
|
||||
{detail?.purchase.status === 0
|
||||
? '逐笔修改后保存,系统将自动重算每笔订单的附加金额与订单总金额。'
|
||||
: '采购单已完成,仅可查看。'}
|
||||
</div>
|
||||
<Form form={containerForm} layout="vertical" onFinish={handleContainerSave}>
|
||||
<Form.List name="orders">
|
||||
{(fields) => (
|
||||
<div className="overflow-hidden rounded border border-gray-200">
|
||||
<div className="flex bg-gray-50 px-4 py-2 text-sm text-gray-500">
|
||||
<div className="flex-1">订单号</div>
|
||||
<div className="w-36 shrink-0 text-center">
|
||||
周转框(单价 ¥{containerStore?.box_price ?? '0.00'})
|
||||
</div>
|
||||
<div className="w-36 shrink-0 text-center">
|
||||
周转托盘(单价 ¥{containerStore?.tray_price ?? '0.00'})
|
||||
</div>
|
||||
</div>
|
||||
{fields.map((field) => {
|
||||
const order = containerStore?.orders[field.name];
|
||||
return (
|
||||
<div key={field.key} className="flex items-center border-t border-gray-100 px-4 py-2">
|
||||
<div className="min-w-0 flex-1 pr-2 text-sm">
|
||||
<Text copyable={{ text: order?.order_no ?? '' }}>{order?.order_no ?? '-'}</Text>
|
||||
</div>
|
||||
<Form.Item name={[field.name, 'order_id']} hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="m-0! w-36 shrink-0"
|
||||
name={[field.name, 'box_num']}
|
||||
rules={[{ required: true, message: '请输入周转框数量' }]}
|
||||
>
|
||||
<InputNumber
|
||||
className="w-full"
|
||||
min={0}
|
||||
precision={0}
|
||||
disabled={detail?.purchase.status !== 0}
|
||||
placeholder="周转框数量"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="m-0! w-36 shrink-0"
|
||||
name={[field.name, 'tray_num']}
|
||||
rules={[{ required: true, message: '请输入周转托盘数量' }]}
|
||||
>
|
||||
<InputNumber
|
||||
className="w-full"
|
||||
min={0}
|
||||
precision={0}
|
||||
disabled={detail?.purchase.status !== 0}
|
||||
placeholder="周转托盘数量"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Spin spinning={billLoading}>
|
||||
{billPrepare && (
|
||||
<>
|
||||
<div className="py-2 text-gray-500">
|
||||
每个门店单独生成一张账单;商品金额由订单汇总不可修改,请填写各门店的配送费与周转筐/托盘数量。
|
||||
{billAllGenerated ? '该采购单已全部生成账单,仅可查看。' : '生成后采购单中的全部订单将关联到对应门店账单。'}
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
<Space orientation="vertical" className="mt-3! w-full rounded bg-gray-50 p-3">
|
||||
<div>
|
||||
<Text type="secondary">周转框合计:</Text>
|
||||
<Text strong>{previewContainerBox}</Text>
|
||||
<Text type="secondary" className="ml-6!">周转托盘合计:</Text>
|
||||
<Text strong>{previewContainerTray}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">附加金额合计:</Text>
|
||||
<Text strong type="danger">¥{previewContainerAdded.toFixed(2)}</Text>
|
||||
</div>
|
||||
</Space>
|
||||
<Form form={billForm} layout="vertical" onFinish={handleBillSave}>
|
||||
<Form.List name="stores">
|
||||
{(fields) => (
|
||||
<div className="overflow-hidden rounded border border-gray-200">
|
||||
<div className="flex bg-gray-50 px-4 py-2 text-sm text-gray-500">
|
||||
<div className="w-40 shrink-0">门店</div>
|
||||
<div className="w-28 shrink-0 text-center">商品金额</div>
|
||||
<div className="w-32 shrink-0 text-center">配送费(元)</div>
|
||||
<div className="w-32 shrink-0 text-center">周转筐(¥{billPrepare.stores[0]?.box_price ?? '0.00'})</div>
|
||||
<div className="w-32 shrink-0 text-center">周转托盘(¥{billPrepare.stores[0]?.tray_price ?? '0.00'})</div>
|
||||
<div className="w-28 shrink-0 text-center">附加金额</div>
|
||||
<div className="flex-1 text-center">账单总金额</div>
|
||||
</div>
|
||||
{fields.map((field) => {
|
||||
const row = billPrepare.stores[field.name];
|
||||
const billed = row?.bill != null;
|
||||
return (
|
||||
<div key={field.key} className="flex items-center border-t border-gray-100 px-4 py-2">
|
||||
<div className="w-40 shrink-0 pr-2 text-sm">
|
||||
<div>{row?.store_name ?? '-'}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{row?.order_count ?? 0} 笔订单
|
||||
{billed && <Tag className="ml-1!" color="success">已生成</Tag>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-28 shrink-0 text-center">
|
||||
<Text strong>¥{row?.product_amount ?? '0.00'}</Text>
|
||||
</div>
|
||||
<Form.Item name={[field.name, 'store_id']} hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="m-0! w-32 shrink-0 px-1!"
|
||||
name={[field.name, 'delivery_fee']}
|
||||
rules={[{ required: true, message: '请输入配送费' }]}
|
||||
>
|
||||
<InputNumber
|
||||
className="w-full"
|
||||
min={0}
|
||||
precision={2}
|
||||
disabled={billed}
|
||||
placeholder="配送费"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="m-0! w-32 shrink-0 px-1!"
|
||||
name={[field.name, 'box_num']}
|
||||
rules={[{ required: true, message: '请输入周转筐数量' }]}
|
||||
>
|
||||
<InputNumber
|
||||
className="w-full"
|
||||
min={0}
|
||||
precision={0}
|
||||
disabled={billed}
|
||||
placeholder="周转筐数量"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="m-0! w-32 shrink-0 px-1!"
|
||||
name={[field.name, 'tray_num']}
|
||||
rules={[{ required: true, message: '请输入周转托盘数量' }]}
|
||||
>
|
||||
<InputNumber
|
||||
className="w-full"
|
||||
min={0}
|
||||
precision={0}
|
||||
disabled={billed}
|
||||
placeholder="周转托盘数量"
|
||||
/>
|
||||
</Form.Item>
|
||||
<div className="w-28 shrink-0 text-center">
|
||||
¥{(billed ? Number(row.bill!.added_amount) : billPreview[field.name]?.added ?? 0).toFixed(2)}
|
||||
</div>
|
||||
<div className="flex-1 text-center">
|
||||
<Text strong type="danger">
|
||||
¥{(billed ? Number(row.bill!.total_amount) : billPreview[field.name]?.total ?? 0).toFixed(2)}
|
||||
</Text>
|
||||
{billed && (
|
||||
<div className="text-xs text-gray-400">{row.bill!.bill_no}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Spin>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
message,
|
||||
Modal,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import { UnorderedListOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type {
|
||||
XinTableColumn,
|
||||
XinTableInstance,
|
||||
XinTableProps,
|
||||
} from '@/components/XinTable/typings.ts';
|
||||
import type { IBill, IBillDetail, IBillGoodsItem, IBillOrder } from '@/domain/iBill.ts';
|
||||
import { BILL_STATUS_MAP } from '@/domain/iBill.ts';
|
||||
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
|
||||
import { getBillDetail, payBill } from '@/api/recon/bill.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 PayFormValues {
|
||||
paid_at: dayjs.Dayjs;
|
||||
pay_remark?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 门店账单(采购单完成后按门店生成;详情含合并后的商品明细与关联订单;线下收款手动登记)
|
||||
*/
|
||||
const BillPage: React.FC = () => {
|
||||
const tableRef = useRef<XinTableInstance<IBill>>(null);
|
||||
const [stores, setStores] = useState<IStore[]>([]);
|
||||
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detail, setDetail] = useState<IBillDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
// 确认收款(线下收款手动登记)
|
||||
const [payTarget, setPayTarget] = useState<IBill | null>(null);
|
||||
const [paySaving, setPaySaving] = useState(false);
|
||||
const [payForm] = Form.useForm<PayFormValues>();
|
||||
|
||||
useEffect(() => {
|
||||
getStoreOptions().then((res) => setStores(res.data.data ?? []));
|
||||
}, []);
|
||||
|
||||
const openDetail = async (id: number) => {
|
||||
setDetailOpen(true);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const res = await getBillDetail(id);
|
||||
setDetail(res.data.data ?? null);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 打开确认收款弹窗(默认付款时间为当前) */
|
||||
const openPay = (record: IBill) => {
|
||||
setPayTarget(record);
|
||||
payForm.setFieldsValue({ paid_at: dayjs(), pay_remark: '' });
|
||||
};
|
||||
|
||||
/** 提交确认收款:登记付款信息并置为已支付 */
|
||||
const handlePaySave = async (values: PayFormValues) => {
|
||||
if (!payTarget?.id) {
|
||||
return;
|
||||
}
|
||||
setPaySaving(true);
|
||||
try {
|
||||
await payBill(payTarget.id, {
|
||||
paid_at: values.paid_at.format('YYYY-MM-DD HH:mm:ss'),
|
||||
pay_remark: values.pay_remark ?? '',
|
||||
});
|
||||
message.success('收款已登记,账单已置为已支付');
|
||||
setPayTarget(null);
|
||||
await tableRef.current?.reload();
|
||||
} finally {
|
||||
setPaySaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 合并商品明细列:品名/包规/单位/单价(加权平均)/数量/重量/金额 */
|
||||
const itemColumns: TableProps<IBillGoodsItem>['columns'] = [
|
||||
{ title: '品名', dataIndex: 'product_name', width: 160, align: 'center' },
|
||||
{ title: '包规', dataIndex: 'product_spec', width: 100, align: 'center', render: (v) => v || '-' },
|
||||
{ title: '单位', dataIndex: 'unit', width: 80, align: 'center', render: (v) => v || '-' },
|
||||
{
|
||||
title: '单价',
|
||||
dataIndex: 'price',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '数量',
|
||||
dataIndex: 'quantity',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
render: (v) => <Text strong>{v}</Text>,
|
||||
},
|
||||
{ title: '重量', dataIndex: 'weight', width: 100, align: 'center', render: (v) => `${v}斤` },
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
render: (v) => <Text strong>¥{Number(v).toFixed(2)}</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
/** 合并商品明细合计行 */
|
||||
const renderItemSummary = () => {
|
||||
const items = detail?.items ?? [];
|
||||
const totalQuantity = items.reduce((sum, row) => sum + Number(row.quantity), 0);
|
||||
const totalWeight = items.reduce((sum, row) => sum + Number(row.weight), 0);
|
||||
const totalAmount = items.reduce((sum, row) => sum + Number(row.amount), 0);
|
||||
return (
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0} colSpan={4} align="center">
|
||||
<Text strong>合计</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={4} align="center">
|
||||
<Text strong>{totalQuantity}</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={5} align="center">
|
||||
<Text strong>{totalWeight.toFixed(3)}斤</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={6} align="center">
|
||||
<Text strong type="danger">¥{totalAmount.toFixed(2)}</Text>
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
);
|
||||
};
|
||||
|
||||
/** 关联订单列 */
|
||||
const orderColumns: TableProps<IBillOrder>['columns'] = [
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: 'order_no',
|
||||
align: 'center',
|
||||
render: (v) => <Text copyable={{ text: v }}>{v}</Text>,
|
||||
},
|
||||
{ title: '订货日期', dataIndex: 'order_date', align: 'center' },
|
||||
{ title: '订货数量', dataIndex: 'total_quantity', align: 'center' },
|
||||
{ title: '总重量', dataIndex: 'total_weight', align: 'center', render: (v) => `${v}斤` },
|
||||
{
|
||||
title: '订单金额',
|
||||
dataIndex: 'total_amount',
|
||||
align: 'center',
|
||||
render: (v) => <Text strong>¥{v}</Text>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
align: 'center',
|
||||
render: (v) => {
|
||||
const item = STORE_ORDER_STATUS_MAP[Number(v ?? 0)];
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const columns: XinTableColumn<IBill>[] = [
|
||||
{
|
||||
title: '账单号',
|
||||
dataIndex: 'bill_no',
|
||||
valueType: 'text',
|
||||
hideInForm: true,
|
||||
width: 210,
|
||||
render: (_, record) => <Text copyable={{ text: record.bill_no }}>{record.bill_no}</Text>,
|
||||
},
|
||||
{
|
||||
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: 'purchase_no',
|
||||
valueType: 'text',
|
||||
hideInForm: true,
|
||||
render: (_, record) => record.purchase?.purchase_no ?? '-',
|
||||
},
|
||||
{
|
||||
title: '账单日期',
|
||||
dataIndex: 'bill_date',
|
||||
valueType: 'dateRange',
|
||||
hideInForm: true,
|
||||
align: 'center',
|
||||
render: (_, record) => record.bill_date,
|
||||
},
|
||||
{
|
||||
title: '商品金额',
|
||||
dataIndex: 'product_amount',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
render: (_, record) => `¥${record.product_amount}`,
|
||||
},
|
||||
{
|
||||
title: '配送费',
|
||||
dataIndex: 'delivery_fee',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
render: (_, record) => `¥${record.delivery_fee}`,
|
||||
},
|
||||
{
|
||||
title: '附加金额',
|
||||
dataIndex: 'added_amount',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
render: (_, record) => (
|
||||
<span title={`周转筐 ${record.box_num} 个 / 周转托盘 ${record.tray_num} 个`}>
|
||||
¥{record.added_amount}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '账单总金额',
|
||||
dataIndex: 'total_amount',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
render: (_, record) => <Text strong type="danger">¥{record.total_amount}</Text>,
|
||||
},
|
||||
{
|
||||
title: '支付状态',
|
||||
dataIndex: 'status',
|
||||
valueType: 'select',
|
||||
hideInForm: true,
|
||||
align: 'center',
|
||||
fieldProps: {
|
||||
options: Object.entries(BILL_STATUS_MAP).map(([value, item]) => ({
|
||||
value: Number(value),
|
||||
label: item.text,
|
||||
})),
|
||||
},
|
||||
render: (_, record) => {
|
||||
const item = BILL_STATUS_MAP[record.status ?? 0];
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '关联订单',
|
||||
dataIndex: 'orders_count',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
render: (_, record) => `${record.orders_count ?? 0} 笔`,
|
||||
},
|
||||
{
|
||||
title: '生成时间',
|
||||
dataIndex: 'created_at',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
const operateRender: XinTableProps<IBill>['operateRender'] = (record) => [
|
||||
<Button
|
||||
key="detail"
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<UnorderedListOutlined />}
|
||||
onClick={() => openDetail(record.id!)}
|
||||
/>,
|
||||
record.status === 0 ? (
|
||||
<AuthButton key="pay" auth="recon.bill.pay">
|
||||
<Button size="small" variant="solid" color="green" onClick={() => openPay(record)}>
|
||||
确认收款
|
||||
</Button>
|
||||
</AuthButton>
|
||||
) : null,
|
||||
];
|
||||
|
||||
const tableProps: XinTableProps<IBill> = {
|
||||
api: '/recon/bill',
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
accessName: 'recon.bill',
|
||||
tableRef,
|
||||
operateRender,
|
||||
formProps: false,
|
||||
actionBarRender: (dom) => [dom.search, dom.keywordSearch],
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<Title level={3}>门店账单</Title>
|
||||
<Text type="secondary">
|
||||
采购单完成后在「采购单」页生成,每个门店单独一张;账单总金额 = 商品金额 + 配送费 + 附加金额(周转筐/托盘)。
|
||||
</Text>
|
||||
</div>
|
||||
<XinTable<IBill> {...tableProps} />
|
||||
|
||||
<Drawer
|
||||
title={detail ? `账单 ${detail.bill.bill_no}` : '账单详情'}
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
size={1000}
|
||||
loading={detailLoading}
|
||||
>
|
||||
{detail ? (
|
||||
<>
|
||||
<Descriptions column={3} size="small" bordered>
|
||||
<Descriptions.Item label="门店">{detail.bill.store?.name ?? `门店#${detail.bill.store_id}`}</Descriptions.Item>
|
||||
<Descriptions.Item label="采购单号">{detail.bill.purchase?.purchase_no ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="账单日期">{detail.bill.bill_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="商品金额">¥{detail.bill.product_amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="配送费">¥{detail.bill.delivery_fee}</Descriptions.Item>
|
||||
<Descriptions.Item label="附加金额">
|
||||
¥{detail.bill.added_amount}
|
||||
<Text type="secondary" className="ml-2!">
|
||||
(筐 {detail.bill.box_num}×¥{detail.bill.box_price},托盘 {detail.bill.tray_num}×¥{detail.bill.tray_price})
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="账单总金额">
|
||||
<Text strong type="danger">¥{detail.bill.total_amount}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="支付状态">
|
||||
<Tag color={BILL_STATUS_MAP[detail.bill.status ?? 0]?.color}>
|
||||
{BILL_STATUS_MAP[detail.bill.status ?? 0]?.text}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="付款时间">{detail.bill.paid_at ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="收款人">{detail.bill.paid_operator?.nickname ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="生成人">{detail.bill.operator?.nickname ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="生成时间">{detail.bill.created_at}</Descriptions.Item>
|
||||
{detail.bill.pay_remark ? (
|
||||
<Descriptions.Item label="付款备注" span={3}>{detail.bill.pay_remark}</Descriptions.Item>
|
||||
) : null}
|
||||
{detail.bill.remark ? (
|
||||
<Descriptions.Item label="备注" span={3}>{detail.bill.remark}</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
|
||||
<Title level={5} className="mt-6! mb-3!">
|
||||
商品明细(按商品合并)
|
||||
</Title>
|
||||
<Table<IBillGoodsItem>
|
||||
rowKey="product_id"
|
||||
size="small"
|
||||
bordered
|
||||
columns={itemColumns}
|
||||
dataSource={detail.items}
|
||||
pagination={false}
|
||||
summary={renderItemSummary}
|
||||
/>
|
||||
|
||||
<Title level={5} className="mt-6! mb-3!">
|
||||
关联订单({detail.orders.length} 笔)
|
||||
</Title>
|
||||
<Table<IBillOrder>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
bordered
|
||||
columns={orderColumns}
|
||||
dataSource={detail.orders}
|
||||
pagination={false}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Drawer>
|
||||
|
||||
{/* 确认收款:线下收款后手动登记付款信息 */}
|
||||
<Modal
|
||||
title={payTarget ? `确认收款 · ${payTarget.bill_no}` : '确认收款'}
|
||||
open={payTarget !== null}
|
||||
onCancel={() => setPayTarget(null)}
|
||||
onOk={() => payForm.submit()}
|
||||
confirmLoading={paySaving}
|
||||
okText="确认收款"
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="py-2 text-gray-500">
|
||||
应收金额 <Text strong type="danger">¥{payTarget?.total_amount ?? '0.00'}</Text>
|
||||
(商品 ¥{payTarget?.product_amount ?? '0.00'} + 配送费 ¥{payTarget?.delivery_fee ?? '0.00'} + 附加 ¥{payTarget?.added_amount ?? '0.00'});
|
||||
线下收款完成后登记付款信息,账单支付状态将置为「已支付」。
|
||||
</div>
|
||||
<Form form={payForm} layout="vertical" onFinish={handlePaySave}>
|
||||
<Form.Item
|
||||
label="付款时间"
|
||||
name="paid_at"
|
||||
rules={[{ required: true, message: '请选择付款时间' }]}
|
||||
>
|
||||
<DatePicker className="w-full" showTime allowClear={false} />
|
||||
</Form.Item>
|
||||
<Form.Item label="付款备注" name="pay_remark" rules={[{ max: 255 }]}>
|
||||
<Input.TextArea rows={2} maxLength={255} placeholder="如:现金/转账单号等线下收款信息(选填)" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BillPage;
|
||||
@@ -0,0 +1,177 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { message, Tag, Typography } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts';
|
||||
import type IContainerReturn from '@/domain/iContainerReturn.ts';
|
||||
import { CONTAINER_TYPE_MAP } from '@/domain/iContainerReturn.ts';
|
||||
import { getStoreOptions } from '@/api/customer/store.ts';
|
||||
import { Create } from '@/api/common/table.ts';
|
||||
import type IStore from '@/domain/iStore.ts';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/**
|
||||
* 回筐记录(压筐=生成账单时自动写入,只读;回筐=门店退回手动登记,删除后恢复门店待回数量)
|
||||
*/
|
||||
const ContainerReturnPage: React.FC = () => {
|
||||
const [stores, setStores] = useState<IStore[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getStoreOptions().then((res) => setStores(res.data.data ?? []));
|
||||
}, []);
|
||||
|
||||
const columns: XinTableColumn<IContainerReturn>[] = [
|
||||
{
|
||||
title: '门店',
|
||||
dataIndex: 'store_id',
|
||||
valueType: 'select',
|
||||
rules: [{ required: true, message: '请选择门店' }],
|
||||
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: 'type',
|
||||
valueType: 'select',
|
||||
hideInForm: true,
|
||||
align: 'center',
|
||||
fieldProps: {
|
||||
options: Object.entries(CONTAINER_TYPE_MAP).map(([value, item]) => ({
|
||||
value: Number(value),
|
||||
label: item.text,
|
||||
})),
|
||||
},
|
||||
render: (_, record) => {
|
||||
const item = CONTAINER_TYPE_MAP[record.type ?? 0];
|
||||
return <Tag color={item?.color}>{item?.text ?? '-'}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '关联账单',
|
||||
dataIndex: 'bill_id',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
render: (_, record) =>
|
||||
record.bill ? <Text copyable={{ text: record.bill.bill_no }}>{record.bill.bill_no}</Text> : '-',
|
||||
},
|
||||
{
|
||||
title: '周转筐数量',
|
||||
dataIndex: 'box_num',
|
||||
valueType: 'digit',
|
||||
hideInSearch: true,
|
||||
initialValue: 0,
|
||||
rules: [{ required: true, message: '请输入周转筐数量' }],
|
||||
fieldProps: { min: 0, precision: 0 },
|
||||
align: 'center',
|
||||
render: (_, record) => <Text strong>{record.box_num}</Text>,
|
||||
},
|
||||
{
|
||||
title: '周转托盘数量',
|
||||
dataIndex: 'tray_num',
|
||||
valueType: 'digit',
|
||||
hideInSearch: true,
|
||||
initialValue: 0,
|
||||
rules: [{ required: true, message: '请输入周转托盘数量' }],
|
||||
fieldProps: { min: 0, precision: 0 },
|
||||
align: 'center',
|
||||
render: (_, record) => <Text strong>{record.tray_num}</Text>,
|
||||
},
|
||||
{
|
||||
title: '当前待回(筐/托盘)',
|
||||
key: 'pending',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
render: (_, record) =>
|
||||
record.store
|
||||
? `${record.store.pending_box_num ?? 0} / ${record.store.pending_tray_num ?? 0}`
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
title: '退回日期',
|
||||
dataIndex: 'return_date',
|
||||
valueType: 'dateRange',
|
||||
hideInTable: true,
|
||||
hideInForm: true,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '退回日期',
|
||||
dataIndex: 'return_date',
|
||||
valueType: 'date',
|
||||
hideInSearch: true,
|
||||
initialValue: dayjs(),
|
||||
rules: [{ required: true, message: '请选择退回日期' }],
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
dataIndex: 'operator',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
render: (_, record) => record.operator?.nickname ?? '-',
|
||||
},
|
||||
{
|
||||
title: '登记时间',
|
||||
dataIndex: 'created_at',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
valueType: 'textarea',
|
||||
hideInSearch: true,
|
||||
fieldProps: { rows: 2 },
|
||||
render: (v) => v || '-',
|
||||
},
|
||||
];
|
||||
|
||||
const tableProps: XinTableProps<IContainerReturn> = {
|
||||
api: '/recon/container-return',
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
accessName: 'recon.containerReturn',
|
||||
// 压筐记录由账单生成,仅回筐记录可删除;均不允许编辑
|
||||
editShow: false,
|
||||
deleteShow: (record) => record.type === 2,
|
||||
// 自定义提交:DatePicker 值为 dayjs 对象,格式化为 Y-m-d 后再提交
|
||||
handleFinish: async (values) => {
|
||||
await Create('/recon/container-return', {
|
||||
...values,
|
||||
return_date: dayjs(values.return_date).format('YYYY-MM-DD'),
|
||||
});
|
||||
message.success('回筐已登记');
|
||||
return true;
|
||||
},
|
||||
formProps: {
|
||||
grid: true,
|
||||
colProps: { span: 12 },
|
||||
rowProps: { gutter: 20 },
|
||||
layout: 'vertical',
|
||||
},
|
||||
modalProps: { width: 640, title: '回筐登记' },
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<Title level={3}>回筐记录</Title>
|
||||
<Text type="secondary">
|
||||
生成账单时自动写入「压筐」记录并累加门店待回数量;门店退回周转筐/托盘时手动新增「回筐」登记并扣减待回,删除登记将恢复待回数量。
|
||||
</Text>
|
||||
</div>
|
||||
<XinTable<IContainerReturn> {...tableProps} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContainerReturnPage;
|
||||
@@ -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<XinTableInstance<IPayment>>(null);
|
||||
const [stores, setStores] = useState<IStore[]>([]);
|
||||
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detail, setDetail] = useState<IPaymentDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
// 审核弹窗
|
||||
const [auditTarget, setAuditTarget] = useState<IPayment | null>(null);
|
||||
const [auditSaving, setAuditSaving] = useState(false);
|
||||
const [auditForm] = Form.useForm<AuditFormValues>();
|
||||
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<IPaymentBill>['columns'] = [
|
||||
{
|
||||
title: '账单号',
|
||||
dataIndex: 'bill_no',
|
||||
align: 'center',
|
||||
render: (v) => <Text copyable={{ text: v }}>{v}</Text>,
|
||||
},
|
||||
{ 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) => <Text strong>¥{Number(v).toFixed(2)}</Text>,
|
||||
},
|
||||
{
|
||||
title: '支付状态',
|
||||
dataIndex: 'status',
|
||||
align: 'center',
|
||||
render: (v) => {
|
||||
const item = BILL_STATUS_MAP[Number(v ?? 0)];
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const columns: XinTableColumn<IPayment>[] = [
|
||||
{
|
||||
title: '支付单号',
|
||||
dataIndex: 'payment_no',
|
||||
valueType: 'text',
|
||||
hideInForm: true,
|
||||
width: 210,
|
||||
render: (_, record) => <Text copyable={{ text: record.payment_no }}>{record.payment_no}</Text>,
|
||||
},
|
||||
{
|
||||
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) => <Text strong type="danger">¥{record.amount}</Text>,
|
||||
},
|
||||
{
|
||||
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 <Tag color={item?.color}>{item?.text ?? '-'}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
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 <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
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<IPayment>['operateRender'] = (record) => [
|
||||
<Button
|
||||
key="detail"
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<UnorderedListOutlined />}
|
||||
onClick={() => openDetail(record.id!)}
|
||||
/>,
|
||||
record.status === 0 ? (
|
||||
<AuthButton key="audit" auth="recon.payment.audit">
|
||||
<Button size="small" variant="solid" color="orange" onClick={() => openAudit(record)}>
|
||||
审核
|
||||
</Button>
|
||||
</AuthButton>
|
||||
) : null,
|
||||
];
|
||||
|
||||
const tableProps: XinTableProps<IPayment> = {
|
||||
api: '/recon/payment',
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
accessName: 'recon.payment',
|
||||
tableRef,
|
||||
operateRender,
|
||||
formProps: false,
|
||||
actionBarRender: (dom) => [dom.search, dom.keywordSearch],
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<Title level={3}>支付记录</Title>
|
||||
<Text type="secondary">
|
||||
门店在小程序端选择账单合并付款并上传汇款凭证;审核通过后关联账单全部置为已支付,拒绝则释放账单可重新付款。
|
||||
</Text>
|
||||
</div>
|
||||
<XinTable<IPayment> {...tableProps} />
|
||||
|
||||
{/* 支付详情:支付信息 + 凭证 + 合并账单 */}
|
||||
<Drawer
|
||||
title={detail ? `支付单 ${detail.payment.payment_no}` : '支付记录详情'}
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
size={1000}
|
||||
loading={detailLoading}
|
||||
footer={
|
||||
detail && detail.payment.status === 0 ? (
|
||||
<Space className="flex justify-end">
|
||||
<AuthButton auth="recon.payment.audit">
|
||||
<Button type="primary" onClick={() => openAudit(detail.payment)}>
|
||||
审核
|
||||
</Button>
|
||||
</AuthButton>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{detail ? (
|
||||
<>
|
||||
<Descriptions column={3} size="small" bordered>
|
||||
<Descriptions.Item label="门店">{detail.payment.store?.name ?? `门店#${detail.payment.store_id}`}</Descriptions.Item>
|
||||
<Descriptions.Item label="支付金额">
|
||||
<Text strong type="danger">¥{detail.payment.amount}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="支付方式">
|
||||
<Tag color={PAY_METHOD_MAP[detail.payment.pay_method ?? 0]?.color}>
|
||||
{PAY_METHOD_MAP[detail.payment.pay_method ?? 0]?.text ?? '-'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={PAYMENT_STATUS_MAP[detail.payment.status ?? 0]?.color}>
|
||||
{PAYMENT_STATUS_MAP[detail.payment.status ?? 0]?.text}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="提交人">{detail.payment.user?.nickname ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="提交时间">{detail.payment.created_at}</Descriptions.Item>
|
||||
<Descriptions.Item label="审核人">{detail.payment.auditor?.nickname ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="审核时间">{detail.payment.audited_at ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="审核备注">{detail.payment.audit_remark || '-'}</Descriptions.Item>
|
||||
{detail.payment.remark ? (
|
||||
<Descriptions.Item label="门店备注" span={3}>{detail.payment.remark}</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
|
||||
<Title level={5} className="mt-6! mb-3!">
|
||||
汇款凭证
|
||||
</Title>
|
||||
{(detail.payment.voucher_urls ?? []).length > 0 ? (
|
||||
<Image.PreviewGroup>
|
||||
<Space wrap size={12}>
|
||||
{(detail.payment.voucher_urls ?? []).map((url, index) => (
|
||||
<Image
|
||||
key={index}
|
||||
src={url}
|
||||
width={120}
|
||||
height={120}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
) : (
|
||||
<Text type="secondary">无凭证</Text>
|
||||
)}
|
||||
|
||||
<Title level={5} className="mt-6! mb-3!">
|
||||
合并付款账单({detail.bills.length} 张)
|
||||
</Title>
|
||||
<Table<IPaymentBill>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
bordered
|
||||
columns={billColumns}
|
||||
dataSource={detail.bills}
|
||||
pagination={false}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Drawer>
|
||||
|
||||
{/* 审核弹窗 */}
|
||||
<Modal
|
||||
title={auditTarget ? `审核支付单 ${auditTarget.payment_no}` : '审核'}
|
||||
open={auditTarget !== null}
|
||||
onCancel={() => setAuditTarget(null)}
|
||||
onOk={() => auditForm.submit()}
|
||||
confirmLoading={auditSaving}
|
||||
okText="提交审核"
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="py-2 text-gray-500">
|
||||
支付金额 <Text strong type="danger">¥{auditTarget?.amount ?? '0.00'}</Text>({auditTarget?.bills_count ?? 0} 张账单);
|
||||
通过后关联账单全部置为「已支付」,拒绝则释放账单,门店可重新发起付款。
|
||||
</div>
|
||||
<Form form={auditForm} layout="vertical" onFinish={handleAuditSave}>
|
||||
<Form.Item label="审核结果" name="result" rules={[{ required: true, message: '请选择审核结果' }]}>
|
||||
<Radio.Group
|
||||
options={[
|
||||
{ value: 'pass', label: '通过(账单置为已支付)' },
|
||||
{ value: 'reject', label: '拒绝(释放账单)' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={watchAuditResult === 'reject' ? '拒绝原因' : '审核备注'}
|
||||
name="audit_remark"
|
||||
rules={[
|
||||
{ required: watchAuditResult === 'reject', message: '拒绝时请填写原因' },
|
||||
{ max: 255 },
|
||||
]}
|
||||
>
|
||||
<Input.TextArea rows={2} maxLength={255} placeholder="审核备注(拒绝时必填)" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentPage;
|
||||
@@ -1,239 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type {
|
||||
XinTableColumn,
|
||||
XinTableInstance,
|
||||
XinTableProps,
|
||||
} from '@/components/XinTable/typings.ts';
|
||||
import type IStatement from '@/domain/iStatement.ts';
|
||||
import type { IStatementItem } from '@/domain/iStatement.ts';
|
||||
import { STATEMENT_STATUS_MAP } from '@/domain/iStatement.ts';
|
||||
import { RECONCILED_MAP } from '@/domain/iReconciliation.ts';
|
||||
import { getStoreOptions } from '@/api/customer/store.ts';
|
||||
import type IStore from '@/domain/iStore.ts';
|
||||
import { Get } from '@/api/common/table.ts';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/**
|
||||
* 门店对账单(后台只读视角;生成/导出在小程序端)
|
||||
*/
|
||||
const StatementPage: React.FC = () => {
|
||||
const tableRef = useRef<XinTableInstance<IStatement>>(null);
|
||||
const [stores, setStores] = useState<IStore[]>([]);
|
||||
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detail, setDetail] = useState<IStatement | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getStoreOptions().then((res) => setStores(res.data.data ?? []));
|
||||
}, []);
|
||||
|
||||
const openDetail = async (id: number) => {
|
||||
setDetailOpen(true);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const res = await Get<IStatement>('/recon/statement', id);
|
||||
setDetail(res.data.data ?? null);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const itemColumns: TableProps<IStatementItem>['columns'] = [
|
||||
{ title: '品名', dataIndex: 'product_name' },
|
||||
{ title: '单价', dataIndex: 'price', align: 'right', render: (v) => `¥${v}` },
|
||||
{ title: '数量', dataIndex: 'quantity', align: 'right' },
|
||||
{ title: '重量', dataIndex: 'weight', align: 'right' },
|
||||
{ title: '金额', dataIndex: 'amount', align: 'right', render: (v) => `¥${v}` },
|
||||
{
|
||||
title: '对账状态',
|
||||
dataIndex: 'is_reconciled',
|
||||
align: 'center',
|
||||
render: (v) => {
|
||||
const item = RECONCILED_MAP[Number(v ?? 0)];
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '备注', dataIndex: 'store_remark', render: (v) => v || '-' },
|
||||
];
|
||||
|
||||
const columns: XinTableColumn<IStatement>[] = [
|
||||
{
|
||||
title: '对账单号',
|
||||
dataIndex: 'statement_no',
|
||||
valueType: 'text',
|
||||
hideInForm: true,
|
||||
render: (_, record) => <Text copyable={{ text: record.statement_no }}>{record.statement_no}</Text>,
|
||||
},
|
||||
{
|
||||
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 ?? '-',
|
||||
},
|
||||
{
|
||||
title: '对账周期',
|
||||
dataIndex: 'period_start',
|
||||
valueType: 'dateRange',
|
||||
hideInForm: true,
|
||||
render: (_, record) => `${record.period_start} ~ ${record.period_end}`,
|
||||
},
|
||||
{
|
||||
title: '总金额',
|
||||
dataIndex: 'total_amount',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'right',
|
||||
render: (_, record) => <Text strong>¥{record.total_amount}</Text>,
|
||||
},
|
||||
{
|
||||
title: '回款周期',
|
||||
dataIndex: 'payment_cycle_days',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
render: (_, record) => `${record.payment_cycle_days} 天`,
|
||||
},
|
||||
{
|
||||
title: '应结算日期',
|
||||
dataIndex: 'settlement_date',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
render: (_, record) => {
|
||||
const overdue =
|
||||
record.status !== 2 && record.settlement_date
|
||||
? new Date(record.settlement_date).getTime() < Date.now()
|
||||
: false;
|
||||
return (
|
||||
<Text type={overdue ? 'danger' : undefined} strong={overdue}>
|
||||
{record.settlement_date}
|
||||
{overdue ? '(逾期)' : ''}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
valueType: 'select',
|
||||
hideInForm: true,
|
||||
fieldProps: {
|
||||
options: Object.entries(STATEMENT_STATUS_MAP).map(([value, item]) => ({
|
||||
value: Number(value),
|
||||
label: item.text,
|
||||
})),
|
||||
},
|
||||
render: (_, record) => {
|
||||
const item = STATEMENT_STATUS_MAP[record.status ?? 0];
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
const operateRender: XinTableProps<IStatement>['operateRender'] = (record) => [
|
||||
<Button key="detail" size="small" onClick={() => openDetail(record.id!)}>
|
||||
详情
|
||||
</Button>,
|
||||
];
|
||||
|
||||
const tableProps: XinTableProps<IStatement> = {
|
||||
api: '/recon/statement',
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
accessName: 'recon.statement',
|
||||
tableRef,
|
||||
operateRender,
|
||||
formProps: false,
|
||||
actionBarRender: (dom) => [dom.search, dom.keywordSearch],
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<Title level={3}>门店对账单</Title>
|
||||
<Text type="secondary">
|
||||
门店在小程序端自助生成;应结算日期 = 对账周期结束日 + 门店回款周期(生成时快照),逾期高亮提示。
|
||||
</Text>
|
||||
</div>
|
||||
<XinTable<IStatement> {...tableProps} />
|
||||
|
||||
<Drawer
|
||||
title={detail ? `对账单 ${detail.statement_no}` : '对账单详情'}
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
width={860}
|
||||
loading={detailLoading}
|
||||
>
|
||||
{detail ? (
|
||||
<>
|
||||
<Descriptions column={2} size="small" bordered>
|
||||
<Descriptions.Item label="门店">{detail.store?.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={STATEMENT_STATUS_MAP[detail.status ?? 0]?.color}>
|
||||
{STATEMENT_STATUS_MAP[detail.status ?? 0]?.text}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="对账周期">
|
||||
{detail.period_start} ~ {detail.period_end}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="回款周期(快照)">
|
||||
{detail.payment_cycle_days} 天
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="应结算日期">
|
||||
{detail.settlement_date}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="总金额">¥{detail.total_amount}</Descriptions.Item>
|
||||
{detail.remark ? (
|
||||
<Descriptions.Item label="备注" span={2}>
|
||||
{detail.remark}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
<Title level={5} className="!mt-6 !mb-3">
|
||||
对账明细({detail.items?.length ?? 0})
|
||||
</Title>
|
||||
<Table<IStatementItem>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={itemColumns}
|
||||
dataSource={detail.items ?? []}
|
||||
pagination={false}
|
||||
summary={() => (
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0} colSpan={4} align="right">
|
||||
合计
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1} align="right">
|
||||
<Text strong>¥{detail.total_amount}</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={2} colSpan={2} />
|
||||
</Table.Summary.Row>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatementPage;
|
||||
Reference in New Issue
Block a user