first version
This commit is contained in:
@@ -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: users(provider 指向 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 批量调价:事务写入,写完后给受影响门店生成 Notice(type=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.item,authorize: 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user