回筐、账单完成

This commit is contained in:
liu
2026-08-14 01:19:47 +08:00
parent e35c951a59
commit bf8ac68391
39 changed files with 1392 additions and 963 deletions
-67
View File
@@ -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,72 @@
<?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\PostRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
/**
* 小程序门店账单(采购单完成后由后台生成,门店端只读)
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class BillController extends BaseMiniController
{
/** 账单列表:当前门店强制过滤,?page=&pageSize= */
#[GetRoute('/bill', authorize: true)]
public function index(Request $request): JsonResponse
{
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
$data = BillModel::query()
->where('store_id', $store->id)
->with('purchase:id,purchase_no,purchase_date')
->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,
]);
}
/**
* 在线支付(预留接口,本次不实现;当前为线下收款,由后台手动登记)
*/
#[PostRoute('/bill/{id}/pay', authorize: true, where: ['id' => '[0-9]+'])]
public function pay(int $id, Request $request): JsonResponse
{
throw new RepositoryException('在线支付暂未开通,请线下付款后由商家登记收款');
}
}
@@ -108,7 +108,6 @@ class OrderController extends BaseMiniController
'order_date' => $now->toDateString(), 'order_date' => $now->toDateString(),
'total_quantity' => $totalQuantity, 'total_quantity' => $totalQuantity,
'total_weight' => 0, 'total_weight' => 0,
'product_amount' => $totalAmount,
'total_amount' => $totalAmount, 'total_amount' => $totalAmount,
'status' => StoreOrderModel::STATUS_PENDING, 'status' => StoreOrderModel::STATUS_PENDING,
'remark' => $remark, 'remark' => $remark,
@@ -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,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([], '回筐记录已删除,门店待回数量已恢复');
});
}
}
@@ -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,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' => '备注超过最大长度',
];
}
}
+34
View File
@@ -15,6 +15,17 @@ class BillModel extends Model
{ {
use HasFactory; 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 $table = 'bill';
protected $primaryKey = 'id'; protected $primaryKey = 'id';
@@ -31,6 +42,10 @@ class BillModel extends Model
'tray_price', 'tray_price',
'added_amount', 'added_amount',
'total_amount', 'total_amount',
'status',
'paid_at',
'pay_remark',
'paid_operator_id',
'operator_id', 'operator_id',
'remark', 'remark',
]; ];
@@ -47,6 +62,9 @@ class BillModel extends Model
'tray_price' => 'decimal:2', 'tray_price' => 'decimal:2',
'added_amount' => 'decimal:2', 'added_amount' => 'decimal:2',
'total_amount' => 'decimal:2', 'total_amount' => 'decimal:2',
'status' => 'integer',
'paid_at' => 'datetime:Y-m-d H:i:s',
'paid_operator_id' => 'integer',
'operator_id' => 'integer', 'operator_id' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s', 'created_at' => 'datetime:Y-m-d H:i:s',
]; ];
@@ -75,6 +93,14 @@ class BillModel extends Model
return $this->belongsTo(SysUserModel::class, 'operator_id', 'id'); return $this->belongsTo(SysUserModel::class, 'operator_id', 'id');
} }
/**
* 收款操作人(后台系统用户,线下收款登记)
*/
public function paidOperator(): BelongsTo
{
return $this->belongsTo(SysUserModel::class, 'paid_operator_id', 'id');
}
/** /**
* 本账单关联的门店订单 * 本账单关联的门店订单
*/ */
@@ -82,4 +108,12 @@ class BillModel extends Model
{ {
return $this->hasMany(StoreOrderModel::class, 'bill_id', 'id'); return $this->hasMany(StoreOrderModel::class, 'bill_id', 'id');
} }
/**
* 本账单关联的门店订单明细
*/
public function items(): HasMany
{
return $this->hasMany(StoreOrderItemModel::class, 'bill_id', 'id');
}
} }
+76
View File
@@ -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');
}
}
-78
View File
@@ -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');
}
}
-65
View File
@@ -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');
}
}
+5 -3
View File
@@ -38,6 +38,8 @@ class StoreModel extends Model
protected $casts = [ protected $casts = [
'level_id' => 'integer', 'level_id' => 'integer',
'payment_cycle_days' => 'integer', 'payment_cycle_days' => 'integer',
'pending_box_num' => 'integer',
'pending_tray_num' => 'integer',
'status' => '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');
} }
} }
+2
View File
@@ -21,6 +21,7 @@ class StoreOrderItemModel extends Model
protected $fillable = [ protected $fillable = [
'order_id', 'order_id',
'purchase_id', 'purchase_id',
'bill_id',
'store_id', 'store_id',
'product_id', 'product_id',
'category_id', 'category_id',
@@ -42,6 +43,7 @@ class StoreOrderItemModel extends Model
protected $casts = [ protected $casts = [
'order_id' => 'integer', 'order_id' => 'integer',
'purchase_id' => 'integer', 'purchase_id' => 'integer',
'bill_id' => 'integer',
'store_id' => 'integer', 'store_id' => 'integer',
'product_id' => 'integer', 'product_id' => 'integer',
'category_id' => 'integer', 'category_id' => 'integer',
-1
View File
@@ -57,7 +57,6 @@ class StoreOrderModel extends Model
protected $casts = [ protected $casts = [
'store_id' => 'integer', 'store_id' => 'integer',
'purchase_id' => 'integer', 'purchase_id' => 'integer',
'statement_id' => 'integer',
'bill_id' => 'integer', 'bill_id' => 'integer',
'order_date' => 'date:Y-m-d', 'order_date' => 'date:Y-m-d',
'total_quantity' => 'integer', 'total_quantity' => 'integer',
+71
View File
@@ -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);
}
}
+23 -1
View File
@@ -4,8 +4,10 @@ namespace App\Services;
use App\Exceptions\RepositoryException; use App\Exceptions\RepositoryException;
use App\Models\BillModel; use App\Models\BillModel;
use App\Models\ContainerReturnModel;
use App\Models\PurchaseOrderModel; use App\Models\PurchaseOrderModel;
use App\Models\StoreModel; use App\Models\StoreModel;
use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel; use App\Models\StoreOrderModel;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Throwable; use Throwable;
@@ -109,10 +111,30 @@ readonly class BillGenerateService
'operator_id' => $operatorId, 'operator_id' => $operatorId,
]); ]);
// 关联该门店在采购单中的全部订单到账单 // 关联该门店在采购单中的全部订单与订单明细到账单
StoreOrderModel::query() StoreOrderModel::query()
->whereIn('id', $storeOrders->pluck('id')) ->whereIn('id', $storeOrders->pluck('id'))
->update(['bill_id' => $bill->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; $bills[] = $bill;
} }
+1 -2
View File
@@ -25,7 +25,6 @@ class BillNumberService
'PO' => ['purchase_order', 'purchase_no'], 'PO' => ['purchase_order', 'purchase_no'],
'SO' => ['store_order', 'order_no'], 'SO' => ['store_order', 'order_no'],
'RC' => ['reconciliation', 'recon_no'], 'RC' => ['reconciliation', 'recon_no'],
'ST' => ['statement', 'statement_no'],
'JS' => ['settlement', 'settlement_no'], 'JS' => ['settlement', 'settlement_no'],
'ZD' => ['bill', 'bill_no'], 'ZD' => ['bill', 'bill_no'],
]; ];
@@ -33,7 +32,7 @@ class BillNumberService
/** /**
* 生成业务单号 * 生成业务单号
* *
* @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / RC 对账 / ST 对账单 / JS 结算 / ZD 账单 * @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / RC 对账 / JS 结算 / ZD 账单
* @return string 如 PO202607230001 * @return string 如 PO202607230001
*/ */
public function make(string $prefix): string public function make(string $prefix): string
-101
View File
@@ -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_dayssettlement_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;
});
}
}
@@ -60,6 +60,8 @@ return new class extends Migration
$table->string('phone', 20)->default('')->comment('联系电话'); $table->string('phone', 20)->default('')->comment('联系电话');
$table->string('address', 255)->default('')->comment('门店地址'); $table->string('address', 255)->default('')->comment('门店地址');
$table->integer('payment_cycle_days')->default(0)->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->integer('status')->default(1)->comment('状态(1正常 0停用)');
$table->string('remark', 255)->nullable()->default('')->comment('备注'); $table->string('remark', 255)->nullable()->default('')->comment('备注');
$table->timestamps(); $table->timestamps();
@@ -41,6 +41,7 @@ return new class extends Migration
$table->integer('order_id')->comment('订单ID'); $table->integer('order_id')->comment('订单ID');
$table->integer('store_id')->comment('门店ID'); $table->integer('store_id')->comment('门店ID');
$table->integer('purchase_id')->default(0)->comment('归属采购单ID0=未归集)'); $table->integer('purchase_id')->default(0)->comment('归属采购单ID0=未归集)');
$table->integer('bill_id')->default(0)->comment('归属账单ID0=未出账)');
$table->integer('product_id')->comment('商品ID'); $table->integer('product_id')->comment('商品ID');
$table->integer('category_id')->default(0)->comment('分类ID'); $table->integer('category_id')->default(0)->comment('分类ID');
$table->integer('supplier_id')->default(0)->comment('供应商ID'); $table->integer('supplier_id')->default(0)->comment('供应商ID');
@@ -59,6 +60,7 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
$table->index(['order_id'], 'store_order_item_order_index'); $table->index(['order_id'], 'store_order_item_order_index');
$table->index(['purchase_id'], 'store_order_item_purchase_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->index(['store_id', 'product_id'], 'store_order_item_store_product_index');
$table->comment('门店订货明细表'); $table->comment('门店订货明细表');
}); });
@@ -8,7 +8,7 @@ return new class extends Migration
{ {
/** /**
* Run the migrations. * Run the migrations.
* 对账管理(D1-D10):财务对账、门店对账单、结算表 * 财务管理(D1-D10):财务对账、结算表(门店对账单已下线,由采购单账单 bill 表替代)
*/ */
public function up(): void 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 下载存档) // 结算表(D9 对账结束后生成结算表/回框统计表,D10 下载存档)
if (! Schema::hasTable('settlement')) { if (! Schema::hasTable('settlement')) {
Schema::create('settlement', function (Blueprint $table) { Schema::create('settlement', function (Blueprint $table) {
@@ -131,8 +89,6 @@ return new class extends Migration
{ {
Schema::dropIfExists('reconciliation'); Schema::dropIfExists('reconciliation');
Schema::dropIfExists('reconciliation_item'); Schema::dropIfExists('reconciliation_item');
Schema::dropIfExists('statement');
Schema::dropIfExists('statement_item');
Schema::dropIfExists('settlement'); Schema::dropIfExists('settlement');
} }
}; };
@@ -29,11 +29,16 @@ return new class extends Migration
$table->decimal('tray_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('added_amount', 10, 2)->default(0)->comment('附加金额(周转筐/托盘金额)');
$table->decimal('total_amount', 10, 2)->default(0)->comment('账单总金额 = 商品金额 + 配送费 + 附加金额'); $table->decimal('total_amount', 10, 2)->default(0)->comment('账单总金额 = 商品金额 + 配送费 + 附加金额');
$table->integer('status')->default(0)->comment('支付状态(0未支付 1已支付)');
$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->integer('operator_id')->default(0)->comment('生成人(后台系统用户ID');
$table->string('remark', 255)->default('')->comment('备注'); $table->string('remark', 255)->default('')->comment('备注');
$table->timestamps(); $table->timestamps();
$table->unique(['purchase_id', 'store_id'], 'bill_purchase_store_unique'); $table->unique(['purchase_id', 'store_id'], 'bill_purchase_store_unique');
$table->index(['store_id', 'bill_date'], 'bill_store_date_index'); $table->index(['store_id', 'bill_date'], 'bill_store_date_index');
$table->index(['status'], 'bill_status_index');
$table->comment('门店账单表(采购单完成后按门店生成)'); $table->comment('门店账单表(采购单完成后按门店生成)');
}); });
} }
@@ -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('关联账单ID0=手动回筐登记)');
$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');
}
};
+29 -17
View File
@@ -91,6 +91,18 @@ class PermissionSeeder extends Seeder
['type' => 'rule', 'key' => 'product.goods.batchPrice', 'name' => '批量调价'], ['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' => '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', 'type' => 'route',
'key' => 'customer.miniUser', 'key' => 'customer.miniUser',
@@ -202,7 +202,7 @@ class PermissionSeeder extends Seeder
[ [
'type' => 'menu', 'type' => 'menu',
'key' => 'procurement.recon', 'key' => 'procurement.recon',
'name' => '对账管理', 'name' => '财务管理',
'icon' => 'AccountBookOutlined', 'icon' => 'AccountBookOutlined',
'children' => [ 'children' => [
[ [
@@ -223,11 +223,23 @@ class PermissionSeeder extends Seeder
], ],
[ [
'type' => 'route', 'type' => 'route',
'key' => 'recon.statement', 'key' => 'recon.bill',
'name' => '门店账单', 'name' => '门店账单',
'path' => '/recon/statement', 'path' => '/recon/bill',
'children' => [ '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' => '删除'],
], ],
], ],
[ [
@@ -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>
-105
View File
@@ -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());
}
}
+19
View File
@@ -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,
});
}
+41
View File
@@ -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[];
}
+26
View File
@@ -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' },
};
+19
View File
@@ -74,11 +74,30 @@ export interface IBill {
added_amount: string; added_amount: string;
/** 账单总金额 = 商品金额 + 配送费 + 附加金额 */ /** 账单总金额 = 商品金额 + 配送费 + 附加金额 */
total_amount: string; total_amount: string;
/** 支付状态:0未支付 1已支付 */
status?: number;
/** 付款时间(线下收款手动登记) */
paid_at?: string | null;
/** 付款备注(线下收款信息) */
pay_remark?: string;
paid_operator_id?: number;
order_count?: number; order_count?: number;
remark?: string; remark?: string;
created_at?: 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 const BILL_STATUS_MAP: Record<number, { text: string; color: string }> = {
0: { text: '未支付', color: 'warning' },
1: { text: '已支付', color: 'success' },
};
/** 账单生成预览行(按门店汇总,金额只读) */ /** 账单生成预览行(按门店汇总,金额只读) */
export interface IBillPrepareStore { export interface IBillPrepareStore {
store_id: number; store_id: number;
-43
View File
@@ -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' },
};
+4
View File
@@ -14,6 +14,10 @@ export default interface IStore {
address?: string; address?: string;
/** 回款周期(天) */ /** 回款周期(天) */
payment_cycle_days?: number; payment_cycle_days?: number;
/** 待回筐数量(生成账单压筐累加,回筐登记扣减) */
pending_box_num?: number;
/** 待回托盘数量 */
pending_tray_num?: number;
status?: number; status?: number;
remark?: string; remark?: string;
created_at?: string; created_at?: string;
-1
View File
@@ -44,7 +44,6 @@ export default interface IStoreOrder {
order_no?: string; order_no?: string;
store_id?: number; store_id?: number;
purchase_id?: number; purchase_id?: number;
statement_id?: number;
/** 关联账单ID(采购单完成后按门店生成账单时回写) */ /** 关联账单ID(采购单完成后按门店生成账单时回写) */
bill_id?: number; bill_id?: number;
store?: { store?: {
+24
View File
@@ -86,6 +86,30 @@ const StorePage: React.FC = () => {
fieldProps: { min: 0, precision: 0 }, fieldProps: { min: 0, precision: 0 },
align: 'center', 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: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
+14 -1
View File
@@ -39,7 +39,7 @@ import type {
IPurchaseStoreItem, IPurchaseStoreItem,
IPurchaseStoreSummary, IPurchaseStoreSummary,
} from '@/domain/iPurchaseOrder.ts'; } 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 { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
import { import {
exportPurchase, exportPurchase,
@@ -544,6 +544,16 @@ const PurchaseOrderPage: React.FC = () => {
align: 'center', align: 'center',
render: (v) => <Text strong type="danger">¥{Number(v).toFixed(2)}</Text>, 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>;
},
},
]; ];
/** 门店账单合计行 */ /** 门店账单合计行 */
@@ -573,6 +583,9 @@ const PurchaseOrderPage: React.FC = () => {
<Table.Summary.Cell index={8} align="center"> <Table.Summary.Cell index={8} align="center">
<Text strong type="danger">¥{totalAmount.toFixed(2)}</Text> <Text strong type="danger">¥{totalAmount.toFixed(2)}</Text>
</Table.Summary.Cell> </Table.Summary.Cell>
<Table.Summary.Cell index={9} align="center">
<Text strong>-</Text>
</Table.Summary.Cell>
</Table.Summary.Row> </Table.Summary.Row>
); );
}; };
+422
View File
@@ -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;
+177
View File
@@ -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;
-239
View File
@@ -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;