first version

This commit is contained in:
liu
2026-07-23 20:41:25 +08:00
parent 00a0938a1b
commit 10cff754a4
211 changed files with 11577 additions and 842 deletions
@@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers\Customer;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Customer\CustomerLevelFormRequest;
use App\Models\CustomerLevelModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 客户等级管理(同一商品按客户等级定价)
*/
#[RequestAttribute('/customer/level', 'customer.level')]
class CustomerLevelController extends BaseController
{
protected array $searchField = [
'name' => 'like',
'status' => '=',
];
/** 等级列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, CustomerLevelModel::query())
->orderBy('sort')
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 创建等级 */
#[PostRoute(authorize: 'create')]
public function create(CustomerLevelFormRequest $request): JsonResponse
{
CustomerLevelModel::create($request->validated());
return $this->success();
}
/** 编辑等级 */
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function update(int $id, CustomerLevelFormRequest $request): JsonResponse
{
$model = CustomerLevelModel::find($id);
if (empty($model)) {
throw new RepositoryException('客户等级不存在');
}
$model->update($request->validated());
return $this->success();
}
/** 删除等级(被门店引用时拒绝) */
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
public function delete(int $id): JsonResponse
{
$model = CustomerLevelModel::find($id);
if (empty($model)) {
throw new RepositoryException('客户等级不存在');
}
if ($model->stores()->exists()) {
throw new RepositoryException('该等级下存在门店,无法删除');
}
if ($model->prices()->exists()) {
throw new RepositoryException('该等级下存在商品价格,无法删除');
}
$model->delete();
return $this->success();
}
/** 等级下拉选项(门店表单 / 价格矩阵用) */
#[GetRoute('/options', 'query')]
public function options(): JsonResponse
{
$data = CustomerLevelModel::query()
->where('status', CustomerLevelModel::STATUS_NORMAL)
->orderBy('sort')
->get(['id', 'name'])
->toArray();
return $this->success($data);
}
}
@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers\Customer;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Customer\MiniUserBindRequest;
use App\Models\StoreModel;
use App\Models\SupplierModel;
use App\Models\UserModel;
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('/customer/miniUser', 'customer.miniUser')]
class MiniUserController extends BaseController
{
protected array $searchField = [
'type' => '=',
'store_id' => '=',
'supplier_id' => '=',
'status' => '=',
];
protected array $quickSearchField = ['nickname', 'phone'];
/** 小程序用户列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, UserModel::query()->with('store:id,name', 'supplier:id,name'))
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/**
* 绑定门店/供应商(一个门店可绑多个账号,一个账号只绑一个主体)
*/
#[PutRoute(route: '/{id}/bind', authorize: 'bind', where: ['id' => '[0-9]+'])]
public function bind(int $id, MiniUserBindRequest $request): JsonResponse
{
$validated = $request->validated();
$user = UserModel::find($id);
if (empty($user)) {
throw new RepositoryException('用户不存在');
}
if ((int) $validated['type'] === UserModel::TYPE_STORE) {
$store = StoreModel::find((int) $validated['store_id']);
if (empty($store)) {
throw new RepositoryException('门店不存在');
}
$user->type = UserModel::TYPE_STORE;
$user->store_id = $store->id;
$user->supplier_id = 0;
} else {
$supplier = SupplierModel::find((int) $validated['supplier_id']);
if (empty($supplier)) {
throw new RepositoryException('供应商不存在');
}
$user->type = UserModel::TYPE_SUPPLIER;
$user->supplier_id = $supplier->id;
$user->store_id = 0;
}
$user->save();
return $this->success();
}
/** 启用/停用(停用后登录时检查 status 拒绝,token 鉴权拦截) */
#[PutRoute(route: '/{id}/status', authorize: 'update', where: ['id' => '[0-9]+'])]
public function status(int $id, Request $request): JsonResponse
{
$data = $request->validate([
'status' => 'required|integer|in:0,1',
], [
'status.required' => '状态不能为空',
'status.in' => '状态值不正确',
]);
$user = UserModel::find($id);
if (empty($user)) {
throw new RepositoryException('用户不存在');
}
$user->status = (int) $data['status'];
$user->save();
return $this->success();
}
}
@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Customer;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Customer\NoticeFormRequest;
use App\Models\NoticeModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
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;
/**
* 通知管理(小程序端消息;user_id=0 为全员广播)
*/
#[RequestAttribute('/customer/notice', 'customer.notice')]
class NoticeController extends BaseController
{
protected array $searchField = [
'type' => '=',
'is_read' => '=',
'user_id' => '=',
];
protected array $quickSearchField = ['title', 'content'];
/** 通知列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, NoticeModel::query())
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 发布通知(user_id=0 全员广播) */
#[PostRoute(authorize: 'create')]
public function create(NoticeFormRequest $request): JsonResponse
{
NoticeModel::create($request->validated() + ['is_read' => NoticeModel::UNREAD]);
return $this->success();
}
/** 删除通知 */
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
public function delete(int $id): JsonResponse
{
$model = NoticeModel::find($id);
if (empty($model)) {
throw new RepositoryException('通知不存在');
}
$model->delete();
return $this->success();
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\Customer;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Customer\StoreFormRequest;
use App\Models\StoreModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 门店管理(小程序下单主体,即客户)
*/
#[RequestAttribute('/customer/store', 'customer.store')]
class StoreController extends BaseController
{
protected array $searchField = [
'name' => 'like',
'code' => 'like',
'level_id' => '=',
'status' => '=',
];
protected array $quickSearchField = ['name', 'code', 'contact', 'phone'];
/** 门店列表(含等级名回显) */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, StoreModel::query()->with('level:id,name'))
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 创建门店 */
#[PostRoute(authorize: 'create')]
public function create(StoreFormRequest $request): JsonResponse
{
StoreModel::create($request->validated());
return $this->success();
}
/** 编辑门店 */
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function update(int $id, StoreFormRequest $request): JsonResponse
{
$model = StoreModel::find($id);
if (empty($model)) {
throw new RepositoryException('门店不存在');
}
$model->update($request->validated());
return $this->success();
}
/** 删除门店(软删除) */
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
public function delete(int $id): JsonResponse
{
$model = StoreModel::find($id);
if (empty($model)) {
throw new RepositoryException('门店不存在');
}
$model->delete();
return $this->success();
}
/** 门店下拉选项(小程序用户绑定、订单筛选用) */
#[GetRoute('/options', 'query')]
public function options(): JsonResponse
{
$data = StoreModel::query()
->where('status', StoreModel::STATUS_NORMAL)
->orderBy('id')
->get(['id', 'name', 'code'])
->toArray();
return $this->success($data);
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\Customer;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Customer\SupplierFormRequest;
use App\Models\SupplierModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 供应商管理(采购单接收方)
*/
#[RequestAttribute('/customer/supplier', 'customer.supplier')]
class SupplierController extends BaseController
{
protected array $searchField = [
'name' => 'like',
'status' => '=',
];
protected array $quickSearchField = ['name', 'contact', 'phone'];
/** 供应商列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, SupplierModel::query())
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 创建供应商 */
#[PostRoute(authorize: 'create')]
public function create(SupplierFormRequest $request): JsonResponse
{
SupplierModel::create($request->validated());
return $this->success();
}
/** 编辑供应商 */
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function update(int $id, SupplierFormRequest $request): JsonResponse
{
$model = SupplierModel::find($id);
if (empty($model)) {
throw new RepositoryException('供应商不存在');
}
$model->update($request->validated());
return $this->success();
}
/** 删除供应商(软删除) */
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
public function delete(int $id): JsonResponse
{
$model = SupplierModel::find($id);
if (empty($model)) {
throw new RepositoryException('供应商不存在');
}
$model->delete();
return $this->success();
}
/** 供应商下拉选项 */
#[GetRoute('/options', 'query')]
public function options(): JsonResponse
{
$data = SupplierModel::query()
->where('status', SupplierModel::STATUS_NORMAL)
->orderBy('id')
->get(['id', 'name'])
->toArray();
return $this->success($data);
}
}
@@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException;
use App\Models\StoreModel;
use App\Models\SupplierModel;
use App\Models\UserModel;
use App\Services\WechatService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
/**
* 小程序认证(微信登录 / 手机号绑定 / 当前用户信息)
*
* authGuard: usersprovider 指向 UserModel,与后台 sys_users 天然隔离);
* authorize: true 仅要求登录(sanctum + authGuard:users),不做细粒度权限点;
* token abilities ['mini'] 作来源标记。
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class AuthController extends BaseMiniController
{
/** 小程序登录:wx.login 的 code → openid → 自动注册/登录 → 签发 token */
#[PostRoute('/auth/login', authorize: false)]
public function login(Request $request): JsonResponse
{
$data = $request->validate([
'code' => 'required|string',
], [
'code.required' => '缺少登录凭证 code',
]);
$session = app(WechatService::class)->code2Session($data['code']);
$user = UserModel::firstOrNew(['openid' => $session['openid']]);
$isNew = ! $user->exists;
if ($user->status === UserModel::STATUS_DISABLED) {
throw new RepositoryException('账号已被停用,请联系客服');
}
if ($isNew) {
$user->type = UserModel::TYPE_PENDING;
$user->status = UserModel::STATUS_NORMAL;
}
if (! empty($session['unionid'])) {
$user->unionid = $session['unionid'];
}
$user->last_login_at = now();
$user->save();
$token = $user->createToken('mini', ['mini'])->plainTextToken;
return $this->success([
'token' => $token,
'user' => $this->formatUser($user->fresh(['store.level:id,name', 'supplier:id,name'])),
], $isNew ? '注册成功' : '登录成功');
}
/**
* 绑定手机号:phoneCode 换手机号 → 按手机号自动匹配门店/供应商
* (命中门店 → type=1+store_id;命中供应商 → type=2+supplier_id;都不命中 → 保持待绑定,后台人工处理)
*/
#[PostRoute('/auth/phone', authorize: true)]
public function phone(Request $request): JsonResponse
{
$data = $request->validate([
'phoneCode' => 'required|string',
], [
'phoneCode.required' => '缺少手机号授权凭证 phoneCode',
]);
$phone = app(WechatService::class)->getPhone($data['phoneCode']);
$user = $this->currentUser($request);
$user->phone = $phone;
if (! $user->isBound()) {
$store = StoreModel::where('phone', $phone)
->where('status', StoreModel::STATUS_NORMAL)
->first();
if ($store !== null) {
$user->type = UserModel::TYPE_STORE;
$user->store_id = $store->id;
$user->supplier_id = 0;
} else {
$supplier = SupplierModel::where('phone', $phone)
->where('status', SupplierModel::STATUS_NORMAL)
->first();
if ($supplier !== null) {
$user->type = UserModel::TYPE_SUPPLIER;
$user->supplier_id = $supplier->id;
$user->store_id = 0;
}
}
}
$user->save();
return $this->success([
'user' => $this->formatUser($user->fresh(['store.level:id,name', 'supplier:id,name'])),
]);
}
/** 当前用户信息(含门店客户等级 —— 全局价格体系依据 / 供应商信息) */
#[GetRoute('/auth/info', authorize: true)]
public function info(Request $request): JsonResponse
{
$user = UserModel::with(['store.level:id,name', 'supplier:id,name'])
->find($request->user()->id);
if ($user === null) {
throw new RepositoryException('账号不存在');
}
return $this->success(['user' => $this->formatUser($user)]);
}
/**
* 小程序端用户信息输出结构
*
* @return array<string, mixed>
*/
private function formatUser(UserModel $user): array
{
return [
'id' => $user->id,
'nickname' => $user->nickname,
'avatar' => $user->avatar,
'phone' => $user->phone,
'type' => $user->type,
'store' => $user->store,
'supplier' => $user->supplier,
];
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException;
use App\Models\StoreModel;
use App\Models\SupplierModel;
use App\Models\UserModel;
use Illuminate\Http\Request;
use Modules\Common\Http\Controllers\BaseController;
/**
* 小程序端控制器基类
*
* 无 #[RequestAttribute],不会被 AnnoRoute 注册为路由。
* 提供当前用户获取与门店/供应商绑定前置校验。
*/
abstract class BaseMiniController extends BaseController
{
/**
* 当前小程序用户(auth:sanctum 注入的 tokenable
*/
protected function currentUser(Request $request): UserModel
{
$user = UserModel::find($request->user()->id);
if ($user === null || $user->status === UserModel::STATUS_DISABLED) {
throw new RepositoryException('账号不存在或已被停用');
}
return $user;
}
/**
* 门店端前置校验:type=门店 且 store_id>0 且门店正常
*/
protected function ensureStoreBound(UserModel $user): StoreModel
{
if ($user->type !== UserModel::TYPE_STORE || $user->store_id <= 0) {
throw new RepositoryException('尚未绑定门店,请联系客服处理');
}
$store = StoreModel::find($user->store_id);
if ($store === null || $store->status !== StoreModel::STATUS_NORMAL) {
throw new RepositoryException('门店不存在或已停用,请联系客服处理');
}
return $store;
}
/**
* 供应商端前置校验:type=供应商 且 supplier_id>0 且供应商正常
*/
protected function ensureSupplierBound(UserModel $user): SupplierModel
{
if ($user->type !== UserModel::TYPE_SUPPLIER || $user->supplier_id <= 0) {
throw new RepositoryException('尚未绑定供应商,请联系客服处理');
}
$supplier = SupplierModel::find($user->supplier_id);
if ($supplier === null || $supplier->status !== SupplierModel::STATUS_NORMAL) {
throw new RepositoryException('供应商不存在或已停用,请联系客服处理');
}
return $supplier;
}
}
@@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException;
use App\Models\NoticeModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
/**
* 小程序通知(本人通知 + 全员广播)
*
* 广播已读处理:user_id=0 的广播是全局共享记录,直接改 is_read 会影响其他用户,
* 因此标记已读时复制一条本人专属的已读记录(data.broadcast_from 记来源),
* 列表查询时排除已有已读副本的广播。
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class NoticeController extends BaseMiniController
{
/** 本人通知 + 全员广播(user_id in [0, 当前id]),分页 + unread_count */
#[GetRoute('/notice', authorize: true)]
public function index(Request $request): JsonResponse
{
$user = $this->currentUser($request);
// 本人已读过的广播来源ID(已读副本记录)
$readBroadcastIds = NoticeModel::query()
->where('user_id', $user->id)
->whereNotNull('data->broadcast_from')
->pluck('data->broadcast_from');
$query = NoticeModel::query()->where(function ($q) use ($user, $readBroadcastIds) {
$q->where('user_id', $user->id)
->orWhere(function ($broadcastQuery) use ($readBroadcastIds) {
$broadcastQuery->where('user_id', NoticeModel::BROADCAST_USER_ID);
if ($readBroadcastIds->isNotEmpty()) {
$broadcastQuery->whereNotIn('id', $readBroadcastIds->all());
}
});
});
$unreadCount = (clone $query)->where('is_read', NoticeModel::UNREAD)->count();
$data = $query->orderBy('id', 'desc')
->paginate((int) $request->input('pageSize', 10))
->toArray();
$data['unread_count'] = $unreadCount;
return $this->success($data);
}
/** 标记已读(广播 → 复制本人已读副本;个人通知 → 直接更新) */
#[PutRoute('/notice/{id}/read', authorize: true, where: ['id' => '[0-9]+'])]
public function read(int $id, Request $request): JsonResponse
{
$user = $this->currentUser($request);
$notice = NoticeModel::whereIn('user_id', [NoticeModel::BROADCAST_USER_ID, $user->id])->find($id);
if ($notice === null) {
throw new RepositoryException('通知不存在');
}
if ($notice->user_id === NoticeModel::BROADCAST_USER_ID) {
$exists = NoticeModel::where('user_id', $user->id)
->where('data->broadcast_from', $notice->id)
->exists();
if (! $exists) {
NoticeModel::create([
'user_id' => $user->id,
'type' => $notice->type,
'title' => $notice->title,
'content' => $notice->content,
'data' => ['broadcast_from' => $notice->id] + (array) $notice->data,
'is_read' => NoticeModel::READ,
'read_at' => now(),
]);
}
} elseif ($notice->is_read === NoticeModel::UNREAD) {
$notice->is_read = NoticeModel::READ;
$notice->read_at = now();
$notice->save();
}
return $this->success();
}
}
@@ -0,0 +1,219 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Mini\MiniOrderRequest;
use App\Models\ProductModel;
use App\Models\ProductPriceModel;
use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
use App\Services\BillNumberService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
/**
* 小程序门店订单(下单 / 历史 / 详情 / 取消 / 周期汇总)
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class OrderController extends BaseMiniController
{
/**
* 下单:逐行取当前门店等级价快照,服务端重算 amount 与 total(不接受前端金额)
*/
#[PostRoute('/order', authorize: true)]
public function store(MiniOrderRequest $request): JsonResponse
{
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
if ($store->level_id <= 0) {
throw new RepositoryException('门店未设置客户等级,无法下单,请联系客服');
}
$items = $request->validated('items');
$remark = (string) ($request->validated('remark') ?? '');
$order = DB::transaction(function () use ($store, $items, $remark) {
$productIds = array_map(static fn ($row) => (int) $row['product_id'], $items);
$products = ProductModel::query()
->where('status', ProductModel::STATUS_ON)
->whereIn('id', $productIds)
->get()
->keyBy('id');
$prices = ProductPriceModel::query()
->where('level_id', $store->level_id)
->whereIn('product_id', $productIds)
->pluck('price', 'product_id');
$totalQuantity = '0';
$totalAmount = '0';
$now = now();
$rows = [];
foreach ($items as $row) {
$productId = (int) $row['product_id'];
$product = $products->get($productId);
if ($product === null) {
throw new RepositoryException('存在已下架或不存在的商品,请刷新后重试');
}
if (! isset($prices[$productId])) {
throw new RepositoryException('商品「' . $product->name . '」未设置您所在等级的价格,无法下单');
}
$price = (string) $prices[$productId];
$quantity = (string) $row['quantity'];
$amount = bcmul($price, $quantity, 2);
$totalQuantity = bcadd($totalQuantity, $quantity, 2);
$totalAmount = bcadd($totalAmount, $amount, 2);
$rows[] = [
'store_id' => $store->id,
'product_id' => $productId,
'product_name' => $product->name,
'product_spec' => $product->spec,
'price' => $price,
'quantity' => $quantity,
'weight' => 0,
'amount' => $amount,
'remark' => '',
'created_at' => $now,
'updated_at' => $now,
];
}
$order = StoreOrderModel::create([
'order_no' => app(BillNumberService::class)->make('SO'),
'store_id' => $store->id,
'order_date' => $now->toDateString(),
'total_quantity' => $totalQuantity,
'total_weight' => 0,
'total_amount' => $totalAmount,
'status' => StoreOrderModel::STATUS_PENDING,
'remark' => $remark,
]);
foreach ($rows as &$itemRow) {
$itemRow['order_id'] = $order->id;
}
StoreOrderItemModel::insert($rows);
return $order;
});
return $this->success([
'id' => $order->id,
'order_no' => $order->order_no,
'total_amount' => $order->total_amount,
], '下单成功');
}
/** 历史订单:当前门店强制过滤,?status=&page=&pageSize= */
#[GetRoute('/order', authorize: true)]
public function index(Request $request): JsonResponse
{
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
$query = StoreOrderModel::query()->where('store_id', $store->id);
if ($request->filled('status')) {
$query->where('status', (int) $request->input('status'));
}
$data = $query->orderBy('order_date', 'desc')
->orderBy('id', 'desc')
->paginate((int) $request->input('pageSize', 10))
->toArray();
return $this->success($data);
}
/**
* 按周期聚合金额/数量:?period=day|week|month(分组列表,period_label 可作下钻查询参数)
*/
#[GetRoute('/order/summary', authorize: true)]
public function summary(Request $request): JsonResponse
{
$period = (string) $request->query('period', 'month');
if (! in_array($period, ['day', 'week', 'month'], true)) {
throw new RepositoryException('period 参数只能是 day/week/month');
}
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
// 按数据库方言选择周期分组表达式(生产 MySQL / 测试 SQLite
$driver = DB::connection()->getDriverName();
if ($driver === 'sqlite') {
$format = match ($period) {
'day' => '%Y-%m-%d',
'week' => '%Y-W%W',
default => '%Y-%m',
};
$labelExpr = "strftime('{$format}', order_date)";
} else {
$format = match ($period) {
'day' => '%Y-%m-%d',
'week' => '%x-W%v',
default => '%Y-%m',
};
$labelExpr = "DATE_FORMAT(order_date, '{$format}')";
}
$rows = StoreOrderModel::query()
->where('store_id', $store->id)
->where('status', '<>', StoreOrderModel::STATUS_CANCELLED)
->selectRaw("{$labelExpr} as period_label")
->selectRaw('SUM(total_amount) as total_amount, SUM(total_quantity) as total_quantity, COUNT(*) as order_count')
->groupBy('period_label')
->orderByDesc('period_label')
->limit(50)
->get()
->toArray();
return $this->success(['period' => $period, 'groups' => $rows]);
}
/** 订单详情(校验归属:仅能查看本店订单) */
#[GetRoute('/order/{id}', authorize: true, where: ['id' => '[0-9]+'])]
public function detail(int $id, Request $request): JsonResponse
{
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
$order = StoreOrderModel::with('items')
->where('store_id', $store->id)
->find($id);
if ($order === null) {
throw new RepositoryException('订单不存在');
}
return $this->success($order->toArray());
}
/** 取消订单(仅待汇总可取消) */
#[PutRoute('/order/{id}/cancel', authorize: true, where: ['id' => '[0-9]+'])]
public function cancel(int $id, Request $request): JsonResponse
{
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
$order = StoreOrderModel::where('store_id', $store->id)->find($id);
if ($order === null) {
throw new RepositoryException('订单不存在');
}
if ($order->status !== StoreOrderModel::STATUS_PENDING) {
throw new RepositoryException('仅待汇总的订单可以取消');
}
$order->status = StoreOrderModel::STATUS_CANCELLED;
$order->save();
return $this->success([], '订单已取消');
}
}
@@ -0,0 +1,99 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException;
use App\Models\ProductCategoryModel;
use App\Models\ProductModel;
use App\Models\ProductPriceModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
/**
* 小程序商品(分类树 + 列表,价格 = 当前门店客户等级价)
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class ProductController extends BaseMiniController
{
/** 分类树(仅含上架商品的分类及其祖先,保证树结构完整) */
#[GetRoute('/product/categories', authorize: true)]
public function categories(): JsonResponse
{
$activeCategoryIds = ProductModel::query()
->where('status', ProductModel::STATUS_ON)
->distinct()
->pluck('category_id')
->map(static fn ($id) => (int) $id)
->filter(static fn (int $id) => $id > 0);
$categories = ProductCategoryModel::query()
->where('status', ProductCategoryModel::STATUS_NORMAL)
->get()
->keyBy('id');
// 保留有上架商品的分类 + 其全部祖先
$keep = [];
foreach ($activeCategoryIds as $categoryId) {
$cursor = $categoryId;
$guard = 0;
while ($cursor > 0 && $guard++ < 20 && $categories->has($cursor)) {
$keep[$cursor] = true;
$cursor = (int) $categories[$cursor]->parent_id;
}
}
$filtered = array_values(array_filter(
$categories->toArray(),
static fn (array $item) => isset($keep[$item['id']])
));
return $this->success(ProductCategoryModel::buildTree($filtered));
}
/**
* 商品列表:价格取当前门店客户等级价(未绑等级的门店报错);
* ?category_id=&keyword=&page=&pageSize=
*/
#[GetRoute('/product/list', authorize: true)]
public function products(Request $request): JsonResponse
{
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
if ($store->level_id <= 0) {
throw new RepositoryException('门店未设置客户等级,无法展示价格,请联系客服');
}
$query = ProductModel::query()
->where('status', ProductModel::STATUS_ON)
->with('category:id,name')
->with(['prices' => static fn ($q) => $q->where('level_id', $store->level_id)]);
$categoryId = (int) $request->input('category_id', 0);
if ($categoryId > 0) {
$query->where('category_id', $categoryId);
}
$keyword = trim((string) $request->input('keyword', ''));
if ($keyword !== '') {
$query->where(static function ($q) use ($keyword) {
$q->where('name', 'like', '%' . $keyword . '%')
->orWhere('spec', 'like', '%' . $keyword . '%');
});
}
$pageSize = (int) $request->input('pageSize', 10);
$data = $query->orderBy('sort')
->orderBy('id')
->paginate($pageSize)
->toArray();
// 扁平化价格:prices[0].price → price(未设等级价为 null
foreach ($data['data'] as &$row) {
$row['price'] = $row['prices'][0]['price'] ?? null;
unset($row['prices']);
}
return $this->success($data);
}
}
@@ -0,0 +1,105 @@
<?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());
}
/** 导出对账单:?format=xlsx|pdf */
#[GetRoute('/statement/{id}/export', authorize: true, where: ['id' => '[0-9]+'])]
public function export(int $id, Request $request): Response
{
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
$statement = StatementModel::where('store_id', $store->id)->find($id);
if ($statement === null) {
throw new RepositoryException('对账单不存在');
}
return app(ExportService::class)->download(
'statement',
$statement,
(string) $request->query('format', ExportService::FORMAT_XLSX),
);
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Mini;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
/**
* 小程序门店设置(回款周期自配置,影响对账单应结算日期)
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class StoreController extends BaseMiniController
{
/** 修改回款周期(≥0,无上限) */
#[PutRoute('/store/paymentCycle', authorize: true)]
public function paymentCycle(Request $request): JsonResponse
{
$data = $request->validate([
'payment_cycle_days' => 'required|integer|min:0',
], [
'payment_cycle_days.required' => '回款周期不能为空',
'payment_cycle_days.integer' => '回款周期必须为整数',
'payment_cycle_days.min' => '回款周期不能小于 0',
]);
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
$store->payment_cycle_days = (int) $data['payment_cycle_days'];
$store->save();
return $this->success(['payment_cycle_days' => $store->payment_cycle_days], '回款周期已更新');
}
}
@@ -0,0 +1,104 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Exceptions\RepositoryException;
use App\Models\PurchaseOrderItemModel;
use App\Models\PurchaseOrderModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
/**
* 小程序供应商端(接收采购单 / 明细 / 确认接单)
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class SupplierController extends BaseMiniController
{
/** 收到的采购单:含本供应商 is_sent=1 明细的采购单(去重) */
#[GetRoute('/supplier/purchases', authorize: true)]
public function purchases(Request $request): JsonResponse
{
$user = $this->currentUser($request);
$supplier = $this->ensureSupplierBound($user);
$purchaseIds = PurchaseOrderItemModel::query()
->where('supplier_id', $supplier->id)
->where('is_sent', PurchaseOrderItemModel::SENT)
->distinct()
->pluck('purchase_id');
$data = PurchaseOrderModel::query()
->whereIn('id', $purchaseIds)
->orderBy('purchase_date', 'desc')
->orderBy('id', 'desc')
->paginate((int) $request->input('pageSize', 10))
->toArray();
return $this->success($data);
}
/** 采购单明细:仅本供应商且已发送的明细行 */
#[GetRoute('/supplier/purchases/{id}', authorize: true, where: ['id' => '[0-9]+'])]
public function detail(int $id, Request $request): JsonResponse
{
$user = $this->currentUser($request);
$supplier = $this->ensureSupplierBound($user);
$purchase = PurchaseOrderModel::find($id);
if ($purchase === null) {
throw new RepositoryException('采购单不存在');
}
$items = $purchase->items()
->where('supplier_id', $supplier->id)
->where('is_sent', PurchaseOrderItemModel::SENT)
->get();
if ($items->isEmpty()) {
throw new RepositoryException('该采购单无贵司的采购明细');
}
return $this->success([
'id' => $purchase->id,
'purchase_no' => $purchase->purchase_no,
'purchase_date' => $purchase->purchase_date?->toDateString(),
'remark' => $purchase->remark,
'items' => $items->toArray(),
]);
}
/** 确认接单:本供应商已发送明细批量记录 supplier_confirmed_at(幂等) */
#[PutRoute('/supplier/purchases/{id}/confirm', authorize: true, where: ['id' => '[0-9]+'])]
public function confirm(int $id, Request $request): JsonResponse
{
$user = $this->currentUser($request);
$supplier = $this->ensureSupplierBound($user);
$purchase = PurchaseOrderModel::find($id);
if ($purchase === null) {
throw new RepositoryException('采购单不存在');
}
$items = $purchase->items()
->where('supplier_id', $supplier->id)
->where('is_sent', PurchaseOrderItemModel::SENT)
->get();
if ($items->isEmpty()) {
throw new RepositoryException('该采购单无贵司的采购明细');
}
$now = now();
$confirmed = 0;
foreach ($items as $item) {
if ($item->supplier_confirmed_at === null) {
$item->supplier_confirmed_at = $now;
$item->save();
$confirmed++;
}
}
return $this->success(['confirmed' => $confirmed], '已确认接单');
}
}
@@ -0,0 +1,166 @@
<?php
namespace App\Http\Controllers\Order;
use App\Exceptions\RepositoryException;
use App\Models\NoticeModel;
use App\Models\ProductModel;
use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
use App\Models\UserModel;
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('/order/store', 'order.store')]
class StoreOrderController extends BaseController
{
/** 状态中文名(通知文案用) */
private const STATUS_NAMES = [
StoreOrderModel::STATUS_PENDING => '待汇总',
StoreOrderModel::STATUS_SUMMARIZED => '已汇总',
StoreOrderModel::STATUS_DELIVERING => '配送中',
StoreOrderModel::STATUS_COMPLETED => '已完成',
StoreOrderModel::STATUS_CANCELLED => '已取消',
];
protected array $searchField = [
'store_id' => '=',
'status' => '=',
'order_no' => 'like',
'order_date' => 'betweenDate',
];
/** 订单列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, StoreOrderModel::query()->with('store:id,name'))
->orderBy('order_date', 'desc')
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/**
* 待汇总预览:聚合所有待汇总订单明细(按商品分组),
* 供生成采购单前确认(C1 前置)
*/
#[GetRoute('/summary', 'query')]
public function summary(): JsonResponse
{
$rows = StoreOrderItemModel::query()
->select('product_id')
->selectRaw('MAX(product_name) as product_name')
->selectRaw('MAX(product_spec) as product_spec')
->selectRaw('SUM(quantity) as total_quantity')
->selectRaw('COUNT(DISTINCT store_id) as store_count')
->whereHas('order', function ($query) {
$query->where('status', StoreOrderModel::STATUS_PENDING);
})
->groupBy('product_id')
->orderBy('product_id')
->get()
->toArray();
// 补充计价单位(商品档案,含已下架/软删除)
$units = ProductModel::withTrashed()
->whereIn('id', array_column($rows, 'product_id'))
->pluck('unit', 'id');
foreach ($rows as &$row) {
$row['unit'] = $units[$row['product_id']] ?? '';
}
return $this->success($rows);
}
/** 订单详情:订单头 + 明细(含商品快照) */
#[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])]
public function detail(int $id): JsonResponse
{
$order = StoreOrderModel::with(['store:id,name', 'items'])->find($id);
if (empty($order)) {
throw new RepositoryException('订单不存在');
}
return $this->success($order->toArray());
}
/**
* 状态流转(待汇总→配送中→完成;待汇总可取消;已汇总可转配送中)
*/
#[PutRoute(route: '/{id}/status', authorize: 'update', where: ['id' => '[0-9]+'])]
public function status(int $id, Request $request): JsonResponse
{
$data = $request->validate([
'status' => 'required|integer|in:2,3,9',
], [
'status.required' => '目标状态不能为空',
'status.in' => '目标状态值不正确',
]);
$order = StoreOrderModel::find($id);
if (empty($order)) {
throw new RepositoryException('订单不存在');
}
$target = (int) $data['status'];
$allowed = match ($order->status) {
StoreOrderModel::STATUS_PENDING => [
StoreOrderModel::STATUS_DELIVERING,
StoreOrderModel::STATUS_CANCELLED,
],
StoreOrderModel::STATUS_SUMMARIZED => [
StoreOrderModel::STATUS_DELIVERING,
],
StoreOrderModel::STATUS_DELIVERING => [
StoreOrderModel::STATUS_COMPLETED,
],
default => [],
};
if (! in_array($target, $allowed, true)) {
throw new RepositoryException(
'订单当前状态为「' . (self::STATUS_NAMES[$order->status] ?? $order->status) . '」,不允许该操作'
);
}
$order->status = $target;
$order->save();
$this->notifyStore($order);
return $this->success();
}
/**
* 状态流转后通知门店用户
*/
private function notifyStore(StoreOrderModel $order): void
{
$userIds = UserModel::query()
->where('type', UserModel::TYPE_STORE)
->where('store_id', $order->store_id)
->where('status', UserModel::STATUS_NORMAL)
->pluck('id');
$statusName = self::STATUS_NAMES[$order->status] ?? (string) $order->status;
foreach ($userIds as $userId) {
NoticeModel::create([
'user_id' => $userId,
'type' => NoticeModel::TYPE_ORDER,
'title' => '订单状态更新',
'content' => mb_substr("您的订单 {$order->order_no} 状态已更新为「{$statusName}", 0, 500),
'data' => ['order_id' => $order->id],
'is_read' => NoticeModel::UNREAD,
]);
}
}
}
@@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers\Product;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Product\ProductCategoryFormRequest;
use App\Models\ProductCategoryModel;
use App\Models\ProductModel;
use Illuminate\Http\JsonResponse;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 商品分类管理(多级分类:蔬菜/水果/其他)
*/
#[RequestAttribute('/product/category', 'product.category')]
class ProductCategoryController extends BaseController
{
/** 分类树列表(后端组装 children,前端树表展示) */
#[GetRoute(authorize: 'query')]
public function query(): JsonResponse
{
return $this->success(ProductCategoryModel::getTreeData());
}
/** 级联选项(商品表单分类下拉、对账筛选用;仅启用分类) */
#[GetRoute('/tree', 'query')]
public function tree(): JsonResponse
{
return $this->success(ProductCategoryModel::getTreeData(onlyEnabled: true));
}
/** 创建分类 */
#[PostRoute(authorize: 'create')]
public function create(ProductCategoryFormRequest $request): JsonResponse
{
$validated = $request->validated();
$parentId = (int) $validated['parent_id'];
if ($parentId > 0 && ! ProductCategoryModel::whereKey($parentId)->exists()) {
throw new RepositoryException('父级分类不存在');
}
ProductCategoryModel::create($validated);
return $this->success();
}
/** 编辑分类(防自引用成环) */
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function update(int $id, ProductCategoryFormRequest $request): JsonResponse
{
$model = ProductCategoryModel::find($id);
if (empty($model)) {
throw new RepositoryException('分类不存在');
}
$validated = $request->validated();
$this->assertNoCycle($id, (int) $validated['parent_id']);
$model->update($validated);
return $this->success();
}
/** 删除分类(有子分类或挂载商品时拒绝) */
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
public function delete(int $id): JsonResponse
{
$model = ProductCategoryModel::find($id);
if (empty($model)) {
throw new RepositoryException('分类不存在');
}
if (ProductCategoryModel::where('parent_id', $id)->exists()) {
throw new RepositoryException('该分类下存在子分类,无法删除');
}
if (ProductModel::where('category_id', $id)->exists()) {
throw new RepositoryException('该分类下存在商品,无法删除');
}
$model->delete();
return $this->success();
}
/**
* 沿父链向上检查,防止 parent_id 指向自身或子孙分类形成环
*/
private function assertNoCycle(int $id, int $parentId): void
{
if ($parentId === 0) {
return;
}
if ($parentId === $id) {
throw new RepositoryException('父级分类不能是自身');
}
$cursor = $parentId;
$guard = 0;
while ($cursor > 0 && $guard++ < 100) {
$next = ProductCategoryModel::whereKey($cursor)->value('parent_id');
if ($next === null) {
throw new RepositoryException('父级分类不存在');
}
if ((int) $next === $id) {
throw new RepositoryException('父级分类不能是子级分类,会形成循环');
}
$cursor = (int) $next;
}
}
}
@@ -0,0 +1,240 @@
<?php
namespace App\Http\Controllers\Product;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Product\BatchPriceRequest;
use App\Http\Requests\Product\ProductFormRequest;
use App\Models\CustomerLevelModel;
use App\Models\NoticeModel;
use App\Models\ProductModel;
use App\Models\ProductPriceModel;
use App\Models\StoreModel;
use App\Models\UserModel;
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\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 商品档案管理(A1 商品列表 / A2 价格矩阵与批量调价 / 多等级价格体系)
*/
#[RequestAttribute('/product/goods', 'product.goods')]
class ProductController extends BaseController
{
protected array $searchField = [
'name' => 'like',
'category_id' => '=',
'supplier_id' => '=',
'status' => '=',
];
protected array $quickSearchField = ['name', 'spec'];
/** A1 商品列表(含分类/供应商/各等级价格) */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch(
$params,
ProductModel::query()->with(['category:id,name', 'supplier:id,name', 'prices.level:id,name'])
)
->orderBy('sort')
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 创建商品(事务内建商品 + 同步等级价格) */
#[PostRoute(authorize: 'create')]
public function create(ProductFormRequest $request): JsonResponse
{
$validated = $request->validated();
$prices = $validated['prices'] ?? [];
unset($validated['prices']);
$product = DB::transaction(function () use ($validated, $prices) {
$product = ProductModel::create($validated);
foreach ($prices as $row) {
ProductPriceModel::create([
'product_id' => $product->id,
'level_id' => (int) $row['level_id'],
'price' => $row['price'],
]);
}
return $product;
});
return $this->success(['id' => $product->id]);
}
/** 编辑商品(prices 按 level_id upsert,删除已移除的等级行;未提交 prices 键时保持原价) */
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function update(int $id, ProductFormRequest $request): JsonResponse
{
$product = ProductModel::find($id);
if (empty($product)) {
throw new RepositoryException('商品不存在');
}
$validated = $request->validated();
$prices = $validated['prices'] ?? [];
unset($validated['prices']);
DB::transaction(function () use ($product, $validated, $prices, $request) {
$product->update($validated);
if ($request->has('prices')) {
$levelIds = [];
foreach ($prices as $row) {
$levelId = (int) $row['level_id'];
$levelIds[] = $levelId;
ProductPriceModel::updateOrCreate(
['product_id' => $product->id, 'level_id' => $levelId],
['price' => $row['price']],
);
}
$product->prices()->whereNotIn('level_id', $levelIds)->delete();
}
});
return $this->success();
}
/** 删除商品(软删除,连带价格行一并删除) */
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
public function delete(int $id): JsonResponse
{
$product = ProductModel::find($id);
if (empty($product)) {
throw new RepositoryException('商品不存在');
}
DB::transaction(function () use ($product) {
$product->prices()->delete();
$product->delete();
});
return $this->success();
}
/**
* A2 价格矩阵:行=商品(支持 category_id / keyword 过滤),列=全部启用等级,值=price(缺失为 null
*/
#[GetRoute('/priceMatrix', 'query')]
public function priceMatrix(Request $request): JsonResponse
{
$query = ProductModel::query()->with('prices:id,product_id,level_id,price');
if (($categoryId = (int) $request->input('category_id', 0)) > 0) {
$query->where('category_id', $categoryId);
}
$keyword = trim((string) $request->input('keyword', ''));
if ($keyword !== '') {
$query->where(function ($q) use ($keyword) {
$q->where('name', 'like', '%' . $keyword . '%')
->orWhere('spec', 'like', '%' . $keyword . '%');
});
}
$products = $query->orderBy('sort')->orderBy('id')->get();
$levels = CustomerLevelModel::query()
->where('status', CustomerLevelModel::STATUS_NORMAL)
->orderBy('sort')
->get(['id', 'name']);
$rows = $products->map(function (ProductModel $product) use ($levels) {
$priceMap = $product->prices->keyBy('level_id');
$row = [
'id' => $product->id,
'name' => $product->name,
'spec' => $product->spec,
'unit' => $product->unit,
];
foreach ($levels as $level) {
$row['price_' . $level->id] = isset($priceMap[$level->id])
? (float) $priceMap[$level->id]->price
: null;
}
return $row;
});
return $this->success([
'levels' => $levels->toArray(),
'rows' => $rows->toArray(),
]);
}
/**
* A2 批量调价:事务写入,写完后给受影响门店生成 Noticetype=price
*/
#[PutRoute('/batchPrice', 'batchPrice')]
public function batchPrice(BatchPriceRequest $request): JsonResponse
{
$updates = $request->validated('updates');
DB::transaction(function () use ($updates) {
$productIds = [];
$levelIds = [];
foreach ($updates as $row) {
ProductPriceModel::updateOrCreate(
['product_id' => (int) $row['product_id'], 'level_id' => (int) $row['level_id']],
['price' => $row['price']],
);
$productIds[(int) $row['product_id']] = true;
$levelIds[(int) $row['level_id']] = true;
}
$productNames = ProductModel::whereIn('id', array_keys($productIds))
->pluck('name')
->implode('、');
$content = mb_substr('以下商品价格已调整:' . $productNames . ',下次登录小程序后按新价格显示', 0, 500);
// 受影响门店:客户等级在本次调价等级范围内的正常门店,通知其绑定的正常用户
$userIds = UserModel::query()
->where('type', UserModel::TYPE_STORE)
->where('status', UserModel::STATUS_NORMAL)
->whereIn('store_id', function ($q) use ($levelIds) {
$q->select('id')
->from('store')
->where('status', StoreModel::STATUS_NORMAL)
->whereIn('level_id', array_keys($levelIds));
})
->pluck('id');
foreach ($userIds as $userId) {
NoticeModel::create([
'user_id' => $userId,
'type' => NoticeModel::TYPE_PRICE,
'title' => '商品价格变更',
'content' => $content,
'data' => [
'product_ids' => array_keys($productIds),
'level_ids' => array_keys($levelIds),
],
'is_read' => NoticeModel::UNREAD,
]);
}
});
return $this->success();
}
/** 商品下拉选项(仅上架,下单等场景用) */
#[GetRoute('/options', 'query')]
public function options(Request $request): JsonResponse
{
$query = ProductModel::query()->where('status', ProductModel::STATUS_ON);
$keyword = trim((string) $request->input('keyword', ''));
if ($keyword !== '') {
$query->where('name', 'like', '%' . $keyword . '%');
}
$data = $query->orderBy('sort')
->get(['id', 'name', 'spec', 'unit'])
->toArray();
return $this->success($data);
}
}
@@ -0,0 +1,249 @@
<?php
namespace App\Http\Controllers\Purchase;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Purchase\PurchaseItemUpdateRequest;
use App\Models\PurchaseAllocationModel;
use App\Models\PurchaseOrderItemModel;
use App\Models\PurchaseOrderModel;
use App\Services\ExportService;
use App\Services\PurchaseAllocateService;
use App\Services\PurchaseGenerateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Symfony\Component\HttpFoundation\Response;
/**
* 采购单管理(C1 汇总生成 / C2-C3 导出 / C4 修改 / C5-C6 发送供应商 / D3 金额分摊)
*/
#[RequestAttribute('/purchase/order', 'purchase.order')]
class PurchaseOrderController extends BaseController
{
protected array $searchField = [
'status' => '=',
'purchase_no' => 'like',
'purchase_date' => 'betweenDate',
];
/** 采购单列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, PurchaseOrderModel::query()->with('operator:id,nickname'))
->orderBy('purchase_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
{
$purchase = PurchaseOrderModel::with([
'operator:id,nickname',
'items.supplier:id,name',
'items.allocations.store:id,name',
])->find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
return $this->success($purchase->toArray());
}
/** C4 修改采购单头信息(采购日期、备注) */
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function update(int $id, Request $request): JsonResponse
{
$data = $request->validate([
'purchase_date' => 'nullable|date_format:Y-m-d',
'remark' => 'nullable|string|max:255',
], [
'purchase_date.date_format' => '采购日期格式为 Y-m-d',
]);
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$purchase->update(array_filter($data, static fn ($v) => $v !== null));
return $this->success();
}
/** C1 按门店订单汇总生成采购单 */
#[PostRoute('/generate', 'generate')]
public function generate(Request $request): JsonResponse
{
$data = $request->validate([
'purchase_date' => 'required|date_format:Y-m-d',
], [
'purchase_date.required' => '请选择采购日期',
'purchase_date.date_format' => '采购日期格式为 Y-m-d',
]);
$purchase = app(PurchaseGenerateService::class)->generate(
$data['purchase_date'],
(int) $request->user()->id,
);
return $this->success(
['id' => $purchase->id, 'purchase_no' => $purchase->purchase_no],
'采购单已生成'
);
}
/** C2/C3 导出采购单:?type=all|category & format=xlsx|pdf */
#[GetRoute(route: '/{id}/export', authorize: 'export', where: ['id' => '[0-9]+'])]
public function export(int $id, Request $request): Response
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$type = (string) $request->query('type', 'all');
if (! in_array($type, ['all', 'category'], true)) {
throw new RepositoryException('导出类型参数不正确(all 全品类 / category 蔬果分类)');
}
return app(ExportService::class)->download(
'purchase',
$purchase,
(string) $request->query('format', ExportService::FORMAT_XLSX),
type: $type,
);
}
/**
* C4 采购明细修改:amount 后端重算(weight>0 ? weight×price : quantity×price),
* 同步回写采购单头汇总(Σ total_weight / actual_amount
*/
#[PutRoute(route: '/item/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function updateItem(int $id, PurchaseItemUpdateRequest $request): JsonResponse
{
$item = PurchaseOrderItemModel::find($id);
if (empty($item)) {
throw new RepositoryException('采购明细不存在');
}
$validated = $request->validated();
$price = (string) $validated['price'];
$quantity = (string) $validated['quantity'];
$weight = (string) ($validated['weight'] ?? 0);
$amount = (float) $weight > 0
? bcmul($weight, $price, 2)
: bcmul($quantity, $price, 2);
$item->update([
'product_name' => $validated['product_name'] ?? $item->product_name,
'product_spec' => $validated['product_spec'] ?? $item->product_spec,
'price' => $price,
'quantity' => $quantity,
'weight' => $weight,
'amount' => $amount,
'remark' => $validated['remark'] ?? $item->remark,
]);
// 回写采购单头汇总
$sums = PurchaseOrderItemModel::query()
->where('purchase_id', $item->purchase_id)
->selectRaw('COALESCE(SUM(weight), 0) as total_weight, COALESCE(SUM(amount), 0) as actual_amount')
->first();
PurchaseOrderModel::whereKey($item->purchase_id)->update([
'total_weight' => $sums->total_weight,
'actual_amount' => $sums->actual_amount,
]);
return $this->success(['amount' => $amount]);
}
/** C5/C6 明细发送供应商:is_sent=1 + sent_at;联动采购单状态(全发送→ALL_SENT,否则 PART_SENT */
#[PutRoute(route: '/item/{id}/send', authorize: 'send', where: ['id' => '[0-9]+'])]
public function sendItem(int $id): JsonResponse
{
$item = PurchaseOrderItemModel::find($id);
if (empty($item)) {
throw new RepositoryException('采购明细不存在');
}
if ($item->is_sent === PurchaseOrderItemModel::SENT) {
throw new RepositoryException('该明细已发送,请勿重复操作');
}
$item->is_sent = PurchaseOrderItemModel::SENT;
$item->sent_at = now();
$item->save();
$purchase = $item->purchase;
$hasUnsent = $purchase->items()
->where('is_sent', PurchaseOrderItemModel::NOT_SENT)
->exists();
$purchase->status = $hasUnsent
? PurchaseOrderModel::STATUS_PART_SENT
: PurchaseOrderModel::STATUS_ALL_SENT;
$purchase->save();
return $this->success();
}
/** D3 执行金额分摊(按订货比例摊到门店/单品,尾差修正守恒;可重复执行) */
#[PostRoute(route: '/{id}/allocate', authorize: 'allocate', where: ['id' => '[0-9]+'])]
public function allocate(int $id): JsonResponse
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$count = app(PurchaseAllocateService::class)->allocate($purchase);
return $this->success(['count' => $count], '分摊完成');
}
/** 分摊结果:按门店、按商品两个聚合维度 */
#[GetRoute(route: '/{id}/allocation', authorize: 'query', where: ['id' => '[0-9]+'])]
public function allocation(int $id): JsonResponse
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$allocations = PurchaseAllocationModel::query()
->whereIn('purchase_item_id', $purchase->items()->pluck('id'))
->with(['store:id,name', 'product:id,name,unit'])
->get();
$byStore = $allocations->groupBy('store_id')->map(function ($group) {
$first = $group->first();
return [
'store_id' => $first->store_id,
'store_name' => $first->store?->name ?? '',
'quantity' => (float) $group->sum('quantity'),
'weight' => (float) $group->sum('weight'),
'amount' => (float) $group->sum('amount'),
];
})->values();
$byProduct = $allocations->groupBy('product_id')->map(function ($group) {
$first = $group->first();
return [
'product_id' => $first->product_id,
'product_name' => $first->product?->name ?? '',
'unit' => $first->product?->unit ?? '',
'quantity' => (float) $group->sum('quantity'),
'weight' => (float) $group->sum('weight'),
'amount' => (float) $group->sum('amount'),
];
})->values();
return $this->success([
'by_store' => $byStore->toArray(),
'by_product' => $byProduct->toArray(),
'total_amount' => (float) $allocations->sum('amount'),
]);
}
}
@@ -0,0 +1,125 @@
<?php
namespace App\Http\Controllers\Recon;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Recon\ReconItemUpdateRequest;
use App\Models\ReconciliationItemModel;
use App\Models\ReconciliationModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 对账明细操作(D4 修改 / D6 单品级门店备注 / D8 对账状态标记)
* 权限点前缀 recon.itemauthorize: item.update → recon.item.item.update
*/
#[RequestAttribute('/recon/item', 'recon.item')]
class ReconItemController extends BaseController
{
/**
* D4 修改订货量/称重/数量/金额/商品名,自动重算本行 diff + 头汇总
*/
#[PutRoute(route: '/{id}', authorize: 'item.update', where: ['id' => '[0-9]+'])]
public function update(int $id, ReconItemUpdateRequest $request): JsonResponse
{
$item = ReconciliationItemModel::find($id);
if (empty($item)) {
throw new RepositoryException('对账明细不存在');
}
$this->assertEditable($item);
$validated = $request->validated();
if (isset($validated['product_name'])) {
$item->product_name = $validated['product_name'];
}
if (isset($validated['quantity'])) {
$item->quantity = $validated['quantity'];
}
if (isset($validated['weight'])) {
$item->weight = $validated['weight'];
}
if (isset($validated['publish_amount'])) {
$item->publish_amount = $validated['publish_amount'];
}
if (isset($validated['actual_amount'])) {
$item->actual_amount = $validated['actual_amount'];
}
// 重算本行差额
$item->diff_amount = bcsub((string) $item->publish_amount, (string) $item->actual_amount, 2);
$item->save();
$this->refreshReconSummary((int) $item->recon_id);
return $this->success(['diff_amount' => $item->diff_amount]);
}
/** D8 对账状态标记翻转 */
#[PutRoute(route: '/{id}/toggle', authorize: 'item.update', where: ['id' => '[0-9]+'])]
public function toggle(int $id): JsonResponse
{
$item = ReconciliationItemModel::find($id);
if (empty($item)) {
throw new RepositoryException('对账明细不存在');
}
$this->assertEditable($item);
$item->is_reconciled = $item->is_reconciled === ReconciliationItemModel::RECONCILED
? ReconciliationItemModel::NOT_RECONCILED
: ReconciliationItemModel::RECONCILED;
$item->save();
return $this->success(['is_reconciled' => $item->is_reconciled]);
}
/** D6 单品级门店备注 */
#[PutRoute(route: '/{id}/remark', authorize: 'item.update', where: ['id' => '[0-9]+'])]
public function remark(int $id, Request $request): JsonResponse
{
$data = $request->validate([
'store_remark' => 'nullable|string|max:255',
], [
'store_remark.max' => '备注最长 255 个字符',
]);
$item = ReconciliationItemModel::find($id);
if (empty($item)) {
throw new RepositoryException('对账明细不存在');
}
$this->assertEditable($item);
$item->store_remark = (string) ($data['store_remark'] ?? '');
$item->save();
return $this->success();
}
/**
* 已结算的对账单明细不允许修改
*/
private function assertEditable(ReconciliationItemModel $item): void
{
$recon = ReconciliationModel::find($item->recon_id);
if ($recon !== null && $recon->status === ReconciliationModel::STATUS_SETTLED) {
throw new RepositoryException('对账单已结算,明细不能修改');
}
}
/**
* 明细变更后重算对账单头汇总(publish / actual / diff
*/
private function refreshReconSummary(int $reconId): void
{
$sums = ReconciliationItemModel::query()
->where('recon_id', $reconId)
->selectRaw('COALESCE(SUM(publish_amount), 0) as publish_total, COALESCE(SUM(actual_amount), 0) as actual_total')
->first();
ReconciliationModel::whereKey($reconId)->update([
'publish_amount' => $sums->publish_total,
'actual_amount' => $sums->actual_total,
'diff_amount' => bcsub((string) $sums->publish_total, (string) $sums->actual_total, 2),
]);
}
}
@@ -0,0 +1,235 @@
<?php
namespace App\Http\Controllers\Recon;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Recon\ReconciliationFormRequest;
use App\Models\ReconciliationModel;
use App\Models\SettlementModel;
use App\Services\BillNumberService;
use App\Services\ReconciliationBuildService;
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\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 财务对账管理(D1 品类 / D2 供应商筛选、D5 差额对比、D9 结算表生成)
* 对账明细的 D4/D6/D8 操作见 ReconItemController
*/
#[RequestAttribute('/recon/list', 'recon.list')]
class ReconciliationController extends BaseController
{
protected array $searchField = [
'status' => '=',
'category_id' => '=',
'supplier_id' => '=',
'title' => 'like',
'period_start' => 'date',
];
/** 对账单列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, ReconciliationModel::query()->with('operator:id,nickname'))
->orderBy('id', 'desc')
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 创建对账单(草稿,recon_no = RC…) */
#[PostRoute(authorize: 'create')]
public function create(ReconciliationFormRequest $request): JsonResponse
{
$validated = $request->validated();
$recon = ReconciliationModel::create([
'recon_no' => app(BillNumberService::class)->make('RC'),
'title' => $validated['title'],
'period_start' => $validated['period_start'],
'period_end' => $validated['period_end'],
'category_id' => $validated['category_id'],
'supplier_id' => $validated['supplier_id'],
'publish_amount' => 0,
'actual_amount' => 0,
'diff_amount' => 0,
'status' => ReconciliationModel::STATUS_DRAFT,
'operator_id' => (int) $request->user()->id,
'remark' => $validated['remark'] ?? '',
]);
return $this->success(['id' => $recon->id]);
}
/** 编辑对账单(仅草稿/对账中) */
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function update(int $id, ReconciliationFormRequest $request): JsonResponse
{
$recon = ReconciliationModel::find($id);
if (empty($recon)) {
throw new RepositoryException('对账单不存在');
}
if ($recon->status === ReconciliationModel::STATUS_SETTLED) {
throw new RepositoryException('对账单已结算,不能编辑');
}
$validated = $request->validated();
$recon->update([
'title' => $validated['title'],
'period_start' => $validated['period_start'],
'period_end' => $validated['period_end'],
'category_id' => $validated['category_id'],
'supplier_id' => $validated['supplier_id'],
'remark' => $validated['remark'] ?? '',
]);
return $this->success();
}
/** 删除对账单(仅草稿可删,连带明细) */
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
public function delete(int $id): JsonResponse
{
$recon = ReconciliationModel::find($id);
if (empty($recon)) {
throw new RepositoryException('对账单不存在');
}
if ($recon->status !== ReconciliationModel::STATUS_DRAFT) {
throw new RepositoryException('仅草稿状态的对账单可以删除');
}
DB::transaction(function () use ($recon) {
$recon->items()->delete();
$recon->delete();
});
return $this->success();
}
/** 生成对账明细(按周期 + 品类 + 供应商拉取采购分摊数据;可重复生成) */
#[PostRoute(route: '/{id}/build', authorize: 'build', where: ['id' => '[0-9]+'])]
public function build(int $id): JsonResponse
{
$recon = ReconciliationModel::find($id);
if (empty($recon)) {
throw new RepositoryException('对账单不存在');
}
if ($recon->status === ReconciliationModel::STATUS_SETTLED) {
throw new RepositoryException('对账单已结算,不能重新生成明细');
}
$count = app(ReconciliationBuildService::class)->build($recon);
return $this->success(['count' => $count], '对账明细已生成');
}
/**
* D5 差额对比视图:按门店 / 按商品两个维度 + 合计行
*/
#[GetRoute(route: '/{id}/diff', authorize: 'query', where: ['id' => '[0-9]+'])]
public function diff(int $id): JsonResponse
{
$recon = ReconciliationModel::find($id);
if (empty($recon)) {
throw new RepositoryException('对账单不存在');
}
$items = $recon->items()->with('store:id,name')->get();
$byStore = $items->groupBy('store_id')->map(function ($group) {
$first = $group->first();
$publish = $group->sum('publish_amount');
$actual = $group->sum('actual_amount');
return [
'store_id' => $first->store_id,
'store_name' => $first->store?->name ?? '',
'publish' => (float) $publish,
'actual' => (float) $actual,
'diff' => (float) bcsub((string) $publish, (string) $actual, 2),
];
})->values();
$byProduct = $items->groupBy('product_id')->map(function ($group) {
$first = $group->first();
$publish = $group->sum('publish_amount');
$actual = $group->sum('actual_amount');
return [
'product_id' => $first->product_id,
'product_name' => $first->product_name,
'publish' => (float) $publish,
'actual' => (float) $actual,
'diff' => (float) bcsub((string) $publish, (string) $actual, 2),
];
})->values();
$publishTotal = (string) $items->sum('publish_amount');
$actualTotal = (string) $items->sum('actual_amount');
return $this->success([
'by_store' => $byStore->toArray(),
'by_product' => $byProduct->toArray(),
'total' => [
'publish' => (float) $publishTotal,
'actual' => (float) $actualTotal,
'diff' => (float) bcsub($publishTotal, $actualTotal, 2),
],
]);
}
/**
* D9 生成结算表:按门店聚合明细生成 settlement 记录,对账单 status → 已结算
* (回框统计表规则待业务确认,本次仅预留结构)
*/
#[PostRoute(route: '/{id}/settle', authorize: 'settle', where: ['id' => '[0-9]+'])]
public function settle(int $id, Request $request): JsonResponse
{
$recon = ReconciliationModel::find($id);
if (empty($recon)) {
throw new RepositoryException('对账单不存在');
}
if ($recon->status !== ReconciliationModel::STATUS_WORKING) {
throw new RepositoryException('仅「对账中」的对账单可以生成结算表');
}
$count = DB::transaction(function () use ($recon, $request) {
$groups = $recon->items()->get()->groupBy('store_id');
if ($groups->isEmpty()) {
throw new RepositoryException('对账单无明细,请先生成对账明细');
}
$billNumber = app(BillNumberService::class);
foreach ($groups as $storeId => $items) {
$publish = $items->reduce(
static fn (string $carry, $item): string => bcadd($carry, (string) $item->publish_amount, 2),
'0'
);
$actual = $items->reduce(
static fn (string $carry, $item): string => bcadd($carry, (string) $item->actual_amount, 2),
'0'
);
SettlementModel::create([
'settlement_no' => $billNumber->make('JS'),
'recon_id' => $recon->id,
'store_id' => (int) $storeId,
'period_start' => $recon->period_start,
'period_end' => $recon->period_end,
'total_amount' => $publish,
'actual_amount' => $actual,
'diff_amount' => bcsub($publish, $actual, 2),
'status' => SettlementModel::STATUS_SETTLED,
'operator_id' => (int) $request->user()->id,
'settled_at' => now(),
]);
}
$recon->status = ReconciliationModel::STATUS_SETTLED;
$recon->save();
return $groups->count();
});
return $this->success(['count' => $count], '结算表已生成');
}
}
@@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers\Recon;
use App\Exceptions\RepositoryException;
use App\Models\SettlementModel;
use App\Services\ExportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Symfony\Component\HttpFoundation\Response;
/**
* 结算表管理(D9 生成于对账结算,D10 导出下载存档)
*/
#[RequestAttribute('/recon/settlement', 'recon.settlement')]
class SettlementController extends BaseController
{
protected array $searchField = [
'settlement_no' => 'like',
'recon_id' => '=',
'store_id' => '=',
'status' => '=',
];
/** 结算表列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch(
$params,
SettlementModel::query()->with(['store:id,name', 'recon:id,recon_no,title'])
)
->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
{
$settlement = SettlementModel::with(['store:id,name', 'recon:id,recon_no,title', 'operator:id,nickname'])
->find($id);
if (empty($settlement)) {
throw new RepositoryException('结算表不存在');
}
return $this->success($settlement->toArray());
}
/**
* D10 导出下载:?format=xlsx|pdf,成功后回写 file_path 存档标记
*/
#[GetRoute(route: '/{id}/download', authorize: 'download', where: ['id' => '[0-9]+'])]
public function download(int $id, Request $request): Response
{
$settlement = SettlementModel::find($id);
if (empty($settlement)) {
throw new RepositoryException('结算表不存在');
}
$format = (string) $request->query('format', ExportService::FORMAT_XLSX);
$response = app(ExportService::class)->download('settlement', $settlement, $format);
// 同步流式下载不落盘,file_path 仅作存档标记(后续切队列导出时替换为真实文件路径)
$extension = $format === ExportService::FORMAT_PDF ? 'pdf' : 'xlsx';
$settlement->file_path = 'exports/settlement/' . $settlement->settlement_no . '.' . $extension;
$settlement->save();
return $response;
}
}
@@ -0,0 +1,49 @@
<?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,39 @@
<?php
namespace App\Http\Requests\Customer;
use Illuminate\Validation\Rule;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 客户等级 创建/编辑 验证
*/
class CustomerLevelFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
$unique = Rule::unique('customer_level', 'name');
if ($this->isUpdate()) {
$unique = $unique->ignore($this->route('id'));
}
return [
'name' => ['required', 'string', 'max:50', $unique],
'sort' => 'nullable|integer',
'status' => 'nullable|integer|in:0,1',
'remark' => 'nullable|string|max:255',
];
}
public function messages(): array
{
return [
'name.required' => '等级名称不能为空',
'name.max' => '等级名称最长 50 个字符',
'name.unique' => '等级名称已存在',
'status.in' => '状态值不正确',
];
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\Customer;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 小程序用户绑定 验证(type=1 门店需 store_idtype=2 供应商需 supplier_id
*/
class MiniUserBindRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'type' => 'required|integer|in:1,2',
'store_id' => 'required_if:type,1|nullable|integer|min:1',
'supplier_id' => 'required_if:type,2|nullable|integer|min:1',
];
}
public function messages(): array
{
return [
'type.required' => '请选择用户类型',
'type.in' => '用户类型只能是门店或供应商',
'store_id.required_if' => '绑定门店时必须选择门店',
'store_id.min' => '门店ID不正确',
'supplier_id.required_if' => '绑定供应商时必须选择供应商',
'supplier_id.min' => '供应商ID不正确',
];
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests\Customer;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 通知 创建 验证(user_id 留空 = 全员广播)
*/
class NoticeFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
protected function prepareForValidation(): void
{
$this->merge(['user_id' => (int) ($this->input('user_id') ?? 0)]);
}
public function rules(): array
{
return [
'user_id' => 'required|integer|min:0',
'type' => 'required|string|in:order,price,system',
'title' => 'required|string|max:100',
'content' => 'nullable|string|max:500',
];
}
public function messages(): array
{
return [
'type.required' => '通知类型不能为空',
'type.in' => '通知类型只能是 order/price/system',
'title.required' => '通知标题不能为空',
'title.max' => '通知标题最长 100 个字符',
'content.max' => '通知内容最长 500 个字符',
];
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Http\Requests\Customer;
use Illuminate\Validation\Rule;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 门店 创建/编辑 验证
*/
class StoreFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
$unique = Rule::unique('store', 'code');
if ($this->isUpdate()) {
$unique = $unique->ignore($this->route('id'));
}
return [
'name' => 'required|string|max:100',
'code' => ['required', 'string', 'max:50', $unique],
'level_id' => 'required|integer|exists:customer_level,id',
'contact' => 'nullable|string|max:50',
'phone' => 'nullable|string|max:20',
'address' => 'nullable|string|max:255',
'payment_cycle_days' => 'nullable|integer|min:0',
'status' => 'nullable|integer|in:0,1',
'remark' => 'nullable|string|max:255',
];
}
public function messages(): array
{
return [
'name.required' => '门店名称不能为空',
'name.max' => '门店名称最长 100 个字符',
'code.required' => '门店编码不能为空',
'code.unique' => '门店编码已存在',
'level_id.required' => '请选择客户等级',
'level_id.exists' => '客户等级不存在',
'payment_cycle_days.integer' => '回款周期必须为整数',
'payment_cycle_days.min' => '回款周期不能小于 0',
'status.in' => '状态值不正确',
];
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests\Customer;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 供应商 创建/编辑 验证
*/
class SupplierFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'name' => 'required|string|max:100',
'contact' => 'nullable|string|max:50',
'phone' => 'nullable|string|max:20',
'address' => 'nullable|string|max:255',
'main_products' => 'nullable|string|max:255',
'status' => 'nullable|integer|in:0,1',
'remark' => 'nullable|string|max:255',
];
}
public function messages(): array
{
return [
'name.required' => '供应商名称不能为空',
'name.max' => '供应商名称最长 100 个字符',
'status.in' => '状态值不正确',
];
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests\Mini;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 小程序下单 验证(金额一律服务端重算,前端不传金额字段)
*/
class MiniOrderRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|integer|exists:product,id',
'items.*.quantity' => 'required|numeric|min:0.01',
'remark' => 'nullable|string|max:255',
];
}
public function messages(): array
{
return [
'items.required' => '请至少选择一件商品',
'items.min' => '请至少选择一件商品',
'items.*.product_id.required' => '下单商品不能为空',
'items.*.product_id.exists' => '商品不存在',
'items.*.quantity.required' => '订货数量不能为空',
'items.*.quantity.numeric' => '订货数量必须为数字',
'items.*.quantity.min' => '订货数量必须大于 0',
'remark.max' => '订单备注最长 255 个字符',
];
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests\Product;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 批量调价 验证(A2 价格矩阵编辑提交)
*/
class BatchPriceRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'updates' => 'required|array|min:1',
'updates.*.product_id' => 'required|integer|exists:product,id',
'updates.*.level_id' => 'required|integer|exists:customer_level,id',
'updates.*.price' => 'required|numeric|min:0',
];
}
public function messages(): array
{
return [
'updates.required' => '请至少提交一条价格调整',
'updates.min' => '请至少提交一条价格调整',
'updates.*.product_id.required' => '调价行缺少商品',
'updates.*.product_id.exists' => '商品不存在',
'updates.*.level_id.required' => '调价行缺少客户等级',
'updates.*.level_id.exists' => '客户等级不存在',
'updates.*.price.required' => '调价行缺少单价',
'updates.*.price.numeric' => '单价必须为数字',
'updates.*.price.min' => '单价不能小于 0',
];
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests\Product;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 商品分类 创建/编辑 验证
*/
class ProductCategoryFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
protected function prepareForValidation(): void
{
$this->merge(['parent_id' => (int) ($this->input('parent_id') ?? 0)]);
}
public function rules(): array
{
return [
'name' => 'required|string|max:50',
'parent_id' => 'required|integer|min:0',
'sort' => 'nullable|integer',
'status' => 'nullable|integer|in:0,1',
];
}
public function messages(): array
{
return [
'name.required' => '分类名称不能为空',
'name.max' => '分类名称最长 50 个字符',
'parent_id.min' => '父级分类ID不正确',
'status.in' => '状态值不正确',
];
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Http\Requests\Product;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 商品档案 创建/编辑 验证(含多等级价格 prices 数组)
*/
class ProductFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'name' => 'required|string|max:100',
'spec' => 'nullable|string|max:100',
'grade' => 'nullable|string|max:50',
'unit' => 'nullable|string|max:20',
'category_id' => 'required|integer|exists:product_category,id',
'supplier_id' => 'nullable|integer|exclude_if:supplier_id,0|exists:supplier,id',
'image' => 'nullable|string|max:255',
'sort' => 'nullable|integer',
'status' => 'nullable|integer|in:0,1',
'remark' => 'nullable|string|max:255',
'prices' => 'nullable|array',
'prices.*.level_id' => 'required|integer|exists:customer_level,id',
'prices.*.price' => 'required|numeric|min:0',
];
}
public function messages(): array
{
return [
'name.required' => '商品名称不能为空',
'name.max' => '商品名称最长 100 个字符',
'category_id.required' => '请选择商品分类',
'category_id.exists' => '商品分类不存在',
'supplier_id.exists' => '供应商不存在',
'status.in' => '状态值不正确',
'prices.*.level_id.required' => '价格行缺少客户等级',
'prices.*.level_id.exists' => '客户等级不存在',
'prices.*.price.required' => '价格行缺少单价',
'prices.*.price.numeric' => '单价必须为数字',
'prices.*.price.min' => '单价不能小于 0',
];
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests\Purchase;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 采购明细修改 验证(C4amount 由后端按 weight>0 ? weight×price : quantity×price 重算)
*/
class PurchaseItemUpdateRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'product_name' => 'nullable|string|max:100',
'product_spec' => 'nullable|string|max:100',
'price' => 'required|numeric|min:0',
'quantity' => 'required|numeric|min:0',
'weight' => 'nullable|numeric|min:0',
'remark' => 'nullable|string|max:255',
];
}
public function messages(): array
{
return [
'price.required' => '采购单价不能为空',
'price.numeric' => '采购单价必须为数字',
'price.min' => '采购单价不能小于 0',
'quantity.required' => '采购量不能为空',
'quantity.numeric' => '采购量必须为数字',
'quantity.min' => '采购量不能小于 0',
'weight.numeric' => '实际称重必须为数字',
'weight.min' => '实际称重不能小于 0',
];
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests\Recon;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 对账明细修改 验证(D4:订货量/称重/数量/金额/商品信息;diff 与头汇总由后端重算)
*/
class ReconItemUpdateRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'product_name' => 'nullable|string|max:100',
'quantity' => 'nullable|numeric|min:0',
'weight' => 'nullable|numeric|min:0',
'publish_amount' => 'nullable|numeric|min:0',
'actual_amount' => 'nullable|numeric|min:0',
];
}
public function messages(): array
{
return [
'quantity.numeric' => '订货量必须为数字',
'quantity.min' => '订货量不能小于 0',
'weight.numeric' => '称重必须为数字',
'weight.min' => '称重不能小于 0',
'publish_amount.numeric' => '公布金额必须为数字',
'publish_amount.min' => '公布金额不能小于 0',
'actual_amount.numeric' => '实际金额必须为数字',
'actual_amount.min' => '实际金额不能小于 0',
];
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Requests\Recon;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 财务对账单 创建/编辑 验证
*/
class ReconciliationFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
protected function prepareForValidation(): void
{
$this->merge([
'category_id' => (int) ($this->input('category_id') ?? 0),
'supplier_id' => (int) ($this->input('supplier_id') ?? 0),
]);
}
public function rules(): array
{
return [
'title' => 'required|string|max:100',
'period_start' => 'required|date_format:Y-m-d',
'period_end' => 'required|date_format:Y-m-d|after_or_equal:period_start',
'category_id' => 'required|integer|min:0',
'supplier_id' => 'required|integer|min:0',
'remark' => 'nullable|string|max:255',
];
}
public function messages(): array
{
return [
'title.required' => '对账标题不能为空',
'title.max' => '对账标题最长 100 个字符',
'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' => '结束日期不能早于开始日期',
];
}
}