Compare commits

...

10 Commits

Author SHA1 Message Date
xinadmin ce1f36b5b4 订单软删除 2026-08-12 09:42:48 +08:00
xinadmin fa4bf6fe61 订单详情 2026-08-12 09:20:36 +08:00
xinadmin ce958d690f 订单详情 2026-08-11 23:26:34 +08:00
xinadmin 5d26f04ba2 生成采购单基础 2026-08-11 21:08:01 +08:00
xinadmin 06c7bd9f88 周转箱 2026-08-11 19:09:10 +08:00
xinadmin 5a3995d6ba 订单信息 2026-08-11 18:35:54 +08:00
xinadmin cba0e7d1bb 门店信息 2026-08-10 10:09:51 +08:00
xinadmin df7631c7a9 小程序登录优化 2026-08-10 08:55:22 +08:00
xinadmin c6f69c5c55 价格增加百分比上浮 2026-08-06 14:52:56 +08:00
xinadmin 07b08c8915 购物车 2026-08-06 10:49:51 +08:00
73 changed files with 4563 additions and 823 deletions
File diff suppressed because one or more lines are too long
+18
View File
@@ -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();
}
+73 -82
View File
@@ -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: usersprovider 指向 UserModel,与后台 sys_users 天然隔离);
* authorize: true 仅要求登录(sanctum + authGuard:users),不做细粒度权限点;
* token abilities ['mini'] 作来源标记。
* 小程序认证
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class AuthController extends BaseMiniController
{
/** 小程序登录wx.login 的 code → openid → 自动注册/登录 → 签发 token */
/** 小程序登录 */
#[PostRoute('/auth/login', authorize: false)]
public function login(Request $request): JsonResponse
{
$data = $request->validate([
'code' => 'required|string',
$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([], '购物车已清空');
}
}
+20 -4
View File
@@ -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,
+16 -10
View File
@@ -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']);
}
+35 -11
View File
@@ -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 批量调价:事务写入,写完后给受影响门店生成 Noticetype=price
* A2 批量调价:三类更新行(成本价 / 固定价 / 成本百分比,可混合同一行),事务写入,
* 写完后给受影响门店生成 Noticetype=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_idtype=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',
];
}
}
+46
View File
@@ -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');
}
}
+8
View File
@@ -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'];
/**
* 封面图片
*/
+54
View File
@@ -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),
);
}
}
+48 -2
View File
@@ -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');
}
}
+24 -10
View File
@@ -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',
];
/**
+6 -34
View File
@@ -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;
}
}
+4 -5
View File
@@ -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');
+52 -24
View File
@@ -14,12 +14,12 @@ use Illuminate\Support\Facades\DB;
* C1 订单汇总生成采购单
*
* 流程(事务内):
* 1. 行锁查询当日全部「待汇总」订单(无则报错;状态条件天然排除已汇总订单,幂等)
* 1. 行锁「已接单」订单(可传 orderIds 只合并指定订单;状态条件天然排除已归集订单,幂等)
* 2. 展开明细按商品聚合(Σquantity,快照品名/规格;供应商取商品默认供应商)
* 3. 估算单价 = 该商品最低等级价(product_price MIN),amount = quantity × 估算单价
* 3. 估算单价 = 该商品最低实际等级价(按计价类型换算后取 minPHP 侧兼容 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();
+2 -2
View File
@@ -11,8 +11,8 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* 微信小程序服务(基于 EasyWeChat 6.x
*
* 封装 code2Session / 手机号解密;配置读取 config('services.wechat.mini')
* envWECHAT_MINI_APPID / WECHAT_MINI_SECRET,需业务方提供)。
* 封装 code2Session / 手机号解密;配置读取 site_config('wechatMini')
* 后台「小程序设置」面板维护,存 sys_site_config 表)。
*
* 测试策略:通过 setHttpClient() 注入 Symfony MockHttpClient 拦截微信 HTTP 调用。
*/
+2 -1
View File
@@ -35,7 +35,8 @@
"Database\\Seeders\\": "database/seeders/"
},
"files": [
"modules/Common/helpers.php"
"modules/Common/helpers.php",
"app/Helpers/functions.php"
]
},
"autoload-dev": {
+25
View File
@@ -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]);
}
}
+51
View File
@@ -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]);
}
}
+55
View File
@@ -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('关联门店IDtype=1时有效)');
$table->integer('supplier_id')->default(0)->comment('关联供应商IDtype=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');
}
};
+1
View File
@@ -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' => '删除(仅已取消订单)'],
],
],
],
+6
View File
@@ -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();
+331
View File
@@ -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);
}
}
+4
View File
@@ -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
View File
@@ -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_configWechatService 经
// 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
{
+110
View File
@@ -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, '校验失败不应落库');
}
}
+377
View File
@@ -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');
}
}
+30 -4
View File
@@ -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', '尚未绑定门店,请联系客服处理');
}
/** 批量调价:事务写入 + 通知受影响门店用户(不影响无关门店) */
+93 -12
View File
@@ -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');
}
}
+4
View File
@@ -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);
+330
View File
@@ -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);
}
}
+174
View File
@@ -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 -4
View File
@@ -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
View File
@@ -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',
});
}
+7 -2
View File
@@ -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',
+3 -3
View File
@@ -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);
+1 -1
View File
@@ -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 });
-11
View File
@@ -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
View File
@@ -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;
}
+55 -9
View File
@@ -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' },
};
+25 -94
View File
@@ -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>
</>
+11 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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 → costnull 视为未修改,不提交) */
const costDirtyRef = useRef<Record<number, number | null>>({});
/** 服务端原始成本价:productId → costloadMatrix 填充;跨页百分比行保存时反算 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>
</>
+2 -2
View File
@@ -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 ? (
+2 -1
View File
@@ -364,7 +364,8 @@ return app(ExportService::class)->download('settlement', $settlement, $format);
| `/mini/statement/generate` | POST | `{period_start, period_end}``StatementGenerateService`——拉周期内订单明细,**快照当前 payment_cycle_dayssettlement_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 |
-9
View File
@@ -104,12 +104,3 @@ F3 页面:前端UI页面整体搭建
3、对账管理模块(含结算表生成、下载存档)
4、权限管理系统
5、导出功能,支持Excel格式导出
阶段四:测试与上线(5天)
1、功能测试、兼容性测试、性能测试
2、小程序提交微信审核
3、部署上线
六、风险与注意事项
1、特殊业务处理:周转柜、周转托盘、调货、售后、物流是否涉及重大变动,需业务方明确规则,否则影响系统设计和与开发进度
2、价格实时性:后台调价后需确保门店端及时同步
3、数据安全:对账单涉及金额数据,需做好权限隔离,确保门店仅可见自身数据