回筐、账单完成
This commit is contained in:
@@ -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(),
|
||||
'total_quantity' => $totalQuantity,
|
||||
'total_weight' => 0,
|
||||
'product_amount' => $totalAmount,
|
||||
'total_amount' => $totalAmount,
|
||||
'status' => StoreOrderModel::STATUS_PENDING,
|
||||
'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' => '备注超过最大长度',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,17 @@ 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';
|
||||
|
||||
@@ -31,6 +42,10 @@ class BillModel extends Model
|
||||
'tray_price',
|
||||
'added_amount',
|
||||
'total_amount',
|
||||
'status',
|
||||
'paid_at',
|
||||
'pay_remark',
|
||||
'paid_operator_id',
|
||||
'operator_id',
|
||||
'remark',
|
||||
];
|
||||
@@ -47,6 +62,9 @@ class BillModel extends Model
|
||||
'tray_price' => 'decimal:2',
|
||||
'added_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',
|
||||
'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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 收款操作人(后台系统用户,线下收款登记)
|
||||
*/
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 本账单关联的门店订单明细
|
||||
*/
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -57,7 +57,6 @@ class StoreOrderModel extends Model
|
||||
protected $casts = [
|
||||
'store_id' => 'integer',
|
||||
'purchase_id' => 'integer',
|
||||
'statement_id' => 'integer',
|
||||
'bill_id' => 'integer',
|
||||
'order_date' => 'date:Y-m-d',
|
||||
'total_quantity' => 'integer',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ 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;
|
||||
@@ -109,10 +111,30 @@ readonly class BillGenerateService
|
||||
'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;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ 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'],
|
||||
];
|
||||
@@ -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
|
||||
*/
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user