Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c59f3bc99 | |||
| 6c809d893b | |||
| f8cc40a609 |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Client;
|
||||||
|
|
||||||
|
use App\Exceptions\RepositoryException;
|
||||||
|
use App\Http\Requests\Client\SpecialBatchAddRequest;
|
||||||
|
use App\Http\Requests\Client\SpecialFormRequest;
|
||||||
|
use App\Models\HomeSpecialModel;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Modules\AnnoRoute\Attribute\DeleteRoute;
|
||||||
|
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||||
|
use Modules\AnnoRoute\Attribute\PostRoute;
|
||||||
|
use Modules\AnnoRoute\Attribute\PutRoute;
|
||||||
|
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||||
|
use Modules\Common\Http\Controllers\BaseController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 特价推荐商品配置(小程序首页)
|
||||||
|
*/
|
||||||
|
#[RequestAttribute('/client/special', 'client.special')]
|
||||||
|
class SpecialController extends BaseController
|
||||||
|
{
|
||||||
|
protected array $searchField = [
|
||||||
|
'status' => '=',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 推荐列表(默认按排序升序;keyword 按商品名模糊搜索) */
|
||||||
|
#[GetRoute(authorize: 'query')]
|
||||||
|
public function query(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$params = $request->all();
|
||||||
|
$pageSize = $params['pageSize'] ?? 10;
|
||||||
|
$query = $this->buildSearch($params, HomeSpecialModel::query()->with('product'));
|
||||||
|
|
||||||
|
$keyword = trim((string) ($params['keyword'] ?? ''));
|
||||||
|
if ($keyword !== '') {
|
||||||
|
$query->whereHas('product', static function ($q) use ($keyword) {
|
||||||
|
$q->where('name', 'like', '%' . $keyword . '%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $query->orderBy('sort')
|
||||||
|
->orderBy('id')
|
||||||
|
->paginate($pageSize)
|
||||||
|
->toArray();
|
||||||
|
return $this->success($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量添加推荐商品(已在推荐中的自动跳过) */
|
||||||
|
#[PostRoute('/batch', 'create')]
|
||||||
|
public function batchAdd(SpecialBatchAddRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$productIds = $request->validated()['product_ids'];
|
||||||
|
$exists = HomeSpecialModel::whereIn('product_id', $productIds)->pluck('product_id')->all();
|
||||||
|
$newIds = array_values(array_diff($productIds, $exists));
|
||||||
|
|
||||||
|
foreach ($newIds as $productId) {
|
||||||
|
HomeSpecialModel::create([
|
||||||
|
'product_id' => $productId,
|
||||||
|
'sort' => 0,
|
||||||
|
'status' => HomeSpecialModel::STATUS_NORMAL,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'added' => count($newIds),
|
||||||
|
'skipped' => count($productIds) - count($newIds),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑推荐(仅排序与状态) */
|
||||||
|
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||||
|
public function update(int $id, SpecialFormRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$model = HomeSpecialModel::find($id);
|
||||||
|
if (empty($model)) {
|
||||||
|
throw new RepositoryException('推荐不存在');
|
||||||
|
}
|
||||||
|
$model->update($request->validated());
|
||||||
|
return $this->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除推荐 */
|
||||||
|
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
|
||||||
|
public function delete(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$model = HomeSpecialModel::find($id);
|
||||||
|
if (empty($model)) {
|
||||||
|
throw new RepositoryException('推荐不存在');
|
||||||
|
}
|
||||||
|
$model->delete();
|
||||||
|
return $this->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量删除推荐 */
|
||||||
|
#[DeleteRoute('/batch', 'delete')]
|
||||||
|
public function batchDelete(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'ids' => 'required|array|min:1',
|
||||||
|
'ids.*' => 'integer|distinct',
|
||||||
|
], [
|
||||||
|
'ids.required' => '请选择要删除的推荐',
|
||||||
|
'ids.min' => '请选择要删除的推荐',
|
||||||
|
'ids.*.integer' => '推荐 ID 格式错误',
|
||||||
|
'ids.*.distinct' => '存在重复的推荐',
|
||||||
|
]);
|
||||||
|
HomeSpecialModel::whereIn('id', $data['ids'])->delete();
|
||||||
|
return $this->success();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ class ProductController extends BaseMiniController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$pageSize = (int) $request->input('pageSize', 10);
|
$pageSize = (int) $request->input('pageSize', 10);
|
||||||
$paginator = $query->orderBy('sort')
|
$paginator = $query->orderBy('sort', 'desc')
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->paginate($pageSize);
|
->paginate($pageSize);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Mini;
|
||||||
|
|
||||||
|
use App\Models\CustomerLevelModel;
|
||||||
|
use App\Models\HomeSpecialModel;
|
||||||
|
use App\Models\ProductModel;
|
||||||
|
use App\Services\CartService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||||
|
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小程序特价推荐
|
||||||
|
*/
|
||||||
|
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
||||||
|
class SpecialController extends BaseMiniController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 特价推荐商品列表(免登录浏览;行结构与 /mini/product/list 一致:
|
||||||
|
* 登录门店附等级价 price 与 cart_id/cart_quantity 供列表直接加减购物车;
|
||||||
|
* data.cart 为购物车悬浮球汇总,未登录返回零值结构)
|
||||||
|
*/
|
||||||
|
#[GetRoute('/special/list', authorize: false)]
|
||||||
|
public function specials(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$pageSize = (int) $request->input('pageSize', 10);
|
||||||
|
$paginator = HomeSpecialModel::query()
|
||||||
|
->where('status', HomeSpecialModel::STATUS_NORMAL)
|
||||||
|
->whereHas('product', static function ($q) {
|
||||||
|
$q->where('status', ProductModel::STATUS_ON);
|
||||||
|
})
|
||||||
|
->with('product')
|
||||||
|
->orderBy('sort')
|
||||||
|
->orderBy('id')
|
||||||
|
->paginate($pageSize);
|
||||||
|
|
||||||
|
// 当前门店的等级(售价 = 成本价 × (100 + 等级上浮比例) / 100)与购物车行
|
||||||
|
$store = $this->optionalStore($request);
|
||||||
|
$level = ($store !== null && $store->level_id > 0) ? $store->level : null;
|
||||||
|
$cartService = app(CartService::class);
|
||||||
|
$cartRows = $store !== null ? $cartService->cartRowMap($store->id) : [];
|
||||||
|
|
||||||
|
$paginator->getCollection()->transform(
|
||||||
|
static function (HomeSpecialModel $special) use ($level, $cartRows): array {
|
||||||
|
$product = $special->product;
|
||||||
|
$row = $product->toArray();
|
||||||
|
// 实际价(按等级上浮比例换算;成本价不随序列化输出)
|
||||||
|
$row['price'] = $level !== null
|
||||||
|
? CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent)
|
||||||
|
: null;
|
||||||
|
// 购物车数量(列表直接加减用;不在购物车为 0/'0.00')
|
||||||
|
$row['cart_id'] = $cartRows[$product->id]['id'] ?? 0;
|
||||||
|
$row['cart_quantity'] = $cartRows[$product->id]['quantity'] ?? '0.00';
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
$data = $paginator->toArray();
|
||||||
|
$data['cart'] = $cartService->summary($store);
|
||||||
|
|
||||||
|
return $this->success($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ class ProductController extends BaseController
|
|||||||
$params,
|
$params,
|
||||||
ProductModel::query()->with(['category:id,name', 'supplier:id,name'])
|
ProductModel::query()->with(['category:id,name', 'supplier:id,name'])
|
||||||
)
|
)
|
||||||
->orderBy('sort')
|
->orderBy('sort', 'desc')
|
||||||
->orderBy('id', 'desc')
|
->orderBy('id', 'desc')
|
||||||
->paginate($pageSize);
|
->paginate($pageSize);
|
||||||
$data->getCollection()->makeVisible('cost_price');
|
$data->getCollection()->makeVisible('cost_price');
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Client;
|
||||||
|
|
||||||
|
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 特价推荐 批量添加 验证
|
||||||
|
*/
|
||||||
|
class SpecialBatchAddRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
protected $stopOnFirstFailure = true;
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'product_ids' => 'required|array|min:1',
|
||||||
|
'product_ids.*' => 'integer|distinct|exists:product,id',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'product_ids.required' => '请选择要添加的商品',
|
||||||
|
'product_ids.min' => '请选择要添加的商品',
|
||||||
|
'product_ids.*.integer' => '商品 ID 格式错误',
|
||||||
|
'product_ids.*.distinct' => '存在重复的商品',
|
||||||
|
'product_ids.*.exists' => '所选商品不存在',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Client;
|
||||||
|
|
||||||
|
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 特价推荐 编辑 验证(仅排序与状态可编辑,商品不可更换)
|
||||||
|
*/
|
||||||
|
class SpecialFormRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
protected $stopOnFirstFailure = true;
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'sort' => 'nullable|integer|min:0',
|
||||||
|
'status' => 'required|integer|in:0,1',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'sort.min' => '排序不能小于 0',
|
||||||
|
'status.in' => '状态只能是 0 或 1',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小程序首页特价推荐商品
|
||||||
|
*/
|
||||||
|
class HomeSpecialModel extends Model
|
||||||
|
{
|
||||||
|
/** 状态:停用 */
|
||||||
|
public const int STATUS_DISABLED = 0;
|
||||||
|
/** 状态:正常 */
|
||||||
|
public const int STATUS_NORMAL = 1;
|
||||||
|
|
||||||
|
protected $table = 'mini_home_special';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'product_id',
|
||||||
|
'sort',
|
||||||
|
'status',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'product_id' => 'integer',
|
||||||
|
'sort' => 'integer',
|
||||||
|
'status' => 'integer',
|
||||||
|
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||||
|
'updated_at' => 'datetime:Y-m-d H:i:s',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联商品
|
||||||
|
*/
|
||||||
|
public function product(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ProductModel::class, 'product_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('mini_home_special')) {
|
||||||
|
Schema::create('mini_home_special', function (Blueprint $table) {
|
||||||
|
$table->increments('id')->comment('推荐ID');
|
||||||
|
$table->integer('product_id')->comment('商品ID');
|
||||||
|
$table->integer('sort')->default(0)->comment('排序(越小越靠前)');
|
||||||
|
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
||||||
|
$table->timestamps();
|
||||||
|
$table->unique('product_id', 'uk_mini_home_special_product');
|
||||||
|
$table->index(['status', 'sort'], 'idx_mini_home_special_status_sort');
|
||||||
|
$table->comment('小程序首页特价推荐商品');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('mini_home_special');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -271,6 +271,18 @@ class PermissionSeeder extends Seeder
|
|||||||
['type' => 'rule', 'key' => 'client.promo.delete', 'name' => '删除'],
|
['type' => 'rule', 'key' => 'client.promo.delete', 'name' => '删除'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'type' => 'route',
|
||||||
|
'key' => 'client.special',
|
||||||
|
'name' => '特价推荐',
|
||||||
|
'path' => '/client/special',
|
||||||
|
'children' => [
|
||||||
|
['type' => 'rule', 'key' => 'client.special.query', 'name' => '查询'],
|
||||||
|
['type' => 'rule', 'key' => 'client.special.create', 'name' => '新增'],
|
||||||
|
['type' => 'rule', 'key' => 'client.special.update', 'name' => '编辑'],
|
||||||
|
['type' => 'rule', 'key' => 'client.special.delete', 'name' => '删除'],
|
||||||
|
],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# 小程序接口文档:特价推荐
|
||||||
|
|
||||||
|
> 小程序首页「特价推荐」商品区:后台在「客户端配置 → 特价推荐」批量选择商品标记为推荐,
|
||||||
|
> 小程序端通过本接口分页获取推荐商品列表。
|
||||||
|
>
|
||||||
|
> 推荐仅标记商品、不设特价字段:**价格按登录门店的客户等级价展示**(售价 = 成本价 × (100 + 等级上浮比例) / 100),
|
||||||
|
> 与 `/mini/product/list` 价格口径一致。
|
||||||
|
|
||||||
|
## 通用约定
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 鉴权 | 免登录;携带门店 token(`Authorization: Bearer <token>`,登录见 `/mini/auth/login`)时返回等级价与购物车数量 |
|
||||||
|
| 响应格式 | `{ "success": true|false, "data": {...}, "msg": "..." }` |
|
||||||
|
| 金额单位 | 元,字符串两位小数(如 `26.00`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 特价推荐商品列表
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 请求方式 | `GET` |
|
||||||
|
| 路径 | `/mini/special/list` |
|
||||||
|
| 鉴权 | 无(可带门店 token) |
|
||||||
|
|
||||||
|
### 请求参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `page` | int | 否 | 页码,默认 1 |
|
||||||
|
| `pageSize` | int | 否 | 每页条数,默认 10 |
|
||||||
|
|
||||||
|
### 行为说明
|
||||||
|
|
||||||
|
- 仅返回「状态正常」且商品「已上架」的推荐,按推荐排序 `sort` 升序(越小越靠前);
|
||||||
|
- 行结构与 `/mini/product/list` 完全一致:商品基础字段 + `price` + `cart_id` / `cart_quantity`;
|
||||||
|
- 未登录 / 门店未设客户等级时 `price = null`,前端应引导登录后查看价格;
|
||||||
|
- `cost_price`(成本价)属于商业敏感字段,任何情况下都不会输出;
|
||||||
|
- `data.cart` 为购物车悬浮球汇总,未登录返回零值结构。
|
||||||
|
|
||||||
|
### 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": {
|
||||||
|
"current_page": 1,
|
||||||
|
"per_page": 10,
|
||||||
|
"total": 2,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"category_id": 2,
|
||||||
|
"name": "西红柿",
|
||||||
|
"spec": "约5斤/份",
|
||||||
|
"unit": "斤",
|
||||||
|
"price_unit": "元/斤",
|
||||||
|
"market": "新发地",
|
||||||
|
"image_ids": "321,322",
|
||||||
|
"images_arr": [
|
||||||
|
{ "id": 321, "preview_url": "https://example.com/storage/xxx.jpg" }
|
||||||
|
],
|
||||||
|
"status": 1,
|
||||||
|
"sort": 0,
|
||||||
|
"price": "3.50",
|
||||||
|
"cart_id": 88,
|
||||||
|
"cart_quantity": "2.00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 23,
|
||||||
|
"category_id": 3,
|
||||||
|
"name": "麒麟西瓜",
|
||||||
|
"spec": "约8斤/个",
|
||||||
|
"unit": "个",
|
||||||
|
"price_unit": "元/个",
|
||||||
|
"market": "岳各庄",
|
||||||
|
"image_ids": "340",
|
||||||
|
"images_arr": [
|
||||||
|
{ "id": 340, "preview_url": "https://example.com/storage/yyy.jpg" }
|
||||||
|
],
|
||||||
|
"status": 1,
|
||||||
|
"sort": 1,
|
||||||
|
"price": "26.00",
|
||||||
|
"cart_id": 0,
|
||||||
|
"cart_quantity": "0.00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cart": {
|
||||||
|
"count": 2,
|
||||||
|
"quantity": "2.00",
|
||||||
|
"amount": "7.00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `id` | 商品 ID,点击跳转商品详情 `/mini/product/{id}` |
|
||||||
|
| `price` | 当前登录门店的等级售价;未登录 / 未设等级为 `null` |
|
||||||
|
| `cart_id` | 该商品在当前门店购物车中的行 ID(0 = 不在购物车),列表直接加减购物车用 |
|
||||||
|
| `cart_quantity` | 购物车中该商品数量(不在购物车为 `"0.00"`) |
|
||||||
|
| `cart` | 购物车悬浮球汇总:`count` 行数 / `quantity` 总数量 / `amount` 总金额(元) |
|
||||||
|
|
||||||
|
### 小程序端调用示例
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 首页特价推荐区(带上拉加载更多)
|
||||||
|
Page({
|
||||||
|
data: { specials: [], page: 1, hasMore: true },
|
||||||
|
|
||||||
|
async loadSpecials() {
|
||||||
|
const res = await request.get('/mini/special/list', {
|
||||||
|
page: this.data.page,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
const { data: rows, total } = res.data;
|
||||||
|
this.setData({
|
||||||
|
specials: this.data.page === 1 ? rows : [...this.data.specials, ...rows],
|
||||||
|
hasMore: this.data.specials.length + rows.length < total,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onReachBottom() {
|
||||||
|
if (!this.data.hasMore) return;
|
||||||
|
this.setData({ page: this.data.page + 1 });
|
||||||
|
this.loadSpecials();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后台配置入口
|
||||||
|
|
||||||
|
PC 后台「客户端配置 → 特价推荐」:
|
||||||
|
|
||||||
|
| 操作 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 添加商品 | 按商品名称搜索(仅上架商品可选),支持一次多选批量添加;已在推荐中的商品自动跳过 |
|
||||||
|
| 批量删除 | 勾选后批量移除推荐(不影响商品档案本身) |
|
||||||
|
| 编辑 | 仅可调整排序与状态(正常/停用) |
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
|||||||
import{i as e}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as t,t as n}from"./jsx-runtime-CRBytmvs.js";import{t as r}from"./typography-DRFhazK9.js";import{t as i}from"./button-BILozH6U.js";import{r as a}from"./DoubleRightOutlined-Blfli1UU.js";import{n as o}from"./LockOutlined-B8eRH1x4.js";import{u as s}from"./XinForm-BPDYdeax.js";import{t as c}from"./modal-g8Q5gNCx.js";import{t as l}from"./form-B3R3kIqp.js";import{t as u}from"./DownloadOutlined-DJY9Mu8c.js";import{t as d}from"./XinTable-DOLiJ5rL.js";import{t as f}from"./AuthButton-DvrpreK4.js";import{t as p}from"./store-JDnJsv44.js";import{t as m}from"./download-DC9wDwqQ.js";var h=e(t(),1),g=e(o(),1);async function _(e,t,n){return m(`/recon/container-return/export`,{start_date:e,end_date:t,...n.length>0?{store_ids:n.join(`,`)}:{}},`回筐记录.xlsx`)}var v=n(),{Title:y,Text:b}=r,{RangePicker:x}=s,S=({value:e})=>{let t=Number(e??0);return t>0?(0,v.jsxs)(b,{strong:!0,type:`warning`,children:[`+`,t]}):t<0?(0,v.jsx)(b,{strong:!0,type:`success`,children:t}):(0,v.jsx)(b,{type:`secondary`,children:`0`})},C=({value:e})=>{let t=Number(e??0);return t>0?(0,v.jsxs)(b,{strong:!0,type:`warning`,children:[`+¥`,t.toFixed(2)]}):t<0?(0,v.jsxs)(b,{strong:!0,type:`success`,children:[`-¥`,Math.abs(t).toFixed(2)]}):(0,v.jsx)(b,{type:`secondary`,children:`¥0.00`})},w=()=>{let[e,t]=(0,h.useState)([]),[n,r]=(0,h.useState)(!1),[o,s]=(0,h.useState)(!1),[m]=l.useForm();(0,h.useEffect)(()=>{p().then(e=>t(e.data.data??[]))},[]);let w=async e=>{let[t,n]=e.date_range;s(!0);try{await _(t.format(`YYYY-MM-DD`),n.format(`YYYY-MM-DD`),e.store_ids??[]),r(!1)}finally{s(!1)}},T={api:`/recon/container-return`,columns:[{title:`门店`,dataIndex:`store_id`,valueType:`select`,hideInForm:!0,fieldProps:{options:e.map(e=>({label:e.name,value:e.id})),showSearch:!0,optionFilterProp:`label`},render:(e,t)=>t.store?.name??`门店#${t.store_id}`},{title:`关联账单`,dataIndex:`bill_id`,hideInForm:!0,hideInSearch:!0,render:(e,t)=>t.bill?(0,v.jsxs)(v.Fragment,{children:[(0,v.jsx)(b,{copyable:{text:t.bill.bill_no},children:t.bill.bill_no}),(0,v.jsx)(`div`,{className:`text-[12px] text-[#999]`,children:t.bill.bill_date})]}):`-`},{title:`压(回)筐数量`,dataIndex:`box_num`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>(0,v.jsx)(S,{value:t.box_num})},{title:`压(回)托盘数量`,dataIndex:`tray_num`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>(0,v.jsx)(S,{value:t.tray_num})},{title:`筐单价`,dataIndex:`box_price`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>`¥${Number(t.box_price??0).toFixed(2)}`},{title:`托盘单价`,dataIndex:`tray_price`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>`¥${Number(t.tray_price??0).toFixed(2)}`},{title:`抵扣(附加)金额`,dataIndex:`amount`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>(0,v.jsx)(C,{value:t.amount})},{title:`操作人`,dataIndex:`operator`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>t.operator?.nickname??`-`},{title:`记录时间`,dataIndex:`created_at`,valueType:`dateRange`,hideInForm:!0,hideInTable:!0,align:`center`},{title:`记录时间`,dataIndex:`created_at`,hideInForm:!0,hideInSearch:!0,align:`center`}],rowKey:`id`,accessName:`recon.containerReturn`,addShow:!1,editShow:!1,deleteShow:!1,formProps:!1,toolBarRender:e=>[(0,v.jsx)(f,{auth:`recon.containerReturn.export`,children:(0,v.jsx)(i,{icon:(0,v.jsx)(u,{}),onClick:()=>r(!0),children:`导出`})},`export`),e.columnSetting,e.hideBorder,e.reload,e.columnHeight]};return(0,v.jsxs)(v.Fragment,{children:[(0,v.jsxs)(`div`,{className:`mb-5`,children:[(0,v.jsx)(y,{level:3,children:`回筐记录`}),(0,v.jsx)(b,{type:`secondary`,children:`周转筐/托盘跟账单走:生成账单时按填写数量自动写入;正数=压筐(附加金额),负数=回筐(抵扣金额),数量为 0 不生成记录; 可按日期区间与门店汇总导出(行=日期,列=门店,含行列合计)。`})]}),(0,v.jsx)(d,{...T}),(0,v.jsxs)(c,{title:`导出回筐记录`,open:n,onCancel:()=>r(!1),onOk:()=>m.submit(),confirmLoading:o,okText:`导出`,destroyOnHidden:!0,children:[(0,v.jsx)(`div`,{className:`py-2 text-gray-500`,children:`按日期区间与门店导出抵扣(附加)金额汇总表:行=日期(同日记录合并),列=门店, 当天门店无记录填 0,含行合计与列合计。门店不选默认导出全部门店。`}),(0,v.jsxs)(l,{form:m,layout:`vertical`,onFinish:w,initialValues:{date_range:[(0,g.default)().startOf(`month`),(0,g.default)()],store_ids:[]},children:[(0,v.jsx)(l.Item,{label:`日期区间`,name:`date_range`,rules:[{required:!0,message:`请选择日期区间`}],children:(0,v.jsx)(x,{className:`w-full`,allowClear:!1})}),(0,v.jsx)(l.Item,{label:`门店`,name:`store_ids`,children:(0,v.jsx)(a,{mode:`multiple`,allowClear:!0,maxTagCount:`responsive`,placeholder:`全部门店`,showSearch:!0,optionFilterProp:`label`,options:e.map(e=>({label:e.name,value:e.id}))})})]})]})]})};export{w as default};
|
import{i as e}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as t,t as n}from"./jsx-runtime-CRBytmvs.js";import{t as r}from"./typography-DRFhazK9.js";import{t as i}from"./button-BILozH6U.js";import{r as a}from"./DoubleRightOutlined-Blfli1UU.js";import{n as o}from"./LockOutlined-B8eRH1x4.js";import{u as s}from"./XinForm-BPDYdeax.js";import{t as c}from"./modal-g8Q5gNCx.js";import{t as l}from"./form-B3R3kIqp.js";import{t as u}from"./DownloadOutlined-DJY9Mu8c.js";import{t as d}from"./XinTable-DOLiJ5rL.js";import{t as f}from"./AuthButton-DvrpreK4.js";import{t as p}from"./download-DC9wDwqQ.js";import{t as m}from"./store-JDnJsv44.js";var h=e(t(),1),g=e(o(),1);async function _(e,t,n){return p(`/recon/container-return/export`,{start_date:e,end_date:t,...n.length>0?{store_ids:n.join(`,`)}:{}},`回筐记录.xlsx`)}var v=n(),{Title:y,Text:b}=r,{RangePicker:x}=s,S=({value:e})=>{let t=Number(e??0);return t>0?(0,v.jsxs)(b,{strong:!0,type:`warning`,children:[`+`,t]}):t<0?(0,v.jsx)(b,{strong:!0,type:`success`,children:t}):(0,v.jsx)(b,{type:`secondary`,children:`0`})},C=({value:e})=>{let t=Number(e??0);return t>0?(0,v.jsxs)(b,{strong:!0,type:`warning`,children:[`+¥`,t.toFixed(2)]}):t<0?(0,v.jsxs)(b,{strong:!0,type:`success`,children:[`-¥`,Math.abs(t).toFixed(2)]}):(0,v.jsx)(b,{type:`secondary`,children:`¥0.00`})},w=()=>{let[e,t]=(0,h.useState)([]),[n,r]=(0,h.useState)(!1),[o,s]=(0,h.useState)(!1),[p]=l.useForm();(0,h.useEffect)(()=>{m().then(e=>t(e.data.data??[]))},[]);let w=async e=>{let[t,n]=e.date_range;s(!0);try{await _(t.format(`YYYY-MM-DD`),n.format(`YYYY-MM-DD`),e.store_ids??[]),r(!1)}finally{s(!1)}},T={api:`/recon/container-return`,columns:[{title:`门店`,dataIndex:`store_id`,valueType:`select`,hideInForm:!0,fieldProps:{options:e.map(e=>({label:e.name,value:e.id})),showSearch:!0,optionFilterProp:`label`},render:(e,t)=>t.store?.name??`门店#${t.store_id}`},{title:`关联账单`,dataIndex:`bill_id`,hideInForm:!0,hideInSearch:!0,render:(e,t)=>t.bill?(0,v.jsxs)(v.Fragment,{children:[(0,v.jsx)(b,{copyable:{text:t.bill.bill_no},children:t.bill.bill_no}),(0,v.jsx)(`div`,{className:`text-[12px] text-[#999]`,children:t.bill.bill_date})]}):`-`},{title:`压(回)筐数量`,dataIndex:`box_num`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>(0,v.jsx)(S,{value:t.box_num})},{title:`压(回)托盘数量`,dataIndex:`tray_num`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>(0,v.jsx)(S,{value:t.tray_num})},{title:`筐单价`,dataIndex:`box_price`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>`¥${Number(t.box_price??0).toFixed(2)}`},{title:`托盘单价`,dataIndex:`tray_price`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>`¥${Number(t.tray_price??0).toFixed(2)}`},{title:`抵扣(附加)金额`,dataIndex:`amount`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>(0,v.jsx)(C,{value:t.amount})},{title:`操作人`,dataIndex:`operator`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>t.operator?.nickname??`-`},{title:`记录时间`,dataIndex:`created_at`,valueType:`dateRange`,hideInForm:!0,hideInTable:!0,align:`center`},{title:`记录时间`,dataIndex:`created_at`,hideInForm:!0,hideInSearch:!0,align:`center`}],rowKey:`id`,accessName:`recon.containerReturn`,addShow:!1,editShow:!1,deleteShow:!1,formProps:!1,toolBarRender:e=>[(0,v.jsx)(f,{auth:`recon.containerReturn.export`,children:(0,v.jsx)(i,{icon:(0,v.jsx)(u,{}),onClick:()=>r(!0),children:`导出`})},`export`),e.columnSetting,e.hideBorder,e.reload,e.columnHeight]};return(0,v.jsxs)(v.Fragment,{children:[(0,v.jsxs)(`div`,{className:`mb-5`,children:[(0,v.jsx)(y,{level:3,children:`回筐记录`}),(0,v.jsx)(b,{type:`secondary`,children:`周转筐/托盘跟账单走:生成账单时按填写数量自动写入;正数=压筐(附加金额),负数=回筐(抵扣金额),数量为 0 不生成记录; 可按日期区间与门店汇总导出(行=日期,列=门店,含行列合计)。`})]}),(0,v.jsx)(d,{...T}),(0,v.jsxs)(c,{title:`导出回筐记录`,open:n,onCancel:()=>r(!1),onOk:()=>p.submit(),confirmLoading:o,okText:`导出`,destroyOnHidden:!0,children:[(0,v.jsx)(`div`,{className:`py-2 text-gray-500`,children:`按日期区间与门店导出抵扣(附加)金额汇总表:行=日期(同日记录合并),列=门店, 当天门店无记录填 0,含行合计与列合计。门店不选默认导出全部门店。`}),(0,v.jsxs)(l,{form:p,layout:`vertical`,onFinish:w,initialValues:{date_range:[(0,g.default)().startOf(`month`),(0,g.default)()],store_ids:[]},children:[(0,v.jsx)(l.Item,{label:`日期区间`,name:`date_range`,rules:[{required:!0,message:`请选择日期区间`}],children:(0,v.jsx)(x,{className:`w-full`,allowClear:!1})}),(0,v.jsx)(l.Item,{label:`门店`,name:`store_ids`,children:(0,v.jsx)(a,{mode:`multiple`,allowClear:!0,maxTagCount:`responsive`,placeholder:`全部门店`,showSearch:!0,optionFilterProp:`label`,options:e.map(e=>({label:e.name,value:e.id}))})})]})]})]})};export{w as default};
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
import{t as e}from"./request--UCyt0wo.js";import{t}from"./download-DC9wDwqQ.js";async function n(){return e({url:`/customer/supplier/options`,method:`get`})}async function r(t){return e({url:`/product/goods/options`,method:`get`,params:t?{keyword:t}:{}})}async function i(e={}){return t(`/product/goods/export`,e,`商品列表.xlsx`)}async function a(){return t(`/product/goods/export`,{template:1},`商品导入模板.xlsx`)}async function o(t){let n=new FormData;return n.append(`file`,t),e({url:`/product/goods/import`,method:`post`,data:n,headers:{"Content-Type":`multipart/form-data`},timeout:6e4})}async function s(t){return e({url:`/product/goods/batch`,method:`delete`,data:{ids:t}})}async function c(t){return e({url:`/product/goods/priceMatrix`,method:`get`,params:t})}async function l(t){return e({url:`/product/goods/batchPrice`,method:`put`,data:{updates:t}})}export{c as a,n as c,i,l as n,r as o,a as r,o as s,s as t};
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
import{t as e}from"./request--UCyt0wo.js";import{t}from"./download-DC9wDwqQ.js";async function n(t){return e({url:`/product/goods/options`,method:`get`,params:t?{keyword:t}:{}})}async function r(e={}){return t(`/product/goods/export`,e,`商品列表.xlsx`)}async function i(){return t(`/product/goods/export`,{template:1},`商品导入模板.xlsx`)}async function a(t){let n=new FormData;return n.append(`file`,t),e({url:`/product/goods/import`,method:`post`,data:n,headers:{"Content-Type":`multipart/form-data`},timeout:6e4})}async function o(t){return e({url:`/product/goods/batch`,method:`delete`,data:{ids:t}})}async function s(t){return e({url:`/product/goods/priceMatrix`,method:`get`,params:t})}async function c(t){return e({url:`/product/goods/batchPrice`,method:`put`,data:{updates:t}})}export{s as a,r as i,c as n,n as o,i as r,a as s,o as t};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
var e={0:{text:`下架`,color:`default`},1:{text:`上架`,color:`success`}};export{e as t};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
import{t as e}from"./request--UCyt0wo.js";import{t}from"./download-DC9wDwqQ.js";async function n(t,n){return e({url:`/purchase/order/generate`,method:`post`,data:{purchase_date:t,order_ids:n}})}async function r(t){return e({url:`/purchase/order/${t}`,method:`get`})}async function i(t,n,r){return e({url:`/purchase/order/${t}/row/${n}`,method:`put`,data:r})}async function a(t,n){return e({url:`/purchase/order/${t}/store`,method:`get`,params:{store_id:n}})}async function o(t){return e({url:`/purchase/order/${t}/bill/prepare`,method:`get`})}async function s(t,n){return e({url:`/purchase/order/${t}/bill`,method:`post`,data:{stores:n}})}async function c(t,n,r){return e({url:`/purchase/order/${t}/store/${n}/item`,method:`post`,data:r})}async function l(t,n,r,i){return e({url:`/purchase/order/${t}/store/${n}/item/${r}`,method:`put`,data:i})}async function u(t,n,r){return e({url:`/purchase/order/${t}/store/${n}/item/${r}`,method:`delete`})}async function d(e,n){return t(`/purchase/order/${e}/export`,n?{supplier_id:n}:{},`采购单_${e}.xlsx`)}async function f(e,n){return t(`/purchase/order/${e}/exportStores`,n?{store_id:n}:{},`门店购买详情_${e}.xlsx`)}async function p(e,n){return t(`/purchase/order/${e}/exportSuppliers`,n?{supplier_id:n}:{},`供应商采购明细_${e}.xlsx`)}export{s as a,r as c,i as d,l as f,p as i,a as l,d as n,n as o,f as r,o as s,c as t,u};
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
import{t as e}from"./request--UCyt0wo.js";import{t}from"./download-DC9wDwqQ.js";async function n(t,n){return e({url:`/purchase/order/generate`,method:`post`,data:{purchase_date:t,order_ids:n}})}async function r(t){return e({url:`/purchase/order/${t}`,method:`get`})}async function i(t,n,r){return e({url:`/purchase/order/${t}/row/${n}`,method:`put`,data:r})}async function a(t,n,r){return e({url:`/purchase/order/${t}/cell`,method:`get`,params:{product_id:n,store_id:r}})}async function o(t,n){return e({url:`/purchase/order/cell/${t}`,method:`put`,data:n})}async function s(t,n){return e({url:`/purchase/order/${t}/store`,method:`get`,params:{store_id:n}})}async function c(t){return e({url:`/purchase/order/${t}/bill/prepare`,method:`get`})}async function l(t,n){return e({url:`/purchase/order/${t}/bill`,method:`post`,data:{stores:n}})}async function u(t,n,r){return e({url:`/purchase/order/${t}/store/${n}/item`,method:`post`,data:r})}async function d(t,n,r,i){return e({url:`/purchase/order/${t}/store/${n}/item/${r}`,method:`put`,data:i})}async function f(t,n,r){return e({url:`/purchase/order/${t}/store/${n}/item/${r}`,method:`delete`})}async function p(e,n){return t(`/purchase/order/${e}/export`,n?{supplier_id:n}:{},`采购单_${e}.xlsx`)}async function m(e,n){return t(`/purchase/order/${e}/exportStores`,n?{store_id:n}:{},`门店购买详情_${e}.xlsx`)}async function h(e,n){return t(`/purchase/order/${e}/exportSuppliers`,n?{supplier_id:n}:{},`供应商采购明细_${e}.xlsx`)}export{l as a,a as c,f as d,o as f,h as i,r as l,d as m,p as n,n as o,i as p,m as r,c as s,u as t,s as u};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{i as e}from"./rolldown-runtime-BgaNhQyE.js";import{Dr as t,t as n}from"./jsx-runtime-CRBytmvs.js";import{t as r}from"./request--UCyt0wo.js";import{t as i}from"./typography-DRFhazK9.js";import{t as a}from"./PlusOutlined-B8K2rG8r.js";import{t as o}from"./button-BILozH6U.js";import{r as s}from"./DoubleRightOutlined-Blfli1UU.js";import{t as c}from"./modal-g8Q5gNCx.js";import{t as l}from"./form-B3R3kIqp.js";import{t as u}from"./image-CTRBNpBE.js";import{r as d}from"./PictureOutlined-CB8Pr7jP.js";import{t as f}from"./tag-DBV1bHre.js";import{t as p}from"./DeleteOutlined-DwOvobSM.js";import{t as m}from"./XinTable-DOLiJ5rL.js";import{t as h}from"./AuthButton-DvrpreK4.js";import{t as g}from"./iProduct-l9WpLyUD.js";import{o as _}from"./goods-DPA1smJU.js";var v=e(t(),1),y={0:{text:`停用`,color:`error`},1:{text:`正常`,color:`success`}};async function b(e){return r({url:`/client/special/batch`,method:`post`,data:{product_ids:e}})}async function x(e){return r({url:`/client/special/batch`,method:`delete`,data:{ids:e}})}var S=n(),{Title:C,Text:w}=i,T=()=>{let e=(0,v.useRef)(null),[t,n]=(0,v.useState)([]),[r,i]=(0,v.useState)(!1),T=()=>{window.$modal?.confirm({title:`确定要删除选中的 ${t.length} 条推荐吗?`,okText:`删除`,okButtonProps:{danger:!0},cancelText:`取消`,onOk:async()=>{i(!0);try{await x(t),d.success(`批量删除成功`),n([]),await e.current?.reload()}finally{i(!1)}}})},[E,D]=(0,v.useState)(!1),[O,k]=(0,v.useState)(!1),[A]=l.useForm(),[j,M]=(0,v.useState)([]),N=()=>{A.resetFields(),D(!0),j.length===0&&_().then(e=>M(e.data.data??[]))};return(0,S.jsxs)(S.Fragment,{children:[(0,S.jsxs)(`div`,{className:`mb-5`,children:[(0,S.jsx)(C,{level:3,children:`特价推荐`}),(0,S.jsx)(w,{type:`secondary`,children:`小程序首页「特价推荐」区展示的商品;价格按门店客户等级价展示,推荐停用或商品下架后不展示,排序越小越靠前。`})]}),(0,S.jsx)(m,{api:`/client/special`,columns:[{title:`ID`,dataIndex:`id`,hideInForm:!0,hideInSearch:!0,width:70,align:`center`},{title:`商品图片`,dataIndex:`image`,hideInForm:!0,hideInSearch:!0,align:`center`,width:100,render:(e,t)=>{let n=t.product?.images_arr?.[0]?.preview_url;return n?(0,S.jsx)(u,{src:n,width:60,height:60,style:{objectFit:`cover`,borderRadius:4}}):`-`}},{title:`商品名称`,dataIndex:`keyword`,hideInForm:!0,valueType:`text`,fieldProps:{placeholder:`按商品名称搜索`},render:(e,t)=>{let n=t.product;return n?`${n.name}${n.spec?`(${n.spec})`:``}`:`-`}},{title:`市场`,dataIndex:`market`,hideInForm:!0,hideInSearch:!0,align:`center`,render:(e,t)=>t.product?.market||`-`},{title:`计价单位`,dataIndex:`unit`,hideInForm:!0,hideInSearch:!0,align:`center`,width:90,render:(e,t)=>t.product?.unit||`-`},{title:`商品状态`,dataIndex:`product_status`,hideInForm:!0,hideInSearch:!0,align:`center`,width:100,render:(e,t)=>{let n=g[t.product?.status??1];return(0,S.jsx)(f,{color:n?.color,children:n?.text})}},{title:`排序`,dataIndex:`sort`,valueType:`digit`,hideInSearch:!0,initialValue:0,fieldProps:{min:0,precision:0},align:`center`,width:80},{title:`状态`,dataIndex:`status`,valueType:`radioButton`,initialValue:1,fieldProps:{options:[{value:1,label:`正常`},{value:0,label:`停用`}]},render:(e,t)=>{let n=y[t.status??1];return(0,S.jsx)(f,{color:n?.color,children:n?.text})},align:`center`,width:90},{title:`创建时间`,dataIndex:`created_at`,hideInForm:!0,hideInSearch:!0,align:`center`}],rowKey:`id`,accessName:`client.special`,tableRef:e,addShow:!1,rowSelection:{selectedRowKeys:t,onChange:e=>n(e.map(Number))},actionBarRender:e=>[e.search,(0,S.jsx)(h,{auth:`client.special.create`,children:(0,S.jsx)(o,{type:`primary`,icon:(0,S.jsx)(a,{}),onClick:N,children:`添加商品`})},`add`),(0,S.jsx)(h,{auth:`client.special.delete`,children:(0,S.jsxs)(o,{danger:!0,icon:(0,S.jsx)(p,{}),disabled:t.length===0,loading:r,onClick:T,children:[`批量删除`,t.length>0?` (${t.length})`:``]})},`batchDelete`)],formProps:{grid:!0,colProps:{span:12},layout:`vertical`},modalProps:{width:640}}),(0,S.jsx)(c,{title:`添加推荐商品`,open:E,onCancel:()=>D(!1),onOk:()=>A.submit(),confirmLoading:O,okText:`添加`,destroyOnHidden:!0,children:(0,S.jsx)(l,{form:A,layout:`vertical`,onFinish:async t=>{k(!0);try{let{added:n=0,skipped:r=0}=(await b(t.product_ids)).data.data??{};d.success(`已添加 ${n} 件推荐商品${r>0?`,${r} 件已在推荐中自动跳过`:``}`),D(!1),await e.current?.reload()}finally{k(!1)}},preserve:!1,children:(0,S.jsx)(l.Item,{label:`商品`,name:`product_ids`,rules:[{required:!0,message:`请选择商品`}],children:(0,S.jsx)(s,{mode:`multiple`,showSearch:{optionFilterProp:`label`},placeholder:`搜索并选择商品(可多选)`,options:j.map(e=>({value:e.id,label:`${e.name}${e.spec?`(${e.spec})`:``}`}))})})})})]})};export{T as default};
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
import{t as e}from"./request--UCyt0wo.js";async function t(){return e({url:`/customer/supplier/options`,method:`get`})}export{t};
|
||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicons.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicons.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>XinAdmin</title>
|
<title>XinAdmin</title>
|
||||||
<script type="module" crossorigin src="/assets/index-eBrnVZAn.js"></script>
|
<script type="module" crossorigin src="/assets/index-B3rYtk3Q.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-BgaNhQyE.js">
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-BgaNhQyE.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/jsx-runtime-CRBytmvs.js">
|
<link rel="modulepreload" crossorigin href="/assets/jsx-runtime-CRBytmvs.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/chunk-KS7C4IRE-Zm15rq6F.js">
|
<link rel="modulepreload" crossorigin href="/assets/chunk-KS7C4IRE-Zm15rq6F.js">
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
<link rel="modulepreload" crossorigin href="/assets/useMobile-Bcq0nkW4.js">
|
<link rel="modulepreload" crossorigin href="/assets/useMobile-Bcq0nkW4.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/dict-CDRllPHM.js">
|
<link rel="modulepreload" crossorigin href="/assets/dict-CDRllPHM.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/relativeTime-jamE_cdZ.js">
|
<link rel="modulepreload" crossorigin href="/assets/relativeTime-jamE_cdZ.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CkDjMKy9.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BeViyrx7.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\HomeSpecialModel;
|
||||||
|
use App\Models\ProductModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台特价推荐(客户端配置):批量添加(重复自动跳过)、商品名搜索、
|
||||||
|
* 编辑排序/状态、单个/批量删除、权限点拦截
|
||||||
|
*/
|
||||||
|
class ClientSpecialTest extends ProcurementTestCase
|
||||||
|
{
|
||||||
|
/** 批量添加成功:全部写入推荐表,added/skipped 计数正确 */
|
||||||
|
public function test_batch_add_creates_specials(): void
|
||||||
|
{
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
$ids = ProductModel::factory()->count(3)->create()->pluck('id')->all();
|
||||||
|
|
||||||
|
$this->postJson('/client/special/batch', ['product_ids' => $ids])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('success', true)
|
||||||
|
->assertJsonPath('data.added', 3)
|
||||||
|
->assertJsonPath('data.skipped', 0);
|
||||||
|
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$this->assertDatabaseHas('mini_home_special', [
|
||||||
|
'product_id' => $id,
|
||||||
|
'status' => HomeSpecialModel::STATUS_NORMAL,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重复添加:已在推荐中的商品自动跳过,不产生重复行 */
|
||||||
|
public function test_batch_add_skips_existing_products(): void
|
||||||
|
{
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
[$a, $b] = ProductModel::factory()->count(2)->create()->all();
|
||||||
|
HomeSpecialModel::create(['product_id' => $a->id, 'sort' => 0, 'status' => 1]);
|
||||||
|
|
||||||
|
$this->postJson('/client/special/batch', ['product_ids' => [$a->id, $b->id]])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('success', true)
|
||||||
|
->assertJsonPath('data.added', 1)
|
||||||
|
->assertJsonPath('data.skipped', 1);
|
||||||
|
|
||||||
|
$this->assertSame(1, HomeSpecialModel::where('product_id', $a->id)->count());
|
||||||
|
$this->assertSame(2, HomeSpecialModel::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** product_ids 为空数组 → 校验失败,不添加任何推荐 */
|
||||||
|
public function test_batch_add_requires_non_empty_product_ids(): void
|
||||||
|
{
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
ProductModel::factory()->create();
|
||||||
|
|
||||||
|
$this->postJson('/client/special/batch', ['product_ids' => []])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('success', false)
|
||||||
|
->assertJsonPath('msg', '请选择要添加的商品');
|
||||||
|
|
||||||
|
$this->assertSame(0, HomeSpecialModel::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 无 client.special.create 权限点 → 拦截 */
|
||||||
|
public function test_batch_add_forbidden_without_permission(): void
|
||||||
|
{
|
||||||
|
// 先建占位用户:每个测试方法内首个系统用户自增 id=1,超管旁路会绕过 abilities 校验
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
$this->actingAsSysUser(['client.special.query']);
|
||||||
|
|
||||||
|
$product = ProductModel::factory()->create();
|
||||||
|
|
||||||
|
$this->postJson('/client/special/batch', ['product_ids' => [$product->id]])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('success', false)
|
||||||
|
->assertJsonPath('msg', 'No Permission');
|
||||||
|
|
||||||
|
$this->assertSame(0, HomeSpecialModel::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表 keyword 按商品名模糊过滤,行内附带商品信息 */
|
||||||
|
public function test_query_filters_by_product_name_keyword(): void
|
||||||
|
{
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
$cabbage = ProductModel::factory()->create(['name' => '有机大白菜']);
|
||||||
|
$apple = ProductModel::factory()->create(['name' => '红富士苹果']);
|
||||||
|
HomeSpecialModel::create(['product_id' => $cabbage->id, 'sort' => 0, 'status' => 1]);
|
||||||
|
HomeSpecialModel::create(['product_id' => $apple->id, 'sort' => 0, 'status' => 1]);
|
||||||
|
|
||||||
|
$data = $this->getJson('/client/special?keyword=' . urlencode('白菜'))
|
||||||
|
->assertOk()
|
||||||
|
->json('data');
|
||||||
|
|
||||||
|
$this->assertSame(1, $data['total']);
|
||||||
|
$this->assertSame('有机大白菜', $data['data'][0]['product']['name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑:仅排序与状态可改 */
|
||||||
|
public function test_update_changes_sort_and_status(): void
|
||||||
|
{
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
$special = HomeSpecialModel::create([
|
||||||
|
'product_id' => ProductModel::factory()->create()->id,
|
||||||
|
'sort' => 0,
|
||||||
|
'status' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->putJson("/client/special/{$special->id}", ['sort' => 5, 'status' => 0])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('success', true);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('mini_home_special', [
|
||||||
|
'id' => $special->id,
|
||||||
|
'sort' => 5,
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单个删除 + 批量删除:选中的删除,未选中的保留 */
|
||||||
|
public function test_delete_and_batch_delete(): void
|
||||||
|
{
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
$products = ProductModel::factory()->count(3)->create();
|
||||||
|
$specials = $products->map(static fn ($p) => HomeSpecialModel::create([
|
||||||
|
'product_id' => $p->id, 'sort' => 0, 'status' => 1,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->deleteJson("/client/special/{$specials[0]->id}")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('success', true);
|
||||||
|
$this->assertDatabaseMissing('mini_home_special', ['id' => $specials[0]->id]);
|
||||||
|
|
||||||
|
$this->deleteJson('/client/special/batch', ['ids' => [$specials[1]->id]])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('success', true);
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('mini_home_special', ['id' => $specials[1]->id]);
|
||||||
|
$this->assertDatabaseHas('mini_home_special', ['id' => $specials[2]->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量删除 ids 为空 → 校验失败 */
|
||||||
|
public function test_batch_delete_requires_non_empty_ids(): void
|
||||||
|
{
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
$special = HomeSpecialModel::create([
|
||||||
|
'product_id' => ProductModel::factory()->create()->id,
|
||||||
|
'sort' => 0,
|
||||||
|
'status' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->deleteJson('/client/special/batch', ['ids' => []])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('success', false)
|
||||||
|
->assertJsonPath('msg', '请选择要删除的推荐');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('mini_home_special', ['id' => $special->id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\CartModel;
|
||||||
|
use App\Models\CustomerLevelModel;
|
||||||
|
use App\Models\HomeSpecialModel;
|
||||||
|
use App\Models\ProductModel;
|
||||||
|
use App\Models\StoreModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小程序特价推荐列表:免登录浏览 price=null、登录门店按等级上浮换算价、
|
||||||
|
* 停用推荐/下架商品不展示、按推荐排序升序、购物车数量随行输出
|
||||||
|
*/
|
||||||
|
class MiniSpecialTest extends ProcurementTestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 造门店 + 上架商品 + 正常推荐
|
||||||
|
*
|
||||||
|
* @return array{0: StoreModel, 1: ProductModel, 2: CustomerLevelModel, 3: HomeSpecialModel}
|
||||||
|
*/
|
||||||
|
private function makeSpecial(string $price = '5.00', int $sort = 0): array
|
||||||
|
{
|
||||||
|
$level = CustomerLevelModel::factory()->create();
|
||||||
|
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||||
|
$product = ProductModel::factory()->create([
|
||||||
|
'status' => ProductModel::STATUS_ON,
|
||||||
|
'cost_price' => $price,
|
||||||
|
]);
|
||||||
|
$special = HomeSpecialModel::create([
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'sort' => $sort,
|
||||||
|
'status' => HomeSpecialModel::STATUS_NORMAL,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [$store, $product, $level, $special];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 游客:返回推荐商品,price=null,成本价不输出 */
|
||||||
|
public function test_guest_sees_specials_with_null_price(): void
|
||||||
|
{
|
||||||
|
[, $product] = $this->makeSpecial();
|
||||||
|
|
||||||
|
$data = $this->getJson('/mini/special/list')->assertOk()->json('data');
|
||||||
|
|
||||||
|
$this->assertSame(1, $data['total']);
|
||||||
|
$row = $data['data'][0];
|
||||||
|
$this->assertSame($product->name, $row['name']);
|
||||||
|
$this->assertNull($row['price'], '未登录不显示价格');
|
||||||
|
$this->assertArrayNotHasKey('cost_price', $row, '成本价不得输出到小程序端');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录门店:按等级上浮比例换算价(成本价 20 上浮 30% = 26.00) */
|
||||||
|
public function test_store_sees_level_price(): void
|
||||||
|
{
|
||||||
|
[$store, $product, $level] = $this->makeSpecial('20.00');
|
||||||
|
$level->update(['percent' => 30]);
|
||||||
|
$this->actingAsMiniStore($store);
|
||||||
|
|
||||||
|
$row = $this->getJson('/mini/special/list')->assertOk()->json('data.data.0');
|
||||||
|
|
||||||
|
$this->assertSame($product->id, $row['id']);
|
||||||
|
$this->assertSame('26.00', $row['price']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停用推荐与下架商品的推荐不展示 */
|
||||||
|
public function test_disabled_special_and_off_shelf_product_excluded(): void
|
||||||
|
{
|
||||||
|
[, $productOn] = $this->makeSpecial();
|
||||||
|
|
||||||
|
$offProduct = ProductModel::factory()->create(['status' => ProductModel::STATUS_OFF]);
|
||||||
|
HomeSpecialModel::create(['product_id' => $offProduct->id, 'sort' => 0, 'status' => 1]);
|
||||||
|
|
||||||
|
$disabledProduct = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||||
|
HomeSpecialModel::create(['product_id' => $disabledProduct->id, 'sort' => 0, 'status' => 0]);
|
||||||
|
|
||||||
|
$data = $this->getJson('/mini/special/list')->assertOk()->json('data');
|
||||||
|
|
||||||
|
$this->assertSame(1, $data['total']);
|
||||||
|
$this->assertSame($productOn->id, $data['data'][0]['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按推荐排序 sort 升序(越小越靠前) */
|
||||||
|
public function test_specials_sorted_by_sort_asc(): void
|
||||||
|
{
|
||||||
|
[, $productB] = $this->makeSpecial('5.00', 5);
|
||||||
|
[, $productA] = $this->makeSpecial('6.00', 1);
|
||||||
|
|
||||||
|
$rows = $this->getJson('/mini/special/list')->assertOk()->json('data.data');
|
||||||
|
|
||||||
|
$this->assertSame($productA->id, $rows[0]['id'], 'sort=1 应排在 sort=5 前');
|
||||||
|
$this->assertSame($productB->id, $rows[1]['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录门店:购物车行 ID 与数量随行输出(供列表直接加减) */
|
||||||
|
public function test_cart_quantity_attached_for_store(): void
|
||||||
|
{
|
||||||
|
[$store, $product] = $this->makeSpecial();
|
||||||
|
CartModel::create([
|
||||||
|
'store_id' => $store->id,
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'quantity' => '2.00',
|
||||||
|
]);
|
||||||
|
$this->actingAsMiniStore($store);
|
||||||
|
|
||||||
|
$row = $this->getJson('/mini/special/list')->assertOk()->json('data.data.0');
|
||||||
|
|
||||||
|
$this->assertGreaterThan(0, $row['cart_id']);
|
||||||
|
$this->assertSame('2.00', $row['cart_quantity']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import createAxios from '@/utils/request';
|
||||||
|
|
||||||
|
/** 批量添加特价推荐商品(已在推荐中的自动跳过,返回 added/skipped 计数) */
|
||||||
|
export async function batchAddSpecials(product_ids: number[]) {
|
||||||
|
return createAxios<{ added: number; skipped: number }>({
|
||||||
|
url: '/client/special/batch',
|
||||||
|
method: 'post',
|
||||||
|
data: { product_ids },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量删除特价推荐 */
|
||||||
|
export async function batchDeleteSpecials(ids: number[]) {
|
||||||
|
return createAxios({
|
||||||
|
url: '/client/special/batch',
|
||||||
|
method: 'delete',
|
||||||
|
data: { ids },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type IProduct from '@/domain/iProduct.ts';
|
||||||
|
|
||||||
|
/** 小程序首页特价推荐商品 */
|
||||||
|
export default interface IHomeSpecial {
|
||||||
|
id?: number;
|
||||||
|
product_id?: number;
|
||||||
|
product?: IProduct;
|
||||||
|
sort?: number;
|
||||||
|
status?: number;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HOME_SPECIAL_STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||||
|
0: { text: '停用', color: 'error' },
|
||||||
|
1: { text: '正常', color: 'success' },
|
||||||
|
};
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import { Button, Form, Image, message, Modal, Select, Tag, Typography } from 'antd';
|
||||||
|
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
|
import XinTable from '@/components/XinTable';
|
||||||
|
import AuthButton from '@/components/AuthButton';
|
||||||
|
import type { XinTableColumn, XinTableInstance, XinTableProps } from '@/components/XinTable/typings.ts';
|
||||||
|
import type IHomeSpecial from '@/domain/iHomeSpecial.ts';
|
||||||
|
import { HOME_SPECIAL_STATUS_MAP } from '@/domain/iHomeSpecial.ts';
|
||||||
|
import type IProduct from '@/domain/iProduct.ts';
|
||||||
|
import { PRODUCT_STATUS_MAP } from '@/domain/iProduct.ts';
|
||||||
|
import { getProductOptions } from '@/api/product/goods.ts';
|
||||||
|
import { batchAddSpecials, batchDeleteSpecials } from '@/api/client/special.ts';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 特价推荐商品配置(小程序首页)
|
||||||
|
*
|
||||||
|
* 仅标记推荐商品,不设特价字段:小程序端按门店客户等级价展示;
|
||||||
|
* 推荐停用或商品下架后不展示,排序越小越靠前。
|
||||||
|
*/
|
||||||
|
const HomeSpecialPage: React.FC = () => {
|
||||||
|
const tableRef = useRef<XinTableInstance<IHomeSpecial> | null>(null);
|
||||||
|
|
||||||
|
// ===== 批量删除 =====
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||||
|
const [batchDeleting, setBatchDeleting] = useState(false);
|
||||||
|
|
||||||
|
/** 批量删除(成功后清空勾选并刷新列表) */
|
||||||
|
const handleBatchDelete = () => {
|
||||||
|
window.$modal?.confirm({
|
||||||
|
title: `确定要删除选中的 ${selectedRowKeys.length} 条推荐吗?`,
|
||||||
|
okText: '删除',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
setBatchDeleting(true);
|
||||||
|
try {
|
||||||
|
await batchDeleteSpecials(selectedRowKeys);
|
||||||
|
message.success('批量删除成功');
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
await tableRef.current?.reload();
|
||||||
|
} finally {
|
||||||
|
setBatchDeleting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== 批量添加 =====
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [addSaving, setAddSaving] = useState(false);
|
||||||
|
const [addForm] = Form.useForm();
|
||||||
|
const [productOptions, setProductOptions] = useState<IProduct[]>([]);
|
||||||
|
|
||||||
|
/** 打开添加弹窗(首次打开时加载上架商品选项) */
|
||||||
|
const openAdd = () => {
|
||||||
|
addForm.resetFields();
|
||||||
|
setAddOpen(true);
|
||||||
|
if (productOptions.length === 0) {
|
||||||
|
getProductOptions().then((res) => setProductOptions(res.data.data ?? []));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 提交批量添加(重复推荐后端自动跳过) */
|
||||||
|
const handleAddSave = async (values: { product_ids: number[] }) => {
|
||||||
|
setAddSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await batchAddSpecials(values.product_ids);
|
||||||
|
const { added = 0, skipped = 0 } = res.data.data ?? {};
|
||||||
|
message.success(`已添加 ${added} 件推荐商品${skipped > 0 ? `,${skipped} 件已在推荐中自动跳过` : ''}`);
|
||||||
|
setAddOpen(false);
|
||||||
|
await tableRef.current?.reload();
|
||||||
|
} finally {
|
||||||
|
setAddSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: XinTableColumn<IHomeSpecial>[] = [
|
||||||
|
{
|
||||||
|
title: 'ID',
|
||||||
|
dataIndex: 'id',
|
||||||
|
hideInForm: true,
|
||||||
|
hideInSearch: true,
|
||||||
|
width: 70,
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '商品图片',
|
||||||
|
dataIndex: 'image',
|
||||||
|
hideInForm: true,
|
||||||
|
hideInSearch: true,
|
||||||
|
align: 'center',
|
||||||
|
width: 100,
|
||||||
|
render: (_, record) => {
|
||||||
|
const url = record.product?.images_arr?.[0]?.preview_url;
|
||||||
|
if (!url) return '-';
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src={url}
|
||||||
|
width={60}
|
||||||
|
height={60}
|
||||||
|
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '商品名称',
|
||||||
|
dataIndex: 'keyword',
|
||||||
|
hideInForm: true,
|
||||||
|
valueType: 'text',
|
||||||
|
fieldProps: { placeholder: '按商品名称搜索' },
|
||||||
|
render: (_, record) => {
|
||||||
|
const product = record.product;
|
||||||
|
if (!product) return '-';
|
||||||
|
return `${product.name}${product.spec ? `(${product.spec})` : ''}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '市场',
|
||||||
|
dataIndex: 'market',
|
||||||
|
hideInForm: true,
|
||||||
|
hideInSearch: true,
|
||||||
|
align: 'center',
|
||||||
|
render: (_, record) => record.product?.market || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '计价单位',
|
||||||
|
dataIndex: 'unit',
|
||||||
|
hideInForm: true,
|
||||||
|
hideInSearch: true,
|
||||||
|
align: 'center',
|
||||||
|
width: 90,
|
||||||
|
render: (_, record) => record.product?.unit || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '商品状态',
|
||||||
|
dataIndex: 'product_status',
|
||||||
|
hideInForm: true,
|
||||||
|
hideInSearch: true,
|
||||||
|
align: 'center',
|
||||||
|
width: 100,
|
||||||
|
render: (_, record) => {
|
||||||
|
const item = PRODUCT_STATUS_MAP[record.product?.status ?? 1];
|
||||||
|
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '排序',
|
||||||
|
dataIndex: 'sort',
|
||||||
|
valueType: 'digit',
|
||||||
|
hideInSearch: true,
|
||||||
|
initialValue: 0,
|
||||||
|
fieldProps: { min: 0, precision: 0 },
|
||||||
|
align: 'center',
|
||||||
|
width: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
valueType: 'radioButton',
|
||||||
|
initialValue: 1,
|
||||||
|
fieldProps: {
|
||||||
|
options: [
|
||||||
|
{ value: 1, label: '正常' },
|
||||||
|
{ value: 0, label: '停用' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
render: (_, record) => {
|
||||||
|
const item = HOME_SPECIAL_STATUS_MAP[record.status ?? 1];
|
||||||
|
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||||
|
},
|
||||||
|
align: 'center',
|
||||||
|
width: 90,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
hideInForm: true,
|
||||||
|
hideInSearch: true,
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const tableProps: XinTableProps<IHomeSpecial> = {
|
||||||
|
api: '/client/special',
|
||||||
|
columns,
|
||||||
|
rowKey: 'id',
|
||||||
|
accessName: 'client.special',
|
||||||
|
tableRef,
|
||||||
|
addShow: false,
|
||||||
|
rowSelection: {
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: (keys) => setSelectedRowKeys(keys.map(Number)),
|
||||||
|
},
|
||||||
|
actionBarRender: (dom) => [
|
||||||
|
dom.search,
|
||||||
|
<AuthButton key="add" auth="client.special.create">
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
|
||||||
|
添加商品
|
||||||
|
</Button>
|
||||||
|
</AuthButton>,
|
||||||
|
<AuthButton key="batchDelete" auth="client.special.delete">
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
disabled={selectedRowKeys.length === 0}
|
||||||
|
loading={batchDeleting}
|
||||||
|
onClick={handleBatchDelete}
|
||||||
|
>
|
||||||
|
批量删除{selectedRowKeys.length > 0 ? ` (${selectedRowKeys.length})` : ''}
|
||||||
|
</Button>
|
||||||
|
</AuthButton>,
|
||||||
|
],
|
||||||
|
formProps: {
|
||||||
|
grid: true,
|
||||||
|
colProps: { span: 12 },
|
||||||
|
layout: 'vertical',
|
||||||
|
},
|
||||||
|
modalProps: { width: 640 },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-5">
|
||||||
|
<Title level={3}>特价推荐</Title>
|
||||||
|
<Text type="secondary">
|
||||||
|
小程序首页「特价推荐」区展示的商品;价格按门店客户等级价展示,推荐停用或商品下架后不展示,排序越小越靠前。
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<XinTable<IHomeSpecial> {...tableProps} />
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="添加推荐商品"
|
||||||
|
open={addOpen}
|
||||||
|
onCancel={() => setAddOpen(false)}
|
||||||
|
onOk={() => addForm.submit()}
|
||||||
|
confirmLoading={addSaving}
|
||||||
|
okText="添加"
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form form={addForm} layout="vertical" onFinish={handleAddSave} preserve={false}>
|
||||||
|
<Form.Item
|
||||||
|
label="商品"
|
||||||
|
name="product_ids"
|
||||||
|
rules={[{ required: true, message: '请选择商品' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
showSearch={{ optionFilterProp: 'label' }}
|
||||||
|
placeholder="搜索并选择商品(可多选)"
|
||||||
|
options={productOptions.map((p) => ({
|
||||||
|
value: p.id!,
|
||||||
|
label: `${p.name}${p.spec ? `(${p.spec})` : ''}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HomeSpecialPage;
|
||||||
+234
-365
@@ -5,7 +5,6 @@ import {
|
|||||||
Drawer,
|
Drawer,
|
||||||
Empty,
|
Empty,
|
||||||
Form,
|
Form,
|
||||||
Image,
|
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
message,
|
message,
|
||||||
@@ -32,8 +31,6 @@ import type IPurchaseOrder from '@/domain/iPurchaseOrder.ts';
|
|||||||
import type {
|
import type {
|
||||||
IBill,
|
IBill,
|
||||||
IBillPrepare,
|
IBillPrepare,
|
||||||
IPurchaseCell,
|
|
||||||
IPurchaseCellItem,
|
|
||||||
IPurchaseDetail,
|
IPurchaseDetail,
|
||||||
IPurchaseDetailRow,
|
IPurchaseDetailRow,
|
||||||
IPurchaseStoreItem,
|
IPurchaseStoreItem,
|
||||||
@@ -43,7 +40,6 @@ import type {
|
|||||||
PurchaseStoreItemUpdateParams,
|
PurchaseStoreItemUpdateParams,
|
||||||
} from '@/domain/iPurchaseOrder.ts';
|
} from '@/domain/iPurchaseOrder.ts';
|
||||||
import { PURCHASE_STATUS_MAP, BILL_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
|
import { PURCHASE_STATUS_MAP, BILL_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
|
||||||
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
|
|
||||||
import {
|
import {
|
||||||
addPurchaseStoreItem,
|
addPurchaseStoreItem,
|
||||||
exportPurchase,
|
exportPurchase,
|
||||||
@@ -51,14 +47,11 @@ import {
|
|||||||
exportPurchaseSuppliers,
|
exportPurchaseSuppliers,
|
||||||
generateBill,
|
generateBill,
|
||||||
getBillPrepare,
|
getBillPrepare,
|
||||||
getPurchaseCell,
|
|
||||||
getPurchaseDetail,
|
getPurchaseDetail,
|
||||||
getPurchaseStoreSummary,
|
getPurchaseStoreSummary,
|
||||||
removePurchaseStoreItem,
|
removePurchaseStoreItem,
|
||||||
type BillGenerateStoreParams,
|
type BillGenerateStoreParams,
|
||||||
type PurchaseCellUpdateParams,
|
|
||||||
type PurchaseRowUpdateParams,
|
type PurchaseRowUpdateParams,
|
||||||
updatePurchaseCellItem,
|
|
||||||
updatePurchaseRow,
|
updatePurchaseRow,
|
||||||
updatePurchaseStoreItem,
|
updatePurchaseStoreItem,
|
||||||
} from '@/api/purchase/order.ts';
|
} from '@/api/purchase/order.ts';
|
||||||
@@ -68,9 +61,22 @@ import { getProductOptions } from '@/api/product/goods.ts';
|
|||||||
import type ISupplier from '@/domain/iSupplier.ts';
|
import type ISupplier from '@/domain/iSupplier.ts';
|
||||||
import type IProduct from '@/domain/iProduct.ts';
|
import type IProduct from '@/domain/iProduct.ts';
|
||||||
import AuthButton from '@/components/AuthButton';
|
import AuthButton from '@/components/AuthButton';
|
||||||
|
import useAuth from '@/hooks/useAuth.ts';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
/** 商品明细可行内编辑的字段 */
|
||||||
|
type InlineEditField = 'product_name' | 'supplier_id' | 'product_spec' | 'unit' | 'cost_price';
|
||||||
|
|
||||||
|
/** 行内编辑字段中文名(校验提示用) */
|
||||||
|
const INLINE_FIELD_LABELS: Record<InlineEditField, string> = {
|
||||||
|
product_name: '品名',
|
||||||
|
supplier_id: '供应商',
|
||||||
|
product_spec: '包规',
|
||||||
|
unit: '单位',
|
||||||
|
cost_price: '成本',
|
||||||
|
};
|
||||||
|
|
||||||
/** 每单位参考价 = 整单价(成本/售价) ÷ 包规数值(包规解析不出正数时按 1 处理,与后端同口径;仅展示参考,不参与金额计算) */
|
/** 每单位参考价 = 整单价(成本/售价) ÷ 包规数值(包规解析不出正数时按 1 处理,与后端同口径;仅展示参考,不参与金额计算) */
|
||||||
const calcUnitRefPrice = (total: number, spec: string): number => {
|
const calcUnitRefPrice = (total: number, spec: string): number => {
|
||||||
const pack = parseFloat(spec);
|
const pack = parseFloat(spec);
|
||||||
@@ -83,6 +89,7 @@ const calcUnitRefPrice = (total: number, spec: string): number => {
|
|||||||
*/
|
*/
|
||||||
const PurchaseOrderPage: React.FC = () => {
|
const PurchaseOrderPage: React.FC = () => {
|
||||||
const tableRef = useRef<XinTableInstance<IPurchaseOrder>>(null);
|
const tableRef = useRef<XinTableInstance<IPurchaseOrder>>(null);
|
||||||
|
const { auth } = useAuth();
|
||||||
|
|
||||||
// 详情抽屉
|
// 详情抽屉
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
@@ -91,24 +98,9 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const [completing, setCompleting] = useState(false);
|
const [completing, setCompleting] = useState(false);
|
||||||
|
|
||||||
// 行修改弹窗
|
// 行内编辑:供应商选项(品名/供应商/包规/单位/成本与各门店订货量直接在表格中编辑)
|
||||||
const [editingRow, setEditingRow] = useState<IPurchaseDetailRow | null>(null);
|
|
||||||
const [rowSaving, setRowSaving] = useState(false);
|
|
||||||
const [editForm] = Form.useForm<PurchaseRowUpdateParams>();
|
|
||||||
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
|
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
|
||||||
|
|
||||||
// 单元格下钻弹窗(门店 × 商品订货明细)
|
|
||||||
const [cellOpen, setCellOpen] = useState(false);
|
|
||||||
const [cellLoading, setCellLoading] = useState(false);
|
|
||||||
const [cellData, setCellData] = useState<IPurchaseCell | null>(null);
|
|
||||||
const [cellQuery, setCellQuery] = useState<{ productId: number; storeId: number } | null>(null);
|
|
||||||
|
|
||||||
// 单元格明细编辑(数量/称重)
|
|
||||||
const [cellItemOpen, setCellItemOpen] = useState(false);
|
|
||||||
const [cellItemTarget, setCellItemTarget] = useState<IPurchaseCellItem | null>(null);
|
|
||||||
const [cellItemSaving, setCellItemSaving] = useState(false);
|
|
||||||
const [cellItemForm] = Form.useForm<PurchaseCellUpdateParams>();
|
|
||||||
|
|
||||||
// 门店购买详情(按商品聚合的门店采购汇总)
|
// 门店购买详情(按商品聚合的门店采购汇总)
|
||||||
const [detailTab, setDetailTab] = useState('items');
|
const [detailTab, setDetailTab] = useState('items');
|
||||||
const [storeId, setStoreId] = useState<number>(0);
|
const [storeId, setStoreId] = useState<number>(0);
|
||||||
@@ -131,6 +123,9 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
/** 市场筛选('' = 全部市场) */
|
/** 市场筛选('' = 全部市场) */
|
||||||
const [marketFilter, setMarketFilter] = useState<string>('');
|
const [marketFilter, setMarketFilter] = useState<string>('');
|
||||||
|
|
||||||
|
// 商品明细页签:供应商/市场列筛选值(antd Table 受控筛选,用于底部统计行联动)
|
||||||
|
const [itemColumnFilters, setItemColumnFilters] = useState<Record<string, (React.Key | boolean)[] | null>>({});
|
||||||
|
|
||||||
// 导出弹窗:商品明细(供应商筛选)/ 门店购买详情 / 供应商采购明细
|
// 导出弹窗:商品明细(供应商筛选)/ 门店购买详情 / 供应商采购明细
|
||||||
const [itemExportOpen, setItemExportOpen] = useState(false);
|
const [itemExportOpen, setItemExportOpen] = useState(false);
|
||||||
const [itemExportSupplier, setItemExportSupplier] = useState<number>(0);
|
const [itemExportSupplier, setItemExportSupplier] = useState<number>(0);
|
||||||
@@ -180,6 +175,26 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
}));
|
}));
|
||||||
}, [detail, supplierId, marketFilter]);
|
}, [detail, supplierId, marketFilter]);
|
||||||
|
|
||||||
|
/** 商品明细市场列筛选选项(采购单内出现的全部市场) */
|
||||||
|
const itemMarketOptions = useMemo(() => {
|
||||||
|
const markets = new Set<string>();
|
||||||
|
(detail?.items ?? []).forEach((row) => {
|
||||||
|
if (row.market) {
|
||||||
|
markets.add(row.market);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(markets);
|
||||||
|
}, [detail?.items]);
|
||||||
|
|
||||||
|
/** 商品明细当前可见行:按列筛选值本地过滤,驱动底部成本统计与列筛选联动 */
|
||||||
|
const summaryItems = useMemo<IPurchaseDetailRow[]>(() => {
|
||||||
|
const supplierKeys = itemColumnFilters.supplier;
|
||||||
|
const marketKeys = itemColumnFilters.market;
|
||||||
|
return (detail?.items ?? [])
|
||||||
|
.filter((row) => !supplierKeys?.length || supplierKeys.includes(row.supplier_id))
|
||||||
|
.filter((row) => !marketKeys?.length || marketKeys.includes(row.market ?? ''));
|
||||||
|
}, [detail?.items, itemColumnFilters]);
|
||||||
|
|
||||||
// 生成账单(已完成采购单按门店生成:填写配送费/周转筐/托盘数量,金额只读)
|
// 生成账单(已完成采购单按门店生成:填写配送费/周转筐/托盘数量,金额只读)
|
||||||
const [billOpen, setBillOpen] = useState(false);
|
const [billOpen, setBillOpen] = useState(false);
|
||||||
const [billPrepare, setBillPrepare] = useState<IBillPrepare | null>(null);
|
const [billPrepare, setBillPrepare] = useState<IBillPrepare | null>(null);
|
||||||
@@ -208,13 +223,6 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
|
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 单元格下钻弹窗打开时加载明细
|
|
||||||
useEffect(() => {
|
|
||||||
if (cellOpen && cellQuery) {
|
|
||||||
loadCell();
|
|
||||||
}
|
|
||||||
}, [cellOpen, cellQuery]);
|
|
||||||
|
|
||||||
// 详情加载后默认选中第一个门店(当前选中门店仍在采购单内则保留)
|
// 详情加载后默认选中第一个门店(当前选中门店仍在采购单内则保留)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (detail && detail.stores.length > 0) {
|
if (detail && detail.stores.length > 0) {
|
||||||
@@ -251,6 +259,7 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
const openDetail = async (id: number) => {
|
const openDetail = async (id: number) => {
|
||||||
setDetailTab('items');
|
setDetailTab('items');
|
||||||
setStoreSummary(null);
|
setStoreSummary(null);
|
||||||
|
setItemColumnFilters({});
|
||||||
setDetailOpen(true);
|
setDetailOpen(true);
|
||||||
await loadDetail(id);
|
await loadDetail(id);
|
||||||
};
|
};
|
||||||
@@ -339,92 +348,138 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
await refreshAfterStoreItemChange();
|
await refreshAfterStoreItemChange();
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 打开单元格下钻:门店 + 商品 → 该采购单下全部订货明细 */
|
/** 商品明细是否可行内编辑(进行中采购单 + 修改权限) */
|
||||||
const openCell = (row: IPurchaseDetailRow, store: { id: number; name: string }) => {
|
const canUpdateRow = detail?.purchase.status === 0 && auth('purchase.order.update');
|
||||||
setCellQuery({ productId: row.product_id, storeId: store.id });
|
|
||||||
setCellData(null);
|
|
||||||
setCellOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 加载单元格明细 */
|
/**
|
||||||
const loadCell = async () => {
|
* 行内编辑保存:合并该行当前值整行提交(后端要求全字段);未变更 / 校验失败时不请求。
|
||||||
if (!detail || !cellQuery) {
|
* Enter 或失焦触发保存,保存后刷新详情与列表。
|
||||||
|
*/
|
||||||
|
const handleInlineSave = async (row: IPurchaseDetailRow, dataIndex: InlineEditField, rawValue: string | number) => {
|
||||||
|
if (!detail) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCellLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await getPurchaseCell(detail.purchase.id!, cellQuery.productId, cellQuery.storeId);
|
|
||||||
setCellData(res.data.data ?? null);
|
|
||||||
} finally {
|
|
||||||
setCellLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 单元格明细修改/同步后:刷新弹窗、采购单详情与列表 */
|
const params: PurchaseRowUpdateParams = {
|
||||||
const refreshAfterCellChange = async () => {
|
|
||||||
await loadCell();
|
|
||||||
if (detail) {
|
|
||||||
await loadDetail(detail.purchase.id!);
|
|
||||||
}
|
|
||||||
await tableRef.current?.reload();
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCellItemEdit = (item: IPurchaseCellItem) => {
|
|
||||||
setCellItemTarget(item);
|
|
||||||
cellItemForm.setFieldsValue({
|
|
||||||
quantity: item.quantity,
|
|
||||||
price: Number(item.price ?? 0),
|
|
||||||
weight: Number(item.weight ?? 0),
|
|
||||||
});
|
|
||||||
setCellItemOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 提交单元格明细修改:级联重算明细金额、订货单与采购单汇总 */
|
|
||||||
const handleCellItemSave = async (values: PurchaseCellUpdateParams) => {
|
|
||||||
if (!cellItemTarget?.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCellItemSaving(true);
|
|
||||||
try {
|
|
||||||
await updatePurchaseCellItem(cellItemTarget.id, values);
|
|
||||||
message.success('明细已更新,订货单与采购单汇总已重算');
|
|
||||||
setCellItemOpen(false);
|
|
||||||
setCellItemTarget(null);
|
|
||||||
await refreshAfterCellChange();
|
|
||||||
} finally {
|
|
||||||
setCellItemSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEdit = (row: IPurchaseDetailRow) => {
|
|
||||||
setEditingRow(row);
|
|
||||||
editForm.setFieldsValue({
|
|
||||||
product_name: row.product_name,
|
product_name: row.product_name,
|
||||||
supplier_id: row.supplier_id > 0 ? row.supplier_id : undefined,
|
supplier_id: row.supplier_id,
|
||||||
product_spec: row.product_spec,
|
product_spec: row.product_spec,
|
||||||
unit: row.unit,
|
unit: row.unit,
|
||||||
cost_price: Number(row.cost_price),
|
cost_price: Number(row.cost_price),
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 提交行修改:同步该商品全部订货明细;syncTarget=product 时追加同步商品档案 */
|
if (dataIndex === 'cost_price') {
|
||||||
const handleEditSave = async (values: PurchaseRowUpdateParams) => {
|
const cost = Number(rawValue);
|
||||||
if (!detail || !editingRow) {
|
if (rawValue === '' || !Number.isFinite(cost) || cost < 0) {
|
||||||
|
message.warning('成本无效,已取消修改');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setRowSaving(true);
|
if (cost === Number(row.cost_price)) {
|
||||||
try {
|
return;
|
||||||
const res = await updatePurchaseRow(detail.purchase.id!, editingRow.product_id, {
|
}
|
||||||
...values,
|
params.cost_price = cost;
|
||||||
supplier_id: values.supplier_id ?? 0,
|
} else if (dataIndex === 'supplier_id') {
|
||||||
});
|
const supplierId = Number(rawValue);
|
||||||
|
if (supplierId === row.supplier_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
params.supplier_id = supplierId;
|
||||||
|
} else {
|
||||||
|
const text = String(rawValue).trim();
|
||||||
|
if (!text) {
|
||||||
|
message.warning(`${INLINE_FIELD_LABELS[dataIndex]}不能为空,已取消修改`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (text === row[dataIndex]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
params[dataIndex] = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.supplier_id <= 0) {
|
||||||
|
message.warning('请先通过供应商单元格为该商品设置供应商');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await updatePurchaseRow(detail.purchase.id!, row.product_id, params);
|
||||||
message.success(`已更新 ${res.data.data?.count ?? 0} 条订货明细`);
|
message.success(`已更新 ${res.data.data?.count ?? 0} 条订货明细`);
|
||||||
setEditingRow(null);
|
|
||||||
await loadDetail(detail.purchase.id!);
|
await loadDetail(detail.purchase.id!);
|
||||||
await tableRef.current?.reload();
|
await tableRef.current?.reload();
|
||||||
} finally {
|
};
|
||||||
setRowSaving(false);
|
|
||||||
|
/**
|
||||||
|
* 门店订货量保存:复用门店单品修改接口(仅提交订货量,单价/称重不改动),
|
||||||
|
* 同商品多笔订单明细合并到最早一条并级联重算;未变更 / 校验失败时不请求。
|
||||||
|
*/
|
||||||
|
const handleStoreCellSave = async (row: IPurchaseDetailRow, storeId: number, rawValue: string) => {
|
||||||
|
if (!detail) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const quantity = Number(rawValue);
|
||||||
|
if (rawValue.trim() === '' || !Number.isInteger(quantity) || quantity < 0) {
|
||||||
|
message.warning('订货量无效,已取消修改');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (quantity === row.cells[storeId]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await updatePurchaseStoreItem(detail.purchase.id!, storeId, row.product_id, { quantity });
|
||||||
|
message.success('订货量已更新,订货单与采购单汇总已重算');
|
||||||
|
await loadDetail(detail.purchase.id!);
|
||||||
|
await tableRef.current?.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 行内编辑单元格:直接渲染编辑组件(文本 Input / 供应商 Select / 成本 InputNumber),Enter 或失焦保存 */
|
||||||
|
const renderEditableCell = (
|
||||||
|
row: IPurchaseDetailRow,
|
||||||
|
dataIndex: InlineEditField,
|
||||||
|
display: React.ReactNode,
|
||||||
|
): React.ReactNode => {
|
||||||
|
if (!canUpdateRow) {
|
||||||
|
return display;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataIndex === 'supplier_id') {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
className="w-full"
|
||||||
|
value={row.supplier_id > 0 ? row.supplier_id : undefined}
|
||||||
|
placeholder="选择供应商"
|
||||||
|
showSearch={{ optionFilterProp: 'label' }}
|
||||||
|
options={suppliers.map((s) => ({ value: s.id, label: s.name }))}
|
||||||
|
onChange={(value) => void handleInlineSave(row, dataIndex, value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataIndex === 'cost_price') {
|
||||||
|
return (
|
||||||
|
<InputNumber
|
||||||
|
key={`${row.product_id}-cost-${row.cost_price}`}
|
||||||
|
size="small"
|
||||||
|
min={0}
|
||||||
|
precision={2}
|
||||||
|
prefix="¥"
|
||||||
|
className="w-full"
|
||||||
|
defaultValue={Number(row.cost_price)}
|
||||||
|
onPressEnter={(e) => void handleInlineSave(row, dataIndex, (e.target as HTMLInputElement).value)}
|
||||||
|
onBlur={(e) => void handleInlineSave(row, dataIndex, e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
key={`${row.product_id}-${dataIndex}-${row[dataIndex]}`}
|
||||||
|
size="small"
|
||||||
|
style={{ textAlign: 'center' }}
|
||||||
|
defaultValue={row[dataIndex]}
|
||||||
|
maxLength={dataIndex === 'unit' ? 20 : 100}
|
||||||
|
onPressEnter={(e) => void handleInlineSave(row, dataIndex, (e.target as HTMLInputElement).value)}
|
||||||
|
onBlur={(e) => void handleInlineSave(row, dataIndex, e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 打开生成账单弹窗:拉取按门店汇总的商品金额(只读),初始化配送费/周转筐/托盘数量 */
|
/** 打开生成账单弹窗:拉取按门店汇总的商品金额(只读),初始化配送费/周转筐/托盘数量 */
|
||||||
@@ -483,23 +538,44 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** 明细矩阵列:品名/供应商/单价/包规/单位/成本 + 每门店一列(数量)+ 合计 + 操作 */
|
/** 明细矩阵列:品名/供应商/单价/包规/单位/成本(可点击行内编辑)+ 每门店一列(数量)+ 合计 */
|
||||||
const buildItemColumns = (): TableProps<IPurchaseDetailRow>['columns'] => {
|
const buildItemColumns = (): TableProps<IPurchaseDetailRow>['columns'] => {
|
||||||
const fixed: NonNullable<TableProps<IPurchaseDetailRow>['columns']> = [
|
const fixed: NonNullable<TableProps<IPurchaseDetailRow>['columns']> = [
|
||||||
{ title: '品名', dataIndex: 'product_name', width: 160, align: 'center', fixed: 'left', ellipsis: true },
|
{
|
||||||
|
title: '品名',
|
||||||
|
dataIndex: 'product_name',
|
||||||
|
width: 160,
|
||||||
|
align: 'center',
|
||||||
|
fixed: 'left',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, row) => renderEditableCell(row, 'product_name', row.product_name),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '供应商',
|
title: '供应商',
|
||||||
dataIndex: 'supplier',
|
dataIndex: 'supplier',
|
||||||
width: 110,
|
width: 110,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
fixed: 'left',
|
fixed: 'left',
|
||||||
render: (_, row) => row.supplier?.name ?? '-',
|
filters: purchaseSuppliers.map((s) => ({ text: s.name, value: s.id })),
|
||||||
|
filteredValue: itemColumnFilters.supplier ?? null,
|
||||||
|
onFilter: (value, row) => row.supplier_id === Number(value),
|
||||||
|
render: (_, row) => renderEditableCell(row, 'supplier_id', row.supplier?.name ?? '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '市场',
|
||||||
|
fixed: 'left',
|
||||||
|
dataIndex: 'market',
|
||||||
|
width: 110,
|
||||||
|
align: 'center',
|
||||||
|
filters: itemMarketOptions.map((m) => ({ text: m, value: m })),
|
||||||
|
filteredValue: itemColumnFilters.market ?? null,
|
||||||
|
onFilter: (value, row) => (row.market ?? '') === value,
|
||||||
|
render: (v) => v || '-',
|
||||||
},
|
},
|
||||||
{ title: '市场', fixed: 'left', dataIndex: 'market', width: 90, align: 'center', render: (v) => v || '-' },
|
|
||||||
{
|
{
|
||||||
title: '单价',
|
title: '单价',
|
||||||
key: 'retail_price',
|
key: 'retail_price',
|
||||||
width: 100,
|
width: 110,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (_, row) => {
|
render: (_, row) => {
|
||||||
// 加权平均售价 ÷ 包规数值(无订货数量时无参考价)
|
// 加权平均售价 ÷ 包规数值(无订货数量时无参考价)
|
||||||
@@ -509,13 +585,31 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
return `¥${calcUnitRefPrice(weightedPrice, row.product_spec ?? '').toFixed(2)}`;
|
return `¥${calcUnitRefPrice(weightedPrice, row.product_spec ?? '').toFixed(2)}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: '包规', dataIndex: 'product_spec', align: 'center', width: 90, render: (v) => v || '-' },
|
{
|
||||||
{ title: '单位', dataIndex: 'unit', width: 70, align: 'center', render: (v) => v || '-' },
|
title: '包规',
|
||||||
{ title: '成本', dataIndex: 'cost_price', width: 90, align: 'center', render: (v) => `¥${Number(v).toFixed(2)}` },
|
dataIndex: 'product_spec',
|
||||||
|
align: 'center',
|
||||||
|
width: 110,
|
||||||
|
render: (v, row) => renderEditableCell(row, 'product_spec', v || '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '单位',
|
||||||
|
dataIndex: 'unit',
|
||||||
|
width: 110,
|
||||||
|
align: 'center',
|
||||||
|
render: (v, row) => renderEditableCell(row, 'unit', v || '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '成本',
|
||||||
|
dataIndex: 'cost_price',
|
||||||
|
width: 110,
|
||||||
|
align: 'center',
|
||||||
|
render: (v, row) => renderEditableCell(row, 'cost_price', `¥${Number(v).toFixed(2)}`),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '合计数量',
|
title: '合计数量',
|
||||||
key: 'total_quantity',
|
key: 'total_quantity',
|
||||||
width: 90,
|
width: 110,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (_, row) => <Text strong>{row.quantity}</Text>,
|
render: (_, row) => <Text strong>{row.quantity}</Text>,
|
||||||
},
|
},
|
||||||
@@ -524,51 +618,38 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
const storeColumns = (detail?.stores ?? []).map((store) => ({
|
const storeColumns = (detail?.stores ?? []).map((store) => ({
|
||||||
title: <span className={'text-[red]'}>{store.name}</span>,
|
title: <span className={'text-[red]'}>{store.name}</span>,
|
||||||
key: `store-${store.id}`,
|
key: `store-${store.id}`,
|
||||||
width: 100,
|
|
||||||
align: 'center' as const,
|
align: 'center' as const,
|
||||||
render: (_: unknown, row: IPurchaseDetailRow) => {
|
render: (_: unknown, row: IPurchaseDetailRow) => {
|
||||||
const quantity = row.cells[store.id];
|
const quantity = row.cells[store.id];
|
||||||
return quantity !== undefined ? (
|
if (quantity === undefined) {
|
||||||
<Typography.Link onClick={() => openCell(row, store)}>{quantity}</Typography.Link>
|
return <Text type="secondary">-</Text>;
|
||||||
) : (
|
}
|
||||||
<Text type="secondary">-</Text>
|
if (!canUpdateRow) {
|
||||||
|
return quantity;
|
||||||
|
}
|
||||||
|
// 订货量直接在单元格编辑:Enter 或失焦保存(单价/称重不改动)
|
||||||
|
return (
|
||||||
|
<InputNumber
|
||||||
|
key={`${row.product_id}-${store.id}-${quantity}`}
|
||||||
|
size="small"
|
||||||
|
min={0}
|
||||||
|
precision={0}
|
||||||
|
className="w-full"
|
||||||
|
defaultValue={quantity}
|
||||||
|
onPressEnter={(e) => void handleStoreCellSave(row, store.id, (e.target as HTMLInputElement).value)}
|
||||||
|
onBlur={(e) => void handleStoreCellSave(row, store.id, e.target.value)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const tail: NonNullable<TableProps<IPurchaseDetailRow>['columns']> = [
|
return [...fixed, ...storeColumns];
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'action',
|
|
||||||
width: 90,
|
|
||||||
fixed: 'right',
|
|
||||||
align: 'center',
|
|
||||||
render: (_, row) => (
|
|
||||||
<>
|
|
||||||
{ detail?.purchase.status === 0 ? (
|
|
||||||
<AuthButton auth="purchase.order.update">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
type="link"
|
|
||||||
icon={<EditOutlined />}
|
|
||||||
onClick={() => openEdit(row)}
|
|
||||||
>
|
|
||||||
修改
|
|
||||||
</Button>
|
|
||||||
</AuthButton>
|
|
||||||
) : '-' }
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return [...fixed, ...storeColumns, ...tail];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 底部统计行:按门店统计金额(Σ 门店数量 × 行单价)+ 合计 */
|
/** 底部统计行:按门店统计金额(Σ 门店数量 × 行单价)+ 合计(随供应商/市场列筛选联动) */
|
||||||
const renderSummary = () => {
|
const renderSummary = () => {
|
||||||
const stores = detail?.stores ?? [];
|
const stores = detail?.stores ?? [];
|
||||||
const items = detail?.items ?? [];
|
const items = summaryItems;
|
||||||
const storeTotals = stores.map((store) =>
|
const storeTotals = stores.map((store) =>
|
||||||
items.reduce(
|
items.reduce(
|
||||||
(sum, row) => sum + (row.cells[store.id] ?? 0) * Number(row.cost_price),
|
(sum, row) => sum + (row.cells[store.id] ?? 0) * Number(row.cost_price),
|
||||||
@@ -581,9 +662,12 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<Table.Summary.Row>
|
<Table.Summary.Row>
|
||||||
<Table.Summary.Cell index={0} colSpan={7} align="center">
|
<Table.Summary.Cell index={0} colSpan={7} align="center">
|
||||||
|
<Space size={16}>
|
||||||
<Text strong>成本统计(按门店)</Text>
|
<Text strong>成本统计(按门店)</Text>
|
||||||
|
<Text strong type="danger">总计 ¥{totalAmount.toFixed(2)}</Text>
|
||||||
|
</Space>
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
<Table.Summary.Cell index={8} align="center">
|
<Table.Summary.Cell index={7} align="center">
|
||||||
<Text strong>{totalQuantity}</Text>
|
<Text strong>{totalQuantity}</Text>
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
{storeTotals.map((amount, index) => (
|
{storeTotals.map((amount, index) => (
|
||||||
@@ -591,10 +675,6 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
<Text strong>¥{amount.toFixed(2)}</Text>
|
<Text strong>¥{amount.toFixed(2)}</Text>
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Table.Summary.Cell index={9 + stores.length} align="center">
|
|
||||||
<Text strong>¥{totalAmount.toFixed(2)}</Text>
|
|
||||||
</Table.Summary.Cell>
|
|
||||||
</Table.Summary.Row>
|
</Table.Summary.Row>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1195,6 +1275,11 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
导出
|
导出
|
||||||
</Button>
|
</Button>
|
||||||
</AuthButton>
|
</AuthButton>
|
||||||
|
{canUpdateRow && (
|
||||||
|
<Text type="secondary" className="text-xs">
|
||||||
|
修改保存将同步该商品全部订货明细与商品档案,成本变化时各门店单价按等级上浮自动重算
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
<Table<IPurchaseDetailRow>
|
<Table<IPurchaseDetailRow>
|
||||||
rowKey="product_id"
|
rowKey="product_id"
|
||||||
@@ -1202,6 +1287,7 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
bordered
|
bordered
|
||||||
columns={buildItemColumns()}
|
columns={buildItemColumns()}
|
||||||
dataSource={detail.items}
|
dataSource={detail.items}
|
||||||
|
onChange={(_pagination, filters) => setItemColumnFilters(filters)}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
scroll={{ x: 1200, y: 800 }}
|
scroll={{ x: 1200, y: 800 }}
|
||||||
summary={renderSummary}
|
summary={renderSummary}
|
||||||
@@ -1212,223 +1298,6 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
{/* 行修改:品名/供应商/包规/单位/成本 */}
|
|
||||||
<Modal
|
|
||||||
title={editingRow ? `修改「${editingRow.product_name}」` : '修改明细行'}
|
|
||||||
open={editingRow !== null}
|
|
||||||
onCancel={() => setEditingRow(null)}
|
|
||||||
destroyOnHidden
|
|
||||||
footer={[
|
|
||||||
<Button key="cancel" onClick={() => setEditingRow(null)}>
|
|
||||||
取消
|
|
||||||
</Button>,
|
|
||||||
<Button key="purchase" type="primary" loading={rowSaving} onClick={() => editForm.submit()}>
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<div className="py-2 text-gray-500">
|
|
||||||
保存将同步修改本采购单中该商品的所有订单项,并同步保存到商品档案(商品列表);修改成本时,各门店单价按等级上浮比例自动重算。
|
|
||||||
</div>
|
|
||||||
<Form form={editForm} layout="vertical" onFinish={handleEditSave}>
|
|
||||||
<Form.Item
|
|
||||||
label="品名"
|
|
||||||
name="product_name"
|
|
||||||
rules={[{ required: true, message: '请输入品名' }, { max: 100 }]}
|
|
||||||
>
|
|
||||||
<Input maxLength={100} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label="供应商"
|
|
||||||
name="supplier_id"
|
|
||||||
rules={[{ required: true, message: '请选择供应商' }]}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
allowClear
|
|
||||||
showSearch={{ optionFilterProp: 'label' }}
|
|
||||||
placeholder="选择供应商"
|
|
||||||
options={suppliers.map((s) => ({ value: s.id, label: s.name }))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label="包规"
|
|
||||||
name="product_spec"
|
|
||||||
rules={[{ required: true, message: '请输入包规' }, { max: 100 }]}
|
|
||||||
>
|
|
||||||
<Input maxLength={100} placeholder="如:10斤/箱" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label="单位"
|
|
||||||
name="unit"
|
|
||||||
rules={[{ required: true, message: '请输入单位' },{ max: 20 }]}
|
|
||||||
>
|
|
||||||
<Input maxLength={20} placeholder="如:斤" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label="成本"
|
|
||||||
name="cost_price"
|
|
||||||
rules={[{ required: true, message: '请输入成本' }]}
|
|
||||||
>
|
|
||||||
<InputNumber min={0} precision={2} prefix="¥" className="w-full" />
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* 单元格下钻 */}
|
|
||||||
<Modal
|
|
||||||
title={cellData ? `${cellData.store?.name ?? ''} · ${cellData.product?.name ?? ''}` : '门店商品明细'}
|
|
||||||
open={cellOpen}
|
|
||||||
onCancel={() => setCellOpen(false)}
|
|
||||||
footer={null}
|
|
||||||
width={1000}
|
|
||||||
destroyOnHidden
|
|
||||||
styles={{ body: {paddingTop: 16} }}
|
|
||||||
>
|
|
||||||
<Spin spinning={cellLoading}>
|
|
||||||
{cellData && (
|
|
||||||
<>
|
|
||||||
{cellData.items.length === 0 ? (
|
|
||||||
<Empty
|
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
||||||
description="该门店无此商品明细"
|
|
||||||
className="py-8!"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<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-26 shrink-0 text-center">订货量</div>
|
|
||||||
<div className="w-33 shrink-0 text-center">订货金额</div>
|
|
||||||
<div className="w-33 shrink-0 text-center">重量</div>
|
|
||||||
<div className="w-40 shrink-0 text-center">操作</div>
|
|
||||||
</div>
|
|
||||||
{cellData.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={48}
|
|
||||||
height={48}
|
|
||||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-12 w-12 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}
|
|
||||||
<Tag
|
|
||||||
className="ml-2!"
|
|
||||||
color={STORE_ORDER_STATUS_MAP[item.order_status]?.color}
|
|
||||||
>
|
|
||||||
{STORE_ORDER_STATUS_MAP[item.order_status]?.text}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 text-xs text-gray-500">
|
|
||||||
订单:{item.order_no}
|
|
||||||
</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.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>
|
|
||||||
<div className="w-33 shrink-0 text-center">
|
|
||||||
{item.weight ?? '-'} 斤
|
|
||||||
</div>
|
|
||||||
<div className="w-40 shrink-0 text-center">
|
|
||||||
{detail?.purchase.status === 0 ? (
|
|
||||||
<Space size={0}>
|
|
||||||
<AuthButton auth="purchase.order.update">
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
icon={<EditOutlined />}
|
|
||||||
onClick={() => openCellItemEdit(item)}
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
</AuthButton>
|
|
||||||
</Space>
|
|
||||||
) : (
|
|
||||||
<Text type="secondary">-</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{cellData.items.length > 0 && (
|
|
||||||
<div className="mt-3! flex justify-end gap-6 text-sm">
|
|
||||||
<Text type="secondary">
|
|
||||||
合计数量:
|
|
||||||
<Text strong>{cellData.items.reduce((sum, item) => sum + item.quantity, 0)}</Text>
|
|
||||||
</Text>
|
|
||||||
<Text type="secondary">
|
|
||||||
合计订货金额:
|
|
||||||
<Text strong type="danger">
|
|
||||||
¥
|
|
||||||
{cellData.items
|
|
||||||
.reduce((sum, item) => sum + Number(item.amount), 0)
|
|
||||||
.toFixed(2)}
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Spin>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* 单元格明细编辑 */}
|
|
||||||
<Modal
|
|
||||||
title={cellItemTarget ? `编辑「${cellItemTarget.product_name}」` : '编辑明细'}
|
|
||||||
open={cellItemOpen}
|
|
||||||
onCancel={() => {
|
|
||||||
setCellItemOpen(false);
|
|
||||||
setCellItemTarget(null);
|
|
||||||
}}
|
|
||||||
onOk={() => cellItemForm.submit()}
|
|
||||||
confirmLoading={cellItemSaving}
|
|
||||||
okText="保存"
|
|
||||||
destroyOnHidden
|
|
||||||
>
|
|
||||||
<div className="py-2 text-gray-500">
|
|
||||||
订单 {cellItemTarget?.order_no};修改保存后,系统将自动重算明细金额、订货单与采购单汇总。
|
|
||||||
</div>
|
|
||||||
<Form form={cellItemForm} layout="vertical" onFinish={handleCellItemSave}>
|
|
||||||
<Form.Item
|
|
||||||
label="单价(元)"
|
|
||||||
name="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={0} 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>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* 生成账单:按门店填写配送费/周转筐/托盘数量与售后金额(商品金额只读,由订单汇总) */}
|
{/* 生成账单:按门店填写配送费/周转筐/托盘数量与售后金额(商品金额只读,由订单汇总) */}
|
||||||
<Modal
|
<Modal
|
||||||
title={billPrepare ? `生成账单 · ${billPrepare.purchase.purchase_no}` : '生成账单'}
|
title={billPrepare ? `生成账单 · ${billPrepare.purchase.purchase_no}` : '生成账单'}
|
||||||
|
|||||||
Reference in New Issue
Block a user