Compare commits
4 Commits
889f987f17
...
969eabcd11
| Author | SHA1 | Date | |
|---|---|---|---|
| 969eabcd11 | |||
| f0927e57e9 | |||
| 629d28a369 | |||
| 8b3cef5af0 |
File diff suppressed because one or more lines are too long
@@ -1,74 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Models\ReconciliationItemModel;
|
||||
use App\Models\SettlementModel;
|
||||
use Illuminate\Support\Collection;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
/**
|
||||
* D10 结算表导出(结算头 + 来源对账单中该门店的明细)
|
||||
*/
|
||||
class SettlementExport implements FromCollection, WithHeadings, WithMapping, WithStyles
|
||||
{
|
||||
public function __construct(private readonly SettlementModel $settlement)
|
||||
{
|
||||
}
|
||||
|
||||
public function collection(): Collection
|
||||
{
|
||||
return ReconciliationItemModel::query()
|
||||
->where('recon_id', $this->settlement->recon_id)
|
||||
->where('store_id', $this->settlement->store_id)
|
||||
->orderBy('sort')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return ['品名', '数量', '称重', '公布金额', '实际金额', '差额', '对账状态', '门店备注'];
|
||||
}
|
||||
|
||||
public function map($item): array
|
||||
{
|
||||
return [
|
||||
$item->product_name,
|
||||
(float) $item->quantity,
|
||||
(float) $item->weight,
|
||||
(float) $item->publish_amount,
|
||||
(float) $item->actual_amount,
|
||||
(float) $item->diff_amount,
|
||||
$item->is_reconciled ? '已对账' : '未对账',
|
||||
$item->store_remark,
|
||||
];
|
||||
}
|
||||
|
||||
public function styles(Worksheet $sheet): array
|
||||
{
|
||||
$sheet->freezePane('A2');
|
||||
|
||||
return [
|
||||
1 => ['font' => ['bold' => true]],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF 模板视图数据
|
||||
*
|
||||
* @return array{settlement: SettlementModel, storeName: string, reconNo: string, items: Collection}
|
||||
*/
|
||||
public function viewData(): array
|
||||
{
|
||||
return [
|
||||
'settlement' => $this->settlement,
|
||||
'storeName' => $this->settlement->store?->name ?? '',
|
||||
'reconNo' => $this->settlement->recon?->recon_no ?? '',
|
||||
'items' => $this->collection(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Client;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Http\Requests\Client\BannerFormRequest;
|
||||
use App\Models\HomeBannerModel;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
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;
|
||||
use Modules\SystemTool\Services\SysFileService;
|
||||
|
||||
/**
|
||||
* 首页轮播图配置(小程序首页)
|
||||
*/
|
||||
#[RequestAttribute('/client/banner', 'client.banner')]
|
||||
class BannerController extends BaseController
|
||||
{
|
||||
protected array $searchField = [
|
||||
'status' => '=',
|
||||
];
|
||||
|
||||
protected array $quickSearchField = ['title'];
|
||||
|
||||
/** 轮播图列表(默认按排序升序) */
|
||||
#[GetRoute(authorize: 'query')]
|
||||
public function query(Request $request): JsonResponse
|
||||
{
|
||||
$params = $request->all();
|
||||
$pageSize = $params['pageSize'] ?? 10;
|
||||
$data = $this->buildSearch($params, HomeBannerModel::query())
|
||||
->orderBy('sort')
|
||||
->orderBy('id')
|
||||
->paginate($pageSize)
|
||||
->toArray();
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/** 上传轮播图片(文件分组:客户端配置) */
|
||||
#[PostRoute('/upload', 'create')]
|
||||
public function uploadImage(Request $request, SysFileService $service): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['file' => 'required|file']);
|
||||
$result = $service->upload(
|
||||
$data['file'],
|
||||
12,
|
||||
20,
|
||||
Auth::id()
|
||||
);
|
||||
return $this->success($result);
|
||||
}
|
||||
|
||||
/** 新增轮播图 */
|
||||
#[PostRoute(authorize: 'create')]
|
||||
public function create(BannerFormRequest $request): JsonResponse
|
||||
{
|
||||
HomeBannerModel::create($request->validated());
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/** 编辑轮播图 */
|
||||
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||
public function update(int $id, BannerFormRequest $request): JsonResponse
|
||||
{
|
||||
$model = HomeBannerModel::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 = HomeBannerModel::find($id);
|
||||
if (empty($model)) {
|
||||
throw new RepositoryException('轮播图不存在');
|
||||
}
|
||||
$model->delete();
|
||||
return $this->success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Client;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Http\Requests\Client\NavFormRequest;
|
||||
use App\Models\HomeNavModel;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
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;
|
||||
use Modules\SystemTool\Services\SysFileService;
|
||||
|
||||
/**
|
||||
* 宫格导航配置(小程序首页)
|
||||
*/
|
||||
#[RequestAttribute('/client/nav', 'client.nav')]
|
||||
class NavController extends BaseController
|
||||
{
|
||||
protected array $searchField = [
|
||||
'status' => '=',
|
||||
];
|
||||
|
||||
protected array $quickSearchField = ['name'];
|
||||
|
||||
/** 导航列表(默认按排序升序) */
|
||||
#[GetRoute(authorize: 'query')]
|
||||
public function query(Request $request): JsonResponse
|
||||
{
|
||||
$params = $request->all();
|
||||
$pageSize = $params['pageSize'] ?? 10;
|
||||
$data = $this->buildSearch($params, HomeNavModel::query())
|
||||
->orderBy('sort')
|
||||
->orderBy('id')
|
||||
->paginate($pageSize)
|
||||
->toArray();
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/** 上传导航图标(文件分组:客户端配置) */
|
||||
#[PostRoute('/upload', 'create')]
|
||||
public function uploadImage(Request $request, SysFileService $service): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['file' => 'required|file']);
|
||||
$result = $service->upload(
|
||||
$data['file'],
|
||||
12,
|
||||
20,
|
||||
Auth::id()
|
||||
);
|
||||
return $this->success($result);
|
||||
}
|
||||
|
||||
/** 新增导航 */
|
||||
#[PostRoute(authorize: 'create')]
|
||||
public function create(NavFormRequest $request): JsonResponse
|
||||
{
|
||||
HomeNavModel::create($request->validated());
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/** 编辑导航 */
|
||||
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||
public function update(int $id, NavFormRequest $request): JsonResponse
|
||||
{
|
||||
$model = HomeNavModel::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 = HomeNavModel::find($id);
|
||||
if (empty($model)) {
|
||||
throw new RepositoryException('导航不存在');
|
||||
}
|
||||
$model->delete();
|
||||
return $this->success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Client;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Http\Requests\Client\PromoFormRequest;
|
||||
use App\Models\HomePromoModel;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
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;
|
||||
use Modules\SystemTool\Services\SysFileService;
|
||||
|
||||
/**
|
||||
* 促销推荐卡片配置(小程序首页)
|
||||
*/
|
||||
#[RequestAttribute('/client/promo', 'client.promo')]
|
||||
class PromoController extends BaseController
|
||||
{
|
||||
protected array $searchField = [
|
||||
'status' => '=',
|
||||
];
|
||||
|
||||
protected array $quickSearchField = ['title', 'sub_title'];
|
||||
|
||||
/** 促销卡片列表(默认按排序升序) */
|
||||
#[GetRoute(authorize: 'query')]
|
||||
public function query(Request $request): JsonResponse
|
||||
{
|
||||
$params = $request->all();
|
||||
$pageSize = $params['pageSize'] ?? 10;
|
||||
$data = $this->buildSearch($params, HomePromoModel::query())
|
||||
->orderBy('sort')
|
||||
->orderBy('id')
|
||||
->paginate($pageSize)
|
||||
->toArray();
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/** 上传卡片图片(文件分组:客户端配置) */
|
||||
#[PostRoute('/upload', 'create')]
|
||||
public function uploadImage(Request $request, SysFileService $service): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['file' => 'required|file']);
|
||||
$result = $service->upload(
|
||||
$data['file'],
|
||||
12,
|
||||
20,
|
||||
Auth::id()
|
||||
);
|
||||
return $this->success($result);
|
||||
}
|
||||
|
||||
/** 新增促销卡片 */
|
||||
#[PostRoute(authorize: 'create')]
|
||||
public function create(PromoFormRequest $request): JsonResponse
|
||||
{
|
||||
HomePromoModel::create($request->validated());
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/** 编辑促销卡片 */
|
||||
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||
public function update(int $id, PromoFormRequest $request): JsonResponse
|
||||
{
|
||||
$model = HomePromoModel::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 = HomePromoModel::find($id);
|
||||
if (empty($model)) {
|
||||
throw new RepositoryException('促销卡片不存在');
|
||||
}
|
||||
$model->delete();
|
||||
return $this->success();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Models\StoreModel;
|
||||
use App\Models\UserModel;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Common\Http\Controllers\BaseController;
|
||||
use Modules\SystemUser\Models\SysAccessToken;
|
||||
|
||||
/**
|
||||
* 小程序端控制器基类
|
||||
@@ -29,7 +30,30 @@ abstract class BaseMiniController extends BaseController
|
||||
}
|
||||
|
||||
/**
|
||||
* 门店端前置校验:type=门店 且 store_id>0 且门店正常
|
||||
* 可选登录场景手动识别当前用户(路由关闭 authorize 时使用)
|
||||
*
|
||||
* 手动解析 Bearer token;未携带 token、token 无效或非小程序用户时返回 null(不抛错)。
|
||||
*/
|
||||
protected function optionalUser(Request $request): ?UserModel
|
||||
{
|
||||
$token = $request->bearerToken();
|
||||
if (empty($token)) {
|
||||
return null;
|
||||
}
|
||||
$accessToken = SysAccessToken::findToken($token);
|
||||
if ($accessToken === null || $accessToken->tokenable_type !== UserModel::class) {
|
||||
return null;
|
||||
}
|
||||
$user = UserModel::find($accessToken->tokenable_id);
|
||||
if ($user === null || $user->status === UserModel::STATUS_DISABLED) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 门店端前置校验
|
||||
*/
|
||||
protected function ensureStoreBound(UserModel $user): StoreModel
|
||||
{
|
||||
@@ -46,8 +70,6 @@ abstract class BaseMiniController extends BaseController
|
||||
|
||||
/**
|
||||
* 用户当前绑定的正常门店(未绑定/已停用返回 null,不抛错)
|
||||
*
|
||||
* 供商品浏览等「弱前置」场景使用:未绑定门店仍可浏览商品,仅价格不可见。
|
||||
*/
|
||||
protected function boundStore(UserModel $user): ?StoreModel
|
||||
{
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Mini;
|
||||
|
||||
use App\Models\HomeBannerModel;
|
||||
use App\Models\HomeNavModel;
|
||||
use App\Models\HomePromoModel;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
|
||||
/**
|
||||
* 小程序首页配置(轮播图 + 宫格导航 + 促销推荐卡片,仅返回启用项)
|
||||
*/
|
||||
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
||||
class HomeController extends BaseMiniController
|
||||
{
|
||||
/** 首页配置聚合:banners / navs / promos,按 sort 升序 */
|
||||
#[GetRoute('/home', authorize: false)]
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$banners = HomeBannerModel::query()
|
||||
->where('status', HomeBannerModel::STATUS_NORMAL)
|
||||
->orderBy('sort')
|
||||
->orderBy('id')
|
||||
->get(['id', 'title', 'image_id', 'link', 'sort']);
|
||||
|
||||
$navs = HomeNavModel::query()
|
||||
->where('status', HomeNavModel::STATUS_NORMAL)
|
||||
->orderBy('sort')
|
||||
->orderBy('id')
|
||||
->get(['id', 'name', 'image_id', 'link', 'sort']);
|
||||
|
||||
$promos = HomePromoModel::query()
|
||||
->where('status', HomePromoModel::STATUS_NORMAL)
|
||||
->orderBy('sort')
|
||||
->orderBy('id')
|
||||
->get(['id', 'title', 'sub_title', 'image_id', 'link', 'sort']);
|
||||
|
||||
return $this->success([
|
||||
'banners' => $banners,
|
||||
'navs' => $navs,
|
||||
'promos' => $promos,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -12,68 +12,28 @@ use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
|
||||
/**
|
||||
* 小程序商品(分类树 + 列表)
|
||||
*
|
||||
* 商品浏览仅需登录:未绑定门店/门店未设客户等级的用户也可查看商品,仅价格不可见(price=null);
|
||||
* 加购、下单仍由购物车/订单前置校验拦截,要求绑定门店并已设客户等级。
|
||||
* 小程序商品
|
||||
*/
|
||||
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
||||
class ProductController extends BaseMiniController
|
||||
{
|
||||
/** 分类树(仅含上架商品的分类及其祖先,保证树结构完整) */
|
||||
/** 分类树(全部启用分类,含暂无商品的分类) */
|
||||
#[GetRoute('/product/categories', authorize: true)]
|
||||
public function categories(): JsonResponse
|
||||
{
|
||||
$activeCategoryIds = ProductModel::query()
|
||||
->where('status', ProductModel::STATUS_ON)
|
||||
->distinct()
|
||||
->pluck('category_id')
|
||||
->map(static fn ($id) => (int) $id)
|
||||
->filter(static fn (int $id) => $id > 0);
|
||||
|
||||
$categories = ProductCategoryModel::query()
|
||||
->where('status', ProductCategoryModel::STATUS_NORMAL)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// 保留有上架商品的分类 + 其全部祖先
|
||||
$keep = [];
|
||||
foreach ($activeCategoryIds as $categoryId) {
|
||||
$cursor = $categoryId;
|
||||
$guard = 0;
|
||||
while ($cursor > 0 && $guard++ < 20 && $categories->has($cursor)) {
|
||||
$keep[$cursor] = true;
|
||||
$cursor = (int) $categories[$cursor]->parent_id;
|
||||
}
|
||||
}
|
||||
|
||||
$filtered = array_values(array_filter(
|
||||
$categories->toArray(),
|
||||
static fn (array $item) => isset($keep[$item['id']])
|
||||
));
|
||||
|
||||
return $this->success(ProductCategoryModel::buildTree($filtered));
|
||||
return $this->success(ProductCategoryModel::getTreeData(['*'], true));
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品列表:价格取当前门店客户等级价;
|
||||
* 未绑定门店/门店未设客户等级 → 仍可浏览,price 为 null(不可见价格,加购/下单另由前置校验拦截);
|
||||
* ?category_id=&keyword=&page=&pageSize=
|
||||
* 商品列表
|
||||
*/
|
||||
#[GetRoute('/product/list', authorize: true)]
|
||||
#[GetRoute('/product/list', authorize: false)]
|
||||
public function products(Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$levelId = $this->boundStore($user)?->level_id ?? 0;
|
||||
|
||||
$query = ProductModel::query()
|
||||
->where('status', ProductModel::STATUS_ON)
|
||||
->with('category:id,name');
|
||||
|
||||
if ($levelId > 0) {
|
||||
$query->with(['prices' => static fn ($q) => $q->where('level_id', $levelId)]);
|
||||
}
|
||||
|
||||
$categoryId = (int) $request->input('category_id', 0);
|
||||
if ($categoryId > 0) {
|
||||
$query->where('category_id', $categoryId);
|
||||
@@ -81,8 +41,7 @@ class ProductController extends BaseMiniController
|
||||
$keyword = trim((string) $request->input('keyword', ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(static function ($q) use ($keyword) {
|
||||
$q->where('name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('spec', 'like', '%' . $keyword . '%');
|
||||
$q->where('name', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,12 +51,28 @@ class ProductController extends BaseMiniController
|
||||
->paginate($pageSize)
|
||||
->toArray();
|
||||
|
||||
// 扁平化价格:prices[0].actual_price → price(访问器经 toArray 自动输出换算后实际价;
|
||||
// 未绑定门店或未设等级价为 null——未加载 prices 时 ?? null 兜底)
|
||||
// unset prices 同时移除 price_type/percent,门店端无法反推成本;cost_price 已被 $hidden 过滤
|
||||
foreach ($data['data'] as &$row) {
|
||||
$row['price'] = $row['prices'][0]['actual_price'] ?? null;
|
||||
unset($row['prices']);
|
||||
$row['price'] = null;
|
||||
}
|
||||
|
||||
// 用户
|
||||
$user = $this->optionalUser($request);
|
||||
if (!$user) return $this->success($data);
|
||||
// 门店
|
||||
$store = $this->boundStore($user);
|
||||
if(!$store) return $this->success($data);
|
||||
|
||||
if ($store->level_id > 0) {
|
||||
foreach ($data['data'] as &$row) {
|
||||
$model = ProductPriceModel::where('product_id', $row['id'])->where('level_id', $store->level_id)->first();
|
||||
$price = ProductPriceModel::calcActualPrice(
|
||||
$model->price_type,
|
||||
$model->price,
|
||||
$model->percent,
|
||||
$row->cost_price,
|
||||
);
|
||||
$row['price'] = $price;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success($data);
|
||||
|
||||
@@ -7,15 +7,12 @@ use App\Http\Requests\Product\ProductCategoryFormRequest;
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductModel;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
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;
|
||||
use Modules\SystemTool\Services\SysFileService;
|
||||
|
||||
/**
|
||||
* 商品分类管理(多级分类:蔬菜/水果/其他)
|
||||
@@ -37,34 +34,17 @@ class ProductCategoryController extends BaseController
|
||||
return $this->success(ProductCategoryModel::getTreeData(['id', 'name', 'parent_id'], true));
|
||||
}
|
||||
|
||||
/** 上传商品分类图片文件 */
|
||||
#[PostRoute('/upload', 'create')]
|
||||
public function uploadImage(Request $request, SysFileService $service): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['file' => 'required|file']);
|
||||
$result = $service->upload(
|
||||
$data['file'],
|
||||
9,
|
||||
20,
|
||||
Auth::id()
|
||||
);
|
||||
return $this->success($result);
|
||||
}
|
||||
|
||||
/** 创建分类 */
|
||||
#[PostRoute(authorize: 'create')]
|
||||
public function create(ProductCategoryFormRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$parentId = (int) $validated['parent_id'];
|
||||
if ($parentId > 0 && ! ProductCategoryModel::whereKey($parentId)->exists()) {
|
||||
throw new RepositoryException('父级分类不存在');
|
||||
}
|
||||
$this->assertParentIsTopLevel((int) $validated['parent_id']);
|
||||
ProductCategoryModel::create($validated);
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/** 编辑分类(防自引用成环) */
|
||||
/** 编辑分类(防自引用成环;分类最多二级) */
|
||||
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||
public function update(int $id, ProductCategoryFormRequest $request): JsonResponse
|
||||
{
|
||||
@@ -73,7 +53,12 @@ class ProductCategoryController extends BaseController
|
||||
throw new RepositoryException('分类不存在');
|
||||
}
|
||||
$validated = $request->validated();
|
||||
$this->assertNoCycle($id, (int) $validated['parent_id']);
|
||||
$parentId = (int) $validated['parent_id'];
|
||||
$this->assertNoCycle($id, $parentId);
|
||||
$this->assertParentIsTopLevel($parentId);
|
||||
if ($parentId > 0 && ProductCategoryModel::where('parent_id', $id)->exists()) {
|
||||
throw new RepositoryException('该分类下存在子分类,不能调整为子分类');
|
||||
}
|
||||
$model->update($validated);
|
||||
return $this->success();
|
||||
}
|
||||
@@ -96,6 +81,23 @@ class ProductCategoryController extends BaseController
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验父级分类:存在且必须为顶级分类(分类最多二级)
|
||||
*/
|
||||
private function assertParentIsTopLevel(int $parentId): void
|
||||
{
|
||||
if ($parentId === 0) {
|
||||
return;
|
||||
}
|
||||
$parent = ProductCategoryModel::whereKey($parentId)->first(['id', 'parent_id']);
|
||||
if ($parent === null) {
|
||||
throw new RepositoryException('父级分类不存在');
|
||||
}
|
||||
if ((int) $parent->parent_id !== 0) {
|
||||
throw new RepositoryException('最多支持二级分类,不能选择子分类作为上级');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 沿父链向上检查,防止 parent_id 指向自身或子孙分类形成环
|
||||
*/
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Recon;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Http\Requests\Recon\ReconItemUpdateRequest;
|
||||
use App\Models\ReconciliationItemModel;
|
||||
use App\Models\ReconciliationModel;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\AnnoRoute\Attribute\PutRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Modules\Common\Http\Controllers\BaseController;
|
||||
|
||||
/**
|
||||
* 对账明细操作(D4 修改 / D6 单品级门店备注 / D8 对账状态标记)
|
||||
* 权限点前缀 recon.item,authorize: item.update → recon.item.item.update
|
||||
*/
|
||||
#[RequestAttribute('/recon/item', 'recon.item')]
|
||||
class ReconItemController extends BaseController
|
||||
{
|
||||
/**
|
||||
* D4 修改订货量/称重/数量/金额/商品名,自动重算本行 diff + 头汇总
|
||||
*/
|
||||
#[PutRoute(route: '/{id}', authorize: 'item.update', where: ['id' => '[0-9]+'])]
|
||||
public function update(int $id, ReconItemUpdateRequest $request): JsonResponse
|
||||
{
|
||||
$item = ReconciliationItemModel::find($id);
|
||||
if (empty($item)) {
|
||||
throw new RepositoryException('对账明细不存在');
|
||||
}
|
||||
$this->assertEditable($item);
|
||||
|
||||
$validated = $request->validated();
|
||||
if (isset($validated['product_name'])) {
|
||||
$item->product_name = $validated['product_name'];
|
||||
}
|
||||
if (isset($validated['quantity'])) {
|
||||
$item->quantity = $validated['quantity'];
|
||||
}
|
||||
if (isset($validated['weight'])) {
|
||||
$item->weight = $validated['weight'];
|
||||
}
|
||||
if (isset($validated['publish_amount'])) {
|
||||
$item->publish_amount = $validated['publish_amount'];
|
||||
}
|
||||
if (isset($validated['actual_amount'])) {
|
||||
$item->actual_amount = $validated['actual_amount'];
|
||||
}
|
||||
// 重算本行差额
|
||||
$item->diff_amount = bcsub((string) $item->publish_amount, (string) $item->actual_amount, 2);
|
||||
$item->save();
|
||||
|
||||
$this->refreshReconSummary((int) $item->recon_id);
|
||||
|
||||
return $this->success(['diff_amount' => $item->diff_amount]);
|
||||
}
|
||||
|
||||
/** D8 对账状态标记翻转 */
|
||||
#[PutRoute(route: '/{id}/toggle', authorize: 'item.update', where: ['id' => '[0-9]+'])]
|
||||
public function toggle(int $id): JsonResponse
|
||||
{
|
||||
$item = ReconciliationItemModel::find($id);
|
||||
if (empty($item)) {
|
||||
throw new RepositoryException('对账明细不存在');
|
||||
}
|
||||
$this->assertEditable($item);
|
||||
|
||||
$item->is_reconciled = $item->is_reconciled === ReconciliationItemModel::RECONCILED
|
||||
? ReconciliationItemModel::NOT_RECONCILED
|
||||
: ReconciliationItemModel::RECONCILED;
|
||||
$item->save();
|
||||
|
||||
return $this->success(['is_reconciled' => $item->is_reconciled]);
|
||||
}
|
||||
|
||||
/** D6 单品级门店备注 */
|
||||
#[PutRoute(route: '/{id}/remark', authorize: 'item.update', where: ['id' => '[0-9]+'])]
|
||||
public function remark(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'store_remark' => 'nullable|string|max:255',
|
||||
], [
|
||||
'store_remark.max' => '备注最长 255 个字符',
|
||||
]);
|
||||
$item = ReconciliationItemModel::find($id);
|
||||
if (empty($item)) {
|
||||
throw new RepositoryException('对账明细不存在');
|
||||
}
|
||||
$this->assertEditable($item);
|
||||
|
||||
$item->store_remark = (string) ($data['store_remark'] ?? '');
|
||||
$item->save();
|
||||
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 已结算的对账单明细不允许修改
|
||||
*/
|
||||
private function assertEditable(ReconciliationItemModel $item): void
|
||||
{
|
||||
$recon = ReconciliationModel::find($item->recon_id);
|
||||
if ($recon !== null && $recon->status === ReconciliationModel::STATUS_SETTLED) {
|
||||
throw new RepositoryException('对账单已结算,明细不能修改');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 明细变更后重算对账单头汇总(publish / actual / diff)
|
||||
*/
|
||||
private function refreshReconSummary(int $reconId): void
|
||||
{
|
||||
$sums = ReconciliationItemModel::query()
|
||||
->where('recon_id', $reconId)
|
||||
->selectRaw('COALESCE(SUM(publish_amount), 0) as publish_total, COALESCE(SUM(actual_amount), 0) as actual_total')
|
||||
->first();
|
||||
|
||||
ReconciliationModel::whereKey($reconId)->update([
|
||||
'publish_amount' => $sums->publish_total,
|
||||
'actual_amount' => $sums->actual_total,
|
||||
'diff_amount' => bcsub((string) $sums->publish_total, (string) $sums->actual_total, 2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Recon;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Http\Requests\Recon\ReconciliationFormRequest;
|
||||
use App\Models\ReconciliationModel;
|
||||
use App\Models\SettlementModel;
|
||||
use App\Services\BillNumberService;
|
||||
use App\Services\ReconciliationBuildService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\AnnoRoute\Attribute\DeleteRoute;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\PostRoute;
|
||||
use Modules\AnnoRoute\Attribute\PutRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Modules\Common\Http\Controllers\BaseController;
|
||||
|
||||
/**
|
||||
* 财务对账管理(D1 品类 / D2 供应商筛选、D5 差额对比、D9 结算表生成)
|
||||
* 对账明细的 D4/D6/D8 操作见 ReconItemController
|
||||
*/
|
||||
#[RequestAttribute('/recon/list', 'recon.list')]
|
||||
class ReconciliationController extends BaseController
|
||||
{
|
||||
protected array $searchField = [
|
||||
'status' => '=',
|
||||
'category_id' => '=',
|
||||
'supplier_id' => '=',
|
||||
'title' => 'like',
|
||||
'period_start' => 'date',
|
||||
];
|
||||
|
||||
/** 对账单列表 */
|
||||
#[GetRoute(authorize: 'query')]
|
||||
public function query(Request $request): JsonResponse
|
||||
{
|
||||
$params = $request->all();
|
||||
$pageSize = $params['pageSize'] ?? 10;
|
||||
$data = $this->buildSearch($params, ReconciliationModel::query()->with('operator:id,nickname'))
|
||||
->orderBy('id', 'desc')
|
||||
->paginate($pageSize)
|
||||
->toArray();
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/** 创建对账单(草稿,recon_no = RC…) */
|
||||
#[PostRoute(authorize: 'create')]
|
||||
public function create(ReconciliationFormRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$recon = ReconciliationModel::create([
|
||||
'recon_no' => app(BillNumberService::class)->make('RC'),
|
||||
'title' => $validated['title'],
|
||||
'period_start' => $validated['period_start'],
|
||||
'period_end' => $validated['period_end'],
|
||||
'category_id' => $validated['category_id'],
|
||||
'supplier_id' => $validated['supplier_id'],
|
||||
'publish_amount' => 0,
|
||||
'actual_amount' => 0,
|
||||
'diff_amount' => 0,
|
||||
'status' => ReconciliationModel::STATUS_DRAFT,
|
||||
'operator_id' => (int) $request->user()->id,
|
||||
'remark' => $validated['remark'] ?? '',
|
||||
]);
|
||||
return $this->success(['id' => $recon->id]);
|
||||
}
|
||||
|
||||
/** 编辑对账单(仅草稿/对账中) */
|
||||
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||
public function update(int $id, ReconciliationFormRequest $request): JsonResponse
|
||||
{
|
||||
$recon = ReconciliationModel::find($id);
|
||||
if (empty($recon)) {
|
||||
throw new RepositoryException('对账单不存在');
|
||||
}
|
||||
if ($recon->status === ReconciliationModel::STATUS_SETTLED) {
|
||||
throw new RepositoryException('对账单已结算,不能编辑');
|
||||
}
|
||||
$validated = $request->validated();
|
||||
$recon->update([
|
||||
'title' => $validated['title'],
|
||||
'period_start' => $validated['period_start'],
|
||||
'period_end' => $validated['period_end'],
|
||||
'category_id' => $validated['category_id'],
|
||||
'supplier_id' => $validated['supplier_id'],
|
||||
'remark' => $validated['remark'] ?? '',
|
||||
]);
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/** 删除对账单(仅草稿可删,连带明细) */
|
||||
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
|
||||
public function delete(int $id): JsonResponse
|
||||
{
|
||||
$recon = ReconciliationModel::find($id);
|
||||
if (empty($recon)) {
|
||||
throw new RepositoryException('对账单不存在');
|
||||
}
|
||||
if ($recon->status !== ReconciliationModel::STATUS_DRAFT) {
|
||||
throw new RepositoryException('仅草稿状态的对账单可以删除');
|
||||
}
|
||||
DB::transaction(function () use ($recon) {
|
||||
$recon->items()->delete();
|
||||
$recon->delete();
|
||||
});
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/** 生成对账明细(按周期 + 品类 + 供应商拉取已完成订单明细;可重复生成) */
|
||||
#[PostRoute(route: '/{id}/build', authorize: 'build', where: ['id' => '[0-9]+'])]
|
||||
public function build(int $id): JsonResponse
|
||||
{
|
||||
$recon = ReconciliationModel::find($id);
|
||||
if (empty($recon)) {
|
||||
throw new RepositoryException('对账单不存在');
|
||||
}
|
||||
if ($recon->status === ReconciliationModel::STATUS_SETTLED) {
|
||||
throw new RepositoryException('对账单已结算,不能重新生成明细');
|
||||
}
|
||||
$count = app(ReconciliationBuildService::class)->build($recon);
|
||||
return $this->success(['count' => $count], '对账明细已生成');
|
||||
}
|
||||
|
||||
/**
|
||||
* D5 差额对比视图:按门店 / 按商品两个维度 + 合计行
|
||||
*/
|
||||
#[GetRoute(route: '/{id}/diff', authorize: 'query', where: ['id' => '[0-9]+'])]
|
||||
public function diff(int $id): JsonResponse
|
||||
{
|
||||
$recon = ReconciliationModel::find($id);
|
||||
if (empty($recon)) {
|
||||
throw new RepositoryException('对账单不存在');
|
||||
}
|
||||
|
||||
$items = $recon->items()->with('store:id,name')->get();
|
||||
|
||||
$byStore = $items->groupBy('store_id')->map(function ($group) {
|
||||
$first = $group->first();
|
||||
$publish = $group->sum('publish_amount');
|
||||
$actual = $group->sum('actual_amount');
|
||||
return [
|
||||
'store_id' => $first->store_id,
|
||||
'store_name' => $first->store?->name ?? '',
|
||||
'publish' => (float) $publish,
|
||||
'actual' => (float) $actual,
|
||||
'diff' => (float) bcsub((string) $publish, (string) $actual, 2),
|
||||
];
|
||||
})->values();
|
||||
|
||||
$byProduct = $items->groupBy('product_id')->map(function ($group) {
|
||||
$first = $group->first();
|
||||
$publish = $group->sum('publish_amount');
|
||||
$actual = $group->sum('actual_amount');
|
||||
return [
|
||||
'product_id' => $first->product_id,
|
||||
'product_name' => $first->product_name,
|
||||
'publish' => (float) $publish,
|
||||
'actual' => (float) $actual,
|
||||
'diff' => (float) bcsub((string) $publish, (string) $actual, 2),
|
||||
];
|
||||
})->values();
|
||||
|
||||
$publishTotal = (string) $items->sum('publish_amount');
|
||||
$actualTotal = (string) $items->sum('actual_amount');
|
||||
|
||||
return $this->success([
|
||||
'by_store' => $byStore->toArray(),
|
||||
'by_product' => $byProduct->toArray(),
|
||||
'total' => [
|
||||
'publish' => (float) $publishTotal,
|
||||
'actual' => (float) $actualTotal,
|
||||
'diff' => (float) bcsub($publishTotal, $actualTotal, 2),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* D9 生成结算表:按门店聚合明细生成 settlement 记录,对账单 status → 已结算
|
||||
* (回框统计表规则待业务确认,本次仅预留结构)
|
||||
*/
|
||||
#[PostRoute(route: '/{id}/settle', authorize: 'settle', where: ['id' => '[0-9]+'])]
|
||||
public function settle(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$recon = ReconciliationModel::find($id);
|
||||
if (empty($recon)) {
|
||||
throw new RepositoryException('对账单不存在');
|
||||
}
|
||||
if ($recon->status !== ReconciliationModel::STATUS_WORKING) {
|
||||
throw new RepositoryException('仅「对账中」的对账单可以生成结算表');
|
||||
}
|
||||
|
||||
$count = DB::transaction(function () use ($recon, $request) {
|
||||
$groups = $recon->items()->get()->groupBy('store_id');
|
||||
if ($groups->isEmpty()) {
|
||||
throw new RepositoryException('对账单无明细,请先生成对账明细');
|
||||
}
|
||||
|
||||
$billNumber = app(BillNumberService::class);
|
||||
foreach ($groups as $storeId => $items) {
|
||||
$publish = $items->reduce(
|
||||
static fn (string $carry, $item): string => bcadd($carry, (string) $item->publish_amount, 2),
|
||||
'0'
|
||||
);
|
||||
$actual = $items->reduce(
|
||||
static fn (string $carry, $item): string => bcadd($carry, (string) $item->actual_amount, 2),
|
||||
'0'
|
||||
);
|
||||
|
||||
SettlementModel::create([
|
||||
'settlement_no' => $billNumber->make('JS'),
|
||||
'recon_id' => $recon->id,
|
||||
'store_id' => (int) $storeId,
|
||||
'period_start' => $recon->period_start,
|
||||
'period_end' => $recon->period_end,
|
||||
'total_amount' => $publish,
|
||||
'actual_amount' => $actual,
|
||||
'diff_amount' => bcsub($publish, $actual, 2),
|
||||
'status' => SettlementModel::STATUS_SETTLED,
|
||||
'operator_id' => (int) $request->user()->id,
|
||||
'settled_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$recon->status = ReconciliationModel::STATUS_SETTLED;
|
||||
$recon->save();
|
||||
|
||||
return $groups->count();
|
||||
});
|
||||
|
||||
return $this->success(['count' => $count], '结算表已生成');
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Recon;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\SettlementModel;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Modules\Common\Http\Controllers\BaseController;
|
||||
|
||||
/**
|
||||
* 结算表管理(D9 生成于对账结算,D10 导出下载存档)
|
||||
*/
|
||||
#[RequestAttribute('/recon/settlement', 'recon.settlement')]
|
||||
class SettlementController extends BaseController
|
||||
{
|
||||
protected array $searchField = [
|
||||
'settlement_no' => 'like',
|
||||
'recon_id' => '=',
|
||||
'store_id' => '=',
|
||||
'status' => '=',
|
||||
];
|
||||
|
||||
/** 结算表列表 */
|
||||
#[GetRoute(authorize: 'query')]
|
||||
public function query(Request $request): JsonResponse
|
||||
{
|
||||
$params = $request->all();
|
||||
$pageSize = $params['pageSize'] ?? 10;
|
||||
$data = $this->buildSearch(
|
||||
$params,
|
||||
SettlementModel::query()->with(['store:id,name', 'recon:id,recon_no,title'])
|
||||
)
|
||||
->orderBy('id', 'desc')
|
||||
->paginate($pageSize)
|
||||
->toArray();
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/** 结算表详情 */
|
||||
#[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])]
|
||||
public function detail(int $id): JsonResponse
|
||||
{
|
||||
$settlement = SettlementModel::with(['store:id,name', 'recon:id,recon_no,title', 'operator:id,nickname'])
|
||||
->find($id);
|
||||
if (empty($settlement)) {
|
||||
throw new RepositoryException('结算表不存在');
|
||||
}
|
||||
return $this->success($settlement->toArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Client;
|
||||
|
||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
|
||||
/**
|
||||
* 首页轮播图 创建/编辑 验证
|
||||
*/
|
||||
class BannerFormRequest extends BaseFormRequest
|
||||
{
|
||||
protected $stopOnFirstFailure = true;
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => 'required|string|max:50',
|
||||
'image_id' => 'required|integer|min:1',
|
||||
'link' => 'nullable|string|max:255',
|
||||
'sort' => 'nullable|integer|min:0',
|
||||
'status' => 'required|integer|in:0,1',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'title.required' => '轮播图标题不能为空',
|
||||
'title.max' => '轮播图标题最长 50 个字符',
|
||||
'image_id.required' => '请上传轮播图片',
|
||||
'link.max' => '跳转链接最长 255 个字符',
|
||||
'status.in' => '状态只能是 0 或 1',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Client;
|
||||
|
||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
|
||||
/**
|
||||
* 宫格导航 创建/编辑 验证
|
||||
*/
|
||||
class NavFormRequest extends BaseFormRequest
|
||||
{
|
||||
protected $stopOnFirstFailure = true;
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'required|string|max:50',
|
||||
'image_id' => 'required|integer|min:1',
|
||||
'link' => 'nullable|string|max:255',
|
||||
'sort' => 'nullable|integer|min:0',
|
||||
'status' => 'required|integer|in:0,1',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => '导航名称不能为空',
|
||||
'name.max' => '导航名称最长 50 个字符',
|
||||
'image_id.required' => '请上传导航图标',
|
||||
'link.max' => '跳转链接最长 255 个字符',
|
||||
'status.in' => '状态只能是 0 或 1',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Client;
|
||||
|
||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
|
||||
/**
|
||||
* 促销推荐卡片 创建/编辑 验证
|
||||
*/
|
||||
class PromoFormRequest extends BaseFormRequest
|
||||
{
|
||||
protected $stopOnFirstFailure = true;
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => 'nullable|string|max:50',
|
||||
'sub_title' => 'nullable|string|max:100',
|
||||
'image_id' => 'required|integer|min:1',
|
||||
'link' => 'nullable|string|max:255',
|
||||
'sort' => 'nullable|integer|min:0',
|
||||
'status' => 'required|integer|in:0,1',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'title.required' => '卡片标题不能为空',
|
||||
'title.max' => '卡片标题最长 50 个字符',
|
||||
'sub_title.max' => '副标题最长 100 个字符',
|
||||
'image_id.required' => '请上传卡片图片',
|
||||
'link.max' => '跳转链接最长 255 个字符',
|
||||
'status.in' => '状态只能是 0 或 1',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests\Product;
|
||||
|
||||
use Illuminate\Validation\Rules\Exists;
|
||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
use Modules\SystemTool\Models\SysFileModel;
|
||||
|
||||
/**
|
||||
* 商品分类 创建/编辑 验证
|
||||
@@ -24,7 +22,6 @@ class ProductCategoryFormRequest extends BaseFormRequest
|
||||
'name' => 'required|string|max:50',
|
||||
'parent_id' => 'required|integer|min:0',
|
||||
'sort' => 'nullable|integer',
|
||||
'icon_id' => ['nullable', 'integer', new Exists(SysFileModel::class, 'id')],
|
||||
'status' => 'nullable|integer|in:0,1',
|
||||
];
|
||||
}
|
||||
@@ -36,7 +33,6 @@ class ProductCategoryFormRequest extends BaseFormRequest
|
||||
'name.max' => '分类名称最长 50 个字符',
|
||||
'parent_id.min' => '父级分类ID不正确',
|
||||
'status.in' => '状态值不正确',
|
||||
'icon_id.exists' => '请重新上传图片'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,13 +44,17 @@ class ProductFormRequest extends BaseFormRequest
|
||||
}
|
||||
|
||||
/**
|
||||
* 交叉校验:按成本百分比计价(price_type=1)时上浮百分点必填
|
||||
* 交叉校验:商品只能挂在末级分类;按成本百分比计价(price_type=1)时上浮百分点必填
|
||||
* (Laravel 12 FormRequest 的 after() 需返回单个 Closure,由容器 call 后注册到 Validator)
|
||||
*/
|
||||
public function after(): Closure
|
||||
{
|
||||
return function (Validator $validator): void {
|
||||
$data = (array) $validator->getData();
|
||||
$categoryId = (int) ($data['category_id'] ?? 0);
|
||||
if ($categoryId > 0 && ProductCategoryModel::where('parent_id', $categoryId)->exists()) {
|
||||
$validator->errors()->add('category_id', '该分类下存在子分类,请选择末级分类');
|
||||
}
|
||||
foreach ((array) ($data['prices'] ?? []) as $index => $row) {
|
||||
$priceType = (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED);
|
||||
if ($priceType === ProductPriceModel::PRICE_TYPE_PERCENT
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Recon;
|
||||
|
||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
|
||||
/**
|
||||
* 对账明细修改 验证(D4:订货量/称重/数量/金额/商品信息;diff 与头汇总由后端重算)
|
||||
*/
|
||||
class ReconItemUpdateRequest extends BaseFormRequest
|
||||
{
|
||||
protected $stopOnFirstFailure = true;
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'product_name' => 'nullable|string|max:100',
|
||||
'quantity' => 'nullable|numeric|min:0',
|
||||
'weight' => 'nullable|numeric|min:0',
|
||||
'publish_amount' => 'nullable|numeric|min:0',
|
||||
'actual_amount' => 'nullable|numeric|min:0',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'quantity.numeric' => '订货量必须为数字',
|
||||
'quantity.min' => '订货量不能小于 0',
|
||||
'weight.numeric' => '称重必须为数字',
|
||||
'weight.min' => '称重不能小于 0',
|
||||
'publish_amount.numeric' => '公布金额必须为数字',
|
||||
'publish_amount.min' => '公布金额不能小于 0',
|
||||
'actual_amount.numeric' => '实际金额必须为数字',
|
||||
'actual_amount.min' => '实际金额不能小于 0',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Recon;
|
||||
|
||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
|
||||
/**
|
||||
* 财务对账单 创建/编辑 验证
|
||||
*/
|
||||
class ReconciliationFormRequest extends BaseFormRequest
|
||||
{
|
||||
protected $stopOnFirstFailure = true;
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->merge([
|
||||
'category_id' => (int) ($this->input('category_id') ?? 0),
|
||||
'supplier_id' => (int) ($this->input('supplier_id') ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => 'required|string|max:100',
|
||||
'period_start' => 'required|date_format:Y-m-d',
|
||||
'period_end' => 'required|date_format:Y-m-d|after_or_equal:period_start',
|
||||
'category_id' => 'required|integer|min:0',
|
||||
'supplier_id' => 'required|integer|min:0',
|
||||
'remark' => 'nullable|string|max:255',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'title.required' => '对账标题不能为空',
|
||||
'title.max' => '对账标题最长 100 个字符',
|
||||
'period_start.required' => '请选择对账周期开始日期',
|
||||
'period_start.date_format' => '开始日期格式为 Y-m-d',
|
||||
'period_end.required' => '请选择对账周期结束日期',
|
||||
'period_end.date_format' => '结束日期格式为 Y-m-d',
|
||||
'period_end.after_or_equal' => '结束日期不能早于开始日期',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Modules\SystemTool\Models\SysFileModel;
|
||||
|
||||
/**
|
||||
* 小程序首页轮播图
|
||||
*/
|
||||
class HomeBannerModel extends Model
|
||||
{
|
||||
/** 状态:停用 */
|
||||
public const int STATUS_DISABLED = 0;
|
||||
/** 状态:正常 */
|
||||
public const int STATUS_NORMAL = 1;
|
||||
|
||||
protected $table = 'mini_home_banner';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'image_id',
|
||||
'link',
|
||||
'sort',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'image_id' => 'integer',
|
||||
'sort' => 'integer',
|
||||
'status' => 'integer',
|
||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||
'updated_at' => 'datetime:Y-m-d H:i:s',
|
||||
];
|
||||
|
||||
protected $appends = ['image_url'];
|
||||
|
||||
protected $with = ['image'];
|
||||
|
||||
/**
|
||||
* 关联图片
|
||||
*/
|
||||
public function image(): HasOne
|
||||
{
|
||||
return $this->hasOne(SysFileModel::class, 'id', 'image_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片链接
|
||||
*/
|
||||
public function getImageUrlAttribute(): ?string
|
||||
{
|
||||
return $this->image?->preview_url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Modules\SystemTool\Models\SysFileModel;
|
||||
|
||||
/**
|
||||
* 小程序首页宫格导航
|
||||
*/
|
||||
class HomeNavModel extends Model
|
||||
{
|
||||
/** 状态:停用 */
|
||||
public const int STATUS_DISABLED = 0;
|
||||
/** 状态:正常 */
|
||||
public const int STATUS_NORMAL = 1;
|
||||
|
||||
protected $table = 'mini_home_nav';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'image_id',
|
||||
'link',
|
||||
'sort',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'image_id' => 'integer',
|
||||
'sort' => 'integer',
|
||||
'status' => 'integer',
|
||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||
'updated_at' => 'datetime:Y-m-d H:i:s',
|
||||
];
|
||||
|
||||
protected $appends = ['image_url'];
|
||||
|
||||
protected $with = ['image'];
|
||||
|
||||
/**
|
||||
* 关联图标
|
||||
*/
|
||||
public function image(): HasOne
|
||||
{
|
||||
return $this->hasOne(SysFileModel::class, 'id', 'image_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 图标链接
|
||||
*/
|
||||
public function getImageUrlAttribute(): ?string
|
||||
{
|
||||
return $this->image?->preview_url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Modules\SystemTool\Models\SysFileModel;
|
||||
|
||||
/**
|
||||
* 小程序首页促销推荐卡片
|
||||
*/
|
||||
class HomePromoModel extends Model
|
||||
{
|
||||
/** 状态:停用 */
|
||||
public const int STATUS_DISABLED = 0;
|
||||
/** 状态:正常 */
|
||||
public const int STATUS_NORMAL = 1;
|
||||
|
||||
protected $table = 'mini_home_promo';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'sub_title',
|
||||
'image_id',
|
||||
'link',
|
||||
'sort',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'image_id' => 'integer',
|
||||
'sort' => 'integer',
|
||||
'status' => 'integer',
|
||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||
'updated_at' => 'datetime:Y-m-d H:i:s',
|
||||
];
|
||||
|
||||
protected $appends = ['image_url'];
|
||||
|
||||
protected $with = ['image'];
|
||||
|
||||
/**
|
||||
* 关联图片
|
||||
*/
|
||||
public function image(): HasOne
|
||||
{
|
||||
return $this->hasOne(SysFileModel::class, 'id', 'image_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片链接
|
||||
*/
|
||||
public function getImageUrlAttribute(): ?string
|
||||
{
|
||||
return $this->image?->preview_url;
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Modules\SystemTool\Models\SysFileModel;
|
||||
|
||||
/**
|
||||
* 商品分类模型(蔬菜/水果/其他,多级分类自关联)
|
||||
@@ -25,40 +23,17 @@ class ProductCategoryModel extends Model
|
||||
'parent_id',
|
||||
'name',
|
||||
'sort',
|
||||
'status',
|
||||
'icon_id'
|
||||
'status'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'parent_id' => 'integer',
|
||||
'sort' => 'integer',
|
||||
'status' => 'integer',
|
||||
'icon_id' => 'integer',
|
||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||
'updated_at' => 'datetime:Y-m-d H:i:s',
|
||||
];
|
||||
|
||||
protected $appends = ['icon_url'];
|
||||
|
||||
protected $with = ['icon'];
|
||||
|
||||
/**
|
||||
* 关联图标
|
||||
*/
|
||||
public function icon(): HasOne
|
||||
{
|
||||
return $this->hasOne(SysFileModel::class, 'id', 'icon_id');
|
||||
}
|
||||
|
||||
// 图标链接
|
||||
public function getIconUrlAttribute()
|
||||
{
|
||||
if($this->icon) {
|
||||
return $this->icon->preview_url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 父分类
|
||||
*/
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 对账明细模型(订货量/称重/数量/金额可修改,diff = publish − actual)
|
||||
*/
|
||||
class ReconciliationItemModel extends Model
|
||||
{
|
||||
/** 未对账 */
|
||||
public const NOT_RECONCILED = 0;
|
||||
/** 已对账 */
|
||||
public const RECONCILED = 1;
|
||||
|
||||
protected $table = 'reconciliation_item';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $fillable = [
|
||||
'recon_id',
|
||||
'store_id',
|
||||
'order_item_id',
|
||||
'product_id',
|
||||
'product_name',
|
||||
'quantity',
|
||||
'weight',
|
||||
'publish_amount',
|
||||
'actual_amount',
|
||||
'diff_amount',
|
||||
'is_reconciled',
|
||||
'store_remark',
|
||||
'sort',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'recon_id' => 'integer',
|
||||
'store_id' => 'integer',
|
||||
'order_item_id' => 'integer',
|
||||
'product_id' => 'integer',
|
||||
'quantity' => 'decimal:2',
|
||||
'weight' => 'decimal:3',
|
||||
'publish_amount' => 'decimal:2',
|
||||
'actual_amount' => 'decimal:2',
|
||||
'diff_amount' => 'decimal:2',
|
||||
'is_reconciled' => 'integer',
|
||||
'sort' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 所属对账单
|
||||
*/
|
||||
public function recon(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ReconciliationModel::class, 'recon_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 所属门店
|
||||
*/
|
||||
public function store(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StoreModel::class, 'store_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 对账商品
|
||||
*/
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductModel::class, 'product_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 溯源订货明细
|
||||
*/
|
||||
public function orderItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StoreOrderItemModel::class, 'order_item_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Modules\SystemUser\Models\SysUserModel;
|
||||
|
||||
/**
|
||||
* 财务对账模型(公布金额 vs 实际金额 vs 差额,按品类/供应商筛选)
|
||||
*/
|
||||
class ReconciliationModel extends Model
|
||||
{
|
||||
/** 状态:草稿 */
|
||||
public const STATUS_DRAFT = 0;
|
||||
/** 状态:对账中 */
|
||||
public const STATUS_WORKING = 1;
|
||||
/** 状态:已结算 */
|
||||
public const STATUS_SETTLED = 2;
|
||||
|
||||
protected $table = 'reconciliation';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $fillable = [
|
||||
'recon_no',
|
||||
'title',
|
||||
'period_start',
|
||||
'period_end',
|
||||
'category_id',
|
||||
'supplier_id',
|
||||
'publish_amount',
|
||||
'actual_amount',
|
||||
'diff_amount',
|
||||
'status',
|
||||
'operator_id',
|
||||
'remark',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'period_start' => 'date:Y-m-d',
|
||||
'period_end' => 'date:Y-m-d',
|
||||
'category_id' => 'integer',
|
||||
'supplier_id' => 'integer',
|
||||
'publish_amount' => 'decimal:2',
|
||||
'actual_amount' => 'decimal:2',
|
||||
'diff_amount' => 'decimal:2',
|
||||
'status' => 'integer',
|
||||
'operator_id' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 制单人(后台系统用户)
|
||||
*/
|
||||
public function operator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SysUserModel::class, 'operator_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 对账明细
|
||||
*/
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(ReconciliationItemModel::class, 'recon_id', 'id')->orderBy('sort');
|
||||
}
|
||||
|
||||
/**
|
||||
* 结算表
|
||||
*/
|
||||
public function settlements(): HasMany
|
||||
{
|
||||
return $this->hasMany(SettlementModel::class, 'recon_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\SystemUser\Models\SysUserModel;
|
||||
|
||||
/**
|
||||
* 结算表模型(对账结算按门店聚合生成,可导出存档)
|
||||
*/
|
||||
class SettlementModel extends Model
|
||||
{
|
||||
/** 状态:待结算 */
|
||||
public const STATUS_PENDING = 0;
|
||||
/** 状态:已结算 */
|
||||
public const STATUS_SETTLED = 1;
|
||||
|
||||
protected $table = 'settlement';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $fillable = [
|
||||
'settlement_no',
|
||||
'recon_id',
|
||||
'store_id',
|
||||
'period_start',
|
||||
'period_end',
|
||||
'total_amount',
|
||||
'actual_amount',
|
||||
'diff_amount',
|
||||
'status',
|
||||
'file_path',
|
||||
'operator_id',
|
||||
'settled_at',
|
||||
'remark',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'recon_id' => 'integer',
|
||||
'store_id' => 'integer',
|
||||
'period_start' => 'date:Y-m-d',
|
||||
'period_end' => 'date:Y-m-d',
|
||||
'total_amount' => 'decimal:2',
|
||||
'actual_amount' => 'decimal:2',
|
||||
'diff_amount' => 'decimal:2',
|
||||
'status' => 'integer',
|
||||
'operator_id' => 'integer',
|
||||
'settled_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 来源对账单
|
||||
*/
|
||||
public function recon(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ReconciliationModel::class, 'recon_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 结算门店
|
||||
*/
|
||||
public function store(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StoreModel::class, 'store_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 制单人(后台系统用户)
|
||||
*/
|
||||
public function operator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SysUserModel::class, 'operator_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,6 @@ class BillNumberService
|
||||
private const NUMBER_SOURCES = [
|
||||
'PO' => ['purchase_order', 'purchase_no'],
|
||||
'SO' => ['store_order', 'order_no'],
|
||||
'RC' => ['reconciliation', 'recon_no'],
|
||||
'JS' => ['settlement', 'settlement_no'],
|
||||
'ZD' => ['bill', 'bill_no'],
|
||||
'ZF' => ['payment', 'payment_no'],
|
||||
];
|
||||
@@ -33,7 +31,7 @@ class BillNumberService
|
||||
/**
|
||||
* 生成业务单号
|
||||
*
|
||||
* @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / RC 对账 / JS 结算 / ZD 账单 / ZF 支付
|
||||
* @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / ZD 账单 / ZF 支付
|
||||
* @return string 如 PO202607230001
|
||||
*/
|
||||
public function make(string $prefix): string
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ReconciliationItemModel;
|
||||
use App\Models\ReconciliationModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 对账明细构建(D1 品类 / D2 供应商筛选)
|
||||
*
|
||||
* 流程(事务内,可重复 build:先清后建):
|
||||
* 1. 按周期 + 品类(含子孙分类)+ 供应商拉取「已完成」门店订单的订货明细
|
||||
* 2. 每条订货明细 → 一条对账明细:
|
||||
* publish_amount = 订货金额(order_item.amount = 每包等级价×数量)
|
||||
* actual_amount = 采购成本(数量 × 每包成本价;单价/包规不参与金额计算)
|
||||
* diff = publish − actual,冗余 product_name / store_id
|
||||
* 3. 汇总写回头的 publish/actual/diff_amount,status → 对账中
|
||||
*/
|
||||
class ReconciliationBuildService
|
||||
{
|
||||
/**
|
||||
* @return int 生成的对账明细数
|
||||
*/
|
||||
public function build(ReconciliationModel $recon): int
|
||||
{
|
||||
return DB::transaction(function () use ($recon) {
|
||||
// 1. 按周期 + 品类 + 供应商拉取已完成订单的订货明细
|
||||
$itemQuery = StoreOrderItemModel::query()
|
||||
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
|
||||
->where('store_order.status', StoreOrderModel::STATUS_COMPLETED)
|
||||
->whereNull('store_order.deleted_at')
|
||||
->whereDate('store_order.order_date', '>=', $recon->period_start)
|
||||
->whereDate('store_order.order_date', '<=', $recon->period_end)
|
||||
->select('store_order_item.*');
|
||||
|
||||
if ((int) $recon->supplier_id > 0) {
|
||||
$itemQuery->where('store_order_item.supplier_id', $recon->supplier_id);
|
||||
}
|
||||
if ((int) $recon->category_id > 0) {
|
||||
$itemQuery->whereIn(
|
||||
'store_order_item.category_id',
|
||||
$this->descendantCategoryIds((int) $recon->category_id)
|
||||
);
|
||||
}
|
||||
|
||||
$orderItems = $itemQuery->get()->makeVisible('cost_price');
|
||||
if ($orderItems->isEmpty()) {
|
||||
throw new RepositoryException('周期内无符合筛选条件的已完成订单数据,无法生成对账明细');
|
||||
}
|
||||
|
||||
// 2. 先清后建(幂等)
|
||||
ReconciliationItemModel::where('recon_id', $recon->id)->delete();
|
||||
|
||||
$publishTotal = '0';
|
||||
$actualTotal = '0';
|
||||
$rows = [];
|
||||
$sort = 1;
|
||||
$now = now();
|
||||
foreach ($orderItems as $orderItem) {
|
||||
$publish = (string) $orderItem->amount;
|
||||
$actual = bcmul((string) $orderItem->quantity, (string) ($orderItem->cost_price ?? '0'), 2);
|
||||
$publishTotal = bcadd($publishTotal, $publish, 2);
|
||||
$actualTotal = bcadd($actualTotal, $actual, 2);
|
||||
|
||||
$rows[] = [
|
||||
'recon_id' => $recon->id,
|
||||
'store_id' => $orderItem->store_id,
|
||||
'order_item_id' => $orderItem->id,
|
||||
'product_id' => $orderItem->product_id,
|
||||
'product_name' => $orderItem->product_name,
|
||||
'quantity' => $orderItem->quantity,
|
||||
'weight' => $orderItem->weight,
|
||||
'publish_amount' => $publish,
|
||||
'actual_amount' => $actual,
|
||||
'diff_amount' => bcsub($publish, $actual, 2),
|
||||
'is_reconciled' => ReconciliationItemModel::NOT_RECONCILED,
|
||||
'store_remark' => '',
|
||||
'sort' => $sort++,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
ReconciliationItemModel::insert($rows);
|
||||
|
||||
// 3. 汇总写回头 + 状态流转
|
||||
$recon->publish_amount = $publishTotal;
|
||||
$recon->actual_amount = $actualTotal;
|
||||
$recon->diff_amount = bcsub($publishTotal, $actualTotal, 2);
|
||||
$recon->status = ReconciliationModel::STATUS_WORKING;
|
||||
$recon->save();
|
||||
|
||||
return count($rows);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类自身 + 全部子孙分类ID(多级分类下按顶级分类筛选)
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function descendantCategoryIds(int $categoryId): array
|
||||
{
|
||||
$parentMap = ProductCategoryModel::pluck('parent_id', 'id');
|
||||
$ids = [$categoryId];
|
||||
$queue = [$categoryId];
|
||||
while ($queue !== []) {
|
||||
$current = array_shift($queue);
|
||||
foreach ($parentMap as $id => $parentId) {
|
||||
if ((int) $parentId === $current && ! in_array((int) $id, $ids, true)) {
|
||||
$ids[] = (int) $id;
|
||||
$queue[] = (int) $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ return new class extends Migration
|
||||
Schema::create('product_category', function (Blueprint $table) {
|
||||
$table->increments('id')->comment('分类ID');
|
||||
$table->integer('parent_id')->default(0)->comment('父级分类ID(0为顶级)');
|
||||
$table->integer('icon_id')->nullable()->comment('商品图标');
|
||||
$table->string('name', 50)->comment('分类名称');
|
||||
$table->integer('sort')->default(0)->comment('排序(采购单导出按此排序)');
|
||||
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
* 财务管理(D1-D10):财务对账、结算表(门店对账单已下线,由采购单账单 bill 表替代)
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 财务对账单表(D1 按品类对账、D2 按供应商筛选)
|
||||
if (! Schema::hasTable('reconciliation')) {
|
||||
Schema::create('reconciliation', function (Blueprint $table) {
|
||||
$table->increments('id')->comment('对账ID');
|
||||
$table->string('recon_no', 32)->unique()->comment('对账单编号');
|
||||
$table->string('title', 100)->comment('对账单标题');
|
||||
$table->date('period_start')->comment('对账周期开始');
|
||||
$table->date('period_end')->comment('对账周期结束');
|
||||
$table->integer('category_id')->default(0)->comment('按品类筛选(0为全部,D1)');
|
||||
$table->integer('supplier_id')->default(0)->comment('按供应商筛选(0为全部,D2)');
|
||||
$table->decimal('publish_amount', 10, 2)->default(0)->comment('公布金额合计(D5)');
|
||||
$table->decimal('actual_amount', 10, 2)->default(0)->comment('实际采购金额合计(D5)');
|
||||
$table->decimal('diff_amount', 10, 2)->default(0)->comment('差额合计(D5)');
|
||||
$table->integer('status')->default(0)->comment('状态(0对账中 1已完成 2已生成结算表)');
|
||||
$table->integer('operator_id')->default(0)->comment('对账员(系统用户ID)');
|
||||
$table->string('remark', 255)->default('')->comment('备注');
|
||||
$table->timestamps();
|
||||
$table->index(['period_start', 'period_end'], 'reconciliation_period_index');
|
||||
$table->comment('财务对账单表');
|
||||
});
|
||||
}
|
||||
|
||||
// 财务对账明细表(D4 数据修改、D5 差额对比、D6 单品级门店备注、D8 对账状态标记)
|
||||
if (! Schema::hasTable('reconciliation_item')) {
|
||||
Schema::create('reconciliation_item', function (Blueprint $table) {
|
||||
$table->increments('id')->comment('明细ID');
|
||||
$table->integer('recon_id')->comment('财务对账单ID');
|
||||
$table->integer('store_id')->comment('门店ID');
|
||||
$table->integer('order_item_id')->default(0)->comment('门店订货明细ID');
|
||||
$table->integer('product_id')->comment('商品ID');
|
||||
$table->string('product_name', 100)->comment('品名(快照)');
|
||||
$table->decimal('quantity', 10, 2)->default(0)->comment('数量(D4可修改)');
|
||||
$table->decimal('weight', 10, 3)->default(0)->comment('称重数据(D4可修改)');
|
||||
$table->decimal('publish_amount', 10, 2)->default(0)->comment('公布金额(门店订货金额)');
|
||||
$table->decimal('actual_amount', 10, 2)->default(0)->comment('实际采购金额(分摊)');
|
||||
$table->decimal('diff_amount', 10, 2)->default(0)->comment('差额');
|
||||
$table->integer('is_reconciled')->default(0)->comment('对账状态(1已对账 0未对账,D8)');
|
||||
$table->string('store_remark', 255)->default('')->comment('单品级门店备注(D6)');
|
||||
$table->integer('sort')->default(0)->comment('排序');
|
||||
$table->timestamps();
|
||||
$table->index(['recon_id'], 'reconciliation_item_recon_index');
|
||||
$table->index(['store_id', 'is_reconciled'], 'reconciliation_item_store_index');
|
||||
$table->comment('财务对账明细表');
|
||||
});
|
||||
}
|
||||
|
||||
// 结算表(D9 对账结束后生成结算表/回框统计表,D10 下载存档)
|
||||
if (! Schema::hasTable('settlement')) {
|
||||
Schema::create('settlement', function (Blueprint $table) {
|
||||
$table->increments('id')->comment('结算ID');
|
||||
$table->string('settlement_no', 32)->unique()->comment('结算单编号');
|
||||
$table->integer('recon_id')->default(0)->comment('关联财务对账单ID');
|
||||
$table->integer('store_id')->default(0)->comment('门店ID(0为汇总结算)');
|
||||
$table->date('period_start')->comment('结算周期开始');
|
||||
$table->date('period_end')->comment('结算周期结束');
|
||||
$table->decimal('total_amount', 10, 2)->default(0)->comment('结算总金额(公布)');
|
||||
$table->decimal('actual_amount', 10, 2)->default(0)->comment('实际采购总金额');
|
||||
$table->decimal('diff_amount', 10, 2)->default(0)->comment('差额合计');
|
||||
$table->integer('status')->default(0)->comment('状态(0待结算 1已结算)');
|
||||
$table->string('file_path', 255)->default('')->comment('导出文件路径(Excel/PDF,D10)');
|
||||
$table->integer('operator_id')->default(0)->comment('操作人(系统用户ID)');
|
||||
$table->timestamp('settled_at')->nullable()->comment('结算时间');
|
||||
$table->string('remark', 255)->default('')->comment('备注');
|
||||
$table->timestamps();
|
||||
$table->index(['store_id', 'status'], 'settlement_store_status_index');
|
||||
$table->comment('结算表');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('reconciliation');
|
||||
Schema::dropIfExists('reconciliation_item');
|
||||
Schema::dropIfExists('settlement');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
* 小程序首页配置:轮播图 / 宫格导航 / 促销推荐卡片(后台维护图片、名称、跳转链接)
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('mini_home_banner')) {
|
||||
Schema::create('mini_home_banner', function (Blueprint $table) {
|
||||
$table->increments('id')->comment('轮播图ID');
|
||||
$table->string('title', 50)->nullable()->comment('标题');
|
||||
$table->integer('image_id')->nullable()->default(0)->comment('轮播图片(sys_file ID)');
|
||||
$table->string('link', 255)->nullable()->default('')->comment('小程序跳转链接(如 /pages/goods/detail?id=1)');
|
||||
$table->integer('sort')->default(0)->comment('排序(越小越靠前)');
|
||||
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
||||
$table->timestamps();
|
||||
$table->comment('小程序首页轮播图');
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('mini_home_nav')) {
|
||||
Schema::create('mini_home_nav', function (Blueprint $table) {
|
||||
$table->increments('id')->comment('导航ID');
|
||||
$table->string('name', 50)->nullable()->comment('导航名称');
|
||||
$table->integer('image_id')->nullable()->default(0)->comment('导航图标(sys_file ID)');
|
||||
$table->string('link', 255)->nullable()->default('')->comment('小程序跳转链接');
|
||||
$table->integer('sort')->default(0)->comment('排序(越小越靠前)');
|
||||
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
||||
$table->timestamps();
|
||||
$table->comment('小程序首页宫格导航');
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('mini_home_promo')) {
|
||||
Schema::create('mini_home_promo', function (Blueprint $table) {
|
||||
$table->increments('id')->comment('卡片ID');
|
||||
$table->string('title', 50)->nullable()->comment('卡片标题');
|
||||
$table->string('sub_title', 100)->nullable()->default('')->comment('副标题/促销文案');
|
||||
$table->integer('image_id')->nullable()->default(0)->comment('卡片图片(sys_file ID)');
|
||||
$table->string('link', 255)->nullable()->default('')->comment('小程序跳转链接');
|
||||
$table->integer('sort')->default(0)->comment('排序(越小越靠前)');
|
||||
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
||||
$table->timestamps();
|
||||
$table->comment('小程序首页促销推荐卡片');
|
||||
});
|
||||
}
|
||||
|
||||
// 文件分组:客户端配置图片(幂等,已有库补充分组记录)
|
||||
DB::table('sys_file_group')->insertOrIgnore([
|
||||
'id' => 12,
|
||||
'name' => '客户端配置',
|
||||
'sort' => 11,
|
||||
'describe' => '小程序首页轮播图/宫格导航/促销卡片图片',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('mini_home_promo');
|
||||
Schema::dropIfExists('mini_home_nav');
|
||||
Schema::dropIfExists('mini_home_banner');
|
||||
}
|
||||
};
|
||||
@@ -205,22 +205,6 @@ class PermissionSeeder extends Seeder
|
||||
'name' => '财务管理',
|
||||
'icon' => 'AccountBookOutlined',
|
||||
'children' => [
|
||||
[
|
||||
'type' => 'route',
|
||||
'key' => 'recon.list',
|
||||
'name' => '财务对账',
|
||||
'path' => '/recon/list',
|
||||
'children' => [
|
||||
['type' => 'rule', 'key' => 'recon.list.query', 'name' => '查询'],
|
||||
['type' => 'rule', 'key' => 'recon.list.create', 'name' => '新增'],
|
||||
['type' => 'rule', 'key' => 'recon.list.update', 'name' => '编辑'],
|
||||
['type' => 'rule', 'key' => 'recon.list.delete', 'name' => '删除'],
|
||||
['type' => 'rule', 'key' => 'recon.list.build', 'name' => '生成明细'],
|
||||
['type' => 'rule', 'key' => 'recon.list.settle', 'name' => '生成结算表'],
|
||||
// 对账明细操作权限点在独立控制器 recon.item 下(D4/D6/D8)
|
||||
['type' => 'rule', 'key' => 'recon.item.item.update', 'name' => '对账明细操作'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'route',
|
||||
'key' => 'recon.bill',
|
||||
@@ -252,14 +236,48 @@ class PermissionSeeder extends Seeder
|
||||
['type' => 'rule', 'key' => 'recon.payment.audit', 'name' => '审核'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'menu',
|
||||
'key' => 'procurement.client',
|
||||
'name' => '客户端配置',
|
||||
'icon' => 'MobileOutlined',
|
||||
'children' => [
|
||||
[
|
||||
'type' => 'route',
|
||||
'key' => 'recon.settlement',
|
||||
'name' => '结算表',
|
||||
'path' => '/recon/settlement',
|
||||
'key' => 'client.banner',
|
||||
'name' => '首页轮播图',
|
||||
'path' => '/client/banner',
|
||||
'children' => [
|
||||
['type' => 'rule', 'key' => 'recon.settlement.query', 'name' => '查询'],
|
||||
['type' => 'rule', 'key' => 'recon.settlement.download', 'name' => '下载导出'],
|
||||
['type' => 'rule', 'key' => 'client.banner.query', 'name' => '查询'],
|
||||
['type' => 'rule', 'key' => 'client.banner.create', 'name' => '新增'],
|
||||
['type' => 'rule', 'key' => 'client.banner.update', 'name' => '编辑'],
|
||||
['type' => 'rule', 'key' => 'client.banner.delete', 'name' => '删除'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'route',
|
||||
'key' => 'client.nav',
|
||||
'name' => '宫格导航',
|
||||
'path' => '/client/nav',
|
||||
'children' => [
|
||||
['type' => 'rule', 'key' => 'client.nav.query', 'name' => '查询'],
|
||||
['type' => 'rule', 'key' => 'client.nav.create', 'name' => '新增'],
|
||||
['type' => 'rule', 'key' => 'client.nav.update', 'name' => '编辑'],
|
||||
['type' => 'rule', 'key' => 'client.nav.delete', 'name' => '删除'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'route',
|
||||
'key' => 'client.promo',
|
||||
'name' => '促销推荐卡片',
|
||||
'path' => '/client/promo',
|
||||
'children' => [
|
||||
['type' => 'rule', 'key' => 'client.promo.query', 'name' => '查询'],
|
||||
['type' => 'rule', 'key' => 'client.promo.create', 'name' => '新增'],
|
||||
['type' => 'rule', 'key' => 'client.promo.update', 'name' => '编辑'],
|
||||
['type' => 'rule', 'key' => 'client.promo.delete', 'name' => '删除'],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -70,6 +70,7 @@ class SysDataSeeder extends Seeder
|
||||
['id' => 9, 'name' => '分类图片', 'sort' => 8, 'describe' => '存放商品分类图标', 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 10, 'name' => '商品封面图片', 'sort' => 9, 'describe' => '存放商品封面图片', 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 11, 'name' => '商品详情图片', 'sort' => 10, 'describe' => '存放商品详情图片', 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 12, 'name' => '客户端配置', 'sort' => 11, 'describe' => '小程序首页轮播图/宫格导航/促销卡片图片', 'created_at' => $date, 'updated_at' => $date],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# 小程序首页接口文档
|
||||
|
||||
> 适用端:微信小程序(h5 仓库,Taro)
|
||||
> 后台配置入口:PC 后台「客户端配置」菜单(首页轮播图 / 宫格导航 / 促销推荐卡片)
|
||||
> 更新日期:2026-08-14
|
||||
|
||||
## 通用约定
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 根地址 | `BASE_URL`(见 `src/utils/request.ts`,如 `http://localhost:8000/index.php`) |
|
||||
| 认证 | 请求头 `Authorization: Bearer {token}`,token 来自登录接口,本地存储 key `auth_token` |
|
||||
| 响应包络 | `{ success: boolean, data: T, msg?: string, showType?: number }` |
|
||||
| 失败处理 | `success=false` 时 `msg` 为中文错误信息;HTTP 401 表示登录过期,需重新登录 |
|
||||
|
||||
## GET /mini/home
|
||||
|
||||
首页配置聚合接口:一次返回轮播图、宫格导航、促销推荐卡片三组数据,均为**启用状态(status=1)**且按 `sort` 升序(越小越靠前)。
|
||||
|
||||
- **权限**:需登录(Bearer token)
|
||||
- **请求参数**:无
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"msg": "ok",
|
||||
"data": {
|
||||
"banners": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "新鲜直采",
|
||||
"image_id": 123,
|
||||
"link": "/pages/goods/detail?id=1",
|
||||
"sort": 0,
|
||||
"image_url": "http://localhost:8000/storage/uploads/2026/08/14/xxx.jpg"
|
||||
}
|
||||
],
|
||||
"navs": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "蔬菜专区",
|
||||
"image_id": 124,
|
||||
"link": "/pages/category/index?id=1",
|
||||
"sort": 0,
|
||||
"image_url": "http://localhost:8000/storage/uploads/2026/08/14/yyy.png"
|
||||
}
|
||||
],
|
||||
"promos": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "限时特惠",
|
||||
"sub_title": "全场 8 折起",
|
||||
"image_id": 125,
|
||||
"link": "/pages/promo/detail?id=1",
|
||||
"sort": 0,
|
||||
"image_url": "http://localhost:8000/storage/uploads/2026/08/14/zzz.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
**banners(轮播图)**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | number | 轮播图 ID |
|
||||
| title | string | 标题(后台维护,可用于无障碍/占位) |
|
||||
| image_id | number | 图片文件 ID(sys_file),一般无需使用 |
|
||||
| **image_url** | string \| null | 轮播图片完整 URL,直接用于 `<Image src>`;未传图时为 null |
|
||||
| link | string | 小程序页面跳转路径,**空字符串表示点击不跳转** |
|
||||
| sort | number | 排序值(已按此升序返回,前端无需再排) |
|
||||
|
||||
**navs(宫格导航)**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | number | 导航 ID |
|
||||
| name | string | 导航名称(宫格文字) |
|
||||
| **image_url** | string \| null | 导航图标完整 URL |
|
||||
| link | string | 小程序页面跳转路径,空字符串不跳转 |
|
||||
| sort | number | 排序值 |
|
||||
|
||||
**promos(促销推荐卡片)**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | number | 卡片 ID |
|
||||
| title | string | 卡片标题 |
|
||||
| sub_title | string | 副标题/促销文案,可能为空字符串 |
|
||||
| **image_url** | string \| null | 卡片图片完整 URL |
|
||||
| link | string | 小程序页面跳转路径,空字符串不跳转 |
|
||||
| sort | number | 排序值 |
|
||||
|
||||
### 前端接入示例
|
||||
|
||||
`src/services/home.ts`(新增):
|
||||
|
||||
```ts
|
||||
import { get } from '@/utils/request'
|
||||
|
||||
/** 首页轮播图项 */
|
||||
export interface HomeBanner {
|
||||
id: number
|
||||
title: string
|
||||
image_id: number
|
||||
image_url: string | null
|
||||
link: string
|
||||
sort: number
|
||||
}
|
||||
|
||||
/** 首页宫格导航项 */
|
||||
export interface HomeNav {
|
||||
id: number
|
||||
name: string
|
||||
image_id: number
|
||||
image_url: string | null
|
||||
link: string
|
||||
sort: number
|
||||
}
|
||||
|
||||
/** 首页促销推荐卡片 */
|
||||
export interface HomePromo {
|
||||
id: number
|
||||
title: string
|
||||
sub_title: string
|
||||
image_id: number
|
||||
image_url: string | null
|
||||
link: string
|
||||
sort: number
|
||||
}
|
||||
|
||||
/** 首页配置聚合数据 */
|
||||
export interface HomeConfig {
|
||||
banners: HomeBanner[]
|
||||
navs: HomeNav[]
|
||||
promos: HomePromo[]
|
||||
}
|
||||
|
||||
/** 首页配置(轮播图 + 宫格导航 + 促销卡片):GET /mini/home */
|
||||
export function getHomeConfigApi() {
|
||||
return get<HomeConfig>('/mini/home')
|
||||
}
|
||||
```
|
||||
|
||||
页面中使用(跳转需兼容空链接):
|
||||
|
||||
```tsx
|
||||
const [config, setConfig] = useState<HomeConfig>({ banners: [], navs: [], promos: [] })
|
||||
|
||||
useEffect(() => {
|
||||
getHomeConfigApi().then(res => setConfig(res.data))
|
||||
}, [])
|
||||
|
||||
/** 统一跳转:link 为空不跳转 */
|
||||
function handleLink(link: string) {
|
||||
if (!link) return
|
||||
Taro.navigateTo({ url: link })
|
||||
}
|
||||
```
|
||||
|
||||
### 注意事项
|
||||
|
||||
1. **三组数据均可能为空数组**(后台未配置或全部停用),页面需做空态处理。
|
||||
2. `image_url` 可能为 `null`(后台未上传图片),渲染前判空。
|
||||
3. `link` 为小程序内部页面路径(以 `/` 开头),用 `Taro.navigateTo` 跳转;若目标为 tabBar 页面需改用 `Taro.switchTab`(建议后台配置时避免填 tabBar 路径)。
|
||||
4. 数据实时生效:后台修改后,小程序下次进入首页请求即为最新内容,无缓存。
|
||||
5. 接口需登录后调用;未登录(401)会由 request 封装自动跳登录页。
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* 商品分类两级限制 + 商品只能挂末级分类:
|
||||
* 创建/编辑分类的父级必须为顶级、含子分类的分类不可移动为子级、商品分类必须为末级
|
||||
*/
|
||||
class ProductCategoryTest extends ProcurementTestCase
|
||||
{
|
||||
/** 造一个顶级分类 */
|
||||
private function makeTopCategory(string $name = '蔬菜'): ProductCategoryModel
|
||||
{
|
||||
return ProductCategoryModel::create([
|
||||
'parent_id' => 0,
|
||||
'name' => $name,
|
||||
'sort' => 1,
|
||||
'status' => ProductCategoryModel::STATUS_NORMAL,
|
||||
]);
|
||||
}
|
||||
|
||||
/** 顶级分类下可创建子分类(二级) */
|
||||
public function test_create_child_category_under_top_level(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
$top = $this->makeTopCategory();
|
||||
|
||||
$this->postJson('/product/category', [
|
||||
'parent_id' => $top->id,
|
||||
'name' => '叶菜类',
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$this->assertDatabaseHas('product_category', ['name' => '叶菜类', 'parent_id' => $top->id]);
|
||||
}
|
||||
|
||||
/** 二级分类不可作为父级(最多二级) */
|
||||
public function test_create_category_under_second_level_rejected(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
$top = $this->makeTopCategory();
|
||||
$child = ProductCategoryModel::create([
|
||||
'parent_id' => $top->id,
|
||||
'name' => '叶菜类',
|
||||
'sort' => 1,
|
||||
'status' => ProductCategoryModel::STATUS_NORMAL,
|
||||
]);
|
||||
|
||||
$this->postJson('/product/category', [
|
||||
'parent_id' => $child->id,
|
||||
'name' => '菠菜',
|
||||
])->assertOk()
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '最多支持二级分类,不能选择子分类作为上级');
|
||||
}
|
||||
|
||||
/** 编辑:二级分类可平级移动到另一个顶级分类下 */
|
||||
public function test_update_second_level_move_to_another_top(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
$topA = $this->makeTopCategory('蔬菜');
|
||||
$topB = $this->makeTopCategory('水果');
|
||||
$child = ProductCategoryModel::create([
|
||||
'parent_id' => $topA->id,
|
||||
'name' => '叶菜类',
|
||||
'sort' => 1,
|
||||
'status' => ProductCategoryModel::STATUS_NORMAL,
|
||||
]);
|
||||
|
||||
$this->putJson('/product/category/' . $child->id, [
|
||||
'parent_id' => $topB->id,
|
||||
'name' => '叶菜类',
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$this->assertSame($topB->id, (int) $child->fresh()->parent_id);
|
||||
}
|
||||
|
||||
/** 编辑:含子分类的分类不能调整为子分类(否则子分类变三级) */
|
||||
public function test_update_parent_with_children_cannot_move_under_category(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
$top = $this->makeTopCategory('蔬菜');
|
||||
$other = $this->makeTopCategory('水果');
|
||||
ProductCategoryModel::create([
|
||||
'parent_id' => $top->id,
|
||||
'name' => '叶菜类',
|
||||
'sort' => 1,
|
||||
'status' => ProductCategoryModel::STATUS_NORMAL,
|
||||
]);
|
||||
|
||||
$this->putJson('/product/category/' . $top->id, [
|
||||
'parent_id' => $other->id,
|
||||
'name' => '蔬菜',
|
||||
])->assertOk()
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '该分类下存在子分类,不能调整为子分类');
|
||||
}
|
||||
|
||||
/** 编辑:父级不存在时提示 */
|
||||
public function test_update_with_missing_parent_rejected(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
$top = $this->makeTopCategory();
|
||||
|
||||
$this->putJson('/product/category/' . $top->id, [
|
||||
'parent_id' => 99999,
|
||||
'name' => '蔬菜',
|
||||
])->assertOk()
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '父级分类不存在');
|
||||
}
|
||||
|
||||
/** 商品只能挂在末级分类:选择有子分类的分类创建商品 → 验证失败 */
|
||||
public function test_create_product_with_parent_category_rejected(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
$top = $this->makeTopCategory('蔬菜');
|
||||
ProductCategoryModel::create([
|
||||
'parent_id' => $top->id,
|
||||
'name' => '叶菜类',
|
||||
'sort' => 1,
|
||||
'status' => ProductCategoryModel::STATUS_NORMAL,
|
||||
]);
|
||||
|
||||
$this->postJson('/product/goods', [
|
||||
'category_id' => $top->id,
|
||||
'name' => '大白菜',
|
||||
])->assertOk()
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '该分类下存在子分类,请选择末级分类');
|
||||
}
|
||||
|
||||
/** 商品挂在末级分类(无子分类)可正常创建 */
|
||||
public function test_create_product_with_leaf_category_ok(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
$top = $this->makeTopCategory('蔬菜');
|
||||
$leaf = ProductCategoryModel::create([
|
||||
'parent_id' => $top->id,
|
||||
'name' => '叶菜类',
|
||||
'sort' => 1,
|
||||
'status' => ProductCategoryModel::STATUS_NORMAL,
|
||||
]);
|
||||
|
||||
$this->postJson('/product/goods', [
|
||||
'category_id' => $leaf->id,
|
||||
'name' => '大白菜',
|
||||
'content' => '图文详情',
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
}
|
||||
|
||||
/** 小程序分类树:返回全部启用分类(含无商品的分类),停用分类不返回 */
|
||||
public function test_mini_categories_returns_all_enabled_categories(): void
|
||||
{
|
||||
$top = $this->makeTopCategory('蔬菜');
|
||||
$empty = $this->makeTopCategory('水产'); // 无任何商品
|
||||
ProductCategoryModel::create([
|
||||
'parent_id' => $top->id,
|
||||
'name' => '叶菜类',
|
||||
'sort' => 1,
|
||||
'status' => ProductCategoryModel::STATUS_NORMAL,
|
||||
]);
|
||||
$disabled = ProductCategoryModel::create([
|
||||
'parent_id' => 0,
|
||||
'name' => '停用分类',
|
||||
'sort' => 1,
|
||||
'status' => ProductCategoryModel::STATUS_DISABLED,
|
||||
]);
|
||||
|
||||
$this->actingAsMiniUser(UserModel::factory()->create());
|
||||
|
||||
$response = $this->getJson('/mini/product/categories');
|
||||
$response->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$ids = collect($response->json('data'))->pluck('id');
|
||||
$this->assertContains($top->id, $ids);
|
||||
$this->assertContains($empty->id, $ids, '无商品的分类也应返回');
|
||||
$this->assertNotContains($disabled->id, $ids, '停用分类不应返回');
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\ReconciliationItemModel;
|
||||
use App\Models\ReconciliationModel;
|
||||
use App\Models\SettlementModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\SupplierModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* 财务对账:明细构建(已完成订单直读,品类/供应商筛选)、D4 修改后差额与头汇总重算、D8 标记、D9 结算
|
||||
* publish = 订货金额;actual = 采购成本(称重>0 ? 称重×单价 : 数量×单价,单价 = 成本/包规)
|
||||
*/
|
||||
class ReconciliationTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 构造已完成订单链路:2 门店下单(2/3 件,等级价 10.00;成本 8,包规 1斤 → 单价 8.00),订单置为已完成
|
||||
* 预期:publish 20/30,actual 16/24,diff 4/6
|
||||
*
|
||||
* @return array{0: array<int, StoreModel>, 1: SupplierModel}
|
||||
*/
|
||||
private function buildCompletedOrders(): array
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$supplier = SupplierModel::factory()->create();
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'supplier_id' => $supplier->id,
|
||||
'cost_price' => 8,
|
||||
'spec' => '1斤',
|
||||
]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => '10.00']);
|
||||
|
||||
$stores = [];
|
||||
foreach ([2, 3] as $qty) {
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$stores[] = $store;
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]])
|
||||
->assertJsonPath('success', true);
|
||||
}
|
||||
|
||||
// 订单完成(对账数据源为已完成订单的订货明细)
|
||||
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_COMPLETED]);
|
||||
|
||||
return [$stores, $supplier];
|
||||
}
|
||||
|
||||
private function createRecon(array $extra = []): int
|
||||
{
|
||||
$response = $this->postJson('/recon/list', array_merge([
|
||||
'title' => '测试对账',
|
||||
'period_start' => now()->toDateString(),
|
||||
'period_end' => now()->toDateString(),
|
||||
], $extra));
|
||||
$response->assertJsonPath('success', true);
|
||||
|
||||
return (int) $response->json('data.id');
|
||||
}
|
||||
|
||||
/** 构建明细:publish=订货金额,actual=采购成本(数量×单价),diff=publish-actual,头汇总回写 */
|
||||
public function test_build_creates_reconciliation_items(): void
|
||||
{
|
||||
[$stores] = $this->buildCompletedOrders();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$reconId = $this->createRecon();
|
||||
$this->postJson("/recon/list/{$reconId}/build")->assertJsonPath('success', true);
|
||||
|
||||
$items = ReconciliationItemModel::where('recon_id', $reconId)->get();
|
||||
$this->assertCount(2, $items, '两门店已完成订单 → 两条对账明细');
|
||||
|
||||
$byStore = $items->keyBy('store_id');
|
||||
$this->assertSame('20.00', (string) $byStore[$stores[0]->id]->publish_amount, '订货金额 2×10');
|
||||
$this->assertSame('16.00', (string) $byStore[$stores[0]->id]->actual_amount, '采购成本 2×8');
|
||||
$this->assertSame('4.00', (string) $byStore[$stores[0]->id]->diff_amount);
|
||||
$this->assertSame('30.00', (string) $byStore[$stores[1]->id]->publish_amount);
|
||||
$this->assertSame('24.00', (string) $byStore[$stores[1]->id]->actual_amount);
|
||||
|
||||
$recon = ReconciliationModel::find($reconId);
|
||||
$this->assertSame('50.00', (string) $recon->publish_amount);
|
||||
$this->assertSame('40.00', (string) $recon->actual_amount);
|
||||
$this->assertSame('10.00', (string) $recon->diff_amount);
|
||||
$this->assertSame(ReconciliationModel::STATUS_WORKING, $recon->status);
|
||||
}
|
||||
|
||||
/** 未完成订单不计入对账 */
|
||||
public function test_build_excludes_unfinished_orders(): void
|
||||
{
|
||||
[$stores] = $this->buildCompletedOrders();
|
||||
// 第二家门店订单回退为配送中 → 仅第一家进入对账
|
||||
StoreOrderModel::where('store_id', $stores[1]->id)
|
||||
->update(['status' => StoreOrderModel::STATUS_DISTRIBUTION]);
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$reconId = $this->createRecon();
|
||||
$this->postJson("/recon/list/{$reconId}/build")->assertJsonPath('success', true);
|
||||
|
||||
$items = ReconciliationItemModel::where('recon_id', $reconId)->get();
|
||||
$this->assertCount(1, $items);
|
||||
$this->assertSame($stores[0]->id, $items->first()->store_id);
|
||||
}
|
||||
|
||||
/** 供应商筛选:仅拉取该供应商的订单数据 */
|
||||
public function test_build_filters_by_supplier(): void
|
||||
{
|
||||
[, $supplier] = $this->buildCompletedOrders();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
// 无关供应商 → 无数据报错
|
||||
$other = SupplierModel::factory()->create();
|
||||
$reconId = $this->createRecon(['supplier_id' => $other->id]);
|
||||
$this->postJson("/recon/list/{$reconId}/build")->assertJsonPath('success', false);
|
||||
|
||||
// 正确供应商 → 构建成功
|
||||
$reconId2 = $this->createRecon(['supplier_id' => $supplier->id]);
|
||||
$this->postJson("/recon/list/{$reconId2}/build")->assertJsonPath('success', true);
|
||||
$this->assertSame(2, ReconciliationItemModel::where('recon_id', $reconId2)->count());
|
||||
}
|
||||
|
||||
/** D4 修改明细:自动重算本行 diff 与对账单头汇总 */
|
||||
public function test_update_item_recalculates_diff_and_header(): void
|
||||
{
|
||||
$this->buildCompletedOrders();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$reconId = $this->createRecon();
|
||||
$this->postJson("/recon/list/{$reconId}/build");
|
||||
|
||||
$item = ReconciliationItemModel::where('recon_id', $reconId)->orderBy('id')->first();
|
||||
$this->putJson("/recon/item/{$item->id}", ['actual_amount' => '25.00'])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$item = $item->fresh();
|
||||
$this->assertSame('-5.00', (string) $item->diff_amount, '20.00 - 25.00');
|
||||
|
||||
$recon = ReconciliationModel::find($reconId);
|
||||
$this->assertSame('49.00', (string) $recon->actual_amount, '25 + 24');
|
||||
$this->assertSame('1.00', (string) $recon->diff_amount, '50 - 49');
|
||||
}
|
||||
|
||||
/** D8 对账状态标记翻转 */
|
||||
public function test_toggle_reconciled_flag(): void
|
||||
{
|
||||
$this->buildCompletedOrders();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$reconId = $this->createRecon();
|
||||
$this->postJson("/recon/list/{$reconId}/build");
|
||||
$item = ReconciliationItemModel::where('recon_id', $reconId)->first();
|
||||
$this->assertSame(0, $item->is_reconciled);
|
||||
|
||||
$this->putJson("/recon/item/{$item->id}/toggle")->assertJsonPath('success', true);
|
||||
$this->assertSame(1, $item->fresh()->is_reconciled);
|
||||
|
||||
$this->putJson("/recon/item/{$item->id}/toggle")->assertJsonPath('success', true);
|
||||
$this->assertSame(0, $item->fresh()->is_reconciled);
|
||||
}
|
||||
|
||||
/** D9 结算:按门店生成结算表,对账单转为已结算且不可重复结算 */
|
||||
public function test_settle_creates_settlements_per_store(): void
|
||||
{
|
||||
[$stores] = $this->buildCompletedOrders();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$reconId = $this->createRecon();
|
||||
$this->postJson("/recon/list/{$reconId}/build");
|
||||
|
||||
$this->postJson("/recon/list/{$reconId}/settle")->assertJsonPath('success', true);
|
||||
|
||||
$settlements = SettlementModel::where('recon_id', $reconId)->get();
|
||||
$this->assertCount(2, $settlements, '按门店各生成一张结算表');
|
||||
|
||||
$byStore = $settlements->keyBy('store_id');
|
||||
$this->assertSame('20.00', (string) $byStore[$stores[0]->id]->total_amount);
|
||||
$this->assertSame('16.00', (string) $byStore[$stores[0]->id]->actual_amount);
|
||||
$this->assertSame('4.00', (string) $byStore[$stores[0]->id]->diff_amount);
|
||||
$this->assertStringStartsWith('JS', $byStore[$stores[0]->id]->settlement_no);
|
||||
|
||||
$this->assertSame(ReconciliationModel::STATUS_SETTLED, ReconciliationModel::find($reconId)->status);
|
||||
|
||||
// 已结算不可重复结算
|
||||
$this->postJson("/recon/list/{$reconId}/settle")->assertJsonPath('success', false);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import createAxios from '@/utils/request';
|
||||
import type { IReconDiff } from '@/domain/iReconciliation.ts';
|
||||
|
||||
export interface ReconItemUpdateParams {
|
||||
product_name?: string;
|
||||
quantity?: number | string;
|
||||
weight?: number | string;
|
||||
publish_amount?: number | string;
|
||||
actual_amount?: number | string;
|
||||
}
|
||||
|
||||
/** 生成对账明细(按周期 + 品类 + 供应商拉取采购分摊数据) */
|
||||
export async function buildRecon(id: number) {
|
||||
return createAxios<{ count: number }>({
|
||||
url: `/recon/list/${id}/build`,
|
||||
method: 'post',
|
||||
});
|
||||
}
|
||||
|
||||
/** D4 修改对账明细(diff 与头汇总后端重算) */
|
||||
export async function updateReconItem(id: number, data: ReconItemUpdateParams) {
|
||||
return createAxios<{ diff_amount: string }>({
|
||||
url: `/recon/item/${id}`,
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** D8 对账状态标记翻转 */
|
||||
export async function toggleReconItem(id: number) {
|
||||
return createAxios<{ is_reconciled: number }>({
|
||||
url: `/recon/item/${id}/toggle`,
|
||||
method: 'put',
|
||||
});
|
||||
}
|
||||
|
||||
/** D6 单品级门店备注 */
|
||||
export async function remarkReconItem(id: number, store_remark: string) {
|
||||
return createAxios({
|
||||
url: `/recon/item/${id}/remark`,
|
||||
method: 'put',
|
||||
data: { store_remark },
|
||||
});
|
||||
}
|
||||
|
||||
/** D5 差额对比视图(按门店 / 按商品 + 合计) */
|
||||
export async function getReconDiff(id: number) {
|
||||
return createAxios<IReconDiff>({
|
||||
url: `/recon/list/${id}/diff`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** D9 生成结算表 */
|
||||
export async function settleRecon(id: number) {
|
||||
return createAxios<{ count: number }>({
|
||||
url: `/recon/list/${id}/settle`,
|
||||
method: 'post',
|
||||
});
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { ExportFormat } from '@/domain/iPurchaseOrder.ts';
|
||||
import { downloadBlob } from '@/api/common/download.ts';
|
||||
|
||||
/** D10 结算表下载(blob,成功后后端回写 file_path 存档标记) */
|
||||
export async function downloadSettlement(id: number, format: ExportFormat) {
|
||||
return downloadBlob(
|
||||
`/recon/settlement/${id}/download`,
|
||||
{ format },
|
||||
`结算表_${id}.${format}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type {ISysFileInfo} from "@/domain/iSysFile.ts";
|
||||
|
||||
/** 小程序首页轮播图 */
|
||||
export default interface IHomeBanner {
|
||||
id?: number;
|
||||
title?: string;
|
||||
image_id?: number;
|
||||
image?: ISysFileInfo;
|
||||
image_url?: string;
|
||||
link?: string;
|
||||
sort?: number;
|
||||
status?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export const HOME_BANNER_STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||
0: { text: '停用', color: 'error' },
|
||||
1: { text: '正常', color: 'success' },
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type {ISysFileInfo} from "@/domain/iSysFile.ts";
|
||||
|
||||
/** 小程序首页宫格导航 */
|
||||
export default interface IHomeNav {
|
||||
id?: number;
|
||||
name?: string;
|
||||
image_id?: number;
|
||||
image?: ISysFileInfo;
|
||||
image_url?: string;
|
||||
link?: string;
|
||||
sort?: number;
|
||||
status?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export const HOME_NAV_STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||
0: { text: '停用', color: 'error' },
|
||||
1: { text: '正常', color: 'success' },
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type {ISysFileInfo} from "@/domain/iSysFile.ts";
|
||||
|
||||
/** 小程序首页促销推荐卡片 */
|
||||
export default interface IHomePromo {
|
||||
id?: number;
|
||||
title?: string;
|
||||
sub_title?: string;
|
||||
image_id?: number;
|
||||
image?: ISysFileInfo;
|
||||
image_url?: string;
|
||||
link?: string;
|
||||
sort?: number;
|
||||
status?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export const HOME_PROMO_STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||
0: { text: '停用', color: 'error' },
|
||||
1: { text: '正常', color: 'success' },
|
||||
};
|
||||
@@ -1,5 +1,3 @@
|
||||
import type {ISysFileInfo} from "@/domain/iSysFile.ts";
|
||||
|
||||
/** 商品分类(多级,children 由后端组装) */
|
||||
export default interface IProductCategory {
|
||||
id?: number;
|
||||
@@ -7,9 +5,6 @@ export default interface IProductCategory {
|
||||
name?: string;
|
||||
sort?: number;
|
||||
status?: number;
|
||||
icon_id?: number;
|
||||
icon?: ISysFileInfo;
|
||||
icon_url?: string;
|
||||
children?: IProductCategory[];
|
||||
created_at?: string;
|
||||
}
|
||||
@@ -19,6 +14,10 @@ export interface IProductCategoryTree {
|
||||
id?: number;
|
||||
parent_id?: number;
|
||||
name?: string;
|
||||
/** TreeSelect 节点禁选标记(前端组装:父分类/超层级节点不可选) */
|
||||
disabled?: boolean;
|
||||
/** Tree 节点不可选中标记(前端组装:侧栏父分类仅供展开) */
|
||||
selectable?: boolean;
|
||||
children?: IProductCategoryTree[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
/** 对账明细 */
|
||||
export interface IReconciliationItem {
|
||||
id?: number;
|
||||
recon_id?: number;
|
||||
store_id?: number;
|
||||
order_item_id?: number;
|
||||
product_id?: number;
|
||||
product_name?: string;
|
||||
quantity?: string;
|
||||
weight?: string;
|
||||
/** 公布金额(订货金额) */
|
||||
publish_amount?: string;
|
||||
/** 实际金额(采购成本) */
|
||||
actual_amount?: string;
|
||||
/** 差额 = publish − actual */
|
||||
diff_amount?: string;
|
||||
is_reconciled?: number;
|
||||
store_remark?: string;
|
||||
sort?: number;
|
||||
store?: { id: number; name: string };
|
||||
}
|
||||
|
||||
/** 对账单 */
|
||||
export default interface IReconciliation {
|
||||
id?: number;
|
||||
recon_no?: string;
|
||||
title?: string;
|
||||
period_start?: string;
|
||||
period_end?: string;
|
||||
category_id?: number;
|
||||
supplier_id?: number;
|
||||
publish_amount?: string;
|
||||
actual_amount?: string;
|
||||
diff_amount?: string;
|
||||
/** 0草稿 1对账中 2已结算 */
|
||||
status?: number;
|
||||
operator_id?: number;
|
||||
operator?: { id: number; nickname: string };
|
||||
remark?: string;
|
||||
items?: IReconciliationItem[];
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export const RECON_STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||
0: { text: '草稿', color: 'default' },
|
||||
1: { text: '对账中', color: 'processing' },
|
||||
2: { text: '已结算', color: 'success' },
|
||||
};
|
||||
|
||||
export const RECONCILED_MAP: Record<number, { text: string; color: string }> = {
|
||||
0: { text: '未对账', color: 'warning' },
|
||||
1: { text: '已对账', color: 'success' },
|
||||
};
|
||||
|
||||
/** D5 差额对比视图 */
|
||||
export interface IReconDiffRow {
|
||||
store_id?: number;
|
||||
store_name?: string;
|
||||
product_id?: number;
|
||||
product_name?: string;
|
||||
publish: number;
|
||||
actual: number;
|
||||
diff: number;
|
||||
}
|
||||
|
||||
export interface IReconDiff {
|
||||
by_store: IReconDiffRow[];
|
||||
by_product: IReconDiffRow[];
|
||||
total: { publish: number; actual: number; diff: number };
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/** 结算表 */
|
||||
export default interface ISettlement {
|
||||
id?: number;
|
||||
settlement_no?: string;
|
||||
recon_id?: number;
|
||||
store_id?: number;
|
||||
store?: { id: number; name: string };
|
||||
recon?: { id: number; recon_no: string; title: string };
|
||||
period_start?: string;
|
||||
period_end?: string;
|
||||
total_amount?: string;
|
||||
actual_amount?: string;
|
||||
diff_amount?: string;
|
||||
/** 0待结算 1已结算 */
|
||||
status?: number;
|
||||
file_path?: string;
|
||||
operator_id?: number;
|
||||
operator?: { id: number; nickname: string };
|
||||
settled_at?: string;
|
||||
remark?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export const SETTLEMENT_STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||
0: { text: '待结算', color: 'default' },
|
||||
1: { text: '已结算', color: 'success' },
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { Image, Tag, Typography } from 'antd';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts';
|
||||
import type IHomeBanner from '@/domain/iHomeBanner.ts';
|
||||
import { HOME_BANNER_STATUS_MAP } from '@/domain/iHomeBanner.ts';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/**
|
||||
* 首页轮播图配置(小程序首页)
|
||||
*/
|
||||
const HomeBannerPage: React.FC = () => {
|
||||
const columns: XinTableColumn<IHomeBanner>[] = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 70,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
valueType: 'text',
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请输入轮播图标题' }],
|
||||
},
|
||||
{
|
||||
title: '轮播图片',
|
||||
dataIndex: 'image_id',
|
||||
valueType: 'image',
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请上传轮播图片' }],
|
||||
fieldProps: {
|
||||
action: '/client/banner/upload',
|
||||
mode: 'single',
|
||||
maxCount: 1,
|
||||
changeType: 'id',
|
||||
},
|
||||
render: (_, record: IHomeBanner) => {
|
||||
const url = record.image_url;
|
||||
if (!url) return '-';
|
||||
return (
|
||||
<Image
|
||||
src={url}
|
||||
width={80}
|
||||
height={40}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
align: 'center',
|
||||
hideInSearch: true,
|
||||
},
|
||||
{
|
||||
title: '跳转链接',
|
||||
dataIndex: 'link',
|
||||
valueType: 'text',
|
||||
hideInSearch: true,
|
||||
fieldProps: {
|
||||
placeholder: '小程序页面路径,如 /pages/goods/detail?id=1',
|
||||
},
|
||||
render: (_, record) => record.link || '-',
|
||||
},
|
||||
{
|
||||
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_BANNER_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<IHomeBanner> = {
|
||||
api: '/client/banner',
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
accessName: 'client.banner',
|
||||
formProps: {
|
||||
grid: true,
|
||||
colProps: { span: 12 },
|
||||
layout: 'vertical',
|
||||
rowProps: { gutter: 24 },
|
||||
},
|
||||
modalProps: { width: 640 },
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<Title level={3}>首页轮播图</Title>
|
||||
<Text type="secondary">
|
||||
小程序首页顶部轮播图;停用后不展示,排序越小越靠前;跳转链接为小程序页面路径,留空则点击不跳转。
|
||||
</Text>
|
||||
</div>
|
||||
<XinTable<IHomeBanner> {...tableProps} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeBannerPage;
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { Image, Tag, Typography } from 'antd';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts';
|
||||
import type IHomeNav from '@/domain/iHomeNav.ts';
|
||||
import { HOME_NAV_STATUS_MAP } from '@/domain/iHomeNav.ts';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/**
|
||||
* 宫格导航配置(小程序首页)
|
||||
*/
|
||||
const HomeNavPage: React.FC = () => {
|
||||
const columns: XinTableColumn<IHomeNav>[] = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 70,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '导航名称',
|
||||
dataIndex: 'name',
|
||||
valueType: 'text',
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请输入导航名称' }],
|
||||
},
|
||||
{
|
||||
title: '导航图标',
|
||||
dataIndex: 'image_id',
|
||||
valueType: 'image',
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请上传导航图标' }],
|
||||
fieldProps: {
|
||||
action: '/client/nav/upload',
|
||||
mode: 'single',
|
||||
maxCount: 1,
|
||||
changeType: 'id',
|
||||
},
|
||||
render: (_, record: IHomeNav) => {
|
||||
const url = record.image_url;
|
||||
if (!url) return '-';
|
||||
return (
|
||||
<Image
|
||||
src={url}
|
||||
width={40}
|
||||
height={40}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
align: 'center',
|
||||
hideInSearch: true,
|
||||
},
|
||||
{
|
||||
title: '跳转链接',
|
||||
dataIndex: 'link',
|
||||
valueType: 'text',
|
||||
hideInSearch: true,
|
||||
fieldProps: {
|
||||
placeholder: '小程序页面路径,如 /pages/category/index',
|
||||
},
|
||||
render: (_, record) => record.link || '-',
|
||||
},
|
||||
{
|
||||
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_NAV_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<IHomeNav> = {
|
||||
api: '/client/nav',
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
accessName: 'client.nav',
|
||||
formProps: {
|
||||
grid: true,
|
||||
colProps: { span: 12 },
|
||||
layout: 'vertical',
|
||||
rowProps: { gutter: 24 },
|
||||
},
|
||||
modalProps: { width: 640 },
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<Title level={3}>宫格导航</Title>
|
||||
<Text type="secondary">
|
||||
小程序首页宫格入口(如商品分类、促销活动等);停用后不展示,排序越小越靠前。
|
||||
</Text>
|
||||
</div>
|
||||
<XinTable<IHomeNav> {...tableProps} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeNavPage;
|
||||
@@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import { Image, Tag, Typography } from 'antd';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts';
|
||||
import type IHomePromo from '@/domain/iHomePromo.ts';
|
||||
import { HOME_PROMO_STATUS_MAP } from '@/domain/iHomePromo.ts';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/**
|
||||
* 促销推荐卡片配置(小程序首页)
|
||||
*/
|
||||
const HomePromoPage: React.FC = () => {
|
||||
const columns: XinTableColumn<IHomePromo>[] = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 70,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '卡片标题',
|
||||
dataIndex: 'title',
|
||||
valueType: 'text',
|
||||
},
|
||||
{
|
||||
title: '副标题',
|
||||
dataIndex: 'sub_title',
|
||||
valueType: 'text',
|
||||
hideInSearch: true,
|
||||
fieldProps: {
|
||||
placeholder: '促销文案,如「限时特惠 8 折起」',
|
||||
},
|
||||
render: (_, record) => record.sub_title || '-',
|
||||
},
|
||||
{
|
||||
title: '卡片图片',
|
||||
dataIndex: 'image_id',
|
||||
valueType: 'image',
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请上传卡片图片' }],
|
||||
fieldProps: {
|
||||
action: '/client/promo/upload',
|
||||
mode: 'single',
|
||||
maxCount: 1,
|
||||
changeType: 'id',
|
||||
},
|
||||
render: (_, record: IHomePromo) => {
|
||||
const url = record.image_url;
|
||||
if (!url) return '-';
|
||||
return (
|
||||
<Image
|
||||
src={url}
|
||||
width={80}
|
||||
height={40}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
align: 'center',
|
||||
hideInSearch: true,
|
||||
},
|
||||
{
|
||||
title: '跳转链接',
|
||||
dataIndex: 'link',
|
||||
valueType: 'text',
|
||||
hideInSearch: true,
|
||||
fieldProps: {
|
||||
placeholder: '小程序页面路径,如 /pages/promo/detail?id=1',
|
||||
},
|
||||
render: (_, record) => record.link || '-',
|
||||
},
|
||||
{
|
||||
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_PROMO_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<IHomePromo> = {
|
||||
api: '/client/promo',
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
accessName: 'client.promo',
|
||||
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<IHomePromo> {...tableProps} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePromoPage;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {Button, Image, Tag, Typography} from 'antd';
|
||||
import React, {useEffect, useMemo, useState} from 'react';
|
||||
import {Button, Form, Tag, TreeSelect, Typography} from 'antd';
|
||||
import type {FormInstance} from 'antd';
|
||||
import {NodeExpandOutlined} from '@ant-design/icons';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts';
|
||||
@@ -10,6 +11,45 @@ import {getCategoryTable, getCategoryTree} from '@/api/product/category.ts';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/**
|
||||
* 上级分类选择(分类最多二级):
|
||||
* - 二级分类禁选(不能作为上级,否则出现三级)
|
||||
* - 编辑时禁选自身
|
||||
* - 编辑的分类含子分类时禁选所有一级分类(只能保持顶级,否则其子分类会变成三级)
|
||||
*/
|
||||
const ParentCategorySelect: React.FC<{
|
||||
form: FormInstance;
|
||||
tree: IProductCategoryTree[];
|
||||
value?: number;
|
||||
onChange?: (value: number) => void;
|
||||
}> = ({ form, tree, value, onChange }) => {
|
||||
// id/children 由 XinTable 编辑时 setFieldsValue(record) 写入(非表单项,需传 form 监听)
|
||||
const editingId = Form.useWatch('id', form);
|
||||
const children = Form.useWatch('children', form);
|
||||
const hasChildren = Array.isArray(children) && children.length > 0;
|
||||
|
||||
const treeData = useMemo(() => {
|
||||
const walk = (nodes: IProductCategoryTree[], depth: number): IProductCategoryTree[] =>
|
||||
nodes.map((node) => ({
|
||||
...node,
|
||||
disabled: depth >= 2 || node.id === editingId || (hasChildren && depth >= 1),
|
||||
children: node.children?.length ? walk(node.children, depth + 1) : node.children,
|
||||
}));
|
||||
return [{ id: 0, name: '顶级分类', children: walk(tree, 1) }];
|
||||
}, [tree, editingId, hasChildren]);
|
||||
|
||||
return (
|
||||
<TreeSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
treeData={treeData}
|
||||
fieldNames={{ label: 'name', value: 'id', children: 'children' }}
|
||||
placeholder="默认顶级分类"
|
||||
treeDefaultExpandAll
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 递归收集全部节点 id(用于展开整棵树)
|
||||
*/
|
||||
@@ -52,16 +92,10 @@ const ProductCategoryPage: React.FC = () => {
|
||||
{
|
||||
title: '上级分类',
|
||||
dataIndex: 'parent_id',
|
||||
valueType: 'treeSelect',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
initialValue: 0,
|
||||
fieldProps: {
|
||||
treeData: [{ id: 0, name: '顶级分类', children: categoryTree }],
|
||||
fieldNames: { label: 'name', value: 'id', children: 'children' },
|
||||
placeholder: '默认顶级分类',
|
||||
treeDefaultExpandAll: true,
|
||||
},
|
||||
fieldRender: (form) => <ParentCategorySelect form={form} tree={categoryTree} />,
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
@@ -90,31 +124,6 @@ const ProductCategoryPage: React.FC = () => {
|
||||
},
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '分类图标',
|
||||
dataIndex: 'icon_id',
|
||||
valueType: 'image',
|
||||
fieldProps: {
|
||||
action: '/product/category/upload',
|
||||
mode: 'single',
|
||||
maxCount: 1,
|
||||
changeType: "id"
|
||||
},
|
||||
render: (_, record: IProductCategory) => {
|
||||
const url = record.icon_url
|
||||
if (!url) return '-';
|
||||
return (
|
||||
<Image
|
||||
src={url}
|
||||
width={32}
|
||||
height={32}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
align: 'center',
|
||||
hideInSearch: true,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'updated_at',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Button, Card,
|
||||
Drawer,
|
||||
@@ -41,6 +41,22 @@ const PRICE_TYPE_OPTIONS = [
|
||||
/** 四舍五入保留两位 */
|
||||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||
|
||||
/** 商品表单用分类树:有子分类的节点禁选(商品只能挂在末级分类上) */
|
||||
const markParentDisabled = (nodes: IProductCategoryTree[]): IProductCategoryTree[] =>
|
||||
nodes.map((node) => ({
|
||||
...node,
|
||||
disabled: !!node.children?.length,
|
||||
children: node.children?.length ? markParentDisabled(node.children) : node.children,
|
||||
}));
|
||||
|
||||
/** 侧栏用分类树:有子分类的节点不可选中(仅供展开,筛选按末级分类) */
|
||||
const markParentUnselectable = (nodes: IProductCategoryTree[]): IProductCategoryTree[] =>
|
||||
nodes.map((node) => ({
|
||||
...node,
|
||||
selectable: !node.children?.length,
|
||||
children: node.children?.length ? markParentUnselectable(node.children) : node.children,
|
||||
}));
|
||||
|
||||
/**
|
||||
* 等级价格表单:每个等级 = 计价类型(固定价/成本百分比)+ 对应输入框;
|
||||
* 切换计价类型时按成本价自动换算(固定→百分比:percent=(price/cost-1)*100;百分比→固定:price=cost*(1+percent/100))
|
||||
@@ -159,6 +175,10 @@ const ProductGoodsPage: React.FC = () => {
|
||||
const [levels, setLevels] = useState<ICustomerLevel[]>([]);
|
||||
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
|
||||
const [categoryTree, setCategoryTree] = useState<IProductCategoryTree[]>([]);
|
||||
// 商品表单专用分类树:父分类禁选,仅末级可选(侧栏筛选/价格矩阵仍用原始树)
|
||||
const formCategoryTree = useMemo(() => markParentDisabled(categoryTree), [categoryTree]);
|
||||
// 侧栏分类树:父分类不可选中,仅作展开归组
|
||||
const sidebarCategoryTree = useMemo(() => markParentUnselectable(categoryTree), [categoryTree]);
|
||||
|
||||
// ===== 分类侧栏 =====
|
||||
const [activeCategory, setActiveCategory] = useState<number | undefined>(undefined);
|
||||
@@ -476,12 +496,12 @@ const ProductGoodsPage: React.FC = () => {
|
||||
align: "center",
|
||||
rules: [{ required: true, message: '请选择分类' }],
|
||||
fieldProps: {
|
||||
treeData: categoryTree,
|
||||
treeData: formCategoryTree,
|
||||
fieldNames: { label: 'name', value: 'id', children: 'children' },
|
||||
treeDefaultExpandAll: true,
|
||||
showSearch: true,
|
||||
treeNodeFilterProp: 'name',
|
||||
placeholder: '选择分类',
|
||||
placeholder: '选择末级分类',
|
||||
},
|
||||
hideInSearch: true,
|
||||
render: (_, record) =>
|
||||
@@ -647,7 +667,7 @@ const ProductGoodsPage: React.FC = () => {
|
||||
showLine
|
||||
blockNode
|
||||
onSelect={(selectedKeys) => setActiveCategory(Number(selectedKeys[0]))}
|
||||
treeData={categoryTree}
|
||||
treeData={sidebarCategoryTree}
|
||||
selectedKeys={activeCategory ? [activeCategory] : undefined}
|
||||
fieldNames={{title: 'name', key: 'id', children: 'children'}}
|
||||
/>
|
||||
|
||||
@@ -1,719 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
CheckSquareOutlined,
|
||||
FileDoneOutlined,
|
||||
ToolOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { TableProps } from 'antd';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type {
|
||||
XinTableColumn,
|
||||
XinTableInstance,
|
||||
XinTableProps,
|
||||
} from '@/components/XinTable/typings.ts';
|
||||
import type IReconciliation from '@/domain/iReconciliation.ts';
|
||||
import type { IReconDiff, IReconciliationItem } from '@/domain/iReconciliation.ts';
|
||||
import { RECON_STATUS_MAP } from '@/domain/iReconciliation.ts';
|
||||
import type IProductCategory from '@/domain/iProductCategory.ts';
|
||||
import type ISupplier from '@/domain/iSupplier.ts';
|
||||
import { getCategoryTree } from '@/api/product/category.ts';
|
||||
import { getSupplierOptions } from '@/api/customer/supplier.ts';
|
||||
import {
|
||||
buildRecon,
|
||||
getReconDiff,
|
||||
remarkReconItem,
|
||||
settleRecon,
|
||||
toggleReconItem,
|
||||
updateReconItem,
|
||||
} from '@/api/recon/list.ts';
|
||||
import { Get } from '@/api/common/table.ts';
|
||||
import AuthButton from '@/components/AuthButton';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface EditingItem {
|
||||
product_name: string;
|
||||
quantity: number;
|
||||
weight: number;
|
||||
publish_amount: number;
|
||||
actual_amount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 财务对账(D1/D2 筛选建单、D4 明细修改、D5 差额对比、D6 备注、D8 标记、D9 结算)
|
||||
*/
|
||||
const ReconListPage: React.FC = () => {
|
||||
const tableRef = useRef<XinTableInstance<IReconciliation>>(null);
|
||||
const [categoryTree, setCategoryTree] = useState<IProductCategory[]>([]);
|
||||
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
|
||||
|
||||
// 工作台抽屉
|
||||
const [workOpen, setWorkOpen] = useState(false);
|
||||
const [workLoading, setWorkLoading] = useState(false);
|
||||
const [recon, setRecon] = useState<IReconciliation | null>(null);
|
||||
const [editing, setEditing] = useState<Record<number, EditingItem>>({});
|
||||
const [savingItemId, setSavingItemId] = useState<number | null>(null);
|
||||
const [diff, setDiff] = useState<IReconDiff | null>(null);
|
||||
|
||||
// 备注弹窗
|
||||
const [remarkOpen, setRemarkOpen] = useState(false);
|
||||
const [remarkTarget, setRemarkTarget] = useState<IReconciliationItem | null>(null);
|
||||
const [remarkValue, setRemarkValue] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getCategoryTree().then((res) => setCategoryTree(res.data.data ?? []));
|
||||
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
|
||||
}, []);
|
||||
|
||||
const loadRecon = async (id: number) => {
|
||||
const res = await Get<IReconciliation>('/recon/list', id);
|
||||
const data = res.data.data ?? null;
|
||||
setRecon(data);
|
||||
const editingMap: Record<number, EditingItem> = {};
|
||||
data?.items?.forEach((item) => {
|
||||
if (item.id !== undefined) {
|
||||
editingMap[item.id] = {
|
||||
product_name: item.product_name ?? '',
|
||||
quantity: Number(item.quantity ?? 0),
|
||||
weight: Number(item.weight ?? 0),
|
||||
publish_amount: Number(item.publish_amount ?? 0),
|
||||
actual_amount: Number(item.actual_amount ?? 0),
|
||||
};
|
||||
}
|
||||
});
|
||||
setEditing(editingMap);
|
||||
return data;
|
||||
};
|
||||
|
||||
const openWorkbench = async (id: number) => {
|
||||
setWorkOpen(true);
|
||||
setWorkLoading(true);
|
||||
setDiff(null);
|
||||
try {
|
||||
await loadRecon(id);
|
||||
} finally {
|
||||
setWorkLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadDiff = async (id: number) => {
|
||||
const res = await getReconDiff(id);
|
||||
setDiff(res.data.data ?? null);
|
||||
};
|
||||
|
||||
const handleBuild = async (record: IReconciliation) => {
|
||||
const res = await buildRecon(record.id!);
|
||||
message.success(`已生成 ${res.data.data?.count} 条对账明细`);
|
||||
await tableRef.current?.reload();
|
||||
};
|
||||
|
||||
const handleSettle = async (record: IReconciliation) => {
|
||||
const res = await settleRecon(record.id!);
|
||||
message.success(`已生成 ${res.data.data?.count} 张结算表`);
|
||||
await tableRef.current?.reload();
|
||||
};
|
||||
|
||||
const isItemDirty = (item: IReconciliationItem): boolean => {
|
||||
const edit = editing[item.id!];
|
||||
if (!edit) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
edit.product_name !== (item.product_name ?? '') ||
|
||||
edit.quantity !== Number(item.quantity ?? 0) ||
|
||||
edit.weight !== Number(item.weight ?? 0) ||
|
||||
edit.publish_amount !== Number(item.publish_amount ?? 0) ||
|
||||
edit.actual_amount !== Number(item.actual_amount ?? 0)
|
||||
);
|
||||
};
|
||||
|
||||
const saveItem = async (item: IReconciliationItem) => {
|
||||
const edit = editing[item.id!];
|
||||
if (!edit || !isItemDirty(item)) {
|
||||
return;
|
||||
}
|
||||
setSavingItemId(item.id!);
|
||||
try {
|
||||
const res = await updateReconItem(item.id!, {
|
||||
product_name: edit.product_name,
|
||||
quantity: edit.quantity,
|
||||
weight: edit.weight,
|
||||
publish_amount: edit.publish_amount,
|
||||
actual_amount: edit.actual_amount,
|
||||
});
|
||||
message.success(`已保存,差额 ¥${res.data.data?.diff_amount}`);
|
||||
await loadRecon(recon!.id!);
|
||||
await loadDiff(recon!.id!);
|
||||
} finally {
|
||||
setSavingItemId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (item: IReconciliationItem) => {
|
||||
await toggleReconItem(item.id!);
|
||||
await loadRecon(recon!.id!);
|
||||
};
|
||||
|
||||
const openRemark = (item: IReconciliationItem) => {
|
||||
setRemarkTarget(item);
|
||||
setRemarkValue(item.store_remark ?? '');
|
||||
setRemarkOpen(true);
|
||||
};
|
||||
|
||||
const saveRemark = async () => {
|
||||
await remarkReconItem(remarkTarget!.id!, remarkValue);
|
||||
message.success('备注已保存');
|
||||
setRemarkOpen(false);
|
||||
await loadRecon(recon!.id!);
|
||||
};
|
||||
|
||||
const readonly = recon?.status === 2;
|
||||
|
||||
const itemColumns: TableProps<IReconciliationItem>['columns'] = [
|
||||
{
|
||||
title: '品名',
|
||||
dataIndex: 'product_name',
|
||||
width: 160,
|
||||
render: (_, record) =>
|
||||
readonly ? (
|
||||
record.product_name
|
||||
) : (
|
||||
<Input
|
||||
size="small"
|
||||
value={editing[record.id!]?.product_name}
|
||||
onChange={(e) =>
|
||||
setEditing((prev) => ({
|
||||
...prev,
|
||||
[record.id!]: { ...prev[record.id!], product_name: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '门店',
|
||||
dataIndex: 'store',
|
||||
width: 130,
|
||||
render: (_, record) => record.store?.name ?? `门店#${record.store_id}`,
|
||||
},
|
||||
{
|
||||
title: '订货量',
|
||||
dataIndex: 'quantity',
|
||||
width: 110,
|
||||
render: (_, record) =>
|
||||
readonly ? (
|
||||
record.quantity
|
||||
) : (
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
precision={2}
|
||||
value={editing[record.id!]?.quantity}
|
||||
onChange={(v) =>
|
||||
setEditing((prev) => ({
|
||||
...prev,
|
||||
[record.id!]: { ...prev[record.id!], quantity: v ?? 0 },
|
||||
}))
|
||||
}
|
||||
className="!w-20"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '称重',
|
||||
dataIndex: 'weight',
|
||||
width: 110,
|
||||
render: (_, record) =>
|
||||
readonly ? (
|
||||
record.weight
|
||||
) : (
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
precision={3}
|
||||
value={editing[record.id!]?.weight}
|
||||
onChange={(v) =>
|
||||
setEditing((prev) => ({
|
||||
...prev,
|
||||
[record.id!]: { ...prev[record.id!], weight: v ?? 0 },
|
||||
}))
|
||||
}
|
||||
className="!w-20"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '公布金额',
|
||||
dataIndex: 'publish_amount',
|
||||
width: 120,
|
||||
render: (_, record) =>
|
||||
readonly ? (
|
||||
`¥${record.publish_amount}`
|
||||
) : (
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
value={editing[record.id!]?.publish_amount}
|
||||
onChange={(v) =>
|
||||
setEditing((prev) => ({
|
||||
...prev,
|
||||
[record.id!]: { ...prev[record.id!], publish_amount: v ?? 0 },
|
||||
}))
|
||||
}
|
||||
className="!w-24"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '实际金额',
|
||||
dataIndex: 'actual_amount',
|
||||
width: 120,
|
||||
render: (_, record) =>
|
||||
readonly ? (
|
||||
`¥${record.actual_amount}`
|
||||
) : (
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
value={editing[record.id!]?.actual_amount}
|
||||
onChange={(v) =>
|
||||
setEditing((prev) => ({
|
||||
...prev,
|
||||
[record.id!]: { ...prev[record.id!], actual_amount: v ?? 0 },
|
||||
}))
|
||||
}
|
||||
className="!w-24"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '差额',
|
||||
dataIndex: 'diff_amount',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (v) => {
|
||||
const num = Number(v ?? 0);
|
||||
return (
|
||||
<Text type={num === 0 ? 'secondary' : 'danger'} strong={num !== 0}>
|
||||
¥{String(v)}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '对账',
|
||||
dataIndex: 'is_reconciled',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
render: (_, record) => (
|
||||
<Switch
|
||||
size="small"
|
||||
disabled={readonly}
|
||||
checked={record.is_reconciled === 1}
|
||||
onChange={() => handleToggle(record)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '门店备注',
|
||||
dataIndex: 'store_remark',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (_, record) =>
|
||||
record.store_remark || <Text type="secondary">—</Text>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 130,
|
||||
fixed: 'right',
|
||||
render: (_, record) =>
|
||||
readonly ? null : (
|
||||
<Space size={4}>
|
||||
<AuthButton auth="recon.item.item.update">
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
disabled={!isItemDirty(record)}
|
||||
loading={savingItemId === record.id}
|
||||
onClick={() => saveItem(record)}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</AuthButton>
|
||||
<AuthButton auth="recon.item.item.update">
|
||||
<Button size="small" type="link" onClick={() => openRemark(record)}>
|
||||
备注
|
||||
</Button>
|
||||
</AuthButton>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const diffColumns = (nameTitle: string, nameKey: 'store_name' | 'product_name') => [
|
||||
{ title: nameTitle, dataIndex: nameKey, render: (v: string) => v || '-' },
|
||||
{ title: '公布金额', dataIndex: 'publish', align: 'right' as const, render: (v: number) => `¥${v}` },
|
||||
{ title: '实际金额', dataIndex: 'actual', align: 'right' as const, render: (v: number) => `¥${v}` },
|
||||
{
|
||||
title: '差额',
|
||||
dataIndex: 'diff',
|
||||
align: 'right' as const,
|
||||
render: (v: number) => (
|
||||
<Text type={v === 0 ? 'secondary' : 'danger'} strong={v !== 0}>
|
||||
¥{v}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const columns: XinTableColumn<IReconciliation>[] = [
|
||||
{
|
||||
title: '对账单号',
|
||||
dataIndex: 'recon_no',
|
||||
valueType: 'text',
|
||||
hideInForm: true,
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
valueType: 'text',
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请输入对账标题' }],
|
||||
},
|
||||
{
|
||||
title: '对账周期',
|
||||
dataIndex: 'period',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
render: (_, record) => `${record.period_start} ~ ${record.period_end}`,
|
||||
},
|
||||
{
|
||||
title: '开始日期',
|
||||
dataIndex: 'period_start',
|
||||
valueType: 'date',
|
||||
hideInTable: true,
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请选择开始日期' }],
|
||||
},
|
||||
{
|
||||
title: '结束日期',
|
||||
dataIndex: 'period_end',
|
||||
valueType: 'date',
|
||||
hideInTable: true,
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请选择结束日期' }],
|
||||
},
|
||||
{
|
||||
title: '商品分类',
|
||||
dataIndex: 'category_id',
|
||||
valueType: 'treeSelect',
|
||||
hideInTable: true,
|
||||
initialValue: 0,
|
||||
fieldProps: {
|
||||
treeData: [{ id: 0, name: '全部分类', children: categoryTree }],
|
||||
fieldNames: { label: 'name', value: 'id', children: 'children' },
|
||||
treeDefaultExpandAll: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '供应商',
|
||||
dataIndex: 'supplier_id',
|
||||
valueType: 'select',
|
||||
hideInTable: true,
|
||||
initialValue: 0,
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ label: '全部供应商', value: 0 },
|
||||
...suppliers.map((s) => ({ label: s.name, value: s.id })),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '公布金额',
|
||||
dataIndex: 'publish_amount',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'right',
|
||||
render: (_, record) => `¥${record.publish_amount}`,
|
||||
},
|
||||
{
|
||||
title: '实际金额',
|
||||
dataIndex: 'actual_amount',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'right',
|
||||
render: (_, record) => `¥${record.actual_amount}`,
|
||||
},
|
||||
{
|
||||
title: '差额',
|
||||
dataIndex: 'diff_amount',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'right',
|
||||
render: (_, record) => {
|
||||
const num = Number(record.diff_amount ?? 0);
|
||||
return (
|
||||
<Text type={num === 0 ? 'secondary' : 'danger'} strong={num !== 0}>
|
||||
¥{record.diff_amount}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
valueType: 'select',
|
||||
hideInForm: true,
|
||||
fieldProps: {
|
||||
options: Object.entries(RECON_STATUS_MAP).map(([value, item]) => ({
|
||||
value: Number(value),
|
||||
label: item.text,
|
||||
})),
|
||||
},
|
||||
render: (_, record) => {
|
||||
const item = RECON_STATUS_MAP[record.status ?? 0];
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
valueType: 'textarea',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
fieldProps: { rows: 2 },
|
||||
},
|
||||
];
|
||||
|
||||
const operateRender: XinTableProps<IReconciliation>['operateRender'] = (record, dom) => [
|
||||
<AuthButton key="build" auth="recon.list.build">
|
||||
<Popconfirm
|
||||
title="生成对账明细?"
|
||||
description="按周期/品类/供应商拉取已分摊的采购数据,重复生成会清空现有明细。"
|
||||
disabled={record.status === 2}
|
||||
onConfirm={() => handleBuild(record)}
|
||||
>
|
||||
<Button size="small" disabled={record.status === 2}>
|
||||
生成明细
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>,
|
||||
<Button
|
||||
key="workbench"
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<ToolOutlined />}
|
||||
disabled={record.status === 0}
|
||||
onClick={() => openWorkbench(record.id!)}
|
||||
>
|
||||
工作台
|
||||
</Button>,
|
||||
<AuthButton key="settle" auth="recon.list.settle">
|
||||
<Popconfirm
|
||||
title="生成结算表?"
|
||||
description="按门店聚合对账明细生成结算表,对账单将变为已结算且不可再修改。"
|
||||
disabled={record.status !== 1}
|
||||
onConfirm={() => handleSettle(record)}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<FileDoneOutlined />}
|
||||
disabled={record.status !== 1}
|
||||
>
|
||||
生成结算表
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>,
|
||||
// 编辑/删除由 XinTable 默认提供;删除仅草稿可用,由后端校验拦截
|
||||
dom.edit,
|
||||
dom.del,
|
||||
];
|
||||
|
||||
const tableProps: XinTableProps<IReconciliation> = {
|
||||
api: '/recon/list',
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
accessName: 'recon.list',
|
||||
tableRef,
|
||||
operateRender,
|
||||
scroll: { x: 1300 },
|
||||
formProps: {
|
||||
grid: true,
|
||||
colProps: { span: 12 },
|
||||
layout: 'vertical',
|
||||
},
|
||||
modalProps: { width: 720 },
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<Title level={3}>财务对账</Title>
|
||||
<Text type="secondary">
|
||||
按周期/品类/供应商建立对账单 → 生成明细(采购分摊数据)→ 核对修改 → 差额对比 → 生成结算表。
|
||||
</Text>
|
||||
</div>
|
||||
<XinTable<IReconciliation> {...tableProps} />
|
||||
|
||||
{/* 对账工作台 */}
|
||||
<Drawer
|
||||
title={recon ? `对账工作台 · ${recon.recon_no}` : '对账工作台'}
|
||||
open={workOpen}
|
||||
onClose={() => setWorkOpen(false)}
|
||||
width={1200}
|
||||
loading={workLoading}
|
||||
>
|
||||
{recon ? (
|
||||
<>
|
||||
<Descriptions column={4} size="small" bordered>
|
||||
<Descriptions.Item label="标题">{recon.title}</Descriptions.Item>
|
||||
<Descriptions.Item label="周期">
|
||||
{recon.period_start} ~ {recon.period_end}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={RECON_STATUS_MAP[recon.status ?? 0]?.color}>
|
||||
{RECON_STATUS_MAP[recon.status ?? 0]?.text}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="差额">
|
||||
<Text
|
||||
type={Number(recon.diff_amount) === 0 ? 'secondary' : 'danger'}
|
||||
strong
|
||||
>
|
||||
¥{recon.diff_amount}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Tabs
|
||||
className="mt-4"
|
||||
items={[
|
||||
{
|
||||
key: 'items',
|
||||
label: (
|
||||
<span>
|
||||
<CheckSquareOutlined /> 明细核对({recon.items?.length ?? 0})
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
{!readonly ? (
|
||||
<div className="mb-2 text-gray-500">
|
||||
可修改订货量/称重/公布金额/实际金额,保存后自动重算差额与对账单汇总;开关标记单品对账状态。
|
||||
</div>
|
||||
) : null}
|
||||
<Table<IReconciliationItem>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={itemColumns}
|
||||
dataSource={recon.items ?? []}
|
||||
pagination={{ pageSize: 15, showSizeChanger: false }}
|
||||
scroll={{ x: 1250 }}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'diff',
|
||||
label: '差额对比',
|
||||
children: (
|
||||
<>
|
||||
<Space className="mb-3">
|
||||
<Button onClick={() => loadDiff(recon.id!)}>刷新对比数据</Button>
|
||||
{diff ? (
|
||||
<Text type="secondary">
|
||||
合计:公布 ¥{diff.total.publish} / 实际 ¥{diff.total.actual} /{' '}
|
||||
<Text type={diff.total.diff === 0 ? 'secondary' : 'danger'} strong>
|
||||
差额 ¥{diff.total.diff}
|
||||
</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
{diff ? (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Title level={5}>按门店</Title>
|
||||
<Table
|
||||
rowKey={(row) => String(row.store_id)}
|
||||
size="small"
|
||||
columns={diffColumns('门店', 'store_name')}
|
||||
dataSource={diff.by_store}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Title level={5}>按商品</Title>
|
||||
<Table
|
||||
rowKey={(row) => String(row.product_id)}
|
||||
size="small"
|
||||
columns={diffColumns('商品', 'product_name')}
|
||||
dataSource={diff.by_product}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button type="primary" onClick={() => loadDiff(recon.id!)}>
|
||||
加载差额对比
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Drawer>
|
||||
|
||||
{/* 门店备注弹窗 */}
|
||||
<Modal
|
||||
title="单品门店备注"
|
||||
open={remarkOpen}
|
||||
onCancel={() => setRemarkOpen(false)}
|
||||
onOk={saveRemark}
|
||||
okText="保存备注"
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="mb-2 text-gray-500">
|
||||
{remarkTarget?.product_name}
|
||||
{remarkTarget?.store ? ` · ${remarkTarget.store.name}` : ''}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
maxLength={255}
|
||||
showCount
|
||||
value={remarkValue}
|
||||
onChange={(e) => setRemarkValue(e.target.value)}
|
||||
placeholder="填写该单品针对该门店的备注(如质量异常、补货说明等)"
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReconListPage;
|
||||
@@ -1,236 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Dropdown,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { DownloadOutlined } from '@ant-design/icons';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type {
|
||||
XinTableColumn,
|
||||
XinTableInstance,
|
||||
XinTableProps,
|
||||
} from '@/components/XinTable/typings.ts';
|
||||
import type ISettlement from '@/domain/iSettlement.ts';
|
||||
import { SETTLEMENT_STATUS_MAP } from '@/domain/iSettlement.ts';
|
||||
import { getStoreOptions } from '@/api/customer/store.ts';
|
||||
import type IStore from '@/domain/iStore.ts';
|
||||
import { downloadSettlement } from '@/api/recon/settlement.ts';
|
||||
import { Get } from '@/api/common/table.ts';
|
||||
import AuthButton from '@/components/AuthButton';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/**
|
||||
* 结算表(D9 生成于对账结算,D10 导出存档)
|
||||
*/
|
||||
const SettlementPage: React.FC = () => {
|
||||
const tableRef = useRef<XinTableInstance<ISettlement>>(null);
|
||||
const [stores, setStores] = useState<IStore[]>([]);
|
||||
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detail, setDetail] = useState<ISettlement | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getStoreOptions().then((res) => setStores(res.data.data ?? []));
|
||||
}, []);
|
||||
|
||||
const openDetail = async (id: number) => {
|
||||
setDetailOpen(true);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const res = await Get<ISettlement>('/recon/settlement', id);
|
||||
setDetail(res.data.data ?? null);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: XinTableColumn<ISettlement>[] = [
|
||||
{
|
||||
title: '结算单号',
|
||||
dataIndex: 'settlement_no',
|
||||
valueType: 'text',
|
||||
hideInForm: true,
|
||||
render: (_, record) => <Text copyable={{ text: record.settlement_no }}>{record.settlement_no}</Text>,
|
||||
},
|
||||
{
|
||||
title: '门店',
|
||||
dataIndex: 'store_id',
|
||||
valueType: 'select',
|
||||
hideInForm: true,
|
||||
fieldProps: {
|
||||
options: stores.map((s) => ({ label: s.name, value: s.id })),
|
||||
showSearch: true,
|
||||
optionFilterProp: 'label',
|
||||
},
|
||||
render: (_, record) => record.store?.name ?? '-',
|
||||
},
|
||||
{
|
||||
title: '来源对账单',
|
||||
dataIndex: 'recon',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
render: (_, record) =>
|
||||
record.recon ? (
|
||||
<span>
|
||||
{record.recon.recon_no}
|
||||
<Text type="secondary">({record.recon.title})</Text>
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '结算周期',
|
||||
dataIndex: 'period',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
render: (_, record) => `${record.period_start} ~ ${record.period_end}`,
|
||||
},
|
||||
{
|
||||
title: '公布金额',
|
||||
dataIndex: 'total_amount',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'right',
|
||||
render: (_, record) => `¥${record.total_amount}`,
|
||||
},
|
||||
{
|
||||
title: '实际金额',
|
||||
dataIndex: 'actual_amount',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'right',
|
||||
render: (_, record) => <Text strong>¥{record.actual_amount}</Text>,
|
||||
},
|
||||
{
|
||||
title: '差额',
|
||||
dataIndex: 'diff_amount',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'right',
|
||||
render: (_, record) => {
|
||||
const num = Number(record.diff_amount ?? 0);
|
||||
return (
|
||||
<Text type={num === 0 ? 'secondary' : 'danger'} strong={num !== 0}>
|
||||
¥{record.diff_amount}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
valueType: 'select',
|
||||
hideInForm: true,
|
||||
fieldProps: {
|
||||
options: Object.entries(SETTLEMENT_STATUS_MAP).map(([value, item]) => ({
|
||||
value: Number(value),
|
||||
label: item.text,
|
||||
})),
|
||||
},
|
||||
render: (_, record) => {
|
||||
const item = SETTLEMENT_STATUS_MAP[record.status ?? 0];
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '结算时间',
|
||||
dataIndex: 'settled_at',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
render: (_, record) => record.settled_at ?? '-',
|
||||
},
|
||||
];
|
||||
|
||||
const operateRender: XinTableProps<ISettlement>['operateRender'] = (record) => [
|
||||
<Button key="detail" size="small" onClick={() => openDetail(record.id!)}>
|
||||
详情
|
||||
</Button>,
|
||||
<AuthButton key="download" auth="recon.settlement.download">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'xlsx', label: '下载 Excel', onClick: () => downloadSettlement(record.id!, 'xlsx') },
|
||||
{ key: 'pdf', label: '下载 PDF', onClick: () => downloadSettlement(record.id!, 'pdf') },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button size="small" type="primary" ghost icon={<DownloadOutlined />}>
|
||||
下载
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</AuthButton>,
|
||||
];
|
||||
|
||||
const tableProps: XinTableProps<ISettlement> = {
|
||||
api: '/recon/settlement',
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
accessName: 'recon.settlement',
|
||||
tableRef,
|
||||
operateRender,
|
||||
formProps: false,
|
||||
scroll: { x: 1200 },
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<Title level={3}>结算表</Title>
|
||||
<Text type="secondary">
|
||||
由财务对账结算按门店聚合生成;支持 Excel / PDF 导出存档(回框统计表规则待业务确认后补充)。
|
||||
</Text>
|
||||
</div>
|
||||
<XinTable<ISettlement> {...tableProps} />
|
||||
|
||||
<Drawer
|
||||
title={detail ? `结算表 ${detail.settlement_no}` : '结算表详情'}
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
width={640}
|
||||
loading={detailLoading}
|
||||
>
|
||||
{detail ? (
|
||||
<Descriptions column={2} size="small" bordered>
|
||||
<Descriptions.Item label="门店">{detail.store?.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={SETTLEMENT_STATUS_MAP[detail.status ?? 0]?.color}>
|
||||
{SETTLEMENT_STATUS_MAP[detail.status ?? 0]?.text}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="来源对账单">
|
||||
{detail.recon?.recon_no ?? '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结算周期">
|
||||
{detail.period_start} ~ {detail.period_end}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="公布金额">¥{detail.total_amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="实际金额">¥{detail.actual_amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="差额">¥{detail.diff_amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="结算时间">
|
||||
{detail.settled_at ?? '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="存档文件" span={2}>
|
||||
{detail.file_path ?? <Text type="secondary">未导出</Text>}
|
||||
</Descriptions.Item>
|
||||
{detail.remark ? (
|
||||
<Descriptions.Item label="备注" span={2}>
|
||||
{detail.remark}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
) : null}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettlementPage;
|
||||
Reference in New Issue
Block a user