Compare commits
10 Commits
b33978a0c6
...
ce1f36b5b4
| Author | SHA1 | Date | |
|---|---|---|---|
| ce1f36b5b4 | |||
| fa4bf6fe61 | |||
| ce958d690f | |||
| 5d26f04ba2 | |||
| 06c7bd9f88 | |||
| 5a3995d6ba | |||
| cba0e7d1bb | |||
| df7631c7a9 | |||
| c6f69c5c55 | |||
| 07b08c8915 |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
if (!function_exists('generate_unique_code')) {
|
||||
function generate_unique_code($model, $column = 'code', $length = 6): string
|
||||
{
|
||||
$characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
$max = strlen($characters) - 1;
|
||||
|
||||
do {
|
||||
$code = '';
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$code .= $characters[random_int(0, $max)];
|
||||
}
|
||||
} while ($model::where($column, $code)->exists());
|
||||
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ 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;
|
||||
@@ -21,9 +20,7 @@ use Modules\Common\Http\Controllers\BaseController;
|
||||
class MiniUserController extends BaseController
|
||||
{
|
||||
protected array $searchField = [
|
||||
'type' => '=',
|
||||
'store_id' => '=',
|
||||
'supplier_id' => '=',
|
||||
'status' => '=',
|
||||
];
|
||||
|
||||
@@ -35,7 +32,7 @@ class MiniUserController extends BaseController
|
||||
{
|
||||
$params = $request->all();
|
||||
$pageSize = $params['pageSize'] ?? 10;
|
||||
$data = $this->buildSearch($params, UserModel::query()->with('store:id,name', 'supplier:id,name'))
|
||||
$data = $this->buildSearch($params, UserModel::query()->with('store:id,name'))
|
||||
->orderBy('id', 'desc')
|
||||
->paginate($pageSize)
|
||||
->toArray();
|
||||
@@ -43,7 +40,7 @@ class MiniUserController extends BaseController
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定门店/供应商(一个门店可绑多个账号,一个账号只绑一个主体)
|
||||
* 绑定门店(一个门店可绑多个账号,一个账号只绑一个门店)
|
||||
*/
|
||||
#[PutRoute(route: '/{id}/bind', authorize: 'bind', where: ['id' => '[0-9]+'])]
|
||||
public function bind(int $id, MiniUserBindRequest $request): JsonResponse
|
||||
@@ -53,29 +50,16 @@ class MiniUserController extends BaseController
|
||||
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;
|
||||
$store = StoreModel::find((int) $validated['store_id']);
|
||||
if (empty($store)) {
|
||||
throw new RepositoryException('门店不存在');
|
||||
}
|
||||
$user->store_id = $store->id;
|
||||
$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
|
||||
{
|
||||
|
||||
@@ -46,7 +46,9 @@ class StoreController extends BaseController
|
||||
#[PostRoute(authorize: 'create')]
|
||||
public function create(StoreFormRequest $request): JsonResponse
|
||||
{
|
||||
StoreModel::create($request->validated());
|
||||
$validated = $request->validated();
|
||||
$validated['code'] = generate_unique_code(StoreModel::class);
|
||||
StoreModel::create($validated);
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,134 +3,125 @@
|
||||
namespace App\Http\Controllers\Mini;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Http\Requests\UserUpdateInfoRequest;
|
||||
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\PutRoute;
|
||||
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',
|
||||
$validated = $request->validate([
|
||||
'code' => 'required|string'
|
||||
], [
|
||||
'code.required' => '缺少登录凭证 code',
|
||||
'code.required' => '登录参数格式错误',
|
||||
'code.string' => '登录参数格式错误',
|
||||
]);
|
||||
|
||||
$session = app(WechatService::class)->code2Session($data['code']);
|
||||
$session = app(WechatService::class)->code2Session($validated['code']);
|
||||
|
||||
$user = UserModel::firstOrNew(['openid' => $session['openid']]);
|
||||
$isNew = ! $user->exists;
|
||||
$user = UserModel::where('openid', $session['openid'])->first();
|
||||
|
||||
if ($user->status === UserModel::STATUS_DISABLED) {
|
||||
throw new RepositoryException('账号已被停用,请联系客服');
|
||||
if ($user === null || $user->status === UserModel::STATUS_DISABLED) {
|
||||
return $this->error('账号不存在或已被停用');
|
||||
}
|
||||
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->last_login_at = date('Y-m-d H:i:s');
|
||||
$user->save();
|
||||
|
||||
$token = $user->createToken('mini', ['mini'])->plainTextToken;
|
||||
|
||||
$token = $user->createToken($user->openid)->toArray();
|
||||
return $this->success([
|
||||
'token' => $token,
|
||||
'user' => $this->formatUser($user->fresh(['store.level:id,name', 'supplier:id,name'])),
|
||||
], $isNew ? '注册成功' : '登录成功');
|
||||
'token' => $token['plainTextToken'],
|
||||
'user' => $user->toArray(),
|
||||
], __('user.login_success'));
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定手机号:phoneCode 换手机号 → 按手机号自动匹配门店/供应商
|
||||
* (命中门店 → type=1+store_id;命中供应商 → type=2+supplier_id;都不命中 → 保持待绑定,后台人工处理)
|
||||
*/
|
||||
#[PostRoute('/auth/phone', authorize: true)]
|
||||
public function phone(Request $request): JsonResponse
|
||||
/** 小程序注册 */
|
||||
#[PostRoute('/auth/register', authorize: false)]
|
||||
public function register(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
$validated = $request->validate([
|
||||
'code' => 'required|string',
|
||||
'phoneCode' => 'required|string',
|
||||
'storeCode' => 'required|string'
|
||||
], [
|
||||
'phoneCode.required' => '缺少手机号授权凭证 phoneCode',
|
||||
'code.required' => '注册参数格式错误',
|
||||
'code.string' => '注册参数格式错误',
|
||||
'phoneCode.required' => '注册参数格式错误',
|
||||
'phoneCode.string' => '注册参数格式错误',
|
||||
'storeCode.required' => '门店编码必须填写',
|
||||
'storeCode.string' => '注册参数格式错误',
|
||||
]);
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
$store = StoreModel::where('code', $validated['storeCode'])->first();
|
||||
if (!$store) {
|
||||
return $this->error('门店不存在!');
|
||||
}
|
||||
$user->save();
|
||||
|
||||
// 通过 code 换取 openid、session_key、unionid
|
||||
$session = app(WechatService::class)->code2Session($validated['code']);
|
||||
|
||||
$user = UserModel::where('openid', $session['openid'])->first();
|
||||
|
||||
if ($user) {
|
||||
return $this->error('你的微信已经注册,请直接登录!');
|
||||
}
|
||||
|
||||
$userData = [
|
||||
'openid' => $session['openid'],
|
||||
'unionid' => $session['unionid'] ?? '',
|
||||
'username' => 'wx_'.uniqid(),
|
||||
'nickname' => '微信用户',
|
||||
'store_id' => $store->id,
|
||||
'avatar' => '',
|
||||
'password' => '',
|
||||
'last_login_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
$phone = app(WechatService::class)->getPhone($validated['phoneCode']);
|
||||
$userData['phone'] = $phone ?? '';
|
||||
|
||||
$user = UserModel::create($userData);
|
||||
|
||||
$token = $user->createToken($user->username)->toArray();
|
||||
|
||||
return $this->success([
|
||||
'user' => $this->formatUser($user->fresh(['store.level:id,name', 'supplier:id,name'])),
|
||||
]);
|
||||
'token' => $token['plainTextToken'],
|
||||
'user' => $user->toArray(),
|
||||
], __('user.login_success'));
|
||||
}
|
||||
|
||||
/** 当前用户信息(含门店客户等级 —— 全局价格体系依据 / 供应商信息) */
|
||||
#[GetRoute('/auth/info', authorize: true)]
|
||||
/** 当前用户信息 */
|
||||
#[GetRoute('/auth/info')]
|
||||
public function info(Request $request): JsonResponse
|
||||
{
|
||||
$user = UserModel::with(['store.level:id,name', 'supplier:id,name'])
|
||||
$user = UserModel::with(['store.level:id,name'])
|
||||
->find($request->user()->id);
|
||||
if ($user === null) {
|
||||
throw new RepositoryException('账号不存在');
|
||||
}
|
||||
|
||||
return $this->success(['user' => $this->formatUser($user)]);
|
||||
return $this->success($user->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序端用户信息输出结构
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatUser(UserModel $user): array
|
||||
#[PutRoute('auth/info')]
|
||||
public function setUserInfo(UserUpdateInfoRequest $request): JsonResponse
|
||||
{
|
||||
return [
|
||||
'id' => $user->id,
|
||||
'nickname' => $user->nickname,
|
||||
'avatar' => $user->avatar,
|
||||
'phone' => $user->phone,
|
||||
'type' => $user->type,
|
||||
'store' => $user->store,
|
||||
'supplier' => $user->supplier,
|
||||
];
|
||||
UserModel::where('user_id', auth('user')->id())->update($request->validated());
|
||||
|
||||
return $this->error('更新成功');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ 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;
|
||||
@@ -13,7 +12,7 @@ use Modules\Common\Http\Controllers\BaseController;
|
||||
* 小程序端控制器基类
|
||||
*
|
||||
* 无 #[RequestAttribute],不会被 AnnoRoute 注册为路由。
|
||||
* 提供当前用户获取与门店/供应商绑定前置校验。
|
||||
* 提供当前用户获取与门店绑定前置校验。
|
||||
*/
|
||||
abstract class BaseMiniController extends BaseController
|
||||
{
|
||||
@@ -26,7 +25,6 @@ abstract class BaseMiniController extends BaseController
|
||||
if ($user === null || $user->status === UserModel::STATUS_DISABLED) {
|
||||
throw new RepositoryException('账号不存在或已被停用');
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
@@ -35,7 +33,7 @@ abstract class BaseMiniController extends BaseController
|
||||
*/
|
||||
protected function ensureStoreBound(UserModel $user): StoreModel
|
||||
{
|
||||
if ($user->type !== UserModel::TYPE_STORE || $user->store_id <= 0) {
|
||||
if ($user->store_id <= 0) {
|
||||
throw new RepositoryException('尚未绑定门店,请联系客服处理');
|
||||
}
|
||||
$store = StoreModel::find($user->store_id);
|
||||
@@ -47,18 +45,20 @@ abstract class BaseMiniController extends BaseController
|
||||
}
|
||||
|
||||
/**
|
||||
* 供应商端前置校验:type=供应商 且 supplier_id>0 且供应商正常
|
||||
* 用户当前绑定的正常门店(未绑定/已停用返回 null,不抛错)
|
||||
*
|
||||
* 供商品浏览等「弱前置」场景使用:未绑定门店仍可浏览商品,仅价格不可见。
|
||||
*/
|
||||
protected function ensureSupplierBound(UserModel $user): SupplierModel
|
||||
protected function boundStore(UserModel $user): ?StoreModel
|
||||
{
|
||||
if ($user->type !== UserModel::TYPE_SUPPLIER || $user->supplier_id <= 0) {
|
||||
throw new RepositoryException('尚未绑定供应商,请联系客服处理');
|
||||
if ($user->store_id <= 0) {
|
||||
return null;
|
||||
}
|
||||
$supplier = SupplierModel::find($user->supplier_id);
|
||||
if ($supplier === null || $supplier->status !== SupplierModel::STATUS_NORMAL) {
|
||||
throw new RepositoryException('供应商不存在或已停用,请联系客服处理');
|
||||
$store = StoreModel::find($user->store_id);
|
||||
if ($store === null || $store->status !== StoreModel::STATUS_NORMAL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $supplier;
|
||||
return $store;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Mini;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Http\Requests\Mini\MiniCartRequest;
|
||||
use App\Models\CartModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
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\SystemTool\Models\SysFileModel;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 小程序购物车
|
||||
*/
|
||||
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
||||
class CartController extends BaseMiniController
|
||||
{
|
||||
/** decimal(10,2) 上限 */
|
||||
private const string MAX_QUANTITY = '99999999.99';
|
||||
|
||||
/**
|
||||
* 加购
|
||||
* @throws Throwable
|
||||
*/
|
||||
#[PostRoute('/cart')]
|
||||
public function store(MiniCartRequest $request): JsonResponse
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->ensureStoreBound($user);
|
||||
if ($store->level_id <= 0) {
|
||||
return $this->error('门店未设置客户等级,无法加购,请联系客服');
|
||||
}
|
||||
|
||||
$productId = (int) $request->validated('product_id');
|
||||
$product = ProductModel::where('status', ProductModel::STATUS_ON)->find($productId);
|
||||
if ($product === null) {
|
||||
return $this->error('商品不存在或已下架,请刷新后重试');
|
||||
}
|
||||
// 存在性校验与计价类型无关(百分比行 price 可能为 0 也能加购)
|
||||
$hasPrice = ProductPriceModel::query()
|
||||
->where('product_id', $productId)
|
||||
->where('level_id', $store->level_id)
|
||||
->exists();
|
||||
if (! $hasPrice) {
|
||||
return $this->error('商品「' . $product->name . '」价格未设置,无法加购');
|
||||
}
|
||||
|
||||
$quantity = (string) $request->validated('quantity');
|
||||
|
||||
$cart = DB::transaction(function () use ($user, $productId, $quantity) {
|
||||
$row = CartModel::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('product_id', $productId)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($row !== null) {
|
||||
$merged = bcadd((string) $row->quantity, $quantity, 2);
|
||||
if (bccomp($merged, self::MAX_QUANTITY, 2) > 0) {
|
||||
throw new RepositoryException('该商品在购物车中的数量已达上限');
|
||||
}
|
||||
$row->quantity = $merged;
|
||||
$row->save();
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
return CartModel::create([
|
||||
'user_id' => $user->id,
|
||||
'product_id' => $productId,
|
||||
'quantity' => $quantity,
|
||||
]);
|
||||
});
|
||||
|
||||
return $this->success([
|
||||
'id' => $cart->id,
|
||||
'quantity' => $cart->quantity,
|
||||
], '已加入购物车');
|
||||
}
|
||||
|
||||
/**
|
||||
* 购物车列表
|
||||
*/
|
||||
#[GetRoute('/cart', authorize: true)]
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->ensureStoreBound($user);
|
||||
|
||||
$rows = CartModel::query()
|
||||
->where('user_id', $user->id)
|
||||
->orderBy('id', 'desc')
|
||||
->get();
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
return $this->success([
|
||||
'items' => [],
|
||||
'total_count' => 0,
|
||||
'total_quantity' => '0.00',
|
||||
'total_amount' => '0.00',
|
||||
]);
|
||||
}
|
||||
|
||||
$productIds = $rows->pluck('product_id')
|
||||
->map(static fn ($id) => (int) $id)
|
||||
->unique()->values()->all();
|
||||
|
||||
$products = ProductModel::withTrashed()->whereIn('id', $productIds)->get()->keyBy('id');
|
||||
$priceRows = ProductPriceModel::query()
|
||||
->where('level_id', $store->level_id)
|
||||
->whereIn('product_id', $productIds)
|
||||
->get(['product_id', 'price', 'price_type', 'percent'])
|
||||
->keyBy('product_id');
|
||||
|
||||
// 图片一次查回(避免 ProductModel::$appends images_arr 的 N+1)。
|
||||
// 注意:image_ids 有 imageIds Attribute 访问器(get 返回数组),需取原始值
|
||||
$allFileIds = [];
|
||||
foreach ($products as $product) {
|
||||
foreach (explode(',', (string) $product->getRawOriginal('image_ids')) as $fileId) {
|
||||
if ($fileId !== '') {
|
||||
$allFileIds[] = (int) $fileId;
|
||||
}
|
||||
}
|
||||
}
|
||||
$fileMap = SysFileModel::query()
|
||||
->whereIn('id', $allFileIds)
|
||||
->get()->keyBy('id');
|
||||
|
||||
$items = [];
|
||||
$totalQuantity = '0.00';
|
||||
$totalAmount = '0.00';
|
||||
foreach ($rows as $row) {
|
||||
$product = $products->get($row->product_id);
|
||||
$productOn = $product !== null && $product->status === ProductModel::STATUS_ON;
|
||||
// 实际价(百分比计价行按成本价上浮换算);未设等级价为 null
|
||||
$priceRow = $priceRows->get($row->product_id);
|
||||
$price = $priceRow === null
|
||||
? null
|
||||
: ProductPriceModel::calcActualPrice(
|
||||
(int) $priceRow->price_type,
|
||||
$priceRow->price,
|
||||
$priceRow->percent,
|
||||
(float) ($product?->cost_price ?? 0),
|
||||
);
|
||||
$buyable = $productOn && $price !== null;
|
||||
$quantity = (string) $row->quantity;
|
||||
|
||||
$firstFileId = (int) (explode(',', (string) $product->getRawOriginal('image_ids'))[0] ?? 0);
|
||||
$firstFile = $firstFileId > 0 ? $fileMap->get($firstFileId) : null;
|
||||
|
||||
$item = [
|
||||
'id' => $row->id,
|
||||
'product_id' => $row->product_id,
|
||||
'name' => $product->name ?? '',
|
||||
'spec' => $product->spec ?? '',
|
||||
'unit' => $product->unit ?? '',
|
||||
'image' => $firstFile?->file_url ?? '',
|
||||
'price' => $price,
|
||||
'quantity' => $quantity,
|
||||
'amount' => $buyable ? bcmul($price, $quantity, 2) : null,
|
||||
'status' => $buyable ? 1 : 0,
|
||||
];
|
||||
$items[] = $item;
|
||||
|
||||
if ($buyable) {
|
||||
$totalQuantity = bcadd($totalQuantity, $quantity, 2);
|
||||
$totalAmount = bcadd($totalAmount, $item['amount'], 2);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'items' => $items,
|
||||
'total_count' => count($items),
|
||||
'total_quantity' => $totalQuantity,
|
||||
'total_amount' => $totalAmount,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改数量(校验归属)
|
||||
*/
|
||||
#[PutRoute('/cart/{id}', authorize: true, where: ['id' => '[0-9]+'])]
|
||||
public function update(int $id, MiniCartRequest $request): JsonResponse
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$this->ensureStoreBound($user);
|
||||
|
||||
$row = CartModel::query()
|
||||
->where('id', $id)
|
||||
->where('user_id', $user->id)
|
||||
->first();
|
||||
if ($row === null) {
|
||||
throw new RepositoryException('购物车项不存在');
|
||||
}
|
||||
|
||||
$row->quantity = (string) $request->validated('quantity');
|
||||
$row->save();
|
||||
|
||||
return $this->success(['id' => $row->id, 'quantity' => $row->quantity], '已修改数量');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单项(校验归属)
|
||||
*/
|
||||
#[DeleteRoute('/cart/{id}', authorize: true, where: ['id' => '[0-9]+'])]
|
||||
public function destroy(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$this->ensureStoreBound($user);
|
||||
|
||||
$deleted = CartModel::query()
|
||||
->where('id', $id)
|
||||
->where('user_id', $user->id)
|
||||
->delete();
|
||||
if ($deleted === 0) {
|
||||
throw new RepositoryException('购物车项不存在');
|
||||
}
|
||||
|
||||
return $this->success([], '已删除');
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空购物车(仅当前用户)
|
||||
*/
|
||||
#[DeleteRoute('/cart', authorize: true)]
|
||||
public function clear(Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$this->ensureStoreBound($user);
|
||||
|
||||
CartModel::query()->where('user_id', $user->id)->delete();
|
||||
|
||||
return $this->success([], '购物车已清空');
|
||||
}
|
||||
}
|
||||
@@ -47,10 +47,11 @@ class OrderController extends BaseMiniController
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$prices = ProductPriceModel::query()
|
||||
$priceRows = ProductPriceModel::query()
|
||||
->where('level_id', $store->level_id)
|
||||
->whereIn('product_id', $productIds)
|
||||
->pluck('price', 'product_id');
|
||||
->get(['product_id', 'price', 'price_type', 'percent'])
|
||||
->keyBy('product_id');
|
||||
|
||||
$totalQuantity = '0';
|
||||
$totalAmount = '0';
|
||||
@@ -62,11 +63,18 @@ class OrderController extends BaseMiniController
|
||||
if ($product === null) {
|
||||
throw new RepositoryException('存在已下架或不存在的商品,请刷新后重试');
|
||||
}
|
||||
if (! isset($prices[$productId])) {
|
||||
if (! isset($priceRows[$productId])) {
|
||||
throw new RepositoryException('商品「' . $product->name . '」未设置您所在等级的价格,无法下单');
|
||||
}
|
||||
|
||||
$price = (string) $prices[$productId];
|
||||
// 实际价(百分比计价行按成本价上浮换算,$products 已含 cost_price)
|
||||
$priceRow = $priceRows[$productId];
|
||||
$price = ProductPriceModel::calcActualPrice(
|
||||
(int) $priceRow->price_type,
|
||||
$priceRow->price,
|
||||
$priceRow->percent,
|
||||
$product->cost_price,
|
||||
);
|
||||
$quantity = (string) $row['quantity'];
|
||||
$amount = bcmul($price, $quantity, 2);
|
||||
$totalQuantity = bcadd($totalQuantity, $quantity, 2);
|
||||
@@ -75,12 +83,19 @@ class OrderController extends BaseMiniController
|
||||
$rows[] = [
|
||||
'store_id' => $store->id,
|
||||
'product_id' => $productId,
|
||||
'category_id' => (int) $product->category_id,
|
||||
'supplier_id' => (int) $product->supplier_id,
|
||||
'product_name' => $product->name,
|
||||
'product_spec' => $product->spec,
|
||||
'unit' => (string) $product->unit,
|
||||
'price' => $price,
|
||||
'image_ids' => implode(',', (array) $product->image_ids),
|
||||
'content' => (string) $product->content,
|
||||
'shelf_life' => (int) $product->shelf_life,
|
||||
'quantity' => $quantity,
|
||||
'weight' => 0,
|
||||
'amount' => $amount,
|
||||
'cost_price' => (string) $product->cost_price,
|
||||
'remark' => '',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
@@ -93,6 +108,7 @@ class OrderController extends BaseMiniController
|
||||
'order_date' => $now->toDateString(),
|
||||
'total_quantity' => $totalQuantity,
|
||||
'total_weight' => 0,
|
||||
'product_amount' => $totalAmount,
|
||||
'total_amount' => $totalAmount,
|
||||
'status' => StoreOrderModel::STATUS_PENDING,
|
||||
'remark' => $remark,
|
||||
|
||||
@@ -12,7 +12,10 @@ use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
|
||||
/**
|
||||
* 小程序商品(分类树 + 列表,价格 = 当前门店客户等级价)
|
||||
* 小程序商品(分类树 + 列表)
|
||||
*
|
||||
* 商品浏览仅需登录:未绑定门店/门店未设客户等级的用户也可查看商品,仅价格不可见(price=null);
|
||||
* 加购、下单仍由购物车/订单前置校验拦截,要求绑定门店并已设客户等级。
|
||||
*/
|
||||
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
||||
class ProductController extends BaseMiniController
|
||||
@@ -53,22 +56,23 @@ class ProductController extends BaseMiniController
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品列表:价格取当前门店客户等级价(未绑等级的门店报错);
|
||||
* 商品列表:价格取当前门店客户等级价;
|
||||
* 未绑定门店/门店未设客户等级 → 仍可浏览,price 为 null(不可见价格,加购/下单另由前置校验拦截);
|
||||
* ?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('门店未设置客户等级,无法展示价格,请联系客服');
|
||||
}
|
||||
$levelId = $this->boundStore($user)?->level_id ?? 0;
|
||||
|
||||
$query = ProductModel::query()
|
||||
->where('status', ProductModel::STATUS_ON)
|
||||
->with('category:id,name')
|
||||
->with(['prices' => static fn ($q) => $q->where('level_id', $store->level_id)]);
|
||||
->with('category:id,name');
|
||||
|
||||
if ($levelId > 0) {
|
||||
$query->with(['prices' => static fn ($q) => $q->where('level_id', $levelId)]);
|
||||
}
|
||||
|
||||
$categoryId = (int) $request->input('category_id', 0);
|
||||
if ($categoryId > 0) {
|
||||
@@ -88,9 +92,11 @@ class ProductController extends BaseMiniController
|
||||
->paginate($pageSize)
|
||||
->toArray();
|
||||
|
||||
// 扁平化价格:prices[0].price → price(未设等级价为 null)
|
||||
// 扁平化价格:prices[0].actual_price → price(访问器经 toArray 自动输出换算后实际价;
|
||||
// 未绑定门店或未设等级价为 null——未加载 prices 时 ?? null 兜底)
|
||||
// unset prices 同时移除 price_type/percent,门店端无法反推成本;cost_price 已被 $hidden 过滤
|
||||
foreach ($data['data'] as &$row) {
|
||||
$row['price'] = $row['prices'][0]['price'] ?? null;
|
||||
$row['price'] = $row['prices'][0]['actual_price'] ?? null;
|
||||
unset($row['prices']);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,33 +4,57 @@ namespace App\Http\Controllers\Mini;
|
||||
|
||||
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 StoreController extends BaseMiniController
|
||||
{
|
||||
/** 修改回款周期(≥0,无上限) */
|
||||
#[PutRoute('/store/paymentCycle', authorize: true)]
|
||||
public function paymentCycle(Request $request): JsonResponse
|
||||
/** 门店详情(编辑回显;门店名称 / 编码 / 回款周期为只读,由后台维护) */
|
||||
#[GetRoute('/store/info', authorize: true)]
|
||||
public function info(Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->ensureStoreBound($user);
|
||||
|
||||
return $this->success([
|
||||
'id' => $store->id,
|
||||
'name' => $store->name,
|
||||
'code' => $store->code,
|
||||
'contact' => $store->contact,
|
||||
'phone' => $store->phone,
|
||||
'address' => $store->address,
|
||||
'payment_cycle_days' => $store->payment_cycle_days,
|
||||
]);
|
||||
}
|
||||
|
||||
/** 修改门店信息(仅联系人 / 联系电话 / 地址,白名单更新) */
|
||||
#[PutRoute('/store/info', authorize: true)]
|
||||
public function updateInfo(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'payment_cycle_days' => 'required|integer|min:0',
|
||||
'contact' => 'nullable|string|max:50',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'address' => 'nullable|string|max:255',
|
||||
], [
|
||||
'payment_cycle_days.required' => '回款周期不能为空',
|
||||
'payment_cycle_days.integer' => '回款周期必须为整数',
|
||||
'payment_cycle_days.min' => '回款周期不能小于 0',
|
||||
'contact.max' => '联系人最长 50 个字符',
|
||||
'phone.max' => '联系电话最长 20 个字符',
|
||||
'address.max' => '地址最长 255 个字符',
|
||||
]);
|
||||
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->ensureStoreBound($user);
|
||||
|
||||
$store->payment_cycle_days = (int) $data['payment_cycle_days'];
|
||||
$store->save();
|
||||
$store->update($data);
|
||||
|
||||
return $this->success(['payment_cycle_days' => $store->payment_cycle_days], '回款周期已更新');
|
||||
return $this->success([
|
||||
'contact' => $store->contact,
|
||||
'phone' => $store->phone,
|
||||
'address' => $store->address,
|
||||
], '门店信息已更新');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
<?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], '已确认接单');
|
||||
}
|
||||
}
|
||||
@@ -5,31 +5,44 @@ namespace App\Http\Controllers\Order;
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\NoticeModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
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\PutRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Modules\Common\Http\Controllers\BaseController;
|
||||
use Modules\SystemTool\Models\SysFileModel;
|
||||
|
||||
/**
|
||||
* 门店订单管理(订单只读 + 状态管理;创建/取消在小程序端)
|
||||
* 门店订单管理(订单只读 + 状态管理 + 明细修改/同步;创建/取消在小程序端)
|
||||
*/
|
||||
#[RequestAttribute('/order/store', 'order.store')]
|
||||
class StoreOrderController extends BaseController
|
||||
{
|
||||
/** 状态中文名(通知文案用) */
|
||||
private const STATUS_NAMES = [
|
||||
StoreOrderModel::STATUS_PENDING => '待汇总',
|
||||
StoreOrderModel::STATUS_SUMMARIZED => '已汇总',
|
||||
StoreOrderModel::STATUS_DELIVERING => '配送中',
|
||||
private const array STATUS_NAMES = [
|
||||
StoreOrderModel::STATUS_PENDING => '待接单',
|
||||
StoreOrderModel::STATUS_SUMMARIZED => '已接单',
|
||||
StoreOrderModel::STATUS_DELIVERING => '采购中',
|
||||
StoreOrderModel::STATUS_DISTRIBUTION => '配送中',
|
||||
StoreOrderModel::STATUS_COMPLETED => '已完成',
|
||||
StoreOrderModel::STATUS_CANCELLED => '已取消',
|
||||
];
|
||||
|
||||
/** 明细可编辑状态:待接单、已接单、采购中、配送中(已完成/已取消锁定) */
|
||||
private const array ITEM_EDITABLE_STATUS = [
|
||||
StoreOrderModel::STATUS_PENDING,
|
||||
StoreOrderModel::STATUS_SUMMARIZED,
|
||||
StoreOrderModel::STATUS_DELIVERING,
|
||||
StoreOrderModel::STATUS_DISTRIBUTION,
|
||||
];
|
||||
|
||||
protected array $searchField = [
|
||||
'store_id' => '=',
|
||||
'status' => '=',
|
||||
@@ -37,23 +50,58 @@ class StoreOrderController extends BaseController
|
||||
'order_date' => 'betweenDate',
|
||||
];
|
||||
|
||||
/** 订单列表 */
|
||||
/** 订单列表(支持按包含的商品名称搜索 ?product_name=) */
|
||||
#[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'))
|
||||
$query = StoreOrderModel::query()->with([
|
||||
'store:id,name,address,contact,phone',
|
||||
'items:id,order_id,product_id,product_name,product_spec,unit,price,quantity,amount,image_ids',
|
||||
]);
|
||||
|
||||
// 按包含的商品名称搜索:任一明细品名包含关键字即命中
|
||||
$productName = trim((string) ($params['product_name'] ?? ''));
|
||||
if ($productName !== '') {
|
||||
$keyword = '%' . str_replace('%', '\%', $productName) . '%';
|
||||
$query->whereHas('items', static function ($itemQuery) use ($keyword) {
|
||||
$itemQuery->where('product_name', 'like', $keyword);
|
||||
});
|
||||
}
|
||||
|
||||
$data = $this->buildSearch($params, $query)
|
||||
->orderBy('order_date', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->paginate($pageSize)
|
||||
->toArray();
|
||||
|
||||
// Paginator::toArray() 的 data 仍为模型,先显式转数组(明细/关系一并转换)
|
||||
$data['data'] = array_map(
|
||||
static fn ($order) => is_object($order) ? $order->toArray() : $order,
|
||||
$data['data']
|
||||
);
|
||||
|
||||
// 明细封面图:从快照 image_ids 批量解析(不再关联商品档案表)
|
||||
foreach ($data['data'] as &$order) {
|
||||
$this->resolveItemImages($order['items']);
|
||||
}
|
||||
unset($order);
|
||||
|
||||
$box_amount = site_config('services.box_amount');
|
||||
$tray_amount = site_config('services.tray_amount');
|
||||
foreach ($data['data'] as &$item) {
|
||||
$item['box_price'] = number_format($box_amount, 2);
|
||||
$item['tray_price'] = number_format($tray_amount, 2);
|
||||
$item['box_amount'] = number_format($box_amount * $item['box_num'], 2);
|
||||
$item['tray_amount'] = number_format($tray_amount * $item['tray_num'], 2);
|
||||
}
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 待汇总预览:聚合所有待汇总订单明细(按商品分组),
|
||||
* 供生成采购单前确认(C1 前置)
|
||||
* 已接单预览:聚合所有已接单订单明细(按商品分组),
|
||||
* 供生成采购单前确认(C1 前置);单位取明细快照
|
||||
*/
|
||||
#[GetRoute('/summary', 'query')]
|
||||
public function summary(): JsonResponse
|
||||
@@ -62,46 +110,226 @@ class StoreOrderController extends BaseController
|
||||
->select('product_id')
|
||||
->selectRaw('MAX(product_name) as product_name')
|
||||
->selectRaw('MAX(product_spec) as product_spec')
|
||||
->selectRaw('MAX(unit) as unit')
|
||||
->selectRaw('SUM(quantity) as total_quantity')
|
||||
->selectRaw('COUNT(DISTINCT store_id) as store_count')
|
||||
->whereHas('order', function ($query) {
|
||||
$query->where('status', StoreOrderModel::STATUS_PENDING);
|
||||
$query->where('status', StoreOrderModel::STATUS_SUMMARIZED);
|
||||
})
|
||||
->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']] ?? '';
|
||||
// 快照无单位的历史明细兜底:从商品档案(含已下架/软删除)补齐
|
||||
$emptyUnitProductIds = array_column(array_filter(
|
||||
$rows,
|
||||
static fn (array $row) => ($row['unit'] ?? '') === ''
|
||||
), 'product_id');
|
||||
if ($emptyUnitProductIds !== []) {
|
||||
$units = ProductModel::withTrashed()
|
||||
->whereIn('id', $emptyUnitProductIds)
|
||||
->pluck('unit', 'id');
|
||||
foreach ($rows as &$row) {
|
||||
if (($row['unit'] ?? '') === '') {
|
||||
$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);
|
||||
$order = StoreOrderModel::with([
|
||||
'store:id,name,address,contact,phone',
|
||||
'items' => static fn ($query) => $query->with('supplier:id,name'),
|
||||
])->find($id);
|
||||
if (empty($order)) {
|
||||
throw new RepositoryException('订单不存在');
|
||||
}
|
||||
return $this->success($order->toArray());
|
||||
|
||||
// 成本价默认对序列化隐藏(防泄漏到小程序端),后台恢复可见
|
||||
$order->items->each->makeVisible('cost_price');
|
||||
|
||||
$data = $order->toArray();
|
||||
|
||||
// 明细首图 + 附加金额(与列表接口一致)
|
||||
$this->resolveItemImages($data['items']);
|
||||
$boxAmount = site_config('services.box_amount');
|
||||
$trayAmount = site_config('services.tray_amount');
|
||||
$data['box_price'] = number_format($boxAmount, 2);
|
||||
$data['tray_price'] = number_format($trayAmount, 2);
|
||||
$data['box_amount'] = number_format($boxAmount * $data['box_num'], 2);
|
||||
$data['tray_amount'] = number_format($trayAmount * $data['tray_num'], 2);
|
||||
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态流转(待汇总→配送中→完成;待汇总可取消;已汇总可转配送中)
|
||||
* 明细首图解析:从快照 image_ids 批量解析文件 URL,未找到时置空字符串(列表/详情共用)
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $items 订单明细数组(引用修改)
|
||||
*/
|
||||
private function resolveItemImages(array &$items): void
|
||||
{
|
||||
$fileIds = [];
|
||||
foreach ($items as $line) {
|
||||
foreach ((array) ($line['image_ids'] ?? []) as $fileId) {
|
||||
if ($fileId !== '' && $fileId !== null) {
|
||||
$fileIds[] = (int) $fileId;
|
||||
}
|
||||
}
|
||||
}
|
||||
$fileUrls = [];
|
||||
if ($fileIds !== []) {
|
||||
foreach (SysFileModel::query()->whereIn('id', array_unique($fileIds))->get() as $file) {
|
||||
$fileUrls[$file->id] = $file->preview_url;
|
||||
}
|
||||
}
|
||||
foreach ($items as &$product) {
|
||||
$product['image'] = '';
|
||||
foreach ((array) ($product['image_ids'] ?? []) as $fileId) {
|
||||
if ($fileId !== '' && $fileId !== null && isset($fileUrls[(int) $fileId])) {
|
||||
$product['image'] = $fileUrls[(int) $fileId];
|
||||
break;
|
||||
}
|
||||
}
|
||||
unset($product['image_ids']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改订单明细(商品快照 + 订货量/重量),事务内重算单品金额与订单总价。
|
||||
* 可改字段:供应商、品名、规格、单位、单价、成本价、订货量、重量
|
||||
*/
|
||||
#[PutRoute(route: '/item/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||
public function updateItem(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'supplier_id' => 'required|integer|min:0',
|
||||
'product_name' => 'required|string|max:100',
|
||||
'product_spec' => 'nullable|string|max:100',
|
||||
'unit' => 'required|string|max:20',
|
||||
'price' => 'required|numeric|min:0',
|
||||
'cost_price' => 'required|numeric|min:0',
|
||||
'quantity' => 'required|integer|min:1',
|
||||
'weight' => 'required|numeric|min:0',
|
||||
'remark' => 'nullable|string'
|
||||
], [
|
||||
'supplier_id.required' => '供应商不能为空',
|
||||
'supplier_id.integer' => '供应商ID必须为整数',
|
||||
'supplier_id.min' => '供应商ID不正确',
|
||||
'product_name.required' => '品名不能为空',
|
||||
'product_name.max' => '品名最长 100 个字符',
|
||||
'product_spec.max' => '规格最长 100 个字符',
|
||||
'unit.required' => '计价单位不能为空',
|
||||
'unit.max' => '计价单位最长 20 个字符',
|
||||
'price.required' => '单价不能为空',
|
||||
'price.numeric' => '单价必须为数字',
|
||||
'price.min' => '单价不能小于 0',
|
||||
'cost_price.required' => '成本价不能为空',
|
||||
'cost_price.numeric' => '成本价必须为数字',
|
||||
'cost_price.min' => '成本价不能小于 0',
|
||||
'quantity.required' => '订货量不能为空',
|
||||
'quantity.integer' => '订货量必须为整数',
|
||||
'quantity.min' => '订货量必须大于 0',
|
||||
'weight.required' => '重量不能为空',
|
||||
'weight.numeric' => '重量必须为数字',
|
||||
'weight.min' => '重量不能小于 0',
|
||||
]);
|
||||
|
||||
return DB::transaction(function () use ($id, $data) {
|
||||
$item = StoreOrderItemModel::query()->lockForUpdate()->find($id);
|
||||
if (empty($item)) {
|
||||
throw new RepositoryException('订单明细不存在');
|
||||
}
|
||||
$order = StoreOrderModel::query()->lockForUpdate()->find($item->order_id);
|
||||
if (empty($order)) {
|
||||
throw new RepositoryException('订单不存在');
|
||||
}
|
||||
$this->assertItemEditable($order);
|
||||
|
||||
$item->fill($data);
|
||||
$item->product_spec = (string) ($data['product_spec'] ?? '');
|
||||
$item->amount = bcmul(
|
||||
bcadd((string) $data['price'], '0', 2),
|
||||
(string) $data['quantity'],
|
||||
2
|
||||
);
|
||||
$item->save();
|
||||
|
||||
$this->recalculateOrderTotals($order);
|
||||
|
||||
return $this->success($item->load('supplier:id,name')->makeVisible('cost_price')->toArray());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键同步明细商品快照:按商品ID同步最新商品档案的
|
||||
* 供应商、品名、规格、单位、成本价;单价按门店当前等级价重算
|
||||
* (未设置等级价时保留原单价),并重算订单总价
|
||||
*/
|
||||
#[PutRoute(route: '/item/{id}/sync', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||
public function syncItem(int $id): JsonResponse
|
||||
{
|
||||
return DB::transaction(function () use ($id) {
|
||||
$item = StoreOrderItemModel::query()->lockForUpdate()->find($id);
|
||||
if (empty($item)) {
|
||||
throw new RepositoryException('订单明细不存在');
|
||||
}
|
||||
$order = StoreOrderModel::query()->lockForUpdate()->find($item->order_id);
|
||||
if (empty($order)) {
|
||||
throw new RepositoryException('订单不存在');
|
||||
}
|
||||
$this->assertItemEditable($order);
|
||||
|
||||
// 软删除商品无法同步(下架商品仍可同步最新档案)
|
||||
$product = ProductModel::find($item->product_id);
|
||||
if (empty($product)) {
|
||||
throw new RepositoryException('商品不存在或已被删除,无法同步');
|
||||
}
|
||||
|
||||
$item->supplier_id = (int) $product->supplier_id;
|
||||
$item->product_name = $product->name;
|
||||
$item->product_spec = (string) $product->spec;
|
||||
$item->unit = (string) $product->unit;
|
||||
$item->cost_price = $product->cost_price;
|
||||
|
||||
// 单价:按订货门店当前客户等级价重算(百分比计价按最新成本价换算)
|
||||
$levelId = (int) ($order->store->level_id ?? 0);
|
||||
$priceRow = ProductPriceModel::query()
|
||||
->forProductLevel((int) $item->product_id, $levelId)
|
||||
->first(['price', 'price_type', 'percent']);
|
||||
if ($priceRow !== null) {
|
||||
$item->price = ProductPriceModel::calcActualPrice(
|
||||
(int) $priceRow->price_type,
|
||||
$priceRow->price,
|
||||
$priceRow->percent,
|
||||
$product->cost_price,
|
||||
);
|
||||
}
|
||||
|
||||
$item->amount = bcmul((string) $item->price, (string) $item->quantity, 2);
|
||||
$item->save();
|
||||
|
||||
$this->recalculateOrderTotals($order);
|
||||
|
||||
return $this->success($item->load('supplier:id,name')->makeVisible('cost_price')->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|integer|in:1,3,4,9',
|
||||
], [
|
||||
'status.required' => '目标状态不能为空',
|
||||
'status.in' => '目标状态值不正确',
|
||||
@@ -113,20 +341,7 @@ class StoreOrderController extends BaseController
|
||||
}
|
||||
|
||||
$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)) {
|
||||
if (! in_array($target, $this->allowedTransitions($order->status), true)) {
|
||||
throw new RepositoryException(
|
||||
'订单当前状态为「' . (self::STATUS_NAMES[$order->status] ?? $order->status) . '」,不允许该操作'
|
||||
);
|
||||
@@ -140,13 +355,190 @@ class StoreOrderController extends BaseController
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量状态流转:先全量校验流转路径,任一订单不允许则整批中止;
|
||||
* 全部合法时在事务内流转并逐单通知门店
|
||||
*/
|
||||
#[PutRoute(route: '/batchStatus', authorize: 'update')]
|
||||
public function batchStatus(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'ids' => 'required|array|min:1',
|
||||
'ids.*' => 'integer|exists:store_order,id',
|
||||
'status' => 'required|integer|in:1,3,4,9',
|
||||
], [
|
||||
'ids.required' => '请选择要流转的订单',
|
||||
'ids.min' => '请选择要流转的订单',
|
||||
'ids.*.exists' => '订单不存在',
|
||||
'status.required' => '目标状态不能为空',
|
||||
'status.in' => '目标状态值不正确',
|
||||
]);
|
||||
|
||||
$target = (int) $data['status'];
|
||||
$orders = StoreOrderModel::query()->whereIn('id', $data['ids'])->get();
|
||||
|
||||
// 全量预校验:任一订单不满足流转条件,整批中止,不做任何修改
|
||||
$blocked = [];
|
||||
foreach ($orders as $order) {
|
||||
if (! in_array($target, $this->allowedTransitions($order->status), true)) {
|
||||
$blocked[] = $order->order_no . '(' . (self::STATUS_NAMES[$order->status] ?? $order->status) . ')';
|
||||
}
|
||||
}
|
||||
if (! empty($blocked)) {
|
||||
throw new RepositoryException(
|
||||
'以下订单不允许流转为「' . (self::STATUS_NAMES[$target] ?? $target) . '」,批量操作已中止:' . implode('、', $blocked)
|
||||
);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($orders, $target) {
|
||||
foreach ($orders as $order) {
|
||||
$order->status = $target;
|
||||
$order->save();
|
||||
$this->notifyStore($order);
|
||||
}
|
||||
});
|
||||
|
||||
return $this->success(['success' => $orders->count()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除订单(软删除):仅已取消订单允许删除;删除后后台列表/详情、小程序端均不可见
|
||||
*/
|
||||
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
|
||||
public function delete(int $id): JsonResponse
|
||||
{
|
||||
$order = StoreOrderModel::find($id);
|
||||
if (empty($order)) {
|
||||
throw new RepositoryException('订单不存在');
|
||||
}
|
||||
if ($order->status !== StoreOrderModel::STATUS_CANCELLED) {
|
||||
throw new RepositoryException(
|
||||
'订单当前状态为「' . (self::STATUS_NAMES[$order->status] ?? $order->status) . '」,仅已取消订单可删除'
|
||||
);
|
||||
}
|
||||
|
||||
$order->delete();
|
||||
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态流转合法路径(与迁移状态定义一致):
|
||||
* 待接单→已接单/已取消;采购中→配送中/已完成;配送中→已完成
|
||||
* (已接单→采购中 只能经「生成采购单」完成,不在本流转内)
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
private function allowedTransitions(int $status): array
|
||||
{
|
||||
return match ($status) {
|
||||
StoreOrderModel::STATUS_PENDING => [
|
||||
StoreOrderModel::STATUS_SUMMARIZED,
|
||||
StoreOrderModel::STATUS_CANCELLED,
|
||||
],
|
||||
StoreOrderModel::STATUS_DELIVERING => [
|
||||
StoreOrderModel::STATUS_DISTRIBUTION,
|
||||
StoreOrderModel::STATUS_COMPLETED,
|
||||
],
|
||||
StoreOrderModel::STATUS_DISTRIBUTION => [StoreOrderModel::STATUS_COMPLETED],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改周转框/周转托盘数量(仅已接单、采购中、配送中可改),
|
||||
* 自动重算附加金额与订单总金额
|
||||
*/
|
||||
#[PutRoute(route: '/{id}/container', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||
public function container(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'box_num' => 'required|integer|min:0',
|
||||
'tray_num' => 'required|integer|min:0',
|
||||
], [
|
||||
'box_num.required' => '周转框数量不能为空',
|
||||
'box_num.integer' => '周转框数量必须为整数',
|
||||
'box_num.min' => '周转框数量不能小于 0',
|
||||
'tray_num.required' => '周转托盘数量不能为空',
|
||||
'tray_num.integer' => '周转托盘数量必须为整数',
|
||||
'tray_num.min' => '周转托盘数量不能小于 0',
|
||||
]);
|
||||
|
||||
$order = StoreOrderModel::find($id);
|
||||
if (empty($order)) {
|
||||
throw new RepositoryException('订单不存在');
|
||||
}
|
||||
|
||||
$editable = [
|
||||
StoreOrderModel::STATUS_SUMMARIZED,
|
||||
StoreOrderModel::STATUS_DELIVERING,
|
||||
StoreOrderModel::STATUS_DISTRIBUTION,
|
||||
];
|
||||
if (! in_array($order->status, $editable, true)) {
|
||||
throw new RepositoryException(
|
||||
'订单当前状态为「' . (self::STATUS_NAMES[$order->status] ?? $order->status) . '」,不允许修改周转框/托盘数量'
|
||||
);
|
||||
}
|
||||
|
||||
$boxPrice = (float) site_config('services.box_amount', 0);
|
||||
$trayPrice = (float) site_config('services.tray_amount', 0);
|
||||
$addedAmount = round($data['box_num'] * $boxPrice + $data['tray_num'] * $trayPrice, 2);
|
||||
|
||||
// 历史订单未写商品金额:按「总额 - 附加」反推并回写,保证 总额 = 商品 + 附加 恒成立
|
||||
$productAmount = (float) $order->product_amount;
|
||||
if ($productAmount <= 0 && (float) $order->total_amount > 0) {
|
||||
$productAmount = round((float) $order->total_amount - (float) $order->added_amount, 2);
|
||||
}
|
||||
|
||||
$order->box_num = (int) $data['box_num'];
|
||||
$order->tray_num = (int) $data['tray_num'];
|
||||
$order->product_amount = $productAmount;
|
||||
$order->added_amount = $addedAmount;
|
||||
$order->total_amount = round($productAmount + $addedAmount, 2);
|
||||
$order->save();
|
||||
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 明细编辑状态校验:已完成/已取消订单锁定
|
||||
*/
|
||||
private function assertItemEditable(StoreOrderModel $order): void
|
||||
{
|
||||
if (! in_array($order->status, self::ITEM_EDITABLE_STATUS, true)) {
|
||||
throw new RepositoryException(
|
||||
'订单当前状态为「' . (self::STATUS_NAMES[$order->status] ?? $order->status) . '」,不允许修改商品明细'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重算订单汇总:明细金额合计 → 商品总金额;订货量/重量合计 → 订货总量/总重量;
|
||||
* 订单总金额 = 商品总金额 + 附加金额(恒成立)
|
||||
*/
|
||||
private function recalculateOrderTotals(StoreOrderModel $order): void
|
||||
{
|
||||
$totals = StoreOrderItemModel::query()
|
||||
->where('order_id', $order->id)
|
||||
->selectRaw('COALESCE(SUM(quantity), 0) as total_quantity')
|
||||
->selectRaw('COALESCE(SUM(weight), 0) as total_weight')
|
||||
->selectRaw('COALESCE(SUM(amount), 0) as product_amount')
|
||||
->first();
|
||||
|
||||
$productAmount = bcadd((string) $totals->product_amount, '0', 2);
|
||||
$order->total_quantity = (int) $totals->total_quantity;
|
||||
$order->total_weight = bcadd((string) $totals->total_weight, '0', 3);
|
||||
$order->product_amount = $productAmount;
|
||||
$order->total_amount = bcadd($productAmount, (string) $order->added_amount, 2);
|
||||
$order->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态流转后通知门店用户
|
||||
*/
|
||||
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');
|
||||
|
||||
@@ -38,7 +38,7 @@ class ProductController extends BaseController
|
||||
|
||||
protected array $quickSearchField = ['name', 'spec'];
|
||||
|
||||
/** A1 商品列表(含分类/供应商/各等级价格) */
|
||||
/** A1 商品列表(含分类/供应商/各等级价格;cost_price 在 $hidden 中,后台列表需显式恢复) */
|
||||
#[GetRoute(authorize: 'query')]
|
||||
public function query(Request $request): JsonResponse
|
||||
{
|
||||
@@ -50,9 +50,9 @@ class ProductController extends BaseController
|
||||
)
|
||||
->orderBy('sort')
|
||||
->orderBy('id', 'desc')
|
||||
->paginate($pageSize)
|
||||
->toArray();
|
||||
return $this->success($data);
|
||||
->paginate($pageSize);
|
||||
$data->getCollection()->makeVisible('cost_price');
|
||||
return $this->success($data->toArray());
|
||||
}
|
||||
|
||||
/** 上传商品分类图片文件 */
|
||||
@@ -85,6 +85,8 @@ class ProductController extends BaseController
|
||||
'product_id' => $product->id,
|
||||
'level_id' => (int) $row['level_id'],
|
||||
'price' => $row['price'],
|
||||
'price_type' => (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED),
|
||||
'percent' => $row['percent'] ?? 0,
|
||||
]);
|
||||
}
|
||||
return $product;
|
||||
@@ -114,7 +116,11 @@ class ProductController extends BaseController
|
||||
$levelIds[] = $levelId;
|
||||
ProductPriceModel::updateOrCreate(
|
||||
['product_id' => $product->id, 'level_id' => $levelId],
|
||||
['price' => $row['price']],
|
||||
[
|
||||
'price' => $row['price'],
|
||||
'price_type' => (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED),
|
||||
'percent' => $row['percent'] ?? 0,
|
||||
],
|
||||
);
|
||||
}
|
||||
$product->prices()->whereNotIn('level_id', $levelIds)->delete();
|
||||
@@ -140,12 +146,14 @@ class ProductController extends BaseController
|
||||
}
|
||||
|
||||
/**
|
||||
* A2 价格矩阵:行=商品(支持 category_id / keyword 过滤 + page/pageSize 服务端分页),列=全部启用等级,值=price(缺失为 null)
|
||||
* A2 价格矩阵:行=商品(支持 category_id / keyword 过滤 + page/pageSize 服务端分页),
|
||||
* 列=全部启用等级,值=实际销售价(缺失为 null);行内含 cost_price 与每等级
|
||||
* price_type_{levelId} / percent_{levelId},供前端判断计价类型与联动重算
|
||||
*/
|
||||
#[GetRoute('/priceMatrix', 'query')]
|
||||
public function priceMatrix(Request $request): JsonResponse
|
||||
{
|
||||
$query = ProductModel::query()->with('prices:id,product_id,level_id,price');
|
||||
$query = ProductModel::query()->with('prices:id,product_id,level_id,price,price_type,percent');
|
||||
if (($categoryId = (int) $request->input('category_id', 0)) > 0) {
|
||||
$query->where('category_id', $categoryId);
|
||||
}
|
||||
@@ -172,11 +180,20 @@ class ProductController extends BaseController
|
||||
'name' => $product->name,
|
||||
'spec' => $product->spec,
|
||||
'unit' => $product->unit,
|
||||
'cost_price' => (float) $product->cost_price,
|
||||
];
|
||||
foreach ($levels as $level) {
|
||||
$row['price_' . $level->id] = isset($priceMap[$level->id])
|
||||
? (float) $priceMap[$level->id]->price
|
||||
$price = $priceMap[$level->id] ?? null;
|
||||
$row['price_' . $level->id] = $price
|
||||
? (float) ProductPriceModel::calcActualPrice(
|
||||
(int) $price->price_type,
|
||||
$price->price,
|
||||
$price->percent,
|
||||
$product->cost_price,
|
||||
)
|
||||
: null;
|
||||
$row['price_type_' . $level->id] = $price ? (int) $price->price_type : ProductPriceModel::PRICE_TYPE_FIXED;
|
||||
$row['percent_' . $level->id] = $price ? (float) $price->percent : 0;
|
||||
}
|
||||
return $row;
|
||||
});
|
||||
@@ -189,7 +206,8 @@ class ProductController extends BaseController
|
||||
}
|
||||
|
||||
/**
|
||||
* A2 批量调价:事务写入,写完后给受影响门店生成 Notice(type=price)
|
||||
* A2 批量调价:三类更新行(成本价 / 固定价 / 成本百分比,可混合同一行),事务写入,
|
||||
* 写完后给受影响门店生成 Notice(type=price)
|
||||
*/
|
||||
#[PutRoute('/batchPrice', 'batchPrice')]
|
||||
public function batchPrice(BatchPriceRequest $request): JsonResponse
|
||||
@@ -200,28 +218,51 @@ class ProductController extends BaseController
|
||||
$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;
|
||||
$productId = (int) $row['product_id'];
|
||||
$productIds[$productId] = true;
|
||||
|
||||
// 分支1:成本价更新(百分比计价的基数,可与等级价行同在一行)
|
||||
if (array_key_exists('cost_price', $row) && $row['cost_price'] !== null) {
|
||||
ProductModel::whereKey($productId)->update(['cost_price' => $row['cost_price']]);
|
||||
}
|
||||
|
||||
// 分支2/3:等级价格行(固定价或成本百分比,按 price_type 区分)
|
||||
if (isset($row['level_id'])) {
|
||||
$levelId = (int) $row['level_id'];
|
||||
$levelIds[$levelId] = true;
|
||||
ProductPriceModel::updateOrCreate(
|
||||
['product_id' => $productId, 'level_id' => $levelId],
|
||||
[
|
||||
'price' => $row['price'] ?? 0,
|
||||
'price_type' => (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED),
|
||||
'percent' => $row['percent'] ?? 0,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 成本价变更只影响「该商品下百分比计价」的等级;受影响门店等级 = 等级更新行 ∪ 百分比行等级
|
||||
$percentLevelIds = ProductPriceModel::query()
|
||||
->whereIn('product_id', array_keys($productIds))
|
||||
->where('price_type', ProductPriceModel::PRICE_TYPE_PERCENT)
|
||||
->pluck('level_id')
|
||||
->merge($levelIds)
|
||||
->unique()
|
||||
->all();
|
||||
|
||||
$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) {
|
||||
->whereIn('store_id', function ($q) use ($percentLevelIds) {
|
||||
$q->select('id')
|
||||
->from('store')
|
||||
->where('status', StoreModel::STATUS_NORMAL)
|
||||
->whereIn('level_id', array_keys($levelIds));
|
||||
->whereIn('level_id', $percentLevelIds);
|
||||
})
|
||||
->pluck('id');
|
||||
|
||||
|
||||
@@ -78,20 +78,28 @@ class PurchaseOrderController extends BaseController
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/** C1 按门店订单汇总生成采购单 */
|
||||
/**
|
||||
* C1 生成采购单:合并全部「已接单」门店订单(或指定的 order_ids),
|
||||
* 生成后源订单转为「采购中」并回写 purchase_id
|
||||
*/
|
||||
#[PostRoute('/generate', 'generate')]
|
||||
public function generate(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'purchase_date' => 'required|date_format:Y-m-d',
|
||||
'order_ids' => 'sometimes|array|min:1',
|
||||
'order_ids.*' => 'integer|exists:store_order,id',
|
||||
], [
|
||||
'purchase_date.required' => '请选择采购日期',
|
||||
'purchase_date.date_format' => '采购日期格式为 Y-m-d',
|
||||
'order_ids.min' => '请选择要合并的订单',
|
||||
'order_ids.*.exists' => '订单不存在',
|
||||
]);
|
||||
|
||||
$purchase = app(PurchaseGenerateService::class)->generate(
|
||||
$data['purchase_date'],
|
||||
(int) $request->user()->id,
|
||||
array_map('intval', $data['order_ids'] ?? []),
|
||||
);
|
||||
|
||||
return $this->success(
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace App\Http\Requests\Customer;
|
||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
|
||||
/**
|
||||
* 小程序用户绑定 验证(type=1 门店需 store_id,type=2 供应商需 supplier_id)
|
||||
* 小程序用户绑定 验证(绑定门店)
|
||||
*/
|
||||
class MiniUserBindRequest extends BaseFormRequest
|
||||
{
|
||||
@@ -14,21 +14,15 @@ class MiniUserBindRequest extends BaseFormRequest
|
||||
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',
|
||||
'store_id' => 'required|integer|min:1',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'type.required' => '请选择用户类型',
|
||||
'type.in' => '用户类型只能是门店或供应商',
|
||||
'store_id.required_if' => '绑定门店时必须选择门店',
|
||||
'store_id.required' => '绑定门店时必须选择门店',
|
||||
'store_id.min' => '门店ID不正确',
|
||||
'supplier_id.required_if' => '绑定供应商时必须选择供应商',
|
||||
'supplier_id.min' => '供应商ID不正确',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,14 +14,8 @@ class StoreFormRequest extends BaseFormRequest
|
||||
|
||||
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',
|
||||
@@ -37,8 +31,6 @@ class StoreFormRequest extends BaseFormRequest
|
||||
return [
|
||||
'name.required' => '门店名称不能为空',
|
||||
'name.max' => '门店名称最长 100 个字符',
|
||||
'code.required' => '门店编码不能为空',
|
||||
'code.unique' => '门店编码已存在',
|
||||
'level_id.required' => '请选择客户等级',
|
||||
'level_id.exists' => '客户等级不存在',
|
||||
'payment_cycle_days.integer' => '回款周期必须为整数',
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Mini;
|
||||
|
||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
|
||||
/**
|
||||
* 小程序购物车 验证(product_id 仅加购时必填;PUT 只改数量)
|
||||
*/
|
||||
class MiniCartRequest extends BaseFormRequest
|
||||
{
|
||||
protected $stopOnFirstFailure = true;
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = [
|
||||
'quantity' => 'required|numeric|min:0.01|max:99999999.99',
|
||||
];
|
||||
|
||||
if (! $this->isUpdate()) {
|
||||
$rules['product_id'] = 'required|integer|exists:product,id';
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'product_id.required' => '请选择商品',
|
||||
'product_id.integer' => '商品参数错误',
|
||||
'product_id.exists' => '商品不存在',
|
||||
'quantity.required' => '订货数量不能为空',
|
||||
'quantity.numeric' => '订货数量必须为数字',
|
||||
'quantity.min' => '订货数量必须大于 0',
|
||||
'quantity.max' => '订货数量超出上限',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,18 @@
|
||||
|
||||
namespace App\Http\Requests\Product;
|
||||
|
||||
use App\Models\ProductPriceModel;
|
||||
use Closure;
|
||||
use Illuminate\Validation\Validator;
|
||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
|
||||
/**
|
||||
* 批量调价 验证(A2 价格矩阵编辑提交)
|
||||
*
|
||||
* updates 每行支持三类更新(可混合同一行):
|
||||
* 成本行 {product_id, cost_price}
|
||||
* 固定价行 {product_id, level_id, price_type?:0, price}
|
||||
* 百分比行 {product_id, level_id, price_type:1, percent}(price 可选,等价固定价)
|
||||
*/
|
||||
class BatchPriceRequest extends BaseFormRequest
|
||||
{
|
||||
@@ -16,11 +24,54 @@ class BatchPriceRequest extends BaseFormRequest
|
||||
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',
|
||||
'updates.*.cost_price' => 'nullable|numeric|min:0|max:99999999',
|
||||
'updates.*.level_id' => 'nullable|integer|exists:customer_level,id',
|
||||
'updates.*.price_type' => 'nullable|integer|in:0,1',
|
||||
'updates.*.price' => 'nullable|numeric|min:0|max:99999999',
|
||||
'updates.*.percent' => 'nullable|numeric|min:0|max:999.99',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 行级交叉校验(在 after() 内按实际数据逐行判断,避免 required_with* 通配符参数解析不可靠):
|
||||
* - 每行必须至少包含成本价或等级价格更新
|
||||
* - 出现等级字段(price/percent/price_type)时必须带 level_id
|
||||
* - 等级行必须有 price 或 percent
|
||||
* - 成本百分比计价(price_type=1)时上浮百分点必填
|
||||
*/
|
||||
public function after(): Closure
|
||||
{
|
||||
return function (Validator $validator): void {
|
||||
$data = (array) $validator->getData();
|
||||
foreach ((array) ($data['updates'] ?? []) as $index => $row) {
|
||||
$row = (array) $row;
|
||||
$hasCost = array_key_exists('cost_price', $row) && $row['cost_price'] !== null && $row['cost_price'] !== '';
|
||||
$hasLevel = isset($row['level_id']);
|
||||
$hasPrice = array_key_exists('price', $row) && $row['price'] !== null && $row['price'] !== '';
|
||||
$hasPercent = array_key_exists('percent', $row) && $row['percent'] !== null && $row['percent'] !== '';
|
||||
$hasType = array_key_exists('price_type', $row);
|
||||
|
||||
if (! $hasCost && ! $hasLevel) {
|
||||
$validator->errors()->add("updates.{$index}", '调价行缺少成本价或等级价格');
|
||||
continue;
|
||||
}
|
||||
if (($hasPrice || $hasPercent || $hasType) && ! $hasLevel) {
|
||||
$validator->errors()->add("updates.{$index}.level_id", '等级价格行缺少客户等级');
|
||||
continue;
|
||||
}
|
||||
if ($hasLevel && ! $hasPrice && ! $hasPercent) {
|
||||
$validator->errors()->add("updates.{$index}", '等级价格行缺少单价或上浮百分点');
|
||||
continue;
|
||||
}
|
||||
|
||||
$priceType = (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED);
|
||||
if ($priceType === ProductPriceModel::PRICE_TYPE_PERCENT && ! $hasPercent) {
|
||||
$validator->errors()->add("updates.{$index}.percent", '按成本百分比计价时必须填写上浮百分点');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
@@ -28,11 +79,15 @@ class BatchPriceRequest extends BaseFormRequest
|
||||
'updates.min' => '请至少提交一条价格调整',
|
||||
'updates.*.product_id.required' => '调价行缺少商品',
|
||||
'updates.*.product_id.exists' => '商品不存在',
|
||||
'updates.*.level_id.required' => '调价行缺少客户等级',
|
||||
'updates.*.cost_price.numeric' => '成本价必须为数字',
|
||||
'updates.*.cost_price.min' => '成本价不能小于 0',
|
||||
'updates.*.level_id.exists' => '客户等级不存在',
|
||||
'updates.*.price.required' => '调价行缺少单价',
|
||||
'updates.*.price_type.in' => '计价类型不正确',
|
||||
'updates.*.price.numeric' => '单价必须为数字',
|
||||
'updates.*.price.min' => '单价不能小于 0',
|
||||
'updates.*.percent.numeric' => '上浮百分点必须为数字',
|
||||
'updates.*.percent.min' => '上浮百分点不能小于 0',
|
||||
'updates.*.percent.max' => '上浮百分点不能超过 999.99',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
namespace App\Http\Requests\Product;
|
||||
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\SupplierModel;
|
||||
use Closure;
|
||||
use Illuminate\Validation\Rules\Exists;
|
||||
use Illuminate\Validation\Validator;
|
||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
use Modules\SystemTool\Models\SysFileModel;
|
||||
|
||||
@@ -30,13 +33,34 @@ class ProductFormRequest extends BaseFormRequest
|
||||
'shelf_life' => 'nullable|integer|min:0',
|
||||
'stock' => 'nullable|integer|min:0',
|
||||
'status' => 'nullable|integer|in:0,1',
|
||||
'cost_price' => 'nullable|numeric|min:0|max:99999999',
|
||||
'remark' => 'nullable|string|max:255',
|
||||
'prices' => 'nullable|array',
|
||||
'prices.*.level_id' => 'required|integer|exists:customer_level,id',
|
||||
'prices.*.price' => 'required|numeric|min:0',
|
||||
'prices.*.price_type' => 'nullable|integer|in:0,1',
|
||||
'prices.*.price' => 'required|numeric|min:0|max:99999999',
|
||||
'prices.*.percent' => 'nullable|numeric|min:0|max:999.99',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 交叉校验:按成本百分比计价(price_type=1)时上浮百分点必填
|
||||
* (Laravel 12 FormRequest 的 after() 需返回单个 Closure,由容器 call 后注册到 Validator)
|
||||
*/
|
||||
public function after(): Closure
|
||||
{
|
||||
return function (Validator $validator): void {
|
||||
$data = (array) $validator->getData();
|
||||
foreach ((array) ($data['prices'] ?? []) as $index => $row) {
|
||||
$priceType = (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED);
|
||||
if ($priceType === ProductPriceModel::PRICE_TYPE_PERCENT
|
||||
&& (! array_key_exists('percent', (array) $row) || $row['percent'] === null || $row['percent'] === '')) {
|
||||
$validator->errors()->add("prices.{$index}.percent", '按成本百分比计价时必须填写上浮百分点');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
@@ -51,6 +75,12 @@ class ProductFormRequest extends BaseFormRequest
|
||||
'prices.*.price.required' => '价格行缺少单价',
|
||||
'prices.*.price.numeric' => '单价必须为数字',
|
||||
'prices.*.price.min' => '单价不能小于 0',
|
||||
'cost_price.numeric' => '成本价必须为数字',
|
||||
'cost_price.min' => '成本价不能小于 0',
|
||||
'prices.*.price_type.in' => '计价类型不正确',
|
||||
'prices.*.percent.numeric' => '上浮百分点必须为数字',
|
||||
'prices.*.percent.min' => '上浮百分点不能小于 0',
|
||||
'prices.*.percent.max' => '上浮百分点不能超过 999.99',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 小程序购物车模型(门店订货车:按用户归属,同商品唯一行、加购合并数量)
|
||||
*/
|
||||
class CartModel extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'cart';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'product_id',
|
||||
'quantity',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'user_id' => 'integer',
|
||||
'product_id' => 'integer',
|
||||
'quantity' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
* 归属用户
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(UserModel::class, 'user_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 购物车商品(软删除后为 null;列表接口需 withTrashed 自行判断状态)
|
||||
*/
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductModel::class, 'product_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ class ProductModel extends Model
|
||||
'shelf_life',
|
||||
'stock',
|
||||
'status',
|
||||
'cost_price',
|
||||
'remark',
|
||||
];
|
||||
|
||||
@@ -47,12 +48,19 @@ class ProductModel extends Model
|
||||
'stock' => 'integer',
|
||||
'sort' => 'integer',
|
||||
'status' => 'integer',
|
||||
'cost_price' => 'decimal:2',
|
||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||
'updated_at' => 'datetime:Y-m-d H:i:s',
|
||||
];
|
||||
|
||||
protected $appends = ['images_arr'];
|
||||
|
||||
/**
|
||||
* 成本价属商业敏感数据,默认不随 toArray 输出(防止泄漏到小程序/供应商端);
|
||||
* 后台管理接口需在查询结果上调用 makeVisible('cost_price') 恢复。
|
||||
*/
|
||||
protected $hidden = ['cost_price'];
|
||||
|
||||
/**
|
||||
* 封面图片
|
||||
*/
|
||||
|
||||
@@ -8,11 +8,18 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 商品价格模型(同一商品按客户等级定价,联合键 product_id + level_id)
|
||||
*
|
||||
* 计价类型:固定价(price 即实际单价)或成本百分比(实际单价 = 成本价 × (100 + percent) / 100)
|
||||
*/
|
||||
class ProductPriceModel extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/** 计价类型:固定价 */
|
||||
public const int PRICE_TYPE_FIXED = 0;
|
||||
/** 计价类型:成本百分比(按成本价上浮 percent 百分点) */
|
||||
public const int PRICE_TYPE_PERCENT = 1;
|
||||
|
||||
protected $table = 'product_price';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
@@ -20,14 +27,21 @@ class ProductPriceModel extends Model
|
||||
'product_id',
|
||||
'level_id',
|
||||
'price',
|
||||
'price_type',
|
||||
'percent',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'product_id' => 'integer',
|
||||
'level_id' => 'integer',
|
||||
'price' => 'decimal:2',
|
||||
'price_type' => 'integer',
|
||||
'percent' => 'decimal:2',
|
||||
];
|
||||
|
||||
/** 序列化时附带实际销售价(后台列表/小程序列表直接展示) */
|
||||
protected $appends = ['actual_price'];
|
||||
|
||||
/**
|
||||
* 所属商品
|
||||
*/
|
||||
@@ -51,4 +65,44 @@ class ProductPriceModel extends Model
|
||||
{
|
||||
return $query->where('product_id', $productId)->where('level_id', $levelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算实际销售价(统一换算入口,金额走 bcmath 保证两位小数精度)
|
||||
*
|
||||
* 固定价返回 price 原值;成本百分比返回 cost × (100 + percent) / 100(四舍五入保留两位)。
|
||||
*
|
||||
* @param string|int|float $price 固定价(decimal cast 后为 '5.50' 形式字符串)
|
||||
* @param string|int|float $percent 成本上浮百分点(30 = 上浮 30%)
|
||||
* @param string|int|float $costPrice 商品成本价(decimal cast 字符串)
|
||||
* @return string 两位小数字符串,如 '13.05'
|
||||
*/
|
||||
public static function calcActualPrice(
|
||||
int $priceType,
|
||||
string|int|float $price,
|
||||
string|int|float $percent,
|
||||
string|int|float $costPrice,
|
||||
): string {
|
||||
if ($priceType === self::PRICE_TYPE_PERCENT) {
|
||||
$multiplier = bcadd('100', (string) $percent, 4);
|
||||
return bcdiv(bcmul((string) $costPrice, $multiplier, 4), '100', 2);
|
||||
}
|
||||
// 固定价:归一化为两位小数字符串
|
||||
return bcadd((string) $price, '0', 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际销售价访问器(供 toArray 输出 actual_price)
|
||||
*
|
||||
* 依赖 product 关系取成本价;prices 经商品 eager load 加载时逆向关系自动填充,无 N+1。
|
||||
* 注意:单独序列化本模型且未加载 product 关系时会触发一次查询,成本价缺失按 0 兜底。
|
||||
*/
|
||||
protected function getActualPriceAttribute(): string
|
||||
{
|
||||
return self::calcActualPrice(
|
||||
(int) $this->price_type,
|
||||
(string) $this->price,
|
||||
(string) $this->percent,
|
||||
(string) ($this->product?->cost_price ?? 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 门店订单明细模型(快照下单时的商品名/规格/等级价,历史单据不受调价影响)
|
||||
* 门店订单明细模型(下单时快照商品档案:品名/规格/单位/供应商/图文/成本价 + 等级单价,
|
||||
* 商品调价或档案变更不影响历史单据)
|
||||
*/
|
||||
class StoreOrderItemModel extends Model
|
||||
{
|
||||
@@ -20,12 +22,19 @@ class StoreOrderItemModel extends Model
|
||||
'order_id',
|
||||
'store_id',
|
||||
'product_id',
|
||||
'category_id',
|
||||
'supplier_id',
|
||||
'product_name',
|
||||
'product_spec',
|
||||
'unit',
|
||||
'price',
|
||||
'image_ids',
|
||||
'content',
|
||||
'shelf_life',
|
||||
'quantity',
|
||||
'weight',
|
||||
'amount',
|
||||
'cost_price',
|
||||
'remark',
|
||||
];
|
||||
|
||||
@@ -33,12 +42,33 @@ class StoreOrderItemModel extends Model
|
||||
'order_id' => 'integer',
|
||||
'store_id' => 'integer',
|
||||
'product_id' => 'integer',
|
||||
'category_id' => 'integer',
|
||||
'supplier_id' => 'integer',
|
||||
'price' => 'decimal:2',
|
||||
'quantity' => 'decimal:2',
|
||||
'shelf_life' => 'integer',
|
||||
'quantity' => 'integer',
|
||||
'weight' => 'decimal:3',
|
||||
'amount' => 'decimal:2',
|
||||
'cost_price' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
* 成本价属商业敏感数据,默认不随 toArray 输出(防止泄漏到小程序端);
|
||||
* 后台管理接口需在查询结果上调用 makeVisible('cost_price') 恢复。
|
||||
*/
|
||||
protected $hidden = ['cost_price'];
|
||||
|
||||
/**
|
||||
* 商品图片ID(逗号分隔字符串 ↔ 数组)
|
||||
*/
|
||||
public function imageIds(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn ($value) => $value === '' || $value === null ? [] : explode(',', (string) $value),
|
||||
set: fn ($value) => is_array($value) ? implode(',', $value) : $value,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 所属订单
|
||||
*/
|
||||
@@ -62,4 +92,20 @@ class StoreOrderItemModel extends Model
|
||||
{
|
||||
return $this->belongsTo(ProductModel::class, 'product_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 快照供应商
|
||||
*/
|
||||
public function supplier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SupplierModel::class, 'supplier_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 快照商品分类
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductCategoryModel::class, 'category_id', 'id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,24 +6,27 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* 门店订单模型(小程序下单,快照等级价)
|
||||
* 门店订单模型(小程序下单,快照等级价;软删除,仅已取消订单可由后台删除)
|
||||
*/
|
||||
class StoreOrderModel extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
/** 状态:待汇总(可被采购单生成归集、可取消) */
|
||||
public const STATUS_PENDING = 0;
|
||||
/** 状态:已汇总(已生成采购单) */
|
||||
public const STATUS_SUMMARIZED = 1;
|
||||
/** 状态:待接单(可被采购单生成归集、可取消) */
|
||||
public const int STATUS_PENDING = 0;
|
||||
/** 状态:已接单(已生成采购单) */
|
||||
public const int STATUS_SUMMARIZED = 1;
|
||||
/** 状态:采购中 */
|
||||
public const int STATUS_DELIVERING = 2;
|
||||
/** 状态:配送中 */
|
||||
public const STATUS_DELIVERING = 2;
|
||||
public const int STATUS_DISTRIBUTION = 3;
|
||||
/** 状态:已完成 */
|
||||
public const STATUS_COMPLETED = 3;
|
||||
public const int STATUS_COMPLETED = 4;
|
||||
/** 状态:已取消 */
|
||||
public const STATUS_CANCELLED = 9;
|
||||
public const int STATUS_CANCELLED = 9;
|
||||
|
||||
protected $table = 'store_order';
|
||||
protected $primaryKey = 'id';
|
||||
@@ -35,17 +38,28 @@ class StoreOrderModel extends Model
|
||||
'total_quantity',
|
||||
'total_weight',
|
||||
'total_amount',
|
||||
'product_amount',
|
||||
'added_amount',
|
||||
'box_num',
|
||||
'tray_num',
|
||||
'status',
|
||||
'remark',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'store_id' => 'integer',
|
||||
'purchase_id' => 'integer',
|
||||
'statement_id' => 'integer',
|
||||
'order_date' => 'date:Y-m-d',
|
||||
'total_quantity' => 'decimal:2',
|
||||
'total_quantity' => 'integer',
|
||||
'total_weight' => 'decimal:3',
|
||||
'total_amount' => 'decimal:2',
|
||||
'product_amount' => 'decimal:2',
|
||||
'added_amount' => 'decimal:2',
|
||||
'box_num' => 'integer',
|
||||
'tray_num' => 'integer',
|
||||
'status' => 'integer',
|
||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,17 +15,10 @@ class UserModel extends Authenticatable
|
||||
{
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
/** 用户类型:待绑定(手机号未匹配到门店/供应商,需后台人工绑定) */
|
||||
public const TYPE_PENDING = 0;
|
||||
/** 用户类型:门店 */
|
||||
public const TYPE_STORE = 1;
|
||||
/** 用户类型:供应商 */
|
||||
public const TYPE_SUPPLIER = 2;
|
||||
|
||||
/** 状态:停用 */
|
||||
public const STATUS_DISABLED = 0;
|
||||
public const int STATUS_DISABLED = 0;
|
||||
/** 状态:正常 */
|
||||
public const STATUS_NORMAL = 1;
|
||||
public const int STATUS_NORMAL = 1;
|
||||
|
||||
protected $table = 'user';
|
||||
|
||||
@@ -45,38 +38,26 @@ class UserModel extends Authenticatable
|
||||
'unionid',
|
||||
'phone',
|
||||
'avatar',
|
||||
'type',
|
||||
'store_id',
|
||||
'supplier_id',
|
||||
'status',
|
||||
'last_login_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'email_verified_at' => 'datetime',
|
||||
'last_login_at' => 'datetime',
|
||||
'type' => 'integer',
|
||||
'email_verified_at' => 'datetime:Y-m-d H:i:s',
|
||||
'last_login_at' => 'datetime:Y-m-d H:i:s',
|
||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||
'store_id' => 'integer',
|
||||
'supplier_id' => 'integer',
|
||||
'status' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 关联门店(type=1 时有效)
|
||||
* 关联门店
|
||||
*/
|
||||
public function store(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StoreModel::class, 'store_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联供应商(type=2 时有效)
|
||||
*/
|
||||
public function supplier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SupplierModel::class, 'supplier_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户通知
|
||||
*/
|
||||
@@ -84,13 +65,4 @@ class UserModel extends Authenticatable
|
||||
{
|
||||
return $this->hasMany(NoticeModel::class, 'user_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已绑定业务主体(门店或供应商)
|
||||
*/
|
||||
public function isBound(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_STORE && $this->store_id > 0
|
||||
|| $this->type === self::TYPE_SUPPLIER && $this->supplier_id > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ use App\Models\PurchaseAllocationModel;
|
||||
use App\Models\PurchaseOrderItemModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
@@ -15,7 +14,7 @@ use Illuminate\Support\Facades\DB;
|
||||
*
|
||||
* 流程(事务内):
|
||||
* 1. 采购单须已录入实际金额(存在 amount>0 的明细),否则拒绝
|
||||
* 2. 每个采购明细溯源「采购日当天、已汇总订单」中该商品的订货明细
|
||||
* 2. 每个采购明细按 purchase_id 溯源该采购单合并的门店订单明细(生成采购单时回写)
|
||||
* 3. 按订货数量比例分摊实际金额/数量/重量:bcmul(item.amount, bcdiv(item_qty, total_qty, 6), 2)
|
||||
* 尾差修正——最后一行承担舍入差额,保证 Σallocation.amount === item.amount(金额守恒)
|
||||
* 4. 重复分摊先删旧记录再重建(幂等)
|
||||
@@ -38,11 +37,11 @@ class PurchaseAllocateService
|
||||
throw new RepositoryException('采购单尚未录入实际金额,无法分摊');
|
||||
}
|
||||
|
||||
// 2. 溯源采购日当天「已汇总」订单的订货明细,按商品分组
|
||||
// 2. 按 purchase_id 溯源本采购单合并的门店订单明细,按商品分组
|
||||
$orderItems = StoreOrderItemModel::query()
|
||||
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
|
||||
->whereDate('store_order.order_date', $purchase->purchase_date)
|
||||
->where('store_order.status', StoreOrderModel::STATUS_SUMMARIZED)
|
||||
->where('store_order.purchase_id', $purchase->id)
|
||||
->whereNull('store_order.deleted_at')
|
||||
->select('store_order_item.*')
|
||||
->get()
|
||||
->groupBy('product_id');
|
||||
|
||||
@@ -14,12 +14,12 @@ use Illuminate\Support\Facades\DB;
|
||||
* C1 订单汇总生成采购单
|
||||
*
|
||||
* 流程(事务内):
|
||||
* 1. 行锁查询当日全部「待汇总」订单(无则报错;状态条件天然排除已汇总订单,幂等)
|
||||
* 1. 行锁「已接单」订单(可传 orderIds 只合并指定订单;状态条件天然排除已归集订单,幂等)
|
||||
* 2. 展开明细按商品聚合(Σquantity,快照品名/规格;供应商取商品默认供应商)
|
||||
* 3. 估算单价 = 该商品最低等级价(product_price MIN),amount = quantity × 估算单价
|
||||
* 3. 估算单价 = 该商品最低实际等级价(按计价类型换算后取 min,PHP 侧兼容 MySQL/SQLite),amount = quantity × 估算单价
|
||||
* 4. 创建采购单头(PO 单号,estimate_amount = Σitems.amount)
|
||||
* 5. 明细按「分类 sort → 商品 sort」排序写入 sort 行号
|
||||
* 6. 源订单批量回写 status = 已汇总
|
||||
* 6. 源订单批量回写 status = 采购中、purchase_id = 采购单ID(分摊按 purchase_id 溯源)
|
||||
*/
|
||||
class PurchaseGenerateService
|
||||
{
|
||||
@@ -28,28 +28,36 @@ class PurchaseGenerateService
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $date 订货/采购日期(Y-m-d)
|
||||
* @param string $date 采购日期(Y-m-d)
|
||||
* @param int $operatorId 制单人(后台系统用户ID)
|
||||
* @param int[] $orderIds 指定合并的门店订单ID(空 = 全部已接单订单)
|
||||
*/
|
||||
public function generate(string $date, int $operatorId): PurchaseOrderModel
|
||||
public function generate(string $date, int $operatorId, array $orderIds = []): PurchaseOrderModel
|
||||
{
|
||||
return DB::transaction(function () use ($date, $operatorId) {
|
||||
// 1. 行锁当日待汇总订单(并发防护)
|
||||
$orders = StoreOrderModel::query()
|
||||
->whereDate('order_date', $date)
|
||||
->where('status', StoreOrderModel::STATUS_PENDING)
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
return DB::transaction(function () use ($date, $operatorId, $orderIds) {
|
||||
// 1. 行锁已接单订单(并发防护);指定订单时要求全部处于已接单,否则整批拒绝
|
||||
$query = StoreOrderModel::query()->lockForUpdate();
|
||||
if ($orderIds !== []) {
|
||||
$orders = $query->whereIn('id', $orderIds)->get();
|
||||
$invalid = $orders->where('status', '<>', StoreOrderModel::STATUS_SUMMARIZED);
|
||||
if ($orders->isEmpty() || $invalid->isNotEmpty()) {
|
||||
throw new RepositoryException(
|
||||
'所选订单包含非「已接单」状态,无法生成采购单:' . $invalid->pluck('order_no')->implode('、')
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$orders = $query->where('status', StoreOrderModel::STATUS_SUMMARIZED)->get();
|
||||
}
|
||||
|
||||
if ($orders->isEmpty()) {
|
||||
throw new RepositoryException('当日无待汇总订单');
|
||||
throw new RepositoryException('无已接单订单,无法生成采购单');
|
||||
}
|
||||
|
||||
// 2. 展开明细按商品聚合
|
||||
$aggregated = [];
|
||||
$orderIds = [];
|
||||
$sourceOrderIds = [];
|
||||
foreach ($orders as $order) {
|
||||
$orderIds[] = $order->id;
|
||||
$sourceOrderIds[] = $order->id;
|
||||
foreach ($order->items as $item) {
|
||||
$productId = (int) $item->product_id;
|
||||
if (! isset($aggregated[$productId])) {
|
||||
@@ -69,7 +77,7 @@ class PurchaseGenerateService
|
||||
}
|
||||
|
||||
if ($aggregated === []) {
|
||||
throw new RepositoryException('当日待汇总订单均无明细,无法生成采购单');
|
||||
throw new RepositoryException('已接单订单均无明细,无法生成采购单');
|
||||
}
|
||||
|
||||
$products = ProductModel::withTrashed()
|
||||
@@ -78,12 +86,29 @@ class PurchaseGenerateService
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// 3. 估算单价 = 最低等级价
|
||||
$minPrices = ProductPriceModel::query()
|
||||
// 3. 估算单价 = 最低实际等级价(逐行按计价类型换算后取 min;数量级 = 当日 SKU × 等级,可控)
|
||||
$priceRows = ProductPriceModel::query()
|
||||
->whereIn('product_id', array_keys($aggregated))
|
||||
->groupBy('product_id')
|
||||
->selectRaw('product_id, MIN(price) as min_price')
|
||||
->pluck('min_price', 'product_id');
|
||||
->get(['product_id', 'price', 'price_type', 'percent'])
|
||||
->groupBy('product_id');
|
||||
|
||||
$minPrices = [];
|
||||
foreach ($aggregated as $productId => $item) {
|
||||
$product = $products->get($productId);
|
||||
$minPrice = null;
|
||||
foreach ($priceRows->get($productId, collect()) as $priceRow) {
|
||||
$actual = ProductPriceModel::calcActualPrice(
|
||||
(int) $priceRow->price_type,
|
||||
$priceRow->price,
|
||||
$priceRow->percent,
|
||||
(float) ($product->cost_price ?? 0),
|
||||
);
|
||||
if ($minPrice === null || bccomp($actual, $minPrice, 2) < 0) {
|
||||
$minPrice = $actual;
|
||||
}
|
||||
}
|
||||
$minPrices[$productId] = $minPrice ?? '0';
|
||||
}
|
||||
|
||||
// 组装明细行并按「分类 sort → 商品 sort」排序
|
||||
$rows = [];
|
||||
@@ -147,9 +172,12 @@ class PurchaseGenerateService
|
||||
]);
|
||||
}
|
||||
|
||||
// 6. 源订单回写「已汇总」
|
||||
StoreOrderModel::whereIn('id', $orderIds)
|
||||
->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||
// 6. 源订单回写「采购中」并关联采购单(分摊按 purchase_id 溯源)
|
||||
StoreOrderModel::whereIn('id', $sourceOrderIds)
|
||||
->update([
|
||||
'status' => StoreOrderModel::STATUS_DELIVERING,
|
||||
'purchase_id' => $purchase->id,
|
||||
]);
|
||||
|
||||
return $purchase;
|
||||
});
|
||||
|
||||
@@ -39,6 +39,7 @@ class StatementGenerateService
|
||||
->whereDate('store_order.order_date', '>=', $periodStart)
|
||||
->whereDate('store_order.order_date', '<=', $periodEnd)
|
||||
->where('store_order.status', '<>', StoreOrderModel::STATUS_CANCELLED)
|
||||
->whereNull('store_order.deleted_at')
|
||||
->select('store_order_item.*')
|
||||
->get();
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
/**
|
||||
* 微信小程序服务(基于 EasyWeChat 6.x)
|
||||
*
|
||||
* 封装 code2Session / 手机号解密;配置读取 config('services.wechat.mini')
|
||||
* (env:WECHAT_MINI_APPID / WECHAT_MINI_SECRET,需业务方提供)。
|
||||
* 封装 code2Session / 手机号解密;配置读取 site_config('wechatMini')
|
||||
* (后台「小程序设置」面板维护,存 sys_site_config 表)。
|
||||
*
|
||||
* 测试策略:通过 setHttpClient() 注入 Symfony MockHttpClient 拦截微信 HTTP 调用。
|
||||
*/
|
||||
|
||||
+2
-1
@@ -35,7 +35,8 @@
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
},
|
||||
"files": [
|
||||
"modules/Common/helpers.php"
|
||||
"modules/Common/helpers.php",
|
||||
"app/Helpers/functions.php"
|
||||
]
|
||||
},
|
||||
"autoload-dev": {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\CartModel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* 购物车工厂(user_id / product_id 需调用方指定;无需 Faker)
|
||||
*
|
||||
* @extends Factory<CartModel>
|
||||
*/
|
||||
class CartModelFactory extends Factory
|
||||
{
|
||||
protected $model = CartModel::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => 0,
|
||||
'product_id' => 0,
|
||||
'quantity' => number_format(random_int(100, 10000) / 100, 2, '.', ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\CustomerLevelModel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* 客户等级工厂(确定性序列数据,无需 Faker)
|
||||
*
|
||||
* @extends Factory<CustomerLevelModel>
|
||||
*/
|
||||
class CustomerLevelModelFactory extends Factory
|
||||
{
|
||||
protected $model = CustomerLevelModel::class;
|
||||
|
||||
private static int $sequence = 0;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$seq = ++self::$sequence;
|
||||
|
||||
return [
|
||||
'name' => '客户等级' . $seq,
|
||||
'sort' => $seq,
|
||||
'status' => CustomerLevelModel::STATUS_NORMAL,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ProductModel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* 商品工厂(确定性序列数据,无需 Faker)
|
||||
*
|
||||
* @extends Factory<ProductModel>
|
||||
*/
|
||||
class ProductModelFactory extends Factory
|
||||
{
|
||||
protected $model = ProductModel::class;
|
||||
|
||||
private const NAMES = ['大白菜', '土豆', '西红柿', '黄瓜', '苹果', '香蕉'];
|
||||
private const SPECS = ['500g/袋', '10斤/箱', '散装', '25斤/袋'];
|
||||
private const UNITS = ['斤', '箱', '袋'];
|
||||
|
||||
private static int $sequence = 0;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$seq = ++self::$sequence;
|
||||
|
||||
return [
|
||||
'category_id' => 0,
|
||||
'supplier_id' => 0,
|
||||
'name' => self::NAMES[$seq % count(self::NAMES)] . $seq,
|
||||
'spec' => self::SPECS[$seq % count(self::SPECS)],
|
||||
'unit' => self::UNITS[$seq % count(self::UNITS)],
|
||||
'image_ids' => '',
|
||||
'content' => '',
|
||||
'sort' => $seq,
|
||||
'shelf_life' => 0,
|
||||
'stock' => 0,
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => 0,
|
||||
'remark' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 下架商品
|
||||
*/
|
||||
public function off(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => ProductModel::STATUS_OFF]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ProductPriceModel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* 商品等级价格工厂(product_id / level_id 需调用方指定;无需 Faker)
|
||||
*
|
||||
* @extends Factory<ProductPriceModel>
|
||||
*/
|
||||
class ProductPriceModelFactory extends Factory
|
||||
{
|
||||
protected $model = ProductPriceModel::class;
|
||||
|
||||
private static int $sequence = 0;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$seq = ++self::$sequence;
|
||||
|
||||
return [
|
||||
'product_id' => 0,
|
||||
'level_id' => 0,
|
||||
'price' => number_format(random_int(100, 10000) / 100 + $seq * 0.01, 2, '.', ''),
|
||||
'price_type' => ProductPriceModel::PRICE_TYPE_FIXED,
|
||||
'percent' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* 采购单工厂(无需 Faker)
|
||||
*
|
||||
* @extends Factory<PurchaseOrderModel>
|
||||
*/
|
||||
class PurchaseOrderModelFactory extends Factory
|
||||
{
|
||||
protected $model = PurchaseOrderModel::class;
|
||||
|
||||
private static int $sequence = 0;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$seq = ++self::$sequence;
|
||||
|
||||
return [
|
||||
'purchase_no' => 'PO' . str_pad((string) $seq, 12, '0', STR_PAD_LEFT),
|
||||
'purchase_date' => now()->toDateString(),
|
||||
'status' => PurchaseOrderModel::STATUS_PENDING,
|
||||
'total_quantity' => 0,
|
||||
'total_weight' => 0,
|
||||
'estimate_amount' => 0,
|
||||
'actual_amount' => 0,
|
||||
'operator_id' => 0,
|
||||
'remark' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定采购日期
|
||||
*/
|
||||
public function onDate(string $date): static
|
||||
{
|
||||
return $this->state(fn () => ['purchase_date' => $date]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\StoreModel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* 门店工厂(确定性序列数据,无需 Faker)
|
||||
*
|
||||
* @extends Factory<StoreModel>
|
||||
*/
|
||||
class StoreModelFactory extends Factory
|
||||
{
|
||||
protected $model = StoreModel::class;
|
||||
|
||||
private static int $sequence = 0;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$seq = ++self::$sequence;
|
||||
|
||||
return [
|
||||
'name' => '测试门店' . $seq,
|
||||
'code' => 'S' . str_pad((string) $seq, 6, '0', STR_PAD_LEFT),
|
||||
'level_id' => 0,
|
||||
'contact' => '联系人' . $seq,
|
||||
'phone' => '138' . str_pad((string) $seq, 8, '0', STR_PAD_LEFT),
|
||||
'address' => '测试地址' . $seq . '号',
|
||||
'payment_cycle_days' => $seq % 8,
|
||||
'status' => StoreModel::STATUS_NORMAL,
|
||||
'remark' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 停用门店
|
||||
*/
|
||||
public function disabled(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => StoreModel::STATUS_DISABLED]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定回款周期(天)
|
||||
*/
|
||||
public function paymentCycle(int $days): static
|
||||
{
|
||||
return $this->state(fn () => ['payment_cycle_days' => $days]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* 门店订单明细工厂(order_id / store_id / product_id 需调用方指定;
|
||||
* amount 未显式指定时按 price × quantity 自动计算;无需 Faker)
|
||||
*
|
||||
* @extends Factory<StoreOrderItemModel>
|
||||
*/
|
||||
class StoreOrderItemModelFactory extends Factory
|
||||
{
|
||||
protected $model = StoreOrderItemModel::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'order_id' => 0,
|
||||
'store_id' => 0,
|
||||
'product_id' => 0,
|
||||
'category_id' => 0,
|
||||
'supplier_id' => 0,
|
||||
'product_name' => '测试商品',
|
||||
'product_spec' => '500g/袋',
|
||||
'unit' => '斤',
|
||||
'price' => number_format(random_int(100, 5000) / 100, 2, '.', ''),
|
||||
'image_ids' => '',
|
||||
'content' => '',
|
||||
'shelf_life' => 0,
|
||||
'quantity' => number_format(random_int(100, 10000) / 100, 2, '.', ''),
|
||||
'weight' => 0,
|
||||
'amount' => 0,
|
||||
'cost_price' => 0,
|
||||
'remark' => '',
|
||||
];
|
||||
}
|
||||
|
||||
public function configure(): static
|
||||
{
|
||||
return $this->afterMaking(function (StoreOrderItemModel $item): void {
|
||||
if ((float) $item->amount === 0.0 && (float) $item->price > 0 && (float) $item->quantity > 0) {
|
||||
$item->amount = bcmul((string) $item->price, (string) $item->quantity, 2);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\StoreOrderModel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* 门店订单工厂(store_id 需调用方指定;无需 Faker)
|
||||
*
|
||||
* @extends Factory<StoreOrderModel>
|
||||
*/
|
||||
class StoreOrderModelFactory extends Factory
|
||||
{
|
||||
protected $model = StoreOrderModel::class;
|
||||
|
||||
private static int $sequence = 0;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$seq = ++self::$sequence;
|
||||
|
||||
return [
|
||||
'order_no' => 'SO' . str_pad((string) $seq, 12, '0', STR_PAD_LEFT),
|
||||
'store_id' => 0,
|
||||
'order_date' => now()->toDateString(),
|
||||
'total_quantity' => 0,
|
||||
'total_weight' => 0,
|
||||
'total_amount' => 0,
|
||||
'status' => StoreOrderModel::STATUS_PENDING,
|
||||
'remark' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定订货日期
|
||||
*/
|
||||
public function onDate(string $date): static
|
||||
{
|
||||
return $this->state(fn () => ['order_date' => $date]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 已汇总(已被采购单归集)
|
||||
*/
|
||||
public function summarized(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\SupplierModel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* 供应商工厂(确定性序列数据,无需 Faker)
|
||||
*
|
||||
* @extends Factory<SupplierModel>
|
||||
*/
|
||||
class SupplierModelFactory extends Factory
|
||||
{
|
||||
protected $model = SupplierModel::class;
|
||||
|
||||
private const MAIN_PRODUCTS = ['蔬菜', '水果', '蔬菜/水果', '肉禽蛋'];
|
||||
|
||||
private static int $sequence = 0;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$seq = ++self::$sequence;
|
||||
|
||||
return [
|
||||
'name' => '测试供应商' . $seq,
|
||||
'contact' => '联系人' . $seq,
|
||||
'phone' => '139' . str_pad((string) $seq, 8, '0', STR_PAD_LEFT),
|
||||
'address' => '供应商地址' . $seq . '号',
|
||||
'main_products' => self::MAIN_PRODUCTS[$seq % count(self::MAIN_PRODUCTS)],
|
||||
'status' => SupplierModel::STATUS_NORMAL,
|
||||
'remark' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 停用供应商
|
||||
*/
|
||||
public function disabled(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => SupplierModel::STATUS_DISABLED]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\UserModel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* 小程序用户工厂(微信登录自动生成,供测试使用;无需 Faker)
|
||||
*
|
||||
* @extends Factory<UserModel>
|
||||
*/
|
||||
class UserModelFactory extends Factory
|
||||
{
|
||||
protected $model = UserModel::class;
|
||||
|
||||
private static int $sequence = 0;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$seq = ++self::$sequence;
|
||||
|
||||
return [
|
||||
'username' => null,
|
||||
'password' => null,
|
||||
'nickname' => '微信用户' . $seq,
|
||||
'email' => '',
|
||||
'openid' => 'openid_' . str_pad((string) $seq, 16, '0', STR_PAD_LEFT),
|
||||
'unionid' => '',
|
||||
'phone' => '',
|
||||
'avatar' => '',
|
||||
'store_id' => 0,
|
||||
'status' => UserModel::STATUS_NORMAL,
|
||||
'last_login_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 已绑定门店的门店用户
|
||||
*/
|
||||
public function forStore(int $storeId): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'store_id' => $storeId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停用账号
|
||||
*/
|
||||
public function disabled(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => UserModel::STATUS_DISABLED]);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('user')) {
|
||||
Schema::create('user', function (Blueprint $table) {
|
||||
$table->increments('id')->comment('用户ID');
|
||||
$table->string('username', 20)->nullable()->unique()->comment('用户名(微信注册用户可为空)');
|
||||
$table->string('password', 100)->nullable()->comment('密码(微信注册用户可为空)');
|
||||
$table->string('nickname', 20)->default('')->comment('昵称');
|
||||
$table->string('email', 50)->default('')->comment('邮箱');
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
// 小程序用户扩展字段(微信授权登录,自动识别门店/供应商身份)
|
||||
$table->string('openid', 64)->nullable()->unique()->comment('微信OpenID(小程序用户唯一标识)');
|
||||
$table->string('unionid', 64)->default('')->comment('微信UnionID');
|
||||
$table->string('phone', 20)->default('')->comment('手机号(微信授权获取,用于匹配门店/供应商)');
|
||||
$table->string('avatar', 255)->default('')->comment('头像');
|
||||
$table->integer('type')->default(0)->comment('用户类型(0待绑定 1门店 2供应商)');
|
||||
$table->integer('store_id')->default(0)->comment('关联门店ID(type=1时有效)');
|
||||
$table->integer('supplier_id')->default(0)->comment('关联供应商ID(type=2时有效)');
|
||||
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
||||
$table->timestamp('last_login_at')->nullable()->comment('最后登录时间');
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
$table->index(['type', 'store_id'], 'user_type_store_index');
|
||||
$table->comment('APP用户表(含小程序用户)');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user');
|
||||
}
|
||||
};
|
||||
@@ -8,10 +8,34 @@ return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
* 基础档案:客户等级、门店、供应商、消息通知(小程序用户已并入 user 表)
|
||||
* 基础档案
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('user')) {
|
||||
Schema::create('user', function (Blueprint $table) {
|
||||
$table->increments('id')->comment('用户ID');
|
||||
$table->string('username', 20)->nullable()->unique()->comment('用户名');
|
||||
$table->string('password', 100)->nullable()->comment('密码');
|
||||
$table->string('nickname', 20)->default('')->comment('昵称');
|
||||
$table->string('email', 50)->default('')->comment('邮箱');
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
// 小程序用户扩展字段
|
||||
$table->string('openid', 64)->nullable()->unique()->comment('微信OpenID');
|
||||
$table->string('unionid', 64)->default('')->comment('微信UnionID');
|
||||
$table->string('phone', 20)->default('')->comment('手机号');
|
||||
$table->string('avatar', 255)->default('')->comment('头像');
|
||||
$table->integer('store_id')->default(0)->comment('关联门店ID');
|
||||
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
||||
$table->timestamp('last_login_at')->nullable()->comment('最后登录时间');
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
$table->index('store_id', 'user_type_index');
|
||||
$table->index('openid', 'user_openid_index');
|
||||
$table->comment('APP用户表(含小程序用户)');
|
||||
});
|
||||
}
|
||||
|
||||
// 客户等级表(价格体系按等级定价)
|
||||
if (! Schema::hasTable('customer_level')) {
|
||||
Schema::create('customer_level', function (Blueprint $table) {
|
||||
@@ -35,7 +59,7 @@ return new class extends Migration
|
||||
$table->string('contact', 50)->default('')->comment('联系人');
|
||||
$table->string('phone', 20)->default('')->comment('联系电话');
|
||||
$table->string('address', 255)->default('')->comment('门店地址');
|
||||
$table->integer('payment_cycle_days')->default(0)->comment('回款周期(天),门店可自行修改,影响对账单应结算日期');
|
||||
$table->integer('payment_cycle_days')->default(0)->comment('回款周期(天)');
|
||||
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
||||
$table->string('remark', 255)->default('')->comment('备注');
|
||||
$table->timestamps();
|
||||
@@ -62,8 +86,7 @@ return new class extends Migration
|
||||
});
|
||||
}
|
||||
|
||||
// 消息通知表(订单状态变更、价格调整等通知)
|
||||
// 小程序用户已并入 user 表,不再单独建 mini_user 表
|
||||
// 消息通知表
|
||||
if (! Schema::hasTable('notice')) {
|
||||
Schema::create('notice', function (Blueprint $table) {
|
||||
$table->increments('id')->comment('通知ID');
|
||||
@@ -86,6 +109,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user');
|
||||
Schema::dropIfExists('customer_level');
|
||||
Schema::dropIfExists('store');
|
||||
Schema::dropIfExists('supplier');
|
||||
|
||||
@@ -42,6 +42,7 @@ return new class extends Migration
|
||||
$table->integer('shelf_life')->default(0)->comment('保质期');
|
||||
$table->integer('stock')->default(0)->comment('库存');
|
||||
$table->integer('status')->default(1)->comment('状态(1上架 0下架)');
|
||||
$table->decimal('cost_price', 10, 2)->default(0)->comment('成本价(元,成本百分比计价基数)');
|
||||
$table->string('remark', 255)->default('')->comment('备注');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
@@ -57,7 +58,9 @@ return new class extends Migration
|
||||
$table->increments('id')->comment('价格ID');
|
||||
$table->integer('product_id')->comment('商品ID');
|
||||
$table->integer('level_id')->comment('客户等级ID');
|
||||
$table->decimal('price', 10, 2)->default(0)->comment('该等级下的商品单价');
|
||||
$table->decimal('price', 10, 2)->default(0)->comment('该等级下的商品单价(固定价=实际单价;百分比=等价固定价)');
|
||||
$table->unsignedTinyInteger('price_type')->default(0)->comment('计价类型(0固定价 1成本百分比)');
|
||||
$table->decimal('percent', 5, 2)->default(0)->comment('成本上浮百分点(如 30 = 上浮30%,仅 price_type=1 生效)');
|
||||
$table->timestamps();
|
||||
$table->unique(['product_id', 'level_id'], 'product_price_product_level_unique');
|
||||
$table->comment('商品等级价格表');
|
||||
|
||||
@@ -18,12 +18,19 @@ return new class extends Migration
|
||||
$table->increments('id')->comment('订单ID');
|
||||
$table->string('order_no', 32)->unique()->comment('订单编号');
|
||||
$table->integer('store_id')->comment('门店ID');
|
||||
$table->integer('purchase_id')->nullable()->comment('关联采购单ID');
|
||||
$table->integer('statement_id')->nullable()->comment('关联账单ID');
|
||||
$table->date('order_date')->comment('订货日期');
|
||||
$table->decimal('total_quantity', 10, 2)->default(0)->comment('订货总量');
|
||||
$table->integer('total_quantity')->default(0)->comment('订货总量');
|
||||
$table->decimal('total_weight', 10, 3)->default(0)->comment('总重量');
|
||||
$table->decimal('total_amount', 10, 2)->default(0)->comment('订单总金额');
|
||||
$table->integer('status')->default(0)->comment('订单状态(0待汇总 1已汇总 2配送中 3已完成 9已取消)');
|
||||
$table->decimal('product_amount', 10, 2)->default(0)->comment('商品总金额');
|
||||
$table->decimal('added_amount', 10, 2)->default(0)->comment('附加金额');
|
||||
$table->decimal('box_num', 10, 2)->default(0)->comment('周转框数量');
|
||||
$table->decimal('tray_num', 10, 2)->default(0)->comment('周转托盘数量');
|
||||
$table->integer('status')->default(0)->comment('订单状态(0待接单 1已接单 2采购中 3配送中 4已完成 9已取消)');
|
||||
$table->string('remark', 255)->default('')->comment('订单备注');
|
||||
$table->softDeletes();
|
||||
$table->timestamps();
|
||||
$table->index(['store_id', 'order_date'], 'store_order_store_date_index');
|
||||
$table->index(['status'], 'store_order_status_index');
|
||||
@@ -36,14 +43,21 @@ return new class extends Migration
|
||||
Schema::create('store_order_item', function (Blueprint $table) {
|
||||
$table->increments('id')->comment('明细ID');
|
||||
$table->integer('order_id')->comment('订单ID');
|
||||
$table->integer('store_id')->comment('门店ID(冗余,便于按门店筛选)');
|
||||
$table->integer('store_id')->comment('门店ID');
|
||||
$table->integer('product_id')->comment('商品ID');
|
||||
$table->string('product_name', 100)->comment('品名(快照)');
|
||||
$table->string('product_spec', 100)->default('')->comment('规格/包规(快照)');
|
||||
$table->decimal('price', 10, 2)->default(0)->comment('单价(下单时客户等级价快照)');
|
||||
$table->decimal('quantity', 10, 2)->default(0)->comment('订货量');
|
||||
$table->integer('category_id')->default(0)->comment('分类ID');
|
||||
$table->integer('supplier_id')->default(0)->comment('供应商ID');
|
||||
$table->string('product_name', 100)->comment('品名');
|
||||
$table->string('product_spec', 100)->default('')->comment('规格/包规');
|
||||
$table->string('unit', 20)->default('斤')->comment('计价单位(斤/件/箱等)');
|
||||
$table->decimal('price', 10, 2)->default(0)->comment('单价');
|
||||
$table->string('image_ids', 255)->default('')->comment('商品图片');
|
||||
$table->text('content')->comment('商品图文详情');
|
||||
$table->integer('shelf_life')->default(0)->comment('保质期');
|
||||
$table->integer('quantity')->default(0)->comment('订货量');
|
||||
$table->decimal('weight', 10, 3)->default(0)->comment('重量');
|
||||
$table->decimal('amount', 10, 2)->default(0)->comment('单品金额');
|
||||
$table->decimal('cost_price', 10, 2)->default(0)->comment('成本价');
|
||||
$table->string('remark', 255)->default('')->comment('门店下单备注');
|
||||
$table->timestamps();
|
||||
$table->index(['order_id'], 'store_order_item_order_index');
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
* 小程序购物车(门店订货车):按用户归属,同商品唯一行、加购合并累加
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('cart')) {
|
||||
Schema::create('cart', function (Blueprint $table) {
|
||||
$table->increments('id')->comment('购物车项ID');
|
||||
$table->integer('user_id')->comment('用户ID(购物车归属者)');
|
||||
$table->integer('product_id')->comment('商品ID');
|
||||
$table->decimal('quantity', 10, 2)->default(0)->comment('订货量');
|
||||
$table->timestamps();
|
||||
$table->unique(['user_id', 'product_id'], 'cart_user_product_unique');
|
||||
$table->index(['user_id', 'created_at'], 'cart_user_created_index');
|
||||
$table->comment('小程序购物车表(门店订货车)');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cart');
|
||||
}
|
||||
};
|
||||
@@ -173,6 +173,7 @@ class PermissionSeeder extends Seeder
|
||||
'children' => [
|
||||
['type' => 'rule', 'key' => 'order.store.query', 'name' => '查询'],
|
||||
['type' => 'rule', 'key' => 'order.store.update', 'name' => '状态流转'],
|
||||
['type' => 'rule', 'key' => 'order.store.delete', 'name' => '删除(仅已取消订单)'],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\PurchaseAllocationModel;
|
||||
use App\Models\PurchaseOrderItemModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
@@ -35,6 +36,9 @@ class AllocationTest extends ProcurementTestCase
|
||||
->assertJsonPath('success', true);
|
||||
}
|
||||
|
||||
// 接单后生成采购单(生成来源为已接单订单)
|
||||
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', true);
|
||||
@@ -131,6 +135,8 @@ class AllocationTest extends ProcurementTestCase
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]]);
|
||||
|
||||
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()]);
|
||||
$purchase = PurchaseOrderModel::first();
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\CartModel;
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* 小程序购物车:加购合并、等级价/金额服务端计算、归属校验、数据隔离、清空
|
||||
*/
|
||||
class CartTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 造一家门店 + 一个上架商品(含等级价)+ 该店用户
|
||||
*
|
||||
* @return array{0: StoreModel, 1: ProductModel, 2: UserModel}
|
||||
*/
|
||||
private function makeStoreWithProduct(string $price = '5.00'): array
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price' => $price,
|
||||
]);
|
||||
|
||||
return [$store, $product, UserModel::factory()->forStore($store->id)->create()];
|
||||
}
|
||||
|
||||
/** 加购成功:数量入库、返回购物车项 */
|
||||
public function test_add_to_cart_succeeds(): void
|
||||
{
|
||||
[, $product, $user] = $this->makeStoreWithProduct('5.50');
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$this->postJson('/mini/cart', [
|
||||
'product_id' => $product->id,
|
||||
'quantity' => 2.5,
|
||||
])->assertOk()
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.quantity', '2.50')
|
||||
->assertJsonStructure(['data' => ['id', 'quantity']]);
|
||||
|
||||
$cart = CartModel::first();
|
||||
$this->assertNotNull($cart);
|
||||
$this->assertSame($user->id, $cart->user_id);
|
||||
$this->assertSame('2.50', (string) $cart->quantity);
|
||||
}
|
||||
|
||||
/** 重复加购同一商品合并累加(同商品唯一行) */
|
||||
public function test_duplicate_add_merges_quantity(): void
|
||||
{
|
||||
[, $product, $user] = $this->makeStoreWithProduct();
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 2]);
|
||||
$this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 3])
|
||||
->assertJsonPath('data.quantity', '5.00');
|
||||
|
||||
$this->assertSame(1, CartModel::count());
|
||||
$this->assertSame('5.00', (string) CartModel::first()->quantity);
|
||||
}
|
||||
|
||||
/** 已下架商品拒绝加购 */
|
||||
public function test_off_shelf_product_rejected(): void
|
||||
{
|
||||
[, $product, $user] = $this->makeStoreWithProduct();
|
||||
$product->update(['status' => ProductModel::STATUS_OFF]);
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 1])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', false);
|
||||
|
||||
$this->assertSame(0, CartModel::count());
|
||||
}
|
||||
|
||||
/** 已软删除商品拒绝加购 */
|
||||
public function test_soft_deleted_product_rejected(): void
|
||||
{
|
||||
[, $product, $user] = $this->makeStoreWithProduct();
|
||||
$product->delete();
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 1])
|
||||
->assertJsonPath('success', false);
|
||||
|
||||
$this->assertSame(0, CartModel::count());
|
||||
}
|
||||
|
||||
/** 商品未设置门店等级价时拒绝加购 */
|
||||
public function test_product_without_level_price_rejected(): void
|
||||
{
|
||||
[, $product, $user] = $this->makeStoreWithProduct();
|
||||
ProductPriceModel::where('product_id', $product->id)->delete();
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 1])
|
||||
->assertJsonPath('success', false);
|
||||
|
||||
$this->assertSame(0, CartModel::count());
|
||||
}
|
||||
|
||||
/** 未绑定门店的用户拒绝加购 */
|
||||
public function test_unbound_user_rejected(): void
|
||||
{
|
||||
[, $product] = $this->makeStoreWithProduct();
|
||||
$this->actingAsMiniUser(UserModel::factory()->create());
|
||||
|
||||
$this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 1])
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '尚未绑定门店,请联系客服处理');
|
||||
}
|
||||
|
||||
/** 门店未设置客户等级时拒绝加购 */
|
||||
public function test_store_without_level_rejected(): void
|
||||
{
|
||||
[, $product] = $this->makeStoreWithProduct();
|
||||
$store = StoreModel::factory()->create(['level_id' => 0]);
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
|
||||
$this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 1])
|
||||
->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
/** 列表:实时等级价、服务端金额、汇总(同店两商品,新加入在前) */
|
||||
public function test_list_with_prices_amounts_and_totals(): void
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$p1 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
$p2 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $p1->id, 'level_id' => $level->id, 'price' => '5.50']);
|
||||
ProductPriceModel::factory()->create(['product_id' => $p2->id, 'level_id' => $level->id, 'price' => '3.00']);
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
|
||||
$this->postJson('/mini/cart', ['product_id' => $p1->id, 'quantity' => 3]);
|
||||
$this->postJson('/mini/cart', ['product_id' => $p2->id, 'quantity' => 2]);
|
||||
|
||||
$this->getJson('/mini/cart')->assertOk()
|
||||
->assertJsonPath('success', true)
|
||||
// items 按 id 倒序:后加入的 p2 在前
|
||||
->assertJsonPath('data.items.0.product_id', $p2->id)
|
||||
->assertJsonPath('data.items.0.price', '3.00')
|
||||
->assertJsonPath('data.items.0.quantity', '2.00')
|
||||
->assertJsonPath('data.items.0.amount', '6.00')
|
||||
->assertJsonPath('data.items.0.status', 1)
|
||||
->assertJsonPath('data.items.1.product_id', $p1->id)
|
||||
->assertJsonPath('data.items.1.price', '5.50')
|
||||
->assertJsonPath('data.items.1.amount', '16.50')
|
||||
->assertJsonPath('data.items.1.status', 1)
|
||||
->assertJsonPath('data.total_count', 2)
|
||||
->assertJsonPath('data.total_quantity', '5.00')
|
||||
->assertJsonPath('data.total_amount', '22.50');
|
||||
}
|
||||
|
||||
/** 列表:下架商品标记不可购,不计入汇总 */
|
||||
public function test_list_marks_off_shelf_item_unbuyable(): void
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$p1 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
$p2 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $p1->id, 'level_id' => $level->id, 'price' => '5.50']);
|
||||
ProductPriceModel::factory()->create(['product_id' => $p2->id, 'level_id' => $level->id, 'price' => '3.00']);
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
|
||||
$this->postJson('/mini/cart', ['product_id' => $p1->id, 'quantity' => 3]);
|
||||
$this->postJson('/mini/cart', ['product_id' => $p2->id, 'quantity' => 2]);
|
||||
$p1->update(['status' => ProductModel::STATUS_OFF]);
|
||||
|
||||
$this->getJson('/mini/cart')->assertOk()
|
||||
->assertJsonPath('data.items.1.status', 0)
|
||||
->assertJsonPath('data.items.1.amount', null)
|
||||
->assertJsonPath('data.total_count', 2)
|
||||
->assertJsonPath('data.total_quantity', '2.00')
|
||||
->assertJsonPath('data.total_amount', '6.00');
|
||||
}
|
||||
|
||||
/** 空购物车返回空列表 */
|
||||
public function test_empty_cart_returns_empty_items(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create(['level_id' => CustomerLevelModel::factory()->create()->id]);
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
|
||||
$this->getJson('/mini/cart')->assertOk()
|
||||
->assertJsonPath('data.items', [])
|
||||
->assertJsonPath('data.total_count', 0)
|
||||
->assertJsonPath('data.total_amount', '0.00');
|
||||
}
|
||||
|
||||
/** 修改数量 */
|
||||
public function test_update_quantity(): void
|
||||
{
|
||||
[, $product, $user] = $this->makeStoreWithProduct();
|
||||
$this->actingAsMiniUser($user);
|
||||
$this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 1]);
|
||||
$cart = CartModel::first();
|
||||
|
||||
$this->putJson("/mini/cart/{$cart->id}", ['quantity' => 7])
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.quantity', '7.00');
|
||||
|
||||
$this->assertSame('7.00', (string) $cart->fresh()->quantity);
|
||||
}
|
||||
|
||||
/** 修改他人购物车项拒绝(同店另一用户) */
|
||||
public function test_update_other_users_item_rejected(): void
|
||||
{
|
||||
[$store, $product, $userA] = $this->makeStoreWithProduct();
|
||||
$this->actingAsMiniUser($userA);
|
||||
$this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 1]);
|
||||
$cart = CartModel::first();
|
||||
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
$this->putJson("/mini/cart/{$cart->id}", ['quantity' => 9])
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '购物车项不存在');
|
||||
|
||||
$this->assertSame('1.00', (string) $cart->fresh()->quantity, '他人改数量不应生效');
|
||||
}
|
||||
|
||||
/** 修改不存在的购物车项拒绝 */
|
||||
public function test_update_missing_item_rejected(): void
|
||||
{
|
||||
[$store, $product, $user] = $this->makeStoreWithProduct();
|
||||
$this->actingAsMiniUser($user);
|
||||
$this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 1]);
|
||||
|
||||
$this->putJson('/mini/cart/999999', ['quantity' => 1])->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
/** 数量校验:0 与负值拒绝 */
|
||||
public function test_invalid_quantity_rejected(): void
|
||||
{
|
||||
[, $product, $user] = $this->makeStoreWithProduct();
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 0])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '订货数量必须大于 0');
|
||||
|
||||
$this->assertSame(0, CartModel::count());
|
||||
}
|
||||
|
||||
/** 删除单项 */
|
||||
public function test_delete_item(): void
|
||||
{
|
||||
[, $product, $user] = $this->makeStoreWithProduct();
|
||||
$this->actingAsMiniUser($user);
|
||||
$this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 1]);
|
||||
$cart = CartModel::first();
|
||||
|
||||
$this->deleteJson("/mini/cart/{$cart->id}")->assertJsonPath('success', true);
|
||||
$this->assertSame(0, CartModel::count());
|
||||
}
|
||||
|
||||
/** 删除他人购物车项拒绝 */
|
||||
public function test_delete_other_users_item_rejected(): void
|
||||
{
|
||||
[$store, $product, $userA] = $this->makeStoreWithProduct();
|
||||
$this->actingAsMiniUser($userA);
|
||||
$this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 1]);
|
||||
$cart = CartModel::first();
|
||||
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
$this->deleteJson("/mini/cart/{$cart->id}")
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '购物车项不存在');
|
||||
|
||||
$this->assertSame(1, CartModel::count());
|
||||
}
|
||||
|
||||
/** 清空购物车仅影响当前用户 */
|
||||
public function test_clear_only_own_cart(): void
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$p1 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
$p2 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $p1->id, 'level_id' => $level->id, 'price' => '5.00']);
|
||||
ProductPriceModel::factory()->create(['product_id' => $p2->id, 'level_id' => $level->id, 'price' => '5.00']);
|
||||
$userA = UserModel::factory()->forStore($store->id)->create();
|
||||
$userB = UserModel::factory()->forStore($store->id)->create();
|
||||
|
||||
$this->actingAsMiniUser($userA);
|
||||
$this->postJson('/mini/cart', ['product_id' => $p1->id, 'quantity' => 1]);
|
||||
$this->postJson('/mini/cart', ['product_id' => $p2->id, 'quantity' => 1]);
|
||||
|
||||
$this->actingAsMiniUser($userB);
|
||||
$this->postJson('/mini/cart', ['product_id' => $p1->id, 'quantity' => 1]);
|
||||
|
||||
$this->actingAsMiniUser($userA);
|
||||
$this->deleteJson('/mini/cart')->assertJsonPath('success', true);
|
||||
|
||||
$this->assertSame(0, CartModel::where('user_id', $userA->id)->count());
|
||||
$this->assertSame(1, CartModel::where('user_id', $userB->id)->count(), '他人购物车不受影响');
|
||||
}
|
||||
|
||||
/** 未登录访问购物车 → 401 */
|
||||
public function test_unauthenticated_returns_401(): void
|
||||
{
|
||||
$this->getJson('/mini/cart')->assertStatus(401);
|
||||
}
|
||||
|
||||
/** 跨用户列表隔离:B 看不到 A 的购物车 */
|
||||
public function test_cart_isolated_between_users(): void
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => '5.00']);
|
||||
$userA = UserModel::factory()->forStore($store->id)->create();
|
||||
$userB = UserModel::factory()->forStore($store->id)->create();
|
||||
|
||||
$this->actingAsMiniUser($userA);
|
||||
$this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 1]);
|
||||
|
||||
$this->actingAsMiniUser($userB);
|
||||
$this->getJson('/mini/cart')->assertOk()
|
||||
->assertJsonPath('data.items', [])
|
||||
->assertJsonPath('data.total_count', 0);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\UserModel;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
|
||||
@@ -39,6 +40,9 @@ class ExportTest extends ProcurementTestCase
|
||||
['product_id' => $meat->id, 'quantity' => 1],
|
||||
]])->assertJsonPath('success', true);
|
||||
|
||||
// 接单后生成采购单
|
||||
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
+122
-66
@@ -5,20 +5,43 @@ namespace Tests\Feature;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Services\WechatService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\SystemTool\Services\SysSiteConfigService;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
/**
|
||||
* 小程序认证:EasyWeChat MockHttpClient 拦截微信调用、
|
||||
* 登录自动注册、手机号绑定自动匹配、停用拒绝、双端 token 隔离
|
||||
* 注册绑定门店、登录签发 token、停用拒绝、双端 token 隔离
|
||||
*/
|
||||
class MiniAuthTest extends ProcurementTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
// 测试环境注入微信配置(生产由 WECHAT_MINI_APPID/SECRET 提供)
|
||||
config(['services.wechat.mini' => ['appid' => 'test_appid', 'secret' => 'test_secret']]);
|
||||
// 微信配置为 DB 驱动(后台「小程序设置」→ sys_site_config,WechatService 经
|
||||
// site_config('wechatMini') 读取):测试落库并刷新配置缓存
|
||||
DB::table('sys_site_config_group')->insert([
|
||||
'id' => 100,
|
||||
'title' => '小程序设置',
|
||||
'key' => 'wechatMini',
|
||||
'remark' => '',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::table('sys_site_config_items')->insert([
|
||||
[
|
||||
'group_id' => 100, 'key' => 'appid', 'title' => 'APPID', 'describe' => '',
|
||||
'values' => 'test_appid', 'type' => 'Input', 'options' => null, 'props' => null, 'sort' => 0,
|
||||
'created_at' => now(), 'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'group_id' => 100, 'key' => 'secret', 'title' => 'SecretKey', 'describe' => '',
|
||||
'values' => 'test_secret', 'type' => 'Input', 'options' => null, 'props' => null, 'sort' => 1,
|
||||
'created_at' => now(), 'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
SysSiteConfigService::refreshSiteConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,9 +56,11 @@ class MiniAuthTest extends ProcurementTestCase
|
||||
);
|
||||
}
|
||||
|
||||
/** wx.login code → 自动注册用户并签发 token */
|
||||
public function test_login_creates_user_and_issues_token(): void
|
||||
/** wx.login code → 已注册用户签发 token */
|
||||
public function test_login_issues_token_for_registered_user(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create();
|
||||
$user = UserModel::factory()->forStore($store->id)->create(['openid' => 'openid_test_001']);
|
||||
$this->mockWechat(fn () => new MockResponse((string) json_encode([
|
||||
'openid' => 'openid_test_001',
|
||||
'session_key' => 'session_key_x',
|
||||
@@ -44,14 +69,101 @@ class MiniAuthTest extends ProcurementTestCase
|
||||
$response = $this->postJson('/mini/auth/login', ['code' => 'wx_code']);
|
||||
$response->assertOk()
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonStructure(['data' => ['token', 'user' => ['id', 'type', 'store', 'supplier']]]);
|
||||
->assertJsonStructure(['data' => ['token', 'user' => ['id']]]);
|
||||
|
||||
$this->assertNotEmpty($response->json('data.token'));
|
||||
$this->assertNotNull($user->fresh()->last_login_at);
|
||||
}
|
||||
|
||||
$user = UserModel::where('openid', 'openid_test_001')->first();
|
||||
$this->assertNotNull($user, '应按 openid 自动创建用户');
|
||||
$this->assertSame(UserModel::TYPE_PENDING, $user->type);
|
||||
$this->assertNotNull($user->last_login_at);
|
||||
/** openid 未注册 → 拒绝登录 */
|
||||
public function test_login_rejects_unregistered_openid(): void
|
||||
{
|
||||
$this->mockWechat(fn () => new MockResponse((string) json_encode([
|
||||
'openid' => 'openid_unknown',
|
||||
'session_key' => 'sk',
|
||||
])));
|
||||
|
||||
$this->postJson('/mini/auth/login', ['code' => 'wx_code'])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', false);
|
||||
$this->assertSame(0, UserModel::count());
|
||||
}
|
||||
|
||||
/** 注册:门店编码绑定门店并签发 token */
|
||||
public function test_register_binds_store_and_issues_token(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create(['code' => 'ST001', 'phone' => '13800138000']);
|
||||
$this->mockWechat(function (string $method, string $url): MockResponse {
|
||||
if (str_contains($url, 'jscode2session')) {
|
||||
return new MockResponse((string) json_encode([
|
||||
'openid' => 'openid_reg_001',
|
||||
'session_key' => 'sk',
|
||||
]));
|
||||
}
|
||||
if (str_contains($url, 'cgi-bin/token')) {
|
||||
return new MockResponse((string) json_encode([
|
||||
'access_token' => 'mock_access_token',
|
||||
'expires_in' => 7200,
|
||||
]));
|
||||
}
|
||||
|
||||
return new MockResponse((string) json_encode([
|
||||
'errcode' => 0,
|
||||
'phone_info' => ['phoneNumber' => '13800138000'],
|
||||
]));
|
||||
});
|
||||
|
||||
$response = $this->postJson('/mini/auth/register', [
|
||||
'code' => 'wx_code',
|
||||
'phoneCode' => 'phone_code',
|
||||
'storeCode' => 'ST001',
|
||||
]);
|
||||
$response->assertOk()
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonStructure(['data' => ['token', 'user' => ['id', 'store_id']]]);
|
||||
$this->assertNotEmpty($response->json('data.token'));
|
||||
|
||||
$user = UserModel::where('openid', 'openid_reg_001')->first();
|
||||
$this->assertNotNull($user, '应按 openid 注册用户');
|
||||
$this->assertSame($store->id, $user->store_id);
|
||||
$this->assertSame('13800138000', $user->phone);
|
||||
}
|
||||
|
||||
/** 注册:门店编码不存在 → 拒绝 */
|
||||
public function test_register_rejects_unknown_store(): void
|
||||
{
|
||||
$this->mockWechat(fn () => new MockResponse((string) json_encode([
|
||||
'openid' => 'openid_reg_002',
|
||||
'session_key' => 'sk',
|
||||
])));
|
||||
|
||||
$this->postJson('/mini/auth/register', [
|
||||
'code' => 'wx_code',
|
||||
'phoneCode' => 'phone_code',
|
||||
'storeCode' => 'NOT_EXIST',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', false);
|
||||
$this->assertSame(0, UserModel::count());
|
||||
}
|
||||
|
||||
/** 注册:openid 已注册 → 拒绝重复注册 */
|
||||
public function test_register_rejects_duplicate_openid(): void
|
||||
{
|
||||
UserModel::factory()->create(['openid' => 'openid_dup']);
|
||||
$this->mockWechat(fn () => new MockResponse((string) json_encode([
|
||||
'openid' => 'openid_dup',
|
||||
'session_key' => 'sk',
|
||||
])));
|
||||
|
||||
$this->postJson('/mini/auth/register', [
|
||||
'code' => 'wx_code',
|
||||
'phoneCode' => 'phone_code',
|
||||
'storeCode' => 'ST001',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', false);
|
||||
$this->assertSame(1, UserModel::count());
|
||||
}
|
||||
|
||||
/** 停用账号拒绝登录 */
|
||||
@@ -82,62 +194,6 @@ class MiniAuthTest extends ProcurementTestCase
|
||||
$this->assertSame(0, UserModel::count());
|
||||
}
|
||||
|
||||
/** 手机号绑定:按手机号自动匹配门店 */
|
||||
public function test_phone_binding_matches_store(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create(['phone' => '13800138000']);
|
||||
$user = UserModel::factory()->create();
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$this->mockWechat(function (string $method, string $url): MockResponse {
|
||||
if (str_contains($url, '/cgi-bin/token')) {
|
||||
return new MockResponse((string) json_encode([
|
||||
'access_token' => 'mock_access_token',
|
||||
'expires_in' => 7200,
|
||||
]));
|
||||
}
|
||||
|
||||
return new MockResponse((string) json_encode([
|
||||
'errcode' => 0,
|
||||
'phone_info' => ['phoneNumber' => '13800138000'],
|
||||
]));
|
||||
});
|
||||
|
||||
$this->postJson('/mini/auth/phone', ['phoneCode' => 'phone_code'])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$user = $user->fresh();
|
||||
$this->assertSame('13800138000', $user->phone);
|
||||
$this->assertSame(UserModel::TYPE_STORE, $user->type);
|
||||
$this->assertSame($store->id, $user->store_id);
|
||||
}
|
||||
|
||||
/** 手机号无匹配主体 → 保持待绑定 */
|
||||
public function test_phone_no_match_stays_pending(): void
|
||||
{
|
||||
$user = UserModel::factory()->create();
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$this->mockWechat(function (string $method, string $url): MockResponse {
|
||||
if (str_contains($url, '/cgi-bin/token')) {
|
||||
return new MockResponse((string) json_encode([
|
||||
'access_token' => 'mock_access_token',
|
||||
'expires_in' => 7200,
|
||||
]));
|
||||
}
|
||||
|
||||
return new MockResponse((string) json_encode([
|
||||
'errcode' => 0,
|
||||
'phone_info' => ['phoneNumber' => '19999999999'],
|
||||
]));
|
||||
});
|
||||
|
||||
$this->postJson('/mini/auth/phone', ['phoneCode' => 'phone_code'])
|
||||
->assertJsonPath('success', true);
|
||||
$this->assertSame(UserModel::TYPE_PENDING, $user->fresh()->type);
|
||||
}
|
||||
|
||||
/** 跨端隔离:后台 token 访问小程序接口 → 401 */
|
||||
public function test_sys_token_cannot_access_mini(): void
|
||||
{
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* 小程序门店设置:详情回显、联系人/电话/地址维护、
|
||||
* 名称/编码/回款周期等字段不可通过小程序端修改
|
||||
*/
|
||||
class MiniStoreTest extends ProcurementTestCase
|
||||
{
|
||||
/** 门店详情:返回当前绑定门店信息(含只读回款周期) */
|
||||
public function test_info_returns_bound_store(): void
|
||||
{
|
||||
$store = StoreModel::factory()->paymentCycle(7)->create([
|
||||
'contact' => '张三',
|
||||
'phone' => '13800138000',
|
||||
'address' => '幸福路 1 号',
|
||||
]);
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
|
||||
$this->getJson('/mini/store/info')
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.id', $store->id)
|
||||
->assertJsonPath('data.contact', '张三')
|
||||
->assertJsonPath('data.phone', '13800138000')
|
||||
->assertJsonPath('data.address', '幸福路 1 号')
|
||||
->assertJsonPath('data.payment_cycle_days', 7);
|
||||
}
|
||||
|
||||
/** 修改门店信息:联系人 / 电话 / 地址 */
|
||||
public function test_update_info(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create();
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
|
||||
$this->putJson('/mini/store/info', [
|
||||
'contact' => '李四',
|
||||
'phone' => '13900139000',
|
||||
'address' => '建设路 88 号',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.contact', '李四')
|
||||
->assertJsonPath('data.phone', '13900139000')
|
||||
->assertJsonPath('data.address', '建设路 88 号');
|
||||
|
||||
$fresh = $store->fresh();
|
||||
$this->assertSame('李四', $fresh->contact);
|
||||
$this->assertSame('13900139000', $fresh->phone);
|
||||
$this->assertSame('建设路 88 号', $fresh->address);
|
||||
}
|
||||
|
||||
/** 白名单更新:名称 / 编码 / 回款周期等字段提交后被忽略 */
|
||||
public function test_update_info_ignores_readonly_fields(): void
|
||||
{
|
||||
$store = StoreModel::factory()->paymentCycle(5)->create([
|
||||
'name' => '原门店',
|
||||
'code' => 'ST100',
|
||||
]);
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
|
||||
$this->putJson('/mini/store/info', [
|
||||
'contact' => '王五',
|
||||
'name' => '改名门店',
|
||||
'code' => 'HACK',
|
||||
'payment_cycle_days' => 99,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$fresh = $store->fresh();
|
||||
$this->assertSame('王五', $fresh->contact);
|
||||
$this->assertSame('原门店', $fresh->name);
|
||||
$this->assertSame('ST100', $fresh->code);
|
||||
$this->assertSame(5, $fresh->payment_cycle_days);
|
||||
}
|
||||
|
||||
/** 未绑定门店 → 拒绝访问 */
|
||||
public function test_requires_bound_store(): void
|
||||
{
|
||||
$this->actingAsMiniUser(UserModel::factory()->create());
|
||||
|
||||
$this->getJson('/mini/store/info')
|
||||
->assertOk()
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '尚未绑定门店,请联系客服处理');
|
||||
|
||||
$this->putJson('/mini/store/info', ['contact' => '路人'])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
/** 字段长度校验:联系人超过 50 字符 → 校验失败 */
|
||||
public function test_update_info_validates_length(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create();
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
|
||||
$this->putJson('/mini/store/info', ['contact' => str_repeat('长', 51)])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '联系人最长 50 个字符');
|
||||
|
||||
$this->assertSame($store->contact, $store->fresh()->contact, '校验失败不应落库');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\NoticeModel;
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* 商品成本价 + 等级百分比计价:
|
||||
* 实际价换算、后台/小程序列表展示、创建编辑切换计价类型、批量调价(成本价/百分比)、成本价防泄漏
|
||||
*/
|
||||
class ProductCostPriceTest extends ProcurementTestCase
|
||||
{
|
||||
/** 造一个商品分类(后台创建商品必填) */
|
||||
private function makeCategory(): ProductCategoryModel
|
||||
{
|
||||
return ProductCategoryModel::create([
|
||||
'parent_id' => 0,
|
||||
'name' => '测试分类',
|
||||
'sort' => 1,
|
||||
'status' => ProductCategoryModel::STATUS_NORMAL,
|
||||
]);
|
||||
}
|
||||
|
||||
/** 造门店 + 上架商品(成本价 + 指定计价类型的价格行) + 该店用户 */
|
||||
private function makeStoreWithPercentProduct(float $cost = 10, float $percent = 30): array
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => $cost,
|
||||
]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT,
|
||||
'percent' => $percent,
|
||||
'price' => 13.00,
|
||||
]);
|
||||
|
||||
return [$store, $product, UserModel::factory()->forStore($store->id)->create()];
|
||||
}
|
||||
|
||||
/** 实际价换算:固定价原值;百分比 = 成本 × (100 + percent) / 100 */
|
||||
public function test_calc_actual_price_fixed_and_percent(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
'5.50',
|
||||
ProductPriceModel::calcActualPrice(ProductPriceModel::PRICE_TYPE_FIXED, '5.50', '0', '10.00'),
|
||||
'固定价原样返回'
|
||||
);
|
||||
$this->assertSame(
|
||||
'13.00',
|
||||
ProductPriceModel::calcActualPrice(ProductPriceModel::PRICE_TYPE_PERCENT, '13.00', '30', '10.00'),
|
||||
'10 元上浮 30% = 13.00'
|
||||
);
|
||||
$this->assertSame(
|
||||
'13.05',
|
||||
ProductPriceModel::calcActualPrice(ProductPriceModel::PRICE_TYPE_PERCENT, '13.00', '30.50', '10.00'),
|
||||
'10 元上浮 30.5% = 13.05'
|
||||
);
|
||||
}
|
||||
|
||||
/** 换算边界:成本为 0、上浮 0%、非法类型兜底 */
|
||||
public function test_calc_actual_price_edge_cases(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
'0.00',
|
||||
ProductPriceModel::calcActualPrice(ProductPriceModel::PRICE_TYPE_PERCENT, '13.00', '30', '0'),
|
||||
'成本为 0 时实际价按 0 兜底'
|
||||
);
|
||||
$this->assertSame(
|
||||
'10.00',
|
||||
ProductPriceModel::calcActualPrice(ProductPriceModel::PRICE_TYPE_PERCENT, '13.00', '0', '10.00'),
|
||||
'上浮 0% = 成本价'
|
||||
);
|
||||
$this->assertSame(
|
||||
'5.50',
|
||||
ProductPriceModel::calcActualPrice(99, '5.50', '30', '10.00'),
|
||||
'非法计价类型按固定价兜底'
|
||||
);
|
||||
}
|
||||
|
||||
/** 后台商品列表:含成本价列 + 等级价格行的 actual_price */
|
||||
public function test_admin_list_contains_cost_price_and_actual_price(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$product = ProductModel::factory()->create(['cost_price' => 10.00]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT,
|
||||
'percent' => 30,
|
||||
'price' => 13.00,
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/product/goods');
|
||||
$response->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$row = collect($response->json('data.data'))->firstWhere('id', $product->id);
|
||||
$this->assertNotNull($row);
|
||||
$this->assertSame('10.00', (string) $row['cost_price'], '后台列表应含成本价');
|
||||
$this->assertSame('13.00', (string) $row['prices'][0]['actual_price'], '后台列表等级价格应为实际价');
|
||||
}
|
||||
|
||||
/** 小程序列表:返回换算后实际价,且成本价不泄漏 */
|
||||
public function test_mini_list_returns_actual_price_without_cost_price(): void
|
||||
{
|
||||
[$store, $product, $user] = $this->makeStoreWithPercentProduct();
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$response = $this->getJson('/mini/product/list');
|
||||
$response->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$row = collect($response->json('data.data'))->firstWhere('id', $product->id);
|
||||
$this->assertNotNull($row);
|
||||
$this->assertSame('13.00', (string) $row['price'], '小程序端应返回百分比换算后的实际价');
|
||||
$this->assertArrayNotHasKey('cost_price', $row, '成本价为商业敏感数据,不得泄漏到小程序端');
|
||||
$this->assertArrayNotHasKey('prices', $row, '价格行原始数据(含 percent)也不应下发给小程序');
|
||||
}
|
||||
|
||||
/** 后台创建商品:成本价 + 百分比计价行落库 */
|
||||
public function test_create_product_with_cost_and_percent_pricing(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
$category = $this->makeCategory();
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
|
||||
$this->postJson('/product/goods', [
|
||||
'category_id' => $category->id,
|
||||
'name' => '百分比商品',
|
||||
'content' => '测试图文详情',
|
||||
'cost_price' => 10,
|
||||
'prices' => [
|
||||
['level_id' => $level->id, 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, 'percent' => 30, 'price' => 13],
|
||||
],
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$product = ProductModel::where('name', '百分比商品')->first();
|
||||
$this->assertNotNull($product);
|
||||
$this->assertSame('10.00', (string) $product->cost_price);
|
||||
|
||||
$price = $product->prices->first();
|
||||
$this->assertSame(ProductPriceModel::PRICE_TYPE_PERCENT, (int) $price->price_type);
|
||||
$this->assertSame('30.00', (string) $price->percent);
|
||||
$this->assertSame('13.00', (string) $price->actual_price, '百分比行实际价应按成本价换算');
|
||||
}
|
||||
|
||||
/** 百分比计价缺少上浮百分点 → 验证失败(XinAdmin 验证错误为 200 + success=false) */
|
||||
public function test_create_percent_row_without_percent_fails(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
$category = $this->makeCategory();
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
|
||||
$this->postJson('/product/goods', [
|
||||
'category_id' => $category->id,
|
||||
'name' => '缺百分比',
|
||||
'content' => '',
|
||||
'prices' => [
|
||||
// 有单价但缺上浮百分点:after() 交叉校验应拦截
|
||||
['level_id' => $level->id, 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, 'price' => 13],
|
||||
],
|
||||
])->assertOk()
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '按成本百分比计价时必须填写上浮百分点');
|
||||
}
|
||||
|
||||
/** 编辑商品:从固定价切换为成本百分比计价 */
|
||||
public function test_update_switches_fixed_to_percent(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
$category = $this->makeCategory();
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
|
||||
$product = ProductModel::factory()->create(['category_id' => $category->id, 'cost_price' => 10]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price' => 5.00,
|
||||
]);
|
||||
|
||||
$this->putJson('/product/goods/' . $product->id, [
|
||||
'category_id' => $category->id,
|
||||
'name' => $product->name,
|
||||
'cost_price' => 10,
|
||||
'prices' => [
|
||||
['level_id' => $level->id, 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, 'percent' => 50, 'price' => 15],
|
||||
],
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$price = ProductPriceModel::forProductLevel($product->id, $level->id)->first();
|
||||
$this->assertSame(ProductPriceModel::PRICE_TYPE_PERCENT, (int) $price->price_type);
|
||||
$this->assertSame('50.00', (string) $price->percent);
|
||||
$this->assertSame('15.00', (string) $price->actual_price, '10 元上浮 50% = 15.00');
|
||||
}
|
||||
|
||||
/** 批量调价-纯成本价行:百分比等级门店收到通知,纯固定价等级门店不通知 */
|
||||
public function test_batch_price_cost_only_notifies_percent_level_stores(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$percentLevel = CustomerLevelModel::factory()->create();
|
||||
$fixedLevel = CustomerLevelModel::factory()->create();
|
||||
$percentStore = StoreModel::factory()->create(['level_id' => $percentLevel->id]);
|
||||
$fixedStore = StoreModel::factory()->create(['level_id' => $fixedLevel->id]);
|
||||
$percentUser = UserModel::factory()->forStore($percentStore->id)->create();
|
||||
$fixedUser = UserModel::factory()->forStore($fixedStore->id)->create();
|
||||
|
||||
$product = ProductModel::factory()->create(['cost_price' => 10]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $percentLevel->id,
|
||||
'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT,
|
||||
'percent' => 30,
|
||||
'price' => 13,
|
||||
]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $fixedLevel->id,
|
||||
'price' => 8,
|
||||
]);
|
||||
|
||||
$this->putJson('/product/goods/batchPrice', [
|
||||
'updates' => [
|
||||
['product_id' => $product->id, 'cost_price' => 12],
|
||||
],
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$this->assertSame('12.00', (string) $product->fresh()->cost_price, '成本价应已更新');
|
||||
$this->assertSame(
|
||||
1,
|
||||
NoticeModel::where('user_id', $percentUser->id)->where('type', NoticeModel::TYPE_PRICE)->count(),
|
||||
'成本价变更影响百分比计价等级,该等级门店用户应收到通知'
|
||||
);
|
||||
$this->assertSame(
|
||||
0,
|
||||
NoticeModel::where('user_id', $fixedUser->id)->count(),
|
||||
'固定价等级不受成本价变更影响,不应收到通知'
|
||||
);
|
||||
}
|
||||
|
||||
/** 批量调价-百分比行:存 percent 与等价固定价 */
|
||||
public function test_batch_price_percent_row_update(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$product = ProductModel::factory()->create(['cost_price' => 10]);
|
||||
|
||||
$this->putJson('/product/goods/batchPrice', [
|
||||
'updates' => [
|
||||
[
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT,
|
||||
'percent' => 40,
|
||||
'price' => 14,
|
||||
],
|
||||
],
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$price = ProductPriceModel::forProductLevel($product->id, $level->id)->first();
|
||||
$this->assertSame(ProductPriceModel::PRICE_TYPE_PERCENT, (int) $price->price_type);
|
||||
$this->assertSame('40.00', (string) $price->percent);
|
||||
$this->assertSame('14.00', (string) $price->actual_price, '10 元上浮 40% = 14.00');
|
||||
}
|
||||
|
||||
/** 批量调价-混合行:同一行同时改成本价与等级价 */
|
||||
public function test_batch_price_mixed_row_updates_cost_and_level(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$product = ProductModel::factory()->create(['cost_price' => 10]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 8]);
|
||||
|
||||
$this->putJson('/product/goods/batchPrice', [
|
||||
'updates' => [
|
||||
[
|
||||
'product_id' => $product->id,
|
||||
'cost_price' => 12,
|
||||
'level_id' => $level->id,
|
||||
'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT,
|
||||
'percent' => 25,
|
||||
'price' => 15,
|
||||
],
|
||||
],
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$this->assertSame('12.00', (string) $product->fresh()->cost_price);
|
||||
$price = ProductPriceModel::forProductLevel($product->id, $level->id)->first();
|
||||
$this->assertSame(ProductPriceModel::PRICE_TYPE_PERCENT, (int) $price->price_type);
|
||||
$this->assertSame('25.00', (string) $price->percent);
|
||||
$this->assertSame('15.00', (string) $price->actual_price, '12 元上浮 25% = 15.00');
|
||||
}
|
||||
|
||||
/** 批量调价-空行(仅 product_id)→ 验证失败 */
|
||||
public function test_batch_price_empty_row_rejected(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
$product = ProductModel::factory()->create();
|
||||
|
||||
$this->putJson('/product/goods/batchPrice', [
|
||||
'updates' => [
|
||||
['product_id' => $product->id],
|
||||
],
|
||||
])->assertOk()
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '调价行缺少成本价或等级价格');
|
||||
}
|
||||
|
||||
/** 价格矩阵:行含成本价与每等级的计价类型/百分比,等级格为实际价 */
|
||||
public function test_price_matrix_includes_cost_and_percent_columns(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$product = ProductModel::factory()->create(['cost_price' => 10]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT,
|
||||
'percent' => 30,
|
||||
'price' => 13,
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/product/goods/priceMatrix');
|
||||
$response->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$row = collect($response->json('data.rows'))->firstWhere('id', $product->id);
|
||||
$this->assertNotNull($row);
|
||||
$this->assertSame(10.0, (float) $row['cost_price']);
|
||||
$this->assertSame(13.0, (float) $row['price_' . $level->id], '矩阵等级格应显示实际价');
|
||||
$this->assertSame(ProductPriceModel::PRICE_TYPE_PERCENT, (int) $row['price_type_' . $level->id]);
|
||||
$this->assertSame(30.0, (float) $row['percent_' . $level->id]);
|
||||
}
|
||||
|
||||
/** 小程序下单:百分比计价行按实际价重算金额 */
|
||||
public function test_mini_order_uses_actual_price_for_percent_row(): void
|
||||
{
|
||||
[$store, $product, $user] = $this->makeStoreWithPercentProduct(10, 30);
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$this->postJson('/mini/order', [
|
||||
'items' => [['product_id' => $product->id, 'quantity' => 3]],
|
||||
])->assertOk()
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.total_amount', '39.00');
|
||||
|
||||
$item = $store->orders()->latest('id')->first()->items->first();
|
||||
$this->assertSame('13.00', (string) $item->price, '订单明细快照应为实际价');
|
||||
$this->assertSame('39.00', (string) $item->amount);
|
||||
}
|
||||
|
||||
/** 小程序购物车:列表金额按百分比换算的实际价计算 */
|
||||
public function test_mini_cart_uses_actual_price_for_percent_row(): void
|
||||
{
|
||||
[$store, $product, $user] = $this->makeStoreWithPercentProduct(10, 30);
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 2.5])
|
||||
->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$this->getJson('/mini/cart')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.items.0.price', '13.00')
|
||||
->assertJsonPath('data.items.0.amount', '32.50')
|
||||
->assertJsonPath('data.total_amount', '32.50');
|
||||
}
|
||||
}
|
||||
@@ -34,15 +34,41 @@ class ProductPriceTest extends ProcurementTestCase
|
||||
$this->assertSame(5.50, (float) $row['price'], '应返回门店所在等级的价格');
|
||||
}
|
||||
|
||||
/** 门店未设置客户等级时拒绝展示价格 */
|
||||
public function test_store_without_level_is_rejected(): void
|
||||
/** 门店未设置客户等级:仍可浏览商品,但价格不可见(price=null) */
|
||||
public function test_store_without_level_sees_products_without_price(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create(['level_id' => 0]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
|
||||
$this->getJson('/mini/product/list')
|
||||
$response = $this->getJson('/mini/product/list');
|
||||
$response->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$row = collect($response->json('data.data'))->firstWhere('id', $product->id);
|
||||
$this->assertNotNull($row, '未设等级的门店仍应能看到商品');
|
||||
$this->assertNull($row['price'], '未设等级时价格应为 null');
|
||||
}
|
||||
|
||||
/** 未绑定门店:可浏览商品与分类,但价格不可见;加购仍被拦截(购买前置校验不变) */
|
||||
public function test_unbound_user_can_browse_products_without_price_but_cannot_cart(): void
|
||||
{
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
$this->actingAsMiniUser(UserModel::factory()->create()); // type=0 待绑定
|
||||
|
||||
$list = $this->getJson('/mini/product/list');
|
||||
$list->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$row = collect($list->json('data.data'))->firstWhere('id', $product->id);
|
||||
$this->assertNotNull($row, '未绑定门店应能看到商品');
|
||||
$this->assertNull($row['price'], '未绑定门店不得看到价格');
|
||||
|
||||
$this->getJson('/mini/product/categories')
|
||||
->assertOk()
|
||||
->assertJsonPath('success', false);
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 1])
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '尚未绑定门店,请联系客服处理');
|
||||
}
|
||||
|
||||
/** 批量调价:事务写入 + 通知受影响门店用户(不影响无关门店) */
|
||||
|
||||
@@ -16,10 +16,10 @@ use App\Models\UserModel;
|
||||
class PurchaseGenerateTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 造当日待汇总订单:门店A 两单各 3 件 + 门店B 一单 4 件(同一商品)
|
||||
* 造当日已接单订单:门店A 两单各 3 件 + 门店B 一单 4 件(同一商品),下单后统一接单
|
||||
* 商品设两个等级价 5.00 / 4.00,估算单价应取最低 4.00
|
||||
*/
|
||||
private function seedPendingOrders(): ProductModel
|
||||
private function seedAcceptedOrders(): ProductModel
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$levelLow = CustomerLevelModel::factory()->create();
|
||||
@@ -35,13 +35,16 @@ class PurchaseGenerateTest extends ProcurementTestCase
|
||||
->assertJsonPath('success', true);
|
||||
}
|
||||
|
||||
// 后台接单:待接单 → 已接单(生成采购单的来源状态)
|
||||
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
/** 多门店多订单按商品聚合,估算单价取最低等级价 */
|
||||
public function test_generate_aggregates_orders_by_product(): void
|
||||
{
|
||||
$this->seedPendingOrders();
|
||||
$this->seedAcceptedOrders();
|
||||
$admin = $this->actingAsSysUser();
|
||||
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
@@ -63,33 +66,71 @@ class PurchaseGenerateTest extends ProcurementTestCase
|
||||
$this->assertSame('40.00', (string) $purchase->estimate_amount);
|
||||
}
|
||||
|
||||
/** 源订单状态回写为已汇总 */
|
||||
/** 源订单状态回写为采购中,并关联 purchase_id */
|
||||
public function test_generate_writes_back_order_status(): void
|
||||
{
|
||||
$this->seedPendingOrders();
|
||||
$this->seedAcceptedOrders();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$this->assertSame(0, StoreOrderModel::where('status', StoreOrderModel::STATUS_PENDING)->count());
|
||||
$this->assertSame(3, StoreOrderModel::where('status', StoreOrderModel::STATUS_SUMMARIZED)->count());
|
||||
$purchase = PurchaseOrderModel::first();
|
||||
$this->assertSame(0, StoreOrderModel::where('status', StoreOrderModel::STATUS_SUMMARIZED)->count());
|
||||
$this->assertSame(3, StoreOrderModel::where('status', StoreOrderModel::STATUS_DELIVERING)->count());
|
||||
$this->assertSame(3, StoreOrderModel::where('purchase_id', $purchase->id)->count());
|
||||
}
|
||||
|
||||
/** 当日无待汇总订单 → 报错 */
|
||||
public function test_generate_without_pending_orders_fails(): void
|
||||
/** 仅有待接单订单时不可生成(待接单不再被归集)→ 报错 */
|
||||
public function test_generate_without_accepted_orders_fails(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 5.00]);
|
||||
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', false);
|
||||
$this->assertSame(0, PurchaseOrderModel::count());
|
||||
$this->assertSame(1, StoreOrderModel::where('status', StoreOrderModel::STATUS_PENDING)->count());
|
||||
}
|
||||
|
||||
/** 幂等:已汇总订单不会被重复归集 */
|
||||
/** 指定订单合并:仅归集 order_ids 内的已接单订单,混入非已接单则整批拒绝 */
|
||||
public function test_generate_with_selected_order_ids(): void
|
||||
{
|
||||
$this->seedAcceptedOrders();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$selected = StoreOrderModel::query()->orderBy('id')->limit(2)->pluck('id')->all();
|
||||
|
||||
$this->postJson('/purchase/order/generate', [
|
||||
'purchase_date' => now()->toDateString(),
|
||||
'order_ids' => $selected,
|
||||
])->assertJsonPath('success', true);
|
||||
|
||||
$purchase = PurchaseOrderModel::first();
|
||||
$this->assertSame(2, StoreOrderModel::where('purchase_id', $purchase->id)->count());
|
||||
$this->assertSame(1, StoreOrderModel::where('status', StoreOrderModel::STATUS_SUMMARIZED)->count(), '未选中的订单保持已接单');
|
||||
|
||||
// 混入非已接单订单 → 整批拒绝
|
||||
$remaining = StoreOrderModel::where('status', StoreOrderModel::STATUS_SUMMARIZED)->value('id');
|
||||
$done = StoreOrderModel::where('status', StoreOrderModel::STATUS_DELIVERING)->value('id');
|
||||
$this->postJson('/purchase/order/generate', [
|
||||
'purchase_date' => now()->toDateString(),
|
||||
'order_ids' => [$remaining, $done],
|
||||
])->assertJsonPath('success', false);
|
||||
$this->assertSame(1, PurchaseOrderModel::count(), '整批拒绝,不产生新采购单');
|
||||
}
|
||||
|
||||
/** 幂等:已归集订单不会被重复归集 */
|
||||
public function test_generate_is_idempotent(): void
|
||||
{
|
||||
$this->seedPendingOrders();
|
||||
$this->seedAcceptedOrders();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
@@ -99,4 +140,44 @@ class PurchaseGenerateTest extends ProcurementTestCase
|
||||
|
||||
$this->assertSame(1, PurchaseOrderModel::count(), '第二次生成应被拒绝,不产生新采购单');
|
||||
}
|
||||
|
||||
/** 估算单价取「最低实际价」:固定价与百分比计价混合时按换算后值比较 */
|
||||
public function test_generate_uses_min_actual_price_across_types(): void
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$levelFixed = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => 10,
|
||||
]);
|
||||
// 百分比行:10 × (1+40%) = 14.00;固定价行 5.00 → 估算应取 5.00
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT,
|
||||
'percent' => 40,
|
||||
'price' => 14.00,
|
||||
]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $levelFixed->id,
|
||||
'price' => 5.00,
|
||||
]);
|
||||
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 3]]])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
// 接单后生成
|
||||
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$item = PurchaseOrderModel::first()->items->first();
|
||||
$this->assertSame('5.00', (string) $item->price, '估算单价应取换算后的最低实际价');
|
||||
$this->assertSame('15.00', (string) $item->amount, '3 × 5.00');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Models\ReconciliationItemModel;
|
||||
use App\Models\ReconciliationModel;
|
||||
use App\Models\SettlementModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\SupplierModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
@@ -42,6 +43,9 @@ class ReconciliationTest extends ProcurementTestCase
|
||||
->assertJsonPath('success', true);
|
||||
}
|
||||
|
||||
// 接单后生成采购单
|
||||
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\SupplierModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* 门店订单明细(商品快照):
|
||||
* 下单快照完整商品档案、成本价防泄漏、后台明细修改重算总价、一键同步商品档案、按商品名称搜索订单
|
||||
*/
|
||||
class StoreOrderItemTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 造门店 + 上架商品(指定成本价)+ 固定等级价 + 门店用户
|
||||
*
|
||||
* @return array{0: StoreModel, 1: ProductModel, 2: UserModel}
|
||||
*/
|
||||
private function makeStoreWithProduct(string $price = '5.00', string $costPrice = '4.00'): array
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => $costPrice,
|
||||
]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price' => $price,
|
||||
]);
|
||||
|
||||
return [$store, $product, UserModel::factory()->forStore($store->id)->create()];
|
||||
}
|
||||
|
||||
/** 以小程序身份下一单并返回订单(可指定初始状态) */
|
||||
private function placeOrder(ProductModel $product, UserModel $user, int $quantity = 2, int $status = StoreOrderModel::STATUS_PENDING): StoreOrderModel
|
||||
{
|
||||
$this->actingAsMiniUser($user);
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $quantity]]])
|
||||
->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$order = StoreOrderModel::where('store_id', $user->store_id)->latest('id')->first();
|
||||
$this->assertNotNull($order);
|
||||
if ($status !== StoreOrderModel::STATUS_PENDING) {
|
||||
$order->update(['status' => $status]);
|
||||
}
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/** 下单时明细快照完整商品档案(分类/供应商/单位/图片/图文/保质期/成本价) */
|
||||
public function test_place_order_snapshots_full_product_info(): void
|
||||
{
|
||||
$supplier = SupplierModel::factory()->create();
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'supplier_id' => $supplier->id,
|
||||
'category_id' => 7,
|
||||
'unit' => '箱',
|
||||
'image_ids' => '1,2',
|
||||
'content' => '图文详情',
|
||||
'shelf_life' => 30,
|
||||
'cost_price' => '4.00',
|
||||
]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price' => '5.50',
|
||||
]);
|
||||
$user = UserModel::factory()->forStore($store->id)->create();
|
||||
|
||||
$this->actingAsMiniUser($user);
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 3]]])
|
||||
->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$item = StoreOrderModel::where('store_id', $store->id)->first()->items->first();
|
||||
$this->assertSame($supplier->id, $item->supplier_id, '快照供应商');
|
||||
$this->assertSame(7, $item->category_id, '快照分类');
|
||||
$this->assertSame('箱', $item->unit, '快照单位');
|
||||
$this->assertSame('1,2', implode(',', $item->image_ids), '快照图片');
|
||||
$this->assertSame('图文详情', $item->content, '快照图文详情');
|
||||
$this->assertSame(30, $item->shelf_life, '快照保质期');
|
||||
$this->assertSame('4.00', (string) $item->cost_price, '快照成本价');
|
||||
$this->assertSame('5.50', (string) $item->price, '快照等级单价');
|
||||
}
|
||||
|
||||
/** 小程序订单详情不泄漏成本价 */
|
||||
public function test_mini_detail_hides_cost_price(): void
|
||||
{
|
||||
[, $product, $user] = $this->makeStoreWithProduct('5.00', '4.00');
|
||||
$order = $this->placeOrder($product, $user);
|
||||
|
||||
$this->actingAsMiniUser($user);
|
||||
$this->getJson("/mini/order/{$order->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonMissingPath('data.items.0.cost_price');
|
||||
}
|
||||
|
||||
/** 后台订单详情:成本价可见、附供应商名与单位 */
|
||||
public function test_admin_detail_shows_cost_price_and_supplier(): void
|
||||
{
|
||||
$supplier = SupplierModel::factory()->create();
|
||||
[, $product, $user] = $this->makeStoreWithProduct('5.00', '4.00');
|
||||
$product->update(['supplier_id' => $supplier->id]);
|
||||
$order = $this->placeOrder($product, $user);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->getJson("/order/store/{$order->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.items.0.cost_price', '4.00')
|
||||
->assertJsonPath('data.items.0.unit', $product->unit)
|
||||
->assertJsonPath('data.items.0.supplier.name', $supplier->name);
|
||||
}
|
||||
|
||||
/** 修改明细:重算单品金额与订单总量/总额(总额 = 商品 + 附加) */
|
||||
public function test_update_item_recalculates_order_totals(): void
|
||||
{
|
||||
[$store, $product, $user] = $this->makeStoreWithProduct('5.00');
|
||||
$order = $this->placeOrder($product, $user, 2, StoreOrderModel::STATUS_SUMMARIZED);
|
||||
$order->update(['added_amount' => '6.00']); // 已有附加金额
|
||||
$item = $order->items->first();
|
||||
$supplier = SupplierModel::factory()->create();
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->putJson("/order/store/item/{$item->id}", [
|
||||
'supplier_id' => $supplier->id,
|
||||
'product_name' => '改名后的商品',
|
||||
'product_spec' => '新规格',
|
||||
'unit' => '箱',
|
||||
'price' => '6.00',
|
||||
'cost_price' => '3.50',
|
||||
'quantity' => 5,
|
||||
'weight' => '2.5',
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$item->refresh();
|
||||
$this->assertSame($supplier->id, $item->supplier_id);
|
||||
$this->assertSame('改名后的商品', $item->product_name);
|
||||
$this->assertSame('新规格', $item->product_spec);
|
||||
$this->assertSame('箱', $item->unit);
|
||||
$this->assertSame('6.00', (string) $item->price);
|
||||
$this->assertSame('3.50', (string) $item->cost_price);
|
||||
$this->assertSame(5, $item->quantity);
|
||||
$this->assertSame('2.500', (string) $item->weight);
|
||||
$this->assertSame('30.00', (string) $item->amount, '6.00 × 5');
|
||||
|
||||
$order->refresh();
|
||||
$this->assertSame(5, $order->total_quantity, '订货总量 = 明细合计');
|
||||
$this->assertSame('2.500', (string) $order->total_weight, '总重量 = 明细合计');
|
||||
$this->assertSame('30.00', (string) $order->product_amount, '商品总金额 = 明细金额合计');
|
||||
$this->assertSame('36.00', (string) $order->total_amount, '订单总金额 = 商品 30 + 附加 6');
|
||||
}
|
||||
|
||||
/** 已完成/已取消订单不允许修改明细 */
|
||||
public function test_update_item_rejected_when_completed_or_cancelled(): void
|
||||
{
|
||||
[, $product, $user] = $this->makeStoreWithProduct('5.00');
|
||||
$order = $this->placeOrder($product, $user, 1);
|
||||
$item = $order->items->first();
|
||||
$payload = [
|
||||
'supplier_id' => 0,
|
||||
'product_name' => '不应生效',
|
||||
'product_spec' => '',
|
||||
'unit' => '斤',
|
||||
'price' => '1.00',
|
||||
'cost_price' => '1.00',
|
||||
'quantity' => 1,
|
||||
'weight' => 0,
|
||||
];
|
||||
|
||||
$this->actingAsSysUser();
|
||||
foreach ([StoreOrderModel::STATUS_COMPLETED, StoreOrderModel::STATUS_CANCELLED] as $status) {
|
||||
$order->update(['status' => $status]);
|
||||
$this->putJson("/order/store/item/{$item->id}", $payload)
|
||||
->assertOk()->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
$this->assertNotSame('不应生效', $item->fresh()->product_name, '被拒绝后明细不变');
|
||||
$this->assertSame('5.00', (string) $order->fresh()->total_amount, '被拒绝后总金额不变');
|
||||
}
|
||||
|
||||
/** 修改明细参数校验:负单价被拦截 */
|
||||
public function test_update_item_validates_negative_price(): void
|
||||
{
|
||||
[, $product, $user] = $this->makeStoreWithProduct('5.00');
|
||||
$order = $this->placeOrder($product, $user, 1, StoreOrderModel::STATUS_SUMMARIZED);
|
||||
$item = $order->items->first();
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->putJson("/order/store/item/{$item->id}", [
|
||||
'supplier_id' => 0,
|
||||
'product_name' => $item->product_name,
|
||||
'unit' => '斤',
|
||||
'price' => -1,
|
||||
'cost_price' => 0,
|
||||
'quantity' => 1,
|
||||
'weight' => 0,
|
||||
])->assertOk()
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '单价不能小于 0');
|
||||
}
|
||||
|
||||
/** 一键同步:按商品ID同步最新档案(固定价等级保留原单价) */
|
||||
public function test_sync_item_updates_snapshot_from_product(): void
|
||||
{
|
||||
[$store, $product, $user] = $this->makeStoreWithProduct('5.50', '4.00');
|
||||
$order = $this->placeOrder($product, $user, 3, StoreOrderModel::STATUS_SUMMARIZED);
|
||||
$item = $order->items->first();
|
||||
$this->assertSame('16.50', (string) $item->amount);
|
||||
|
||||
// 下单后商品档案变更:改名/改规格/改单位/换供应商/调成本价
|
||||
$supplier = SupplierModel::factory()->create();
|
||||
$product->update([
|
||||
'name' => '同步后的品名',
|
||||
'spec' => '同步后的规格',
|
||||
'unit' => '箱',
|
||||
'supplier_id' => $supplier->id,
|
||||
'cost_price' => '9.99',
|
||||
]);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->putJson("/order/store/item/{$item->id}/sync")
|
||||
->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$item->refresh();
|
||||
$this->assertSame('同步后的品名', $item->product_name);
|
||||
$this->assertSame('同步后的规格', $item->product_spec);
|
||||
$this->assertSame('箱', $item->unit);
|
||||
$this->assertSame($supplier->id, $item->supplier_id);
|
||||
$this->assertSame('9.99', (string) $item->cost_price);
|
||||
$this->assertSame('5.50', (string) $item->price, '固定价等级单价不随档案变化');
|
||||
$this->assertSame('16.50', (string) $item->amount, '单价未变,金额不变');
|
||||
}
|
||||
|
||||
/** 一键同步:百分比计价等级按最新成本价重算单价与订单总价 */
|
||||
public function test_sync_item_recalculates_percent_price_with_latest_cost(): void
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => '10.00',
|
||||
]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT,
|
||||
'percent' => 30,
|
||||
]);
|
||||
$user = UserModel::factory()->forStore($store->id)->create();
|
||||
|
||||
$order = $this->placeOrder($product, $user, 2, StoreOrderModel::STATUS_SUMMARIZED);
|
||||
$item = $order->items->first();
|
||||
$this->assertSame('13.00', (string) $item->price, '下单时 10 元上浮 30%');
|
||||
$this->assertSame('26.00', (string) $order->total_amount);
|
||||
|
||||
// 成本价上调后同步:单价应按最新成本重算
|
||||
$product->update(['cost_price' => '20.00']);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->putJson("/order/store/item/{$item->id}/sync")
|
||||
->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$item->refresh();
|
||||
$this->assertSame('20.00', (string) $item->cost_price);
|
||||
$this->assertSame('26.00', (string) $item->price, '20 元上浮 30% = 26.00');
|
||||
$this->assertSame('52.00', (string) $item->amount, '26.00 × 2');
|
||||
|
||||
$order->refresh();
|
||||
$this->assertSame('52.00', (string) $order->product_amount);
|
||||
$this->assertSame('52.00', (string) $order->total_amount);
|
||||
}
|
||||
|
||||
/** 一键同步:商品已删除时拒绝同步 */
|
||||
public function test_sync_item_rejected_when_product_deleted(): void
|
||||
{
|
||||
[, $product, $user] = $this->makeStoreWithProduct('5.00');
|
||||
$order = $this->placeOrder($product, $user, 1, StoreOrderModel::STATUS_SUMMARIZED);
|
||||
$item = $order->items->first();
|
||||
|
||||
$product->delete(); // 软删除
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->putJson("/order/store/item/{$item->id}/sync")
|
||||
->assertOk()
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '商品不存在或已被删除,无法同步');
|
||||
}
|
||||
|
||||
/** 订单列表按包含的商品名称搜索 */
|
||||
public function test_order_list_search_by_product_name(): void
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$user = UserModel::factory()->forStore($store->id)->create();
|
||||
|
||||
$cabbage = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'name' => '大白菜A']);
|
||||
$potato = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'name' => '土豆B']);
|
||||
foreach ([$cabbage, $potato] as $p) {
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $p->id,
|
||||
'level_id' => $level->id,
|
||||
'price' => '5.00',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->placeOrder($cabbage, $user);
|
||||
$this->placeOrder($potato, $user);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$response = $this->getJson('/order/store?product_name=' . urlencode('白菜'));
|
||||
$response->assertOk()->assertJsonPath('success', true);
|
||||
$this->assertSame(1, $response->json('data.total'), '仅命中包含「白菜」的订单');
|
||||
$this->assertSame('大白菜A', $response->json('data.data.0.items.0.product_name'));
|
||||
|
||||
// 无匹配关键字 → 空列表
|
||||
$this->getJson('/order/store?product_name=' . urlencode('不存在的商品'))
|
||||
->assertOk()->assertJsonPath('data.total', 0);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ use App\Models\ProductPriceModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\UserModel;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
|
||||
use Modules\SystemTool\Models\SysSiteConfigItemsModel;
|
||||
|
||||
/**
|
||||
* 小程序下单:等级价快照、服务端重算总价、取消限制、门店数据隔离
|
||||
@@ -129,4 +132,175 @@ class StoreOrderTest extends ProcurementTestCase
|
||||
$this->getJson("/mini/order/{$orderOfA->id}")->assertJsonPath('success', false);
|
||||
$this->getJson('/mini/order')->assertJsonPath('data.total', 0);
|
||||
}
|
||||
|
||||
/** 种子化周转框/托盘单价配置(框 2.00、托盘 10.00) */
|
||||
private function seedContainerConfig(): void
|
||||
{
|
||||
$group = SysSiteConfigGroupModel::create(['title' => '业务配置', 'key' => 'services']);
|
||||
foreach ([['box_amount', '周转框单价', '2.00'], ['tray_amount', '周转托盘单价', '10.00']] as [$key, $title, $value]) {
|
||||
SysSiteConfigItemsModel::create([
|
||||
'group_id' => $group->id,
|
||||
'key' => $key,
|
||||
'title' => $title,
|
||||
'type' => 'InputNumber',
|
||||
'values' => $value,
|
||||
'sort' => 0,
|
||||
]);
|
||||
}
|
||||
Cache::forget('site_config');
|
||||
}
|
||||
|
||||
/** 造一笔指定状态的订单(商品 5.00 × 2 = 10.00) */
|
||||
private function makeOrderWithStatus(int $status): StoreOrderModel
|
||||
{
|
||||
[$store, $product, $user] = $this->makeStoreWithProduct('5.00');
|
||||
$this->actingAsMiniUser($user);
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 2]]])
|
||||
->assertJsonPath('success', true);
|
||||
$order = StoreOrderModel::where('store_id', $store->id)->first();
|
||||
$order->update(['status' => $status]);
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/** 修改周转框/托盘:自动重算附加金额与订单总金额 */
|
||||
public function test_container_update_recalculates_amounts(): void
|
||||
{
|
||||
$this->seedContainerConfig();
|
||||
$order = $this->makeOrderWithStatus(StoreOrderModel::STATUS_SUMMARIZED);
|
||||
$this->assertSame('10.00', (string) $order->product_amount, '下单时应写入商品金额');
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->putJson("/order/store/{$order->id}/container", ['box_num' => 3, 'tray_num' => 1])
|
||||
->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$order->refresh();
|
||||
$this->assertSame(3, $order->box_num);
|
||||
$this->assertSame(1, $order->tray_num);
|
||||
$this->assertSame('16.00', (string) $order->added_amount, '3×2.00 + 1×10.00');
|
||||
$this->assertSame('10.00', (string) $order->product_amount);
|
||||
$this->assertSame('26.00', (string) $order->total_amount, '10.00 + 16.00');
|
||||
}
|
||||
|
||||
/** 已接单、采购中、配送中均可修改 */
|
||||
public function test_container_update_allowed_in_editable_statuses(): void
|
||||
{
|
||||
$this->seedContainerConfig();
|
||||
$order = $this->makeOrderWithStatus(StoreOrderModel::STATUS_SUMMARIZED);
|
||||
$this->actingAsSysUser();
|
||||
|
||||
foreach ([
|
||||
StoreOrderModel::STATUS_SUMMARIZED,
|
||||
StoreOrderModel::STATUS_DELIVERING,
|
||||
StoreOrderModel::STATUS_DISTRIBUTION,
|
||||
] as $status) {
|
||||
$order->update(['status' => $status]);
|
||||
$this->putJson("/order/store/{$order->id}/container", ['box_num' => 1, 'tray_num' => 0])
|
||||
->assertOk()->assertJsonPath('success', true);
|
||||
$this->assertSame('2.00', (string) $order->fresh()->added_amount);
|
||||
}
|
||||
}
|
||||
|
||||
/** 待接单、已完成、已取消不允许修改 */
|
||||
public function test_container_update_rejected_in_other_statuses(): void
|
||||
{
|
||||
$this->seedContainerConfig();
|
||||
$order = $this->makeOrderWithStatus(StoreOrderModel::STATUS_PENDING);
|
||||
$this->actingAsSysUser();
|
||||
|
||||
foreach ([
|
||||
StoreOrderModel::STATUS_PENDING,
|
||||
StoreOrderModel::STATUS_COMPLETED,
|
||||
StoreOrderModel::STATUS_CANCELLED,
|
||||
] as $status) {
|
||||
$order->update(['status' => $status]);
|
||||
$this->putJson("/order/store/{$order->id}/container", ['box_num' => 5, 'tray_num' => 5])
|
||||
->assertOk()->assertJsonPath('success', false);
|
||||
}
|
||||
$order->refresh();
|
||||
$this->assertSame(0, $order->box_num, '被拒绝后数量不变');
|
||||
$this->assertSame('10.00', (string) $order->total_amount, '被拒绝后总金额不变');
|
||||
}
|
||||
|
||||
/** 历史订单未写商品金额时按「总额 - 附加」反推回写 */
|
||||
public function test_container_update_heals_legacy_product_amount(): void
|
||||
{
|
||||
$this->seedContainerConfig();
|
||||
$order = $this->makeOrderWithStatus(StoreOrderModel::STATUS_SUMMARIZED);
|
||||
$order->update(['product_amount' => 0]); // 模拟历史数据
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->putJson("/order/store/{$order->id}/container", ['box_num' => 2, 'tray_num' => 0])
|
||||
->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$order->refresh();
|
||||
$this->assertSame('10.00', (string) $order->product_amount, '反推回写商品金额');
|
||||
$this->assertSame('4.00', (string) $order->added_amount, '2×2.00');
|
||||
$this->assertSame('14.00', (string) $order->total_amount);
|
||||
}
|
||||
|
||||
/** 数量为负时校验失败 */
|
||||
public function test_container_update_validates_negative_numbers(): void
|
||||
{
|
||||
$this->seedContainerConfig();
|
||||
$order = $this->makeOrderWithStatus(StoreOrderModel::STATUS_SUMMARIZED);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->putJson("/order/store/{$order->id}/container", ['box_num' => -1, 'tray_num' => 0])
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '周转框数量不能小于 0');
|
||||
}
|
||||
|
||||
/** 软删除:仅已取消订单可由后台删除,删除后后台/小程序端均不可见 */
|
||||
public function test_soft_delete_only_cancelled_orders(): void
|
||||
{
|
||||
$order = $this->makeOrderWithStatus(StoreOrderModel::STATUS_CANCELLED);
|
||||
|
||||
// 删除前小程序端可见
|
||||
$this->getJson('/mini/order')->assertOk()->assertJsonPath('data.total', 1);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->deleteJson("/order/store/{$order->id}")->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$this->assertNotNull($order->fresh()->deleted_at, '软删除应写入 deleted_at');
|
||||
$this->assertNull(StoreOrderModel::find($order->id), '默认查询不可见软删除订单');
|
||||
$this->assertNotNull(StoreOrderModel::withTrashed()->find($order->id), '数据仍保留在库中');
|
||||
|
||||
// 后台列表/详情不可见
|
||||
$this->getJson('/order/store')->assertOk()->assertJsonPath('data.total', 0);
|
||||
$this->getJson("/order/store/{$order->id}")->assertJsonPath('success', false);
|
||||
|
||||
// 小程序端历史订单同样不可见
|
||||
$this->actingAsMiniUser(UserModel::where('store_id', $order->store_id)->first());
|
||||
$this->getJson('/mini/order')->assertOk()->assertJsonPath('data.total', 0);
|
||||
$this->getJson("/mini/order/{$order->id}")->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
/** 软删除:非已取消订单拒绝删除 */
|
||||
public function test_soft_delete_rejected_for_non_cancelled_orders(): void
|
||||
{
|
||||
foreach ([
|
||||
StoreOrderModel::STATUS_PENDING,
|
||||
StoreOrderModel::STATUS_SUMMARIZED,
|
||||
StoreOrderModel::STATUS_DELIVERING,
|
||||
StoreOrderModel::STATUS_DISTRIBUTION,
|
||||
StoreOrderModel::STATUS_COMPLETED,
|
||||
] as $status) {
|
||||
$order = $this->makeOrderWithStatus($status);
|
||||
$this->actingAsSysUser();
|
||||
$this->deleteJson("/order/store/{$order->id}")
|
||||
->assertOk()->assertJsonPath('success', false);
|
||||
$this->assertNull($order->fresh()->deleted_at, '非已取消订单不得删除');
|
||||
}
|
||||
}
|
||||
|
||||
/** 软删除:重复删除返回订单不存在 */
|
||||
public function test_soft_delete_twice_rejected(): void
|
||||
{
|
||||
$order = $this->makeOrderWithStatus(StoreOrderModel::STATUS_CANCELLED);
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->deleteJson("/order/store/{$order->id}")->assertOk()->assertJsonPath('success', true);
|
||||
$this->deleteJson("/order/store/{$order->id}")->assertJsonPath('success', false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import createAxios from '@/utils/request';
|
||||
|
||||
export interface MiniUserBindParams {
|
||||
/** 1门店 2供应商 */
|
||||
type: number;
|
||||
store_id?: number;
|
||||
supplier_id?: number;
|
||||
}
|
||||
|
||||
/** 绑定门店/供应商 */
|
||||
/** 绑定门店 */
|
||||
export async function bindMiniUser(id: number, data: MiniUserBindParams) {
|
||||
return createAxios({
|
||||
url: `/customer/miniUser/${id}/bind`,
|
||||
|
||||
+46
-3
@@ -1,6 +1,6 @@
|
||||
import createAxios from '@/utils/request';
|
||||
import type IStoreOrder from '@/domain/iStoreOrder.ts';
|
||||
import type { IOrderSummaryRow } from '@/domain/iStoreOrder.ts';
|
||||
import type { IOrderSummaryRow, IStoreOrderItem, IStoreOrderItemUpdate } from '@/domain/iStoreOrder.ts';
|
||||
|
||||
/** 订单详情(头 + 明细) */
|
||||
export async function getStoreOrder(id: number) {
|
||||
@@ -10,7 +10,7 @@ export async function getStoreOrder(id: number) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 订单状态流转(2配送中 3已完成 9取消) */
|
||||
/** 订单状态流转(1已接单 3配送中 4已完成 9取消;已接单→采购中由生成采购单完成) */
|
||||
export async function updateOrderStatus(id: number, status: number) {
|
||||
return createAxios({
|
||||
url: `/order/store/${id}/status`,
|
||||
@@ -19,10 +19,53 @@ export async function updateOrderStatus(id: number, status: number) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 待汇总预览(按商品聚合) */
|
||||
/** 批量订单状态流转(全量校验,任一订单不允许流转则整批中止) */
|
||||
export async function batchUpdateOrderStatus(ids: number[], status: number) {
|
||||
return createAxios<{ success: number }>({
|
||||
url: '/order/store/batchStatus',
|
||||
method: 'put',
|
||||
data: { ids, status },
|
||||
});
|
||||
}
|
||||
|
||||
/** 修改周转框/周转托盘数量(自动重算附加金额与订单总金额,仅已接单/采购中/配送中可改) */
|
||||
export async function updateOrderContainer(id: number, data: { box_num: number; tray_num: number }) {
|
||||
return createAxios({
|
||||
url: `/order/store/${id}/container`,
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 已接单预览(按商品聚合,生成采购单前确认) */
|
||||
export async function getOrderSummary() {
|
||||
return createAxios<IOrderSummaryRow[]>({
|
||||
url: '/order/store/summary',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** 修改订单明细(供应商/品名/规格/单位/单价/成本价/订货量/重量,后端重算订单总价) */
|
||||
export async function updateOrderItem(itemId: number, data: IStoreOrderItemUpdate) {
|
||||
return createAxios<IStoreOrderItem>({
|
||||
url: `/order/store/item/${itemId}`,
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 一键同步明细商品快照为最新商品档案信息(供应商/品名/规格/单位/单价/成本价) */
|
||||
export async function syncOrderItem(itemId: number) {
|
||||
return createAxios<IStoreOrderItem>({
|
||||
url: `/order/store/item/${itemId}/sync`,
|
||||
method: 'put',
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除订单(软删除,仅已取消订单允许) */
|
||||
export async function deleteStoreOrder(id: number) {
|
||||
return createAxios({
|
||||
url: `/order/store/${id}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ export interface PriceMatrixParams {
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/** A2 价格矩阵:行=商品,列=启用等级,值=price(缺失 null) */
|
||||
/**
|
||||
* A2 价格矩阵:行=商品,列=启用等级,值=实际销售价(缺失 null);
|
||||
* 行内含 cost_price 与 price_type_{level_id} / percent_{level_id}
|
||||
*/
|
||||
export async function getPriceMatrix(params?: PriceMatrixParams) {
|
||||
return createAxios<IPriceMatrix>({
|
||||
url: '/product/goods/priceMatrix',
|
||||
@@ -18,7 +21,9 @@ export async function getPriceMatrix(params?: PriceMatrixParams) {
|
||||
});
|
||||
}
|
||||
|
||||
/** A2 批量调价(提交后给受影响门店生成价格变更通知) */
|
||||
/**
|
||||
* A2 批量调价:支持成本价 / 固定价 / 成本百分比三类更新行(提交后给受影响门店生成价格变更通知)
|
||||
*/
|
||||
export async function batchPrice(updates: IBatchPriceUpdate[]) {
|
||||
return createAxios({
|
||||
url: '/product/goods/batchPrice',
|
||||
|
||||
@@ -16,12 +16,12 @@ export interface PurchaseItemUpdateParams {
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
/** C1 按门店订单汇总生成采购单 */
|
||||
export async function generatePurchase(purchase_date: string) {
|
||||
/** C1 合并「已接单」门店订单生成采购单(order_ids 为空 = 全部已接单;生成后源订单转采购中) */
|
||||
export async function generatePurchase(purchase_date: string, order_ids?: number[]) {
|
||||
return createAxios<{ id: number; purchase_no: string }>({
|
||||
url: '/purchase/order/generate',
|
||||
method: 'post',
|
||||
data: { purchase_date },
|
||||
data: { purchase_date, order_ids },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -54,12 +54,13 @@ const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
const valueArray = Array.isArray(value) ? value : [value];
|
||||
// changeType='id' 时表单值为文件 id:拉取文件信息回显;
|
||||
if (changeType === 'id') {
|
||||
const ids = valueArray as number[];
|
||||
const ids = valueArray.map(i => Number(i));
|
||||
const oldFileList = fileList.filter(i => ids.includes(Number(i.uid)))
|
||||
|
||||
const idSet = new Set(fileList.map(obj => Number(obj.uid)));
|
||||
const idSet = new Set(oldFileList.map(obj => Number(obj.uid)));
|
||||
|
||||
// 过滤出不在 Set 中的数字
|
||||
const missing = ids.filter(num => !idSet.has(Number(num)));
|
||||
const missing = ids.filter(num => !idSet.has(num));
|
||||
|
||||
if (missing.length > 0) {
|
||||
const fetchers = missing.map((id) => getFileInfo(id));
|
||||
@@ -68,8 +69,10 @@ const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
.map((res) => res.data.data)
|
||||
.filter(i => !!i)
|
||||
.map(fileToList);
|
||||
setFileList([...files, ...fileList]);
|
||||
setFileList([...files, ...oldFileList]);
|
||||
})
|
||||
} else {
|
||||
setFileList(oldFileList);
|
||||
}
|
||||
} else {
|
||||
const newFileList: UploadFile[] = (valueArray as ISysFileInfo[]).map(fileToList);
|
||||
|
||||
@@ -138,7 +138,7 @@ export default function XinTable<T extends Record<string, any> = any>(props: Xin
|
||||
|
||||
// 暴露方法到 tableRef
|
||||
useImperativeHandle(tableRef, (): XinTableInstance<T> => ({
|
||||
reload: async () => { await handleRequest(); },
|
||||
reload: async () => { await handleRequest(requestParams); },
|
||||
reset: async () => {
|
||||
searchRef.resetFields();
|
||||
setRequestParams({ page: 1, pageSize: 10 });
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type IStore from '@/domain/iStore.ts';
|
||||
import type ISupplier from '@/domain/iSupplier.ts';
|
||||
|
||||
/** 小程序用户 */
|
||||
export default interface IMiniUser {
|
||||
@@ -7,23 +6,13 @@ export default interface IMiniUser {
|
||||
nickname?: string;
|
||||
phone?: string;
|
||||
avatar?: string;
|
||||
/** 0待绑定 1门店 2供应商 */
|
||||
type?: number;
|
||||
store_id?: number;
|
||||
supplier_id?: number;
|
||||
store?: IStore;
|
||||
supplier?: ISupplier;
|
||||
status?: number;
|
||||
last_login_at?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export const MINI_USER_TYPE_MAP: Record<number, { text: string; color: string }> = {
|
||||
0: { text: '待绑定', color: 'default' },
|
||||
1: { text: '门店', color: 'blue' },
|
||||
2: { text: '供应商', color: 'purple' },
|
||||
};
|
||||
|
||||
export const MINI_USER_STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||
0: { text: '停用', color: 'error' },
|
||||
1: { text: '正常', color: 'success' },
|
||||
|
||||
+32
-5
@@ -2,12 +2,19 @@ import type IProductCategory from '@/domain/iProductCategory.ts';
|
||||
import type ISupplier from '@/domain/iSupplier.ts';
|
||||
import type {ISysFileInfo} from "@/domain/iSysFile.ts";
|
||||
|
||||
/** 商品等级价格行 */
|
||||
/** 商品等级价格行(null = 该等级未设定价格,编辑时移除该行) */
|
||||
export interface IProductPrice {
|
||||
id?: number;
|
||||
product_id?: number;
|
||||
level_id?: number;
|
||||
price?: string | number;
|
||||
/** 单价:固定价=实际单价;成本百分比=等价固定价 */
|
||||
price?: string | number | null;
|
||||
/** 计价类型:0 固定价(默认) 1 成本百分比 */
|
||||
price_type?: number;
|
||||
/** 成本上浮百分点(price_type=1 时生效,如 30 = 上浮 30%) */
|
||||
percent?: string | number | null;
|
||||
/** 实际销售价(模型换算:百分比 = 成本价 × (100 + percent) / 100) */
|
||||
actual_price?: string | number;
|
||||
level?: { id: number; name: string };
|
||||
}
|
||||
|
||||
@@ -38,6 +45,8 @@ export default interface IProduct {
|
||||
stock?: number;
|
||||
/** 状态 */
|
||||
status?: number;
|
||||
/** 成本价(元,成本百分比计价的基数) */
|
||||
cost_price?: string | number;
|
||||
/** 描述 */
|
||||
remark?: string;
|
||||
/** 分类关联数据 */
|
||||
@@ -55,12 +64,16 @@ export const PRODUCT_STATUS_MAP: Record<number, { text: string; color: string }>
|
||||
1: { text: '上架', color: 'success' },
|
||||
};
|
||||
|
||||
/** 价格矩阵行(price_{level_id} 动态列) */
|
||||
/**
|
||||
* 价格矩阵行:固定列 + price_{level_id}(实际价)/ price_type_{level_id} / percent_{level_id} 动态列
|
||||
*/
|
||||
export type IPriceMatrixRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
spec?: string;
|
||||
unit?: string;
|
||||
/** 成本价(null = 未设置或已清除) */
|
||||
cost_price?: string | number | null;
|
||||
} & Record<string, string | number | null | undefined>;
|
||||
|
||||
export interface IPriceMatrix {
|
||||
@@ -69,8 +82,22 @@ export interface IPriceMatrix {
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量调价更新行(三类,可混合同一行):
|
||||
* - 成本行 { product_id, cost_price }
|
||||
* - 固定价行 { product_id, level_id, price_type: 0, price }
|
||||
* - 百分比行 { product_id, level_id, price_type: 1, percent }(price 可选,等价固定价)
|
||||
*/
|
||||
export interface IBatchPriceUpdate {
|
||||
product_id: number;
|
||||
level_id: number;
|
||||
price: number | string;
|
||||
/** 成本价(成本行) */
|
||||
cost_price?: number;
|
||||
/** 客户等级ID(等级价格行) */
|
||||
level_id?: number;
|
||||
/** 计价类型:0 固定价(默认) 1 成本百分比 */
|
||||
price_type?: number;
|
||||
/** 固定价 / 百分比行的等价固定价 */
|
||||
price?: number;
|
||||
/** 成本上浮百分点(price_type=1 时必填) */
|
||||
percent?: number;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,74 @@
|
||||
/** 门店订单明细(商品快照) */
|
||||
/** 门店订单明细(下单时商品档案快照) */
|
||||
export interface IStoreOrderItem {
|
||||
id?: number;
|
||||
order_id?: number;
|
||||
store_id?: number;
|
||||
product_id?: number;
|
||||
category_id?: number;
|
||||
supplier_id?: number;
|
||||
/** 详情接口附带的供应商信息 */
|
||||
supplier?: { id: number; name: string };
|
||||
product_name?: string;
|
||||
product_spec?: string;
|
||||
/** 计价单位(斤/件/箱等) */
|
||||
unit?: string;
|
||||
price?: string;
|
||||
quantity?: string;
|
||||
/** 列表接口附带的封面图 */
|
||||
image?: string;
|
||||
image_ids?: string;
|
||||
shelf_life?: number;
|
||||
quantity?: number;
|
||||
weight?: string;
|
||||
amount?: string;
|
||||
/** 成本价(仅后台接口可见) */
|
||||
cost_price?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
/** 修改订单明细入参(保存后后端重算单品金额与订单总价) */
|
||||
export interface IStoreOrderItemUpdate {
|
||||
supplier_id: number;
|
||||
product_name: string;
|
||||
product_spec?: string;
|
||||
unit: string;
|
||||
price: number;
|
||||
cost_price: number;
|
||||
quantity: number;
|
||||
weight: number;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
/** 门店订单 */
|
||||
export default interface IStoreOrder {
|
||||
id?: number;
|
||||
order_no?: string;
|
||||
store_id?: number;
|
||||
store?: { id: number; name: string };
|
||||
purchase_id?: number;
|
||||
statement_id?: number;
|
||||
store?: {
|
||||
id: number;
|
||||
name: string;
|
||||
address: string;
|
||||
contact: string;
|
||||
phone: string;
|
||||
};
|
||||
order_date?: string;
|
||||
total_quantity?: string;
|
||||
total_quantity?: number;
|
||||
total_weight?: string;
|
||||
total_amount?: string;
|
||||
/** 0待汇总 1已汇总 2配送中 3已完成 9已取消 */
|
||||
product_amount: string;
|
||||
added_amount: string;
|
||||
box_num: number;
|
||||
tray_num: number;
|
||||
/** 周转框单价(列表接口附加) */
|
||||
box_price?: string;
|
||||
/** 周转托盘单价(列表接口附加) */
|
||||
tray_price?: string;
|
||||
/** 周转框金额(列表接口附加) */
|
||||
box_amount?: string;
|
||||
/** 周转托盘金额(列表接口附加) */
|
||||
tray_amount?: string;
|
||||
/** 0待接单 1已接单 2采购中 3配送中 4已完成 9已取消 */
|
||||
status?: number;
|
||||
remark?: string;
|
||||
items?: IStoreOrderItem[];
|
||||
@@ -31,10 +76,11 @@ export default interface IStoreOrder {
|
||||
}
|
||||
|
||||
export const STORE_ORDER_STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||
0: { text: '待汇总', color: 'default' },
|
||||
1: { text: '已汇总', color: 'processing' },
|
||||
2: { text: '配送中', color: 'warning' },
|
||||
3: { text: '已完成', color: 'success' },
|
||||
0: { text: '待接单', color: 'default' },
|
||||
1: { text: '已接单', color: 'processing' },
|
||||
2: { text: '采购中', color: 'warning' },
|
||||
3: { text: '配送中', color: 'success' },
|
||||
4: { text: '已完成', color: 'success' },
|
||||
9: { text: '已取消', color: 'error' },
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
message,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
@@ -18,20 +17,16 @@ import type {
|
||||
XinTableProps,
|
||||
} from '@/components/XinTable/typings.ts';
|
||||
import type IMiniUser from '@/domain/iMiniUser.ts';
|
||||
import { MINI_USER_STATUS_MAP, MINI_USER_TYPE_MAP } from '@/domain/iMiniUser.ts';
|
||||
import { MINI_USER_STATUS_MAP } from '@/domain/iMiniUser.ts';
|
||||
import type IStore from '@/domain/iStore.ts';
|
||||
import type ISupplier from '@/domain/iSupplier.ts';
|
||||
import { getStoreOptions } from '@/api/customer/store.ts';
|
||||
import { getSupplierOptions } from '@/api/customer/supplier.ts';
|
||||
import { bindMiniUser, toggleMiniUserStatus } from '@/api/customer/miniUser.ts';
|
||||
import AuthButton from '@/components/AuthButton';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface BindFormValues {
|
||||
type: number;
|
||||
store_id?: number;
|
||||
supplier_id?: number;
|
||||
store_id: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,7 +35,6 @@ interface BindFormValues {
|
||||
const MiniUserPage: React.FC = () => {
|
||||
const tableRef = useRef<XinTableInstance<IMiniUser>>(null);
|
||||
const [stores, setStores] = useState<IStore[]>([]);
|
||||
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
|
||||
|
||||
// 绑定弹窗
|
||||
const [bindOpen, setBindOpen] = useState(false);
|
||||
@@ -50,15 +44,12 @@ const MiniUserPage: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
getStoreOptions().then((res) => setStores(res.data.data ?? []));
|
||||
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
|
||||
}, []);
|
||||
|
||||
const openBind = (record: IMiniUser) => {
|
||||
setBindTarget(record);
|
||||
bindForm.setFieldsValue({
|
||||
type: record.type && record.type > 0 ? record.type : undefined,
|
||||
store_id: record.store_id || undefined,
|
||||
supplier_id: record.supplier_id || undefined,
|
||||
});
|
||||
setBindOpen(true);
|
||||
};
|
||||
@@ -70,9 +61,7 @@ const MiniUserPage: React.FC = () => {
|
||||
setBindLoading(true);
|
||||
try {
|
||||
await bindMiniUser(bindTarget.id, {
|
||||
type: values.type,
|
||||
store_id: values.type === 1 ? values.store_id : undefined,
|
||||
supplier_id: values.type === 2 ? values.supplier_id : undefined,
|
||||
store_id: values.store_id,
|
||||
});
|
||||
message.success('绑定成功');
|
||||
setBindOpen(false);
|
||||
@@ -118,36 +107,11 @@ const MiniUserPage: React.FC = () => {
|
||||
render: (_, record) => record.phone || <Text type="secondary">未绑定</Text>,
|
||||
},
|
||||
{
|
||||
title: '用户类型',
|
||||
dataIndex: 'type',
|
||||
valueType: 'select',
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ value: 0, label: '待绑定' },
|
||||
{ value: 1, label: '门店' },
|
||||
{ value: 2, label: '供应商' },
|
||||
],
|
||||
},
|
||||
render: (_, record) => {
|
||||
const item = MINI_USER_TYPE_MAP[record.type ?? 0];
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '绑定主体',
|
||||
dataIndex: 'bound_name',
|
||||
title: '绑定门店',
|
||||
dataIndex: 'store_id',
|
||||
hideInSearch: true,
|
||||
render: (_, record) => {
|
||||
if (record.type === 1) {
|
||||
return <Tag color="blue">{record.store?.name ?? `门店#${record.store_id}`}</Tag>;
|
||||
}
|
||||
if (record.type === 2) {
|
||||
return (
|
||||
<Tag color="purple">{record.supplier?.name ?? `供应商#${record.supplier_id}`}</Tag>
|
||||
);
|
||||
}
|
||||
return <Text type="secondary">待后台绑定</Text>;
|
||||
return <Tag color="blue">{record.store?.name ?? `门店#${record.store_id}`}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -173,6 +137,12 @@ const MiniUserPage: React.FC = () => {
|
||||
align: 'center',
|
||||
render: (_, record) => record.last_login_at ?? <Text type="secondary">从未登录</Text>,
|
||||
},
|
||||
{
|
||||
title: '注册时间',
|
||||
dataIndex: 'created_at',
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
const operateRender: XinTableProps<IMiniUser>['operateRender'] = (record) => [
|
||||
@@ -210,7 +180,7 @@ const MiniUserPage: React.FC = () => {
|
||||
<div className="mb-5">
|
||||
<Title level={3}>小程序用户</Title>
|
||||
<Text type="secondary">
|
||||
用户由微信小程序登录自动创建;手机号授权后按手机号自动匹配门店/供应商,未命中的需在此人工绑定。
|
||||
用户由微信小程序登录自动创建;
|
||||
</Text>
|
||||
</div>
|
||||
<XinTable<IMiniUser> {...tableProps} />
|
||||
@@ -231,60 +201,21 @@ const MiniUserPage: React.FC = () => {
|
||||
className="mt-4"
|
||||
>
|
||||
<Form.Item
|
||||
label="用户类型"
|
||||
name="type"
|
||||
rules={[{ required: true, message: '请选择用户类型' }]}
|
||||
label="绑定门店"
|
||||
name="store_id"
|
||||
rules={[{ required: true, message: '请选择门店' }]}
|
||||
>
|
||||
<Radio.Group
|
||||
options={[
|
||||
{ label: '门店', value: 1 },
|
||||
{ label: '供应商', value: 2 },
|
||||
]}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
<Select
|
||||
showSearch={{
|
||||
optionFilterProp: 'label'
|
||||
}}
|
||||
placeholder="选择门店"
|
||||
options={stores.map((s) => ({
|
||||
label: `${s.name}(${s.code})`,
|
||||
value: s.id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.type !== cur.type}>
|
||||
{({ getFieldValue }) => {
|
||||
const type = getFieldValue('type');
|
||||
if (type === 1) {
|
||||
return (
|
||||
<Form.Item
|
||||
label="绑定门店"
|
||||
name="store_id"
|
||||
rules={[{ required: true, message: '请选择门店' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="选择门店"
|
||||
options={stores.map((s) => ({
|
||||
label: `${s.name}(${s.code})`,
|
||||
value: s.id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
if (type === 2) {
|
||||
return (
|
||||
<Form.Item
|
||||
label="绑定供应商"
|
||||
name="supplier_id"
|
||||
rules={[{ required: true, message: '请选择供应商' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="选择供应商"
|
||||
options={suppliers.map((s) => ({ label: s.name, value: s.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
@@ -41,8 +41,7 @@ const StorePage: React.FC = () => {
|
||||
title: '门店编码',
|
||||
dataIndex: 'code',
|
||||
valueType: 'text',
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请输入门店编码' }],
|
||||
hideInForm: true
|
||||
},
|
||||
{
|
||||
title: '客户等级',
|
||||
@@ -70,6 +69,14 @@ const StorePage: React.FC = () => {
|
||||
valueType: 'text',
|
||||
hideInSearch: true,
|
||||
},
|
||||
{
|
||||
title: '门店地址',
|
||||
dataIndex: 'address',
|
||||
valueType: 'textarea',
|
||||
hideInSearch: true,
|
||||
fieldProps: { rows: 1 },
|
||||
colProps: { span: 24 }
|
||||
},
|
||||
{
|
||||
title: '回款周期(天)',
|
||||
dataIndex: 'payment_cycle_days',
|
||||
@@ -95,14 +102,6 @@ const StorePage: React.FC = () => {
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '门店地址',
|
||||
dataIndex: 'address',
|
||||
valueType: 'textarea',
|
||||
hideInSearch: true,
|
||||
hideInTable: true,
|
||||
fieldProps: { rows: 2 },
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
@@ -110,6 +109,7 @@ const StorePage: React.FC = () => {
|
||||
hideInSearch: true,
|
||||
hideInTable: true,
|
||||
fieldProps: { rows: 2 },
|
||||
colProps: { span: 24 }
|
||||
},
|
||||
];
|
||||
|
||||
@@ -121,6 +121,7 @@ const StorePage: React.FC = () => {
|
||||
formProps: {
|
||||
grid: true,
|
||||
colProps: { span: 12 },
|
||||
rowProps: { gutter: 20 },
|
||||
layout: 'vertical',
|
||||
},
|
||||
modalProps: { width: 720 },
|
||||
|
||||
+745
-81
@@ -1,16 +1,24 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Image,
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type {
|
||||
XinTableColumn,
|
||||
@@ -18,27 +26,52 @@ import type {
|
||||
XinTableProps,
|
||||
} from '@/components/XinTable/typings.ts';
|
||||
import type IStoreOrder from '@/domain/iStoreOrder.ts';
|
||||
import type { IStoreOrderItem } from '@/domain/iStoreOrder.ts';
|
||||
import type { IStoreOrderItem, IStoreOrderItemUpdate } from '@/domain/iStoreOrder.ts';
|
||||
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
|
||||
import { getStoreOrder, updateOrderStatus } from '@/api/order/store.ts';
|
||||
import {
|
||||
getStoreOrder,
|
||||
updateOrderContainer,
|
||||
updateOrderStatus,
|
||||
batchUpdateOrderStatus,
|
||||
updateOrderItem,
|
||||
syncOrderItem,
|
||||
deleteStoreOrder,
|
||||
} from '@/api/order/store.ts';
|
||||
import { generatePurchase } from '@/api/purchase/order.ts';
|
||||
import { getStoreOptions } from '@/api/customer/store.ts';
|
||||
import { getSupplierOptions } from '@/api/customer/supplier.ts';
|
||||
import type IStore from '@/domain/iStore.ts';
|
||||
import type ISupplier from '@/domain/iSupplier.ts';
|
||||
import AuthButton from '@/components/AuthButton';
|
||||
import { DeleteOutlined, EditOutlined, SettingOutlined, SyncOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/** 允许修改周转框/托盘数量的订单状态:已接单、采购中、配送中 */
|
||||
const CONTAINER_EDITABLE_STATUS = [1, 2, 3];
|
||||
|
||||
/** 允许修改/同步商品明细的订单状态:待接单、已接单、采购中、配送中(已完成/已取消锁定) */
|
||||
const ITEM_EDITABLE_STATUS = [0, 1, 2, 3];
|
||||
|
||||
/**
|
||||
* 状态流转合法路径:待汇总→配送中/取消;已汇总→配送中;配送中→已完成
|
||||
* 状态流转合法路径:待接单→已接单/已取消;采购中→配送中/已完成;配送中→已完成
|
||||
* (已接单→采购中 通过「生成采购单」完成,不在流转按钮内)
|
||||
*/
|
||||
const NEXT_STATUS: Record<number, { status: number; label: string; danger?: boolean }[]> = {
|
||||
0: [
|
||||
{ status: 2, label: '开始配送' },
|
||||
{ status: 9, label: '取消订单', danger: true },
|
||||
],
|
||||
1: [{ status: 2, label: '开始配送' }],
|
||||
2: [{ status: 3, label: '完成订单' }],
|
||||
const NEXT_STATUS: Record<number, { status: number; label: string; }> = {
|
||||
0: { status: 1, label: '接单' },
|
||||
2: { status: 3, label: '开始配送' },
|
||||
3: { status: 4, label: '完成订单' },
|
||||
};
|
||||
|
||||
/** 批量流转可选目标状态(弹窗单选,后端全量校验,任一不允许则整批中止) */
|
||||
const BATCH_TARGET_OPTIONS = [
|
||||
{ value: 1, label: '接单(待接单 → 已接单)' },
|
||||
{ value: 3, label: '开始配送(采购中 → 配送中)' },
|
||||
{ value: 4, label: '完成订单(采购中/配送中 → 已完成)' },
|
||||
{ value: 9, label: '取消订单(待接单 → 已取消)' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 门店订单管理(只读 + 状态流转,订单由小程序端创建)
|
||||
*/
|
||||
@@ -50,8 +83,34 @@ const StoreOrderPage: React.FC = () => {
|
||||
const [detail, setDetail] = useState<IStoreOrder | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
const [containerOpen, setContainerOpen] = useState(false);
|
||||
const [containerOrder, setContainerOrder] = useState<IStoreOrder | null>(null);
|
||||
const [containerSaving, setContainerSaving] = useState(false);
|
||||
const [containerForm] = Form.useForm<{ box_num: number; tray_num: number }>();
|
||||
|
||||
/** 勾选行(批量流转/生成采购单用) */
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [batchOpen, setBatchOpen] = useState(false);
|
||||
const [batchTarget, setBatchTarget] = useState<number | null>(null);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
|
||||
/** 生成采购单(合并已接单订单,生成后源订单转采购中) */
|
||||
const [generateOpen, setGenerateOpen] = useState(false);
|
||||
const [generateLoading, setGenerateLoading] = useState(false);
|
||||
const [generateForm] = Form.useForm<{ purchase_date: dayjs.Dayjs }>();
|
||||
|
||||
/** 供应商选项(明细编辑用) */
|
||||
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
|
||||
|
||||
/** 商品明细编辑(弹窗表单,保存后后端重算订单总价) */
|
||||
const [itemEditOpen, setItemEditOpen] = useState(false);
|
||||
const [itemEditTarget, setItemEditTarget] = useState<IStoreOrderItem | null>(null);
|
||||
const [itemSaving, setItemSaving] = useState(false);
|
||||
const [itemForm] = Form.useForm<IStoreOrderItemUpdate>();
|
||||
|
||||
useEffect(() => {
|
||||
getStoreOptions().then((res) => setStores(res.data.data ?? []));
|
||||
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
|
||||
}, []);
|
||||
|
||||
const openDetail = async (id: number) => {
|
||||
@@ -72,28 +131,188 @@ const StoreOrderPage: React.FC = () => {
|
||||
await tableRef.current?.reload();
|
||||
};
|
||||
|
||||
const itemColumns: TableProps<IStoreOrderItem>['columns'] = [
|
||||
{ title: '品名', dataIndex: 'product_name' },
|
||||
{ title: '规格', dataIndex: 'product_spec', render: (v) => v || '-' },
|
||||
{ title: '单价', dataIndex: 'price', align: 'right', render: (v) => `¥${v}` },
|
||||
{ title: '订货量', dataIndex: 'quantity', align: 'right' },
|
||||
{ title: '金额', dataIndex: 'amount', align: 'right', render: (v) => `¥${v}` },
|
||||
{ title: '备注', dataIndex: 'remark', render: (v) => v || '-' },
|
||||
];
|
||||
/** 批量状态流转:后端全量校验,任一订单不允许流转则整批中止 */
|
||||
const handleBatchStatus = async () => {
|
||||
if (batchTarget === null) {
|
||||
message.warning('请选择要流转的目标状态');
|
||||
return;
|
||||
}
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res = await batchUpdateOrderStatus(selectedRowKeys as number[], batchTarget);
|
||||
message.success(`已批量流转 ${res.data.data?.success ?? 0} 单`);
|
||||
setBatchOpen(false);
|
||||
setBatchTarget(null);
|
||||
setSelectedRowKeys([]);
|
||||
await tableRef.current?.reload();
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 生成采购单:勾选时合并所选订单(须均为已接单),未勾选时合并全部已接单订单 */
|
||||
const handleGeneratePurchase = async (values: { purchase_date: dayjs.Dayjs }) => {
|
||||
setGenerateLoading(true);
|
||||
try {
|
||||
const orderIds = selectedRowKeys.length > 0 ? selectedRowKeys.map(Number) : undefined;
|
||||
const res = await generatePurchase(values.purchase_date.format('YYYY-MM-DD'), orderIds);
|
||||
message.success(`采购单 ${res.data.data?.purchase_no} 已生成,源订单已转为采购中`);
|
||||
setGenerateOpen(false);
|
||||
setSelectedRowKeys([]);
|
||||
await tableRef.current?.reload();
|
||||
} finally {
|
||||
setGenerateLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openContainer = (record: IStoreOrder) => {
|
||||
setContainerOrder(record);
|
||||
containerForm.setFieldsValue({ box_num: record.box_num, tray_num: record.tray_num });
|
||||
setContainerOpen(true);
|
||||
};
|
||||
|
||||
const handleContainerSave = async (values: { box_num: number; tray_num: number }) => {
|
||||
if (!containerOrder?.id) return;
|
||||
setContainerSaving(true);
|
||||
try {
|
||||
await updateOrderContainer(containerOrder.id, values);
|
||||
message.success('周转框/托盘数量已更新');
|
||||
setContainerOpen(false);
|
||||
await tableRef.current?.reload();
|
||||
} finally {
|
||||
setContainerSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 打开明细编辑弹窗(商品快照 + 订货量/重量) */
|
||||
const openItemEdit = (item: IStoreOrderItem) => {
|
||||
setItemEditTarget(item);
|
||||
itemForm.setFieldsValue({
|
||||
supplier_id: item.supplier_id ?? 0,
|
||||
product_name: item.product_name ?? '',
|
||||
product_spec: item.product_spec ?? '',
|
||||
unit: item.unit ?? '',
|
||||
price: Number(item.price ?? 0),
|
||||
cost_price: Number(item.cost_price ?? 0),
|
||||
quantity: Number(item.quantity ?? 0),
|
||||
weight: Number(item.weight ?? 0),
|
||||
remark: item.remark ?? '',
|
||||
});
|
||||
setItemEditOpen(true);
|
||||
};
|
||||
|
||||
/** 保存明细修改:后端重算单品金额与订单总价 */
|
||||
const handleItemSave = async (values: IStoreOrderItemUpdate) => {
|
||||
if (!itemEditTarget?.id || !detail?.id) return;
|
||||
setItemSaving(true);
|
||||
try {
|
||||
await updateOrderItem(itemEditTarget.id, values);
|
||||
message.success('明细已更新,订单总价已重算');
|
||||
setItemEditOpen(false);
|
||||
await openDetail(detail.id);
|
||||
await tableRef.current?.reload();
|
||||
} finally {
|
||||
setItemSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 一键同步:按商品ID拉取最新商品档案(供应商/品名/规格/单位/单价/成本价) */
|
||||
const handleItemSync = async (item: IStoreOrderItem) => {
|
||||
if (!item.id || !detail?.id) return;
|
||||
await syncOrderItem(item.id);
|
||||
message.success('已同步最新商品信息,订单总价已重算');
|
||||
await openDetail(detail.id);
|
||||
await tableRef.current?.reload();
|
||||
};
|
||||
|
||||
/** 删除订单(软删除,仅已取消订单可删;删除后后台/小程序端均不可见) */
|
||||
const handleDelete = async (id: number) => {
|
||||
await deleteStoreOrder(id);
|
||||
message.success('订单已删除');
|
||||
setDetailOpen(false);
|
||||
await tableRef.current?.reload();
|
||||
};
|
||||
|
||||
// 弹窗内实时预览:附加金额 = 框×单价 + 托盘×单价;订单总金额 = 商品金额 + 附加金额
|
||||
const watchBoxNum = Number(Form.useWatch('box_num', containerForm) ?? 0);
|
||||
const watchTrayNum = Number(Form.useWatch('tray_num', containerForm) ?? 0);
|
||||
const previewAdded = watchBoxNum * Number(containerOrder?.box_price ?? 0)
|
||||
+ watchTrayNum * Number(containerOrder?.tray_price ?? 0);
|
||||
const previewTotal = Number(containerOrder?.product_amount ?? 0) + previewAdded;
|
||||
|
||||
/** 明细行编辑/同步按钮(仅未完成、未取消订单可见) */
|
||||
const itemEditable = ITEM_EDITABLE_STATUS.includes(detail?.status ?? -1);
|
||||
|
||||
const columns: XinTableColumn<IStoreOrder>[] = [
|
||||
{
|
||||
title: '订单号',
|
||||
hideInTable: true,
|
||||
dataIndex: 'order_no',
|
||||
valueType: 'text',
|
||||
hideInForm: true
|
||||
},
|
||||
{
|
||||
title: '商品名称',
|
||||
hideInTable: true,
|
||||
dataIndex: 'product_name',
|
||||
valueType: 'text',
|
||||
hideInForm: true
|
||||
},
|
||||
{
|
||||
title: '基本信息',
|
||||
dataIndex: 'order_no',
|
||||
hideInForm: true,
|
||||
render: (_, record) => <Text copyable={{ text: record.order_no }}>{record.order_no}</Text>,
|
||||
hideInSearch: true,
|
||||
width: 300,
|
||||
render: (_, record) => {
|
||||
return (
|
||||
<Space orientation={'vertical'}>
|
||||
<div>
|
||||
<Text type={'secondary'}>订单号:</Text>
|
||||
<Text copyable={{ text: record.order_no }}>{record.order_no}</Text>
|
||||
</div>
|
||||
<div><Text type={'secondary'}>订货时间:</Text>{record.created_at}</div>
|
||||
<div>
|
||||
<Text type={'secondary'}>订单状态:</Text>
|
||||
<Tag color={STORE_ORDER_STATUS_MAP[record.status ?? 0]?.color}>
|
||||
{STORE_ORDER_STATUS_MAP[record.status ?? 0]?.text}
|
||||
</Tag>
|
||||
</div>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '商品信息',
|
||||
dataIndex: 'items',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 550,
|
||||
render: (_, record) => {
|
||||
return (
|
||||
<Space wrap size={20}>
|
||||
{ record.items && record.items.length > 0 && record.items.map(item => (
|
||||
<div className={'flex'}>
|
||||
<Image src={item.image} width={60} height={60} ></Image>
|
||||
<div className={'ml-2.5'}>
|
||||
<div>{item.product_name}</div>
|
||||
<div className={'text-[12px] text-[#999]'}>{item.product_spec} {item.unit}</div>
|
||||
<div className={'text-[12px] text-[#999]'}>
|
||||
<span className={'text-[red]'}>{ item.price } ¥</span> × { item.quantity }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '门店',
|
||||
dataIndex: 'store_id',
|
||||
valueType: 'select',
|
||||
hideInForm: true,
|
||||
hideInTable: true,
|
||||
fieldProps: {
|
||||
options: stores.map((s) => ({ label: s.name, value: s.id })),
|
||||
showSearch: true,
|
||||
@@ -101,60 +320,164 @@ const StoreOrderPage: React.FC = () => {
|
||||
},
|
||||
render: (_, record) => record.store?.name ?? '-',
|
||||
},
|
||||
{
|
||||
title: '门店信息',
|
||||
dataIndex: 'store_id',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 300,
|
||||
render: (_, record) => (
|
||||
<Space orientation={'vertical'}>
|
||||
<div>
|
||||
<Text type={'secondary'}>订货门店:</Text>
|
||||
<Tag color="blue">{record.store?.name ?? `门店#${record.store_id}`}</Tag>
|
||||
</div>
|
||||
<div><Text type={'secondary'}>联系人:</Text>{ record.store?.contact ?? '-' }</div>
|
||||
<div><Text type={'secondary'}>联系电话:</Text>{ record.store?.phone ?? '-' }</div>
|
||||
<div><Text type={'secondary'}>门店地址:</Text>{ record.store?.address ?? '-' }</div>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '订货日期',
|
||||
dataIndex: 'order_date',
|
||||
valueType: 'dateRange',
|
||||
hideInForm: true,
|
||||
hideInTable: true,
|
||||
align: 'center',
|
||||
render: (_, record) => record.order_date,
|
||||
},
|
||||
{
|
||||
title: '订货总量',
|
||||
dataIndex: 'total_quantity',
|
||||
title: '附加信息',
|
||||
hideInForm: true,
|
||||
dataIndex: 'status',
|
||||
hideInSearch: true,
|
||||
align: 'right',
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<Space orientation={'vertical'}>
|
||||
<div>
|
||||
<Text type={'secondary'}>订货数量:</Text>
|
||||
{record.total_quantity}
|
||||
</div>
|
||||
<div>
|
||||
<Text type={'secondary'}>周转框数量:</Text>
|
||||
{record.box_num}
|
||||
</div>
|
||||
<div>
|
||||
<Text type={'secondary'}>周转托盘数量:</Text>
|
||||
{record.tray_num}
|
||||
</div>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '订单金额',
|
||||
dataIndex: 'total_amount',
|
||||
hideInForm: true,
|
||||
dataIndex: 'box_num',
|
||||
hideInSearch: true,
|
||||
align: 'right',
|
||||
render: (_, record) => <Text strong>¥{record.total_amount}</Text>,
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<Space orientation={'vertical'}>
|
||||
<div>
|
||||
<Text type={'secondary'}>商品总金额:</Text>
|
||||
<span className={'text-[red]'}>{record.product_amount} ¥</span>
|
||||
</div>
|
||||
<div>
|
||||
<Text type={'secondary'}>附加总金额:</Text>
|
||||
<span className={'text-[red]'}>{record.added_amount} ¥</span>
|
||||
</div>
|
||||
<div>
|
||||
<Text type={'secondary'}>订单总金额:</Text>
|
||||
<span className={'text-[red] text-[16px]'}>{record.total_amount} ¥</span>
|
||||
</div>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
title: '订单状态',
|
||||
dataIndex: 'status',
|
||||
valueType: 'select',
|
||||
hideInForm: true,
|
||||
align: 'center',
|
||||
hideInTable: true,
|
||||
fieldProps: {
|
||||
options: Object.entries(STORE_ORDER_STATUS_MAP).map(([value, item]) => ({
|
||||
value: Number(value),
|
||||
label: item.text,
|
||||
})),
|
||||
},
|
||||
render: (_, record) => {
|
||||
const item = STORE_ORDER_STATUS_MAP[record.status ?? 0];
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
align: 'center',
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
title: '采购单ID',
|
||||
dataIndex: 'purchase_id',
|
||||
valueType: 'digit',
|
||||
hideInForm: true,
|
||||
},
|
||||
{
|
||||
title: '账单ID',
|
||||
dataIndex: 'statement_id',
|
||||
valueType: 'digit',
|
||||
hideInForm: true,
|
||||
},
|
||||
{
|
||||
title: '操作栏',
|
||||
dataIndex: 'operate',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
ellipsis: true,
|
||||
render: (_, record) => record.remark || '-',
|
||||
},
|
||||
];
|
||||
|
||||
const operateRender: XinTableProps<IStoreOrder>['operateRender'] = (record) => [
|
||||
<Button key="detail" size="small" onClick={() => openDetail(record.id!)}>
|
||||
详情
|
||||
</Button>,
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Space size={5} wrap>
|
||||
<Button
|
||||
type={'primary'}
|
||||
icon={<UnorderedListOutlined />}
|
||||
onClick={() => openDetail(record.id!)}
|
||||
/>
|
||||
{ CONTAINER_EDITABLE_STATUS.includes(record.status ?? -1) && (
|
||||
<AuthButton key="container" auth="order.store.update">
|
||||
<Button
|
||||
icon={<SettingOutlined />}
|
||||
type={'primary'}
|
||||
onClick={() => openContainer(record)}
|
||||
/>
|
||||
</AuthButton>
|
||||
)}
|
||||
{ NEXT_STATUS[record.status!] && (
|
||||
<AuthButton auth="order.store.update">
|
||||
<Popconfirm
|
||||
title={`确认将订单状态更新为「${NEXT_STATUS[record.status!].label}」?`}
|
||||
onConfirm={() => handleStatusChange(record.id!, NEXT_STATUS[record.status!].status)}
|
||||
>
|
||||
<Button type={'primary'} color='green' variant="solid">
|
||||
{NEXT_STATUS[record.status!].label}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
)}
|
||||
{ record.status === 0 && (
|
||||
<AuthButton auth="order.store.update">
|
||||
<Popconfirm
|
||||
title={`确认取消该订单吗?`}
|
||||
onConfirm={() => handleStatusChange(record.id!, 9)}
|
||||
>
|
||||
<Button type={'primary'} color='danger' variant="solid">
|
||||
取消订单
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
)}
|
||||
{ record.status === 9 && (
|
||||
<AuthButton auth="order.store.delete">
|
||||
<Popconfirm
|
||||
title="确认删除该订单吗?"
|
||||
description="仅已取消订单可删除,删除后后台与小程序端均不可见"
|
||||
onConfirm={() => handleDelete(record.id!)}
|
||||
>
|
||||
<Button icon={<DeleteOutlined />} color='danger' variant="solid" />
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const tableProps: XinTableProps<IStoreOrder> = {
|
||||
@@ -163,8 +486,30 @@ const StoreOrderPage: React.FC = () => {
|
||||
rowKey: 'id',
|
||||
accessName: 'order.store',
|
||||
tableRef,
|
||||
operateRender,
|
||||
operateShow: false,
|
||||
formProps: false,
|
||||
rowSelection: {
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys),
|
||||
},
|
||||
actionBarRender: (dom) => [
|
||||
dom.search,
|
||||
<AuthButton key="generate" auth="purchase.order.generate">
|
||||
<Button type="primary" onClick={() => setGenerateOpen(true)}>
|
||||
生成采购单
|
||||
</Button>
|
||||
</AuthButton>,
|
||||
<AuthButton key="batch" auth="order.store.update">
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
onClick={() => setBatchOpen(true)}
|
||||
>
|
||||
批量操作{selectedRowKeys.length > 0 ? `(${selectedRowKeys.length})` : ''}
|
||||
</Button>
|
||||
</AuthButton>,
|
||||
dom.keywordSearch,
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -172,7 +517,7 @@ const StoreOrderPage: React.FC = () => {
|
||||
<div className="mb-5">
|
||||
<Title level={3}>门店订单</Title>
|
||||
<Text type="secondary">
|
||||
小程序下单汇总;待汇总订单将进入采购单生成,订单创建与取消在小程序端完成。
|
||||
小程序下单为待接单;接单后通过「生成采购单」合并为采购单并转采购中,采购中可批量流转为配送中或已完成。
|
||||
</Text>
|
||||
</div>
|
||||
<XinTable<IStoreOrder> {...tableProps} />
|
||||
@@ -181,32 +526,76 @@ const StoreOrderPage: React.FC = () => {
|
||||
title="订单详情"
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
width={720}
|
||||
size={1000}
|
||||
loading={detailLoading}
|
||||
footer={
|
||||
detail && NEXT_STATUS[detail.status ?? -1] ? (
|
||||
<Space className="flex justify-end">
|
||||
{NEXT_STATUS[detail.status!].map((action) => (
|
||||
<AuthButton key={action.status} auth="order.store.update">
|
||||
detail ? (
|
||||
<Space className="flex justify-end" wrap>
|
||||
{CONTAINER_EDITABLE_STATUS.includes(detail.status ?? -1) && (
|
||||
<AuthButton auth="order.store.update">
|
||||
<Button icon={<SettingOutlined />} onClick={() => openContainer(detail)}>
|
||||
设置附加信息
|
||||
</Button>
|
||||
</AuthButton>
|
||||
)}
|
||||
{ NEXT_STATUS[detail.status!] && (
|
||||
<AuthButton auth="order.store.update">
|
||||
<Popconfirm
|
||||
title={`确认将订单状态更新为「${action.label}」?`}
|
||||
onConfirm={() => handleStatusChange(detail.id!, action.status)}
|
||||
title={`确认将订单状态更新为「${NEXT_STATUS[detail.status!].label}」?`}
|
||||
onConfirm={() => handleStatusChange(detail.id!, NEXT_STATUS[detail.status!].status)}
|
||||
>
|
||||
<Button type={action.danger ? undefined : 'primary'} danger={action.danger}>
|
||||
{action.label}
|
||||
<Button type={'primary'} color='green' variant="solid">
|
||||
{NEXT_STATUS[detail.status!].label}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
))}
|
||||
)}
|
||||
{ detail.status === 0 && (
|
||||
<AuthButton auth="order.store.update">
|
||||
<Popconfirm
|
||||
title={`确认取消该订单吗?`}
|
||||
onConfirm={() => handleStatusChange(detail.id!, 9)}
|
||||
>
|
||||
<Button type={'primary'} color='danger' variant="solid">
|
||||
取消订单
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
)}
|
||||
{ detail.status === 9 && (
|
||||
<AuthButton auth="order.store.delete">
|
||||
<Popconfirm
|
||||
title="确认删除该订单吗?"
|
||||
description="仅已取消订单可删除,删除后后台与小程序端均不可见"
|
||||
onConfirm={() => handleDelete(detail.id!)}
|
||||
>
|
||||
<Button icon={<DeleteOutlined />} color='danger' variant="solid">
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
)}
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{detail ? (
|
||||
<>
|
||||
<Descriptions column={2} size="small" bordered>
|
||||
{/* 门店信息 */}
|
||||
<Descriptions title="门店信息" column={3} size="small" bordered>
|
||||
<Descriptions.Item label="门店名称">
|
||||
{detail.store?.name ?? `门店#${detail.store_id}`}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="联系人">{detail.store?.contact ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="联系电话">{detail.store?.phone ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店地址" span={3}>
|
||||
{detail.store?.address ?? '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{/* 订单信息 */}
|
||||
<Descriptions title="订单信息" column={3} size="small" bordered className="mt-4!">
|
||||
<Descriptions.Item label="订单号">{detail.order_no}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店">{detail.store?.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="订货日期">{detail.order_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={STORE_ORDER_STATUS_MAP[detail.status ?? 0]?.color}>
|
||||
@@ -214,37 +603,312 @@ const StoreOrderPage: React.FC = () => {
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="订货总量">{detail.total_quantity}</Descriptions.Item>
|
||||
<Descriptions.Item label="订单金额">¥{detail.total_amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="周转框数量">{detail.box_num}</Descriptions.Item>
|
||||
<Descriptions.Item label="周转托盘数量">{detail.tray_num}</Descriptions.Item>
|
||||
{detail.remark ? (
|
||||
<Descriptions.Item label="备注" span={2}>
|
||||
<Descriptions.Item label="备注" span={3}>
|
||||
{detail.remark}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
<Title level={5} className="!mt-6 !mb-3">
|
||||
|
||||
{/* 商品明细(商城模式:首图 + 单价 × 订货量 + 金额) */}
|
||||
<Title level={5} className="mt-6! mb-3!">
|
||||
商品明细
|
||||
</Title>
|
||||
<Table<IStoreOrderItem>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={itemColumns}
|
||||
dataSource={detail.items ?? []}
|
||||
pagination={false}
|
||||
summary={() => (
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0} colSpan={4} align="right">
|
||||
合计
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1} align="right">
|
||||
<Text strong>¥{detail.total_amount}</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={2} />
|
||||
</Table.Summary.Row>
|
||||
)}
|
||||
/>
|
||||
<div className="overflow-hidden rounded border border-gray-200">
|
||||
<div className="flex bg-gray-50 px-4 py-2 text-sm text-gray-500">
|
||||
<div className="flex-1">商品信息</div>
|
||||
<div className="w-30 shrink-0 text-center">供应商</div>
|
||||
<div className="w-30 shrink-0 text-center">单价</div>
|
||||
<div className="w-26 shrink-0 text-center">订货量</div>
|
||||
<div className="w-33 shrink-0 text-center">金额</div>
|
||||
{itemEditable ? <div className="w-40 shrink-0 text-center">操作</div> : null}
|
||||
</div>
|
||||
{(detail.items ?? []).map((item) => (
|
||||
<div key={item.id} className="flex items-center border-t border-gray-100 px-4 py-3">
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<Image.PreviewGroup>
|
||||
{item.image ? (
|
||||
<Image
|
||||
src={item.image}
|
||||
width={64}
|
||||
height={64}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded bg-gray-100 text-xs text-gray-400">
|
||||
暂无图片
|
||||
</div>
|
||||
)}
|
||||
</Image.PreviewGroup>
|
||||
<div className="ml-3 min-w-0">
|
||||
<div className="text-sm font-medium">{item.product_name}</div>
|
||||
<div className="mt-0.5 text-xs text-gray-500">
|
||||
{item.product_spec || '-'} / {item.unit || '-'} · 成本价 ¥
|
||||
{item.cost_price ?? '0.00'}
|
||||
{Number(item.weight ?? 0) > 0 ? ` · 重量:${item.weight}` : ''}
|
||||
</div>
|
||||
{item.remark ? (
|
||||
<div className="mt-0.5 truncate text-xs text-gray-500">备注:{item.remark}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-30 shrink-0 text-center">{item.supplier?.name ?? '-'}</div>
|
||||
<div className="w-30 shrink-0 text-center">¥{item.price}</div>
|
||||
<div className="w-26 shrink-0 text-center">{item.quantity}</div>
|
||||
<div className="w-33 shrink-0 text-center">
|
||||
<Text strong>¥{item.amount}</Text>
|
||||
</div>
|
||||
{itemEditable ? (
|
||||
<div className="w-40 shrink-0 text-center">
|
||||
<Space size={0}>
|
||||
<AuthButton auth="order.store.update">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openItemEdit(item)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
</AuthButton>
|
||||
<AuthButton auth="order.store.update">
|
||||
<Popconfirm
|
||||
title="确认同步最新商品信息?"
|
||||
description="将按商品ID同步供应商、品名、规格、单位、成本价,并按门店等级价重算单价"
|
||||
onConfirm={() => handleItemSync(item)}
|
||||
>
|
||||
<Button type="link" size="small" icon={<SyncOutlined />}>
|
||||
同步
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
</Space>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{(detail.items ?? []).length === 0 ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="暂无商品明细"
|
||||
className="py-8!"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 附加信息 */}
|
||||
<div className="mt-3! flex justify-end">
|
||||
<div className="w-full rounded bg-gray-50 p-4 text-sm">
|
||||
<div className="flex justify-between py-1">
|
||||
<Text type="secondary">
|
||||
周转框金额({detail.box_num} × ¥{detail.box_price ?? '0.00'})
|
||||
</Text>
|
||||
<span>¥{detail.box_amount ?? '0.00'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-1">
|
||||
<Text type="secondary">
|
||||
周转托盘金额({detail.tray_num} × ¥{detail.tray_price ?? '0.00'})
|
||||
</Text>
|
||||
<span>¥{detail.tray_amount ?? '0.00'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-1">
|
||||
<Text type="secondary">商品总金额</Text>
|
||||
<span>¥{detail.product_amount}</span>
|
||||
</div>
|
||||
<div className="mt-1! flex justify-between border-t border-gray-200 pt-2">
|
||||
<Text strong>订单总金额</Text>
|
||||
<Text strong type="danger">
|
||||
¥{detail.total_amount}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Drawer>
|
||||
|
||||
{/* 生成采购单:合并已接单订单 */}
|
||||
<Modal
|
||||
title="生成采购单"
|
||||
open={generateOpen}
|
||||
onCancel={() => setGenerateOpen(false)}
|
||||
onOk={() => generateForm.submit()}
|
||||
confirmLoading={generateLoading}
|
||||
okText="确认生成"
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="py-2 text-gray-500">
|
||||
{selectedRowKeys.length > 0
|
||||
? `将合并选中的 ${selectedRowKeys.length} 笔订单生成一张采购单(须均为已接单状态,否则整批中止)。`
|
||||
: '未勾选订单时,将合并全部「已接单」订单生成一张采购单。'}
|
||||
生成后源订单自动转为「采购中」。
|
||||
</div>
|
||||
<Form form={generateForm} layout="vertical" onFinish={handleGeneratePurchase}>
|
||||
<Form.Item
|
||||
label="采购日期"
|
||||
name="purchase_date"
|
||||
initialValue={dayjs()}
|
||||
rules={[{ required: true, message: '请选择采购日期' }]}
|
||||
>
|
||||
<DatePicker className="w-full" allowClear={false} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 批量状态流转 */}
|
||||
<Modal
|
||||
title="批量状态流转"
|
||||
open={batchOpen}
|
||||
onCancel={() => setBatchOpen(false)}
|
||||
onOk={handleBatchStatus}
|
||||
confirmLoading={batchLoading}
|
||||
okText="确认流转"
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="py-2 text-gray-500">
|
||||
已勾选 {selectedRowKeys.length} 笔订单;任一订单不满足流转条件时,本次批量操作将整批中止,不做任何修改。
|
||||
</div>
|
||||
<Radio.Group
|
||||
className="w-full py-2"
|
||||
value={batchTarget}
|
||||
onChange={(e) => setBatchTarget(e.target.value as number)}
|
||||
>
|
||||
<Space orientation="vertical">
|
||||
{BATCH_TARGET_OPTIONS.map((opt) => (
|
||||
<Radio key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Radio>
|
||||
))}
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</Modal>
|
||||
|
||||
{/* 修改周转框/托盘数量 */}
|
||||
<Modal
|
||||
title="修改周转框/托盘"
|
||||
open={containerOpen}
|
||||
onCancel={() => setContainerOpen(false)}
|
||||
onOk={() => containerForm.submit()}
|
||||
confirmLoading={containerSaving}
|
||||
okText="保存"
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="py-2 text-gray-500">
|
||||
订单 {containerOrder?.order_no},保存后将按单价自动重算附加金额与订单总金额。
|
||||
</div>
|
||||
<Form form={containerForm} layout="vertical" onFinish={handleContainerSave}>
|
||||
<Form.Item
|
||||
label={`周转框数量(单价 ¥${containerOrder?.box_price ?? '0.00'})`}
|
||||
name="box_num"
|
||||
rules={[{ required: true, message: '请输入周转框数量' }]}
|
||||
>
|
||||
<InputNumber className="w-full" min={0} precision={0} placeholder="请输入周转框数量" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={`周转托盘数量(单价 ¥${containerOrder?.tray_price ?? '0.00'})`}
|
||||
name="tray_num"
|
||||
rules={[{ required: true, message: '请输入周转托盘数量' }]}
|
||||
>
|
||||
<InputNumber className="w-full" min={0} precision={0} placeholder="请输入周转托盘数量" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Space orientation="vertical" className="w-full rounded bg-gray-50 p-3">
|
||||
<div>
|
||||
<Text type="secondary">商品总金额:</Text>¥{containerOrder?.product_amount ?? '0.00'}
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">附加总金额:</Text>
|
||||
<Text strong>¥{previewAdded.toFixed(2)}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">订单总金额:</Text>
|
||||
<Text strong type="danger">¥{previewTotal.toFixed(2)}</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
{/* 编辑商品明细(保存后后端重算单品金额与订单总价) */}
|
||||
<Modal
|
||||
title="编辑商品明细"
|
||||
open={itemEditOpen}
|
||||
onCancel={() => setItemEditOpen(false)}
|
||||
onOk={() => itemForm.submit()}
|
||||
confirmLoading={itemSaving}
|
||||
okText="保存"
|
||||
width={640}
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="py-2 text-gray-500">
|
||||
订单 {detail?.order_no};修改保存后,系统将自动重算单品金额与订单总价。
|
||||
</div>
|
||||
<Form form={itemForm} layout="vertical" onFinish={handleItemSave}>
|
||||
<div className="grid grid-cols-2 gap-x-4">
|
||||
<Form.Item
|
||||
label="供应商"
|
||||
name="supplier_id"
|
||||
rules={[{ required: true, message: '请选择供应商' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch={{
|
||||
optionFilterProp: 'label'
|
||||
}}
|
||||
placeholder="请选择供应商"
|
||||
options={suppliers.map((s) => ({ label: s.name, value: s.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="品名"
|
||||
name="product_name"
|
||||
rules={[{ required: true, message: '请输入品名' }]}
|
||||
>
|
||||
<Input placeholder="请输入品名" maxLength={100} />
|
||||
</Form.Item>
|
||||
<Form.Item label="规格/包规" name="product_spec">
|
||||
<Input placeholder="请输入规格/包规" maxLength={100} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="计价单位"
|
||||
name="unit"
|
||||
rules={[{ required: true, message: '请输入计价单位' }]}
|
||||
>
|
||||
<Input placeholder="斤/件/箱等" maxLength={20} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="单价(元)"
|
||||
name="price"
|
||||
rules={[{ required: true, message: '请输入单价' }]}
|
||||
>
|
||||
<InputNumber className="w-full" min={0} precision={2} placeholder="请输入单价" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="成本价(元)"
|
||||
name="cost_price"
|
||||
rules={[{ required: true, message: '请输入成本价' }]}
|
||||
>
|
||||
<InputNumber className="w-full" min={0} precision={2} placeholder="请输入成本价" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="订货量"
|
||||
name="quantity"
|
||||
rules={[{ required: true, message: '请输入订货量' }]}
|
||||
>
|
||||
<InputNumber className="w-full" min={1} precision={0} placeholder="请输入订货量" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="重量"
|
||||
name="weight"
|
||||
rules={[{ required: true, message: '请输入重量' }]}
|
||||
>
|
||||
<InputNumber className="w-full" min={0} precision={3} placeholder="请输入重量" />
|
||||
</Form.Item>
|
||||
<Form.Item label="备注" name="remark">
|
||||
<TextArea className="w-full" placeholder="请输入备注" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+222
-38
@@ -6,6 +6,7 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag, Tree,
|
||||
@@ -29,14 +30,27 @@ import { batchPrice, getPriceMatrix } from '@/api/product/goods.ts';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/** 计价类型:0 固定价 / 1 成本百分比(与后端 ProductPriceModel::PRICE_TYPE_* 一致) */
|
||||
const PRICE_TYPE_FIXED = 0;
|
||||
const PRICE_TYPE_PERCENT = 1;
|
||||
const PRICE_TYPE_OPTIONS = [
|
||||
{ value: PRICE_TYPE_FIXED, label: '固定价' },
|
||||
{ value: PRICE_TYPE_PERCENT, label: '成本百分比' },
|
||||
];
|
||||
|
||||
/** 四舍五入保留两位 */
|
||||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||
|
||||
/**
|
||||
* 等级价格表单
|
||||
* 等级价格表单:每个等级 = 计价类型(固定价/成本百分比)+ 对应输入框;
|
||||
* 切换计价类型时按成本价自动换算(固定→百分比:percent=(price/cost-1)*100;百分比→固定:price=cost*(1+percent/100))
|
||||
*/
|
||||
const LevelPriceFields: React.FC<{
|
||||
form: FormInstance;
|
||||
levels: ICustomerLevel[];
|
||||
}> = ({ form, levels }) => {
|
||||
const prices = Form.useWatch<IProductPrice[]>('prices', form) ?? [];
|
||||
const costPrice = Number(Form.useWatch<string | number | undefined>('cost_price', form) ?? 0);
|
||||
|
||||
if (levels.length === 0) {
|
||||
return (
|
||||
@@ -46,30 +60,92 @@ const LevelPriceFields: React.FC<{
|
||||
);
|
||||
}
|
||||
|
||||
const setLevelPrice = (levelId: number, price: number | null) => {
|
||||
const updateRow = (levelId: number, patch: Partial<IProductPrice>) => {
|
||||
const next = prices.filter((p) => p.level_id !== levelId);
|
||||
if (price !== null) {
|
||||
next.push({ level_id: levelId, price });
|
||||
}
|
||||
next.push({ ...patch, level_id: levelId });
|
||||
form.setFieldValue('prices', next);
|
||||
};
|
||||
|
||||
const removeRow = (levelId: number) => {
|
||||
form.setFieldValue('prices', prices.filter((p) => p.level_id !== levelId));
|
||||
};
|
||||
|
||||
/** 切换计价类型:按成本价自动换算(成本未设置时百分比计价不可用) */
|
||||
const onTypeChange = (levelId: number, row: IProductPrice | undefined, type: number) => {
|
||||
if (type === PRICE_TYPE_PERCENT) {
|
||||
if (!(costPrice > 0)) {
|
||||
message.warning('请先在商品信息中设置成本价,才能按成本百分比计价');
|
||||
return; // Select 为受控组件,未更新表单值即回弹
|
||||
}
|
||||
const price = Number(row?.price ?? 0);
|
||||
updateRow(levelId, {
|
||||
price_type: type,
|
||||
percent: price > 0 ? round2((price / costPrice - 1) * 100) : null,
|
||||
});
|
||||
} else {
|
||||
const percent = Number(row?.percent ?? 0);
|
||||
updateRow(levelId, {
|
||||
price_type: type,
|
||||
price: costPrice > 0 && percent > 0 ? round2(costPrice * (1 + percent / 100)) : (row?.price ?? null),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space wrap>
|
||||
{levels.map((level) => {
|
||||
if (level.id == null) return null;
|
||||
const row = prices.find((p) => p.level_id === level.id);
|
||||
const type = row?.price_type ?? PRICE_TYPE_FIXED;
|
||||
return (
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={2}
|
||||
prefix={<span style={{ color: '#666' }}>{level.name}</span>}
|
||||
suffix={'¥'}
|
||||
placeholder="未设定"
|
||||
value={(row?.price as number | null) ?? null}
|
||||
onChange={(v) => setLevelPrice(level.id as number, v)}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Space key={level.id} wrap={false}>
|
||||
<Text style={{ width: 60, display: 'inline-block', textAlign: 'right' }}>{level.name}</Text>
|
||||
<Select
|
||||
size="middle"
|
||||
style={{ width: 110 }}
|
||||
value={type}
|
||||
options={PRICE_TYPE_OPTIONS}
|
||||
onChange={(v) => onTypeChange(level.id as number, row, v as number)}
|
||||
/>
|
||||
{type === PRICE_TYPE_PERCENT ? (
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
suffix={'%'}
|
||||
placeholder="上浮百分点"
|
||||
value={(row?.percent as number | null) ?? null}
|
||||
onChange={(v) => {
|
||||
if (v === null) {
|
||||
removeRow(level.id as number);
|
||||
return;
|
||||
}
|
||||
// 同步维护等价固定价 price,保证提交给后端的 price 与 percent 一致
|
||||
updateRow(level.id as number, {
|
||||
price_type: PRICE_TYPE_PERCENT,
|
||||
percent: v,
|
||||
price: costPrice > 0 ? round2(costPrice * (1 + v / 100)) : row?.price,
|
||||
});
|
||||
}}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
) : (
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={2}
|
||||
suffix={'¥'}
|
||||
placeholder="未设定"
|
||||
value={(row?.price as number | null) ?? null}
|
||||
onChange={(v) => {
|
||||
if (v === null) {
|
||||
removeRow(level.id as number);
|
||||
return;
|
||||
}
|
||||
updateRow(level.id as number, { price_type: PRICE_TYPE_FIXED, price: v });
|
||||
}}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
@@ -100,8 +176,12 @@ const ProductGoodsPage: React.FC = () => {
|
||||
const [matrixLevels, setMatrixLevels] = useState<ICustomerLevel[]>([]);
|
||||
const [matrixKeyword, setMatrixKeyword] = useState('');
|
||||
const [matrixCategory, setMatrixCategory] = useState<number | undefined>(undefined);
|
||||
/** 跨页未保存的调价:`${productId}:${levelId}` → price(用 ref 避免异步闭包读到旧值) */
|
||||
const matrixDirtyRef = useRef<Record<string, number | null>>({});
|
||||
/** 跨页未保存的等级调价:`${productId}:${levelId}` → { price, price_type }(用 ref 避免异步闭包读到旧值) */
|
||||
const matrixDirtyRef = useRef<Record<string, { price: number | null; price_type: number }>>({});
|
||||
/** 跨页未保存的成本价:productId → cost(null 视为未修改,不提交) */
|
||||
const costDirtyRef = useRef<Record<number, number | null>>({});
|
||||
/** 服务端原始成本价:productId → cost(loadMatrix 填充;跨页百分比行保存时反算 percent 用) */
|
||||
const serverCostRef = useRef<Record<number, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
getLevelOptions().then((res) => setLevels(res.data.data ?? []));
|
||||
@@ -124,14 +204,22 @@ const ProductGoodsPage: React.FC = () => {
|
||||
pageSize,
|
||||
});
|
||||
const rows = res.data.data?.rows ?? [];
|
||||
// 服务端原始成本价快照(供跨页百分比行保存时反算 percent)
|
||||
rows.forEach((row) => {
|
||||
serverCostRef.current[row.id] = Number(row.cost_price ?? 0);
|
||||
});
|
||||
// 叠加跨页未保存的修改,保证翻页后输入值不回退
|
||||
const dirty = matrixDirtyRef.current;
|
||||
const merged = rows.map((row) => {
|
||||
const next = { ...row };
|
||||
Object.keys(dirty).forEach((key) => {
|
||||
// 成本价修改
|
||||
if (costDirtyRef.current[row.id] !== undefined) {
|
||||
next.cost_price = costDirtyRef.current[row.id];
|
||||
}
|
||||
// 等级格修改(快照含 price_type)
|
||||
Object.entries(matrixDirtyRef.current).forEach(([key, snap]) => {
|
||||
const [pid, lid] = key.split(':');
|
||||
if (String(row.id) === pid) {
|
||||
next[`price_${lid}`] = dirty[key];
|
||||
next[`price_${lid}`] = snap.price;
|
||||
}
|
||||
});
|
||||
return next;
|
||||
@@ -157,21 +245,72 @@ const ProductGoodsPage: React.FC = () => {
|
||||
value: number | null
|
||||
) => {
|
||||
setMatrixRows((prev) =>
|
||||
prev.map((row) =>
|
||||
row.id === productId ? { ...row, [`price_${levelId}`]: value } : row
|
||||
)
|
||||
prev.map((row) => {
|
||||
if (row.id !== productId) return row;
|
||||
// 快照该格的计价类型(读当前行数据,避免闭包旧 state)
|
||||
const type = Number(row[`price_type_${levelId}`] ?? PRICE_TYPE_FIXED);
|
||||
matrixDirtyRef.current[`${productId}:${levelId}`] = { price: value, price_type: type };
|
||||
return { ...row, [`price_${levelId}`]: value };
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/** 修改成本价:联动重算「未被手工修改过」的百分比格显示值(percent 取服务端快照) */
|
||||
const onMatrixCostChange = (productId: number, value: number | null) => {
|
||||
costDirtyRef.current[productId] = value;
|
||||
setMatrixRows((prev) =>
|
||||
prev.map((row) => {
|
||||
if (row.id !== productId) return row;
|
||||
const next: IPriceMatrixRow = { ...row, cost_price: value };
|
||||
const cost = Number(value ?? 0);
|
||||
Object.keys(next).forEach((key) => {
|
||||
if (!key.startsWith('percent_')) return;
|
||||
const lid = key.replace('percent_', '');
|
||||
if (matrixDirtyRef.current[`${productId}:${lid}`]) return; // 已手工改过,不覆盖
|
||||
if (Number(next[`price_type_${lid}`]) !== PRICE_TYPE_PERCENT) return;
|
||||
const percent = Number(next[key] ?? 0);
|
||||
next[`price_${lid}`] = cost > 0 ? round2(cost * (1 + percent / 100)) : null;
|
||||
});
|
||||
return next;
|
||||
})
|
||||
);
|
||||
matrixDirtyRef.current[`${productId}:${levelId}`] = value;
|
||||
};
|
||||
|
||||
/** 提交所有跨页未保存的调价(null 视为清除,不提交) */
|
||||
const saveMatrix = async () => {
|
||||
const updates: IBatchPriceUpdate[] = [];
|
||||
Object.entries(matrixDirtyRef.current).forEach(([key, price]) => {
|
||||
if (price === null || price === undefined) return;
|
||||
const [pid, lid] = key.split(':');
|
||||
updates.push({ product_id: Number(pid), level_id: Number(lid), price });
|
||||
// 成本价行
|
||||
Object.entries(costDirtyRef.current).forEach(([pid, cost]) => {
|
||||
if (cost === null || cost === undefined) return;
|
||||
updates.push({ product_id: Number(pid), cost_price: cost });
|
||||
});
|
||||
// 等级价格行(按快照计价类型组装:百分比格按「当前成本价」反算上浮百分点)
|
||||
for (const [key, snap] of Object.entries(matrixDirtyRef.current)) {
|
||||
if (snap.price === null || snap.price === undefined) continue;
|
||||
const [pid, lid] = key.split(':');
|
||||
const productId = Number(pid);
|
||||
if (snap.price_type === PRICE_TYPE_PERCENT) {
|
||||
const cost = costDirtyRef.current[productId] ?? serverCostRef.current[productId] ?? 0;
|
||||
if (!(cost > 0)) {
|
||||
message.error(`商品 #${productId} 未设置成本价,无法保存百分比价格`);
|
||||
return;
|
||||
}
|
||||
updates.push({
|
||||
product_id: productId,
|
||||
level_id: Number(lid),
|
||||
price_type: PRICE_TYPE_PERCENT,
|
||||
percent: round2((snap.price / cost - 1) * 100),
|
||||
price: snap.price, // 等价固定价
|
||||
});
|
||||
} else {
|
||||
updates.push({
|
||||
product_id: productId,
|
||||
level_id: Number(lid),
|
||||
price_type: PRICE_TYPE_FIXED,
|
||||
price: snap.price,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (updates.length === 0) {
|
||||
message.info('没有需要保存的价格调整');
|
||||
return;
|
||||
@@ -181,6 +320,7 @@ const ProductGoodsPage: React.FC = () => {
|
||||
await batchPrice(updates);
|
||||
message.success(`已更新 ${updates.length} 条价格,受影响门店将收到通知`);
|
||||
matrixDirtyRef.current = {};
|
||||
costDirtyRef.current = {};
|
||||
await loadMatrix();
|
||||
} finally {
|
||||
setSaveLoading(false);
|
||||
@@ -203,21 +343,47 @@ const ProductGoodsPage: React.FC = () => {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...matrixLevels.map((level) => ({
|
||||
title: level.name,
|
||||
key: `price_${level.id}`,
|
||||
width: 150,
|
||||
{
|
||||
title: '成本价',
|
||||
key: 'cost_price',
|
||||
fixed: 'left',
|
||||
width: 130,
|
||||
render: (_: unknown, row: IPriceMatrixRow) => (
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
value={row[`price_${level.id}`] as number | null}
|
||||
onChange={(v) => onMatrixPriceChange(row.id, level.id!, v)}
|
||||
value={row.cost_price as number | null}
|
||||
onChange={(v) => onMatrixCostChange(row.id, v)}
|
||||
className="w-32"
|
||||
/>
|
||||
),
|
||||
},
|
||||
...matrixLevels.map((level) => ({
|
||||
title: level.name,
|
||||
key: `price_${level.id}`,
|
||||
width: 150,
|
||||
render: (_: unknown, row: IPriceMatrixRow) => {
|
||||
const isPercent = Number(row[`price_type_${level.id}`] ?? PRICE_TYPE_FIXED) === PRICE_TYPE_PERCENT;
|
||||
const percent = Number(row[`percent_${level.id}`] ?? 0);
|
||||
return (
|
||||
<div>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
value={row[`price_${level.id}`] as number | null}
|
||||
onChange={(v) => onMatrixPriceChange(row.id, level.id!, v)}
|
||||
className="w-32"
|
||||
/>
|
||||
{isPercent && (
|
||||
<div className="text-xs text-gray-400">成本上浮 {percent}%</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
})),
|
||||
];
|
||||
|
||||
@@ -349,20 +515,38 @@ const ProductGoodsPage: React.FC = () => {
|
||||
placeholder: '输入商品图文详情,支持插入图片',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '成本价',
|
||||
dataIndex: 'cost_price',
|
||||
valueType: 'digit',
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
fieldProps: { min: 0, precision: 2, prefix: '¥' },
|
||||
render: (_, record) => {
|
||||
const cost = Number(record.cost_price ?? 0);
|
||||
return cost > 0 ? `¥${record.cost_price}` : <Text type="secondary">未设置</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '等级价格',
|
||||
dataIndex: 'prices',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 370,
|
||||
width: 420,
|
||||
align: 'center',
|
||||
render: (_, record) => (
|
||||
<Space wrap>
|
||||
{record.prices?.length
|
||||
? record.prices.map((p) => (
|
||||
<Tag key={p.id} color="geekblue">
|
||||
<Tag
|
||||
key={p.id}
|
||||
color="geekblue"
|
||||
title={Number(p.price_type) === PRICE_TYPE_PERCENT ? `按成本价上浮 ${p.percent}%` : undefined}
|
||||
>
|
||||
{p.level?.name ?? `等级${p.level_id}`}
|
||||
<span style={{color: 'red', marginLeft: 5 }}>¥{p.price ?? '未设定'}</span>
|
||||
<span style={{color: 'red', marginLeft: 5 }}>
|
||||
¥{p.actual_price ?? p.price ?? '未设定'}
|
||||
</span>
|
||||
</Tag>
|
||||
))
|
||||
: '-'}
|
||||
@@ -533,7 +717,7 @@ const ProductGoodsPage: React.FC = () => {
|
||||
loadMatrix(matrixKeyword, matrixCategory, page, pageSize);
|
||||
},
|
||||
}}
|
||||
scroll={{ x: matrixLevels.length * 150 + 160 }}
|
||||
scroll={{ x: matrixLevels.length * 150 + 290 }}
|
||||
/>
|
||||
</Drawer>
|
||||
</>
|
||||
|
||||
@@ -457,7 +457,7 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="py-2 text-gray-500">
|
||||
将汇总所选日期全部「待汇总」订单,按商品聚合生成采购单(估算单价取最低等级价)。
|
||||
将合并全部「已接单」门店订单,按商品聚合生成采购单(估算单价取最低等级价),生成后源订单转为「采购中」。
|
||||
</div>
|
||||
<Form
|
||||
form={generateForm}
|
||||
@@ -480,7 +480,7 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
title={detail ? `采购单 ${detail.purchase_no}` : '采购单详情'}
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
width={1080}
|
||||
size={1080}
|
||||
loading={detailLoading}
|
||||
>
|
||||
{detail ? (
|
||||
|
||||
@@ -364,7 +364,8 @@ return app(ExportService::class)->download('settlement', $settlement, $format);
|
||||
| `/mini/statement/generate` | POST | `{period_start, period_end}`:`StatementGenerateService`——拉周期内订单明细,**快照当前 payment_cycle_days,settlement_date = period_end + cycle 天**;statement_no = ST… |
|
||||
| `/mini/statement/{id}` | GET | 详情(含单品对账状态标识) |
|
||||
| `/mini/statement/{id}/export` | GET | `?format=xlsx\|pdf`,`ExportService::download('statement', ...)`(blob) |
|
||||
| `/mini/store/paymentCycle` | PUT | `{payment_cycle_days}`(≥0,无上限) |
|
||||
| `/mini/store/info` | GET | 门店详情(编辑回显;name/code/payment_cycle_days 只读) |
|
||||
| `/mini/store/info` | PUT | 修改门店信息:仅 `{contact, phone, address}` 白名单更新;回款周期由后台维护 |
|
||||
| `/mini/notice` | GET | 本人通知 + 全员广播(`user_id in [0, 当前id]`),分页 + `unread_count` |
|
||||
| `/mini/notice/{id}/read` | PUT | 标记已读 + read_at |
|
||||
|
||||
|
||||
@@ -104,12 +104,3 @@ F3 页面:前端UI页面整体搭建
|
||||
3、对账管理模块(含结算表生成、下载存档)
|
||||
4、权限管理系统
|
||||
5、导出功能,支持Excel格式导出
|
||||
|
||||
阶段四:测试与上线(5天)
|
||||
1、功能测试、兼容性测试、性能测试
|
||||
2、小程序提交微信审核
|
||||
3、部署上线
|
||||
六、风险与注意事项
|
||||
1、特殊业务处理:周转柜、周转托盘、调货、售后、物流是否涉及重大变动,需业务方明确规则,否则影响系统设计和与开发进度
|
||||
2、价格实时性:后台调价后需确保门店端及时同步
|
||||
3、数据安全:对账单涉及金额数据,需做好权限隔离,确保门店仅可见自身数据
|
||||
|
||||
Reference in New Issue
Block a user