采购单优化

This commit is contained in:
liu
2026-08-12 23:20:05 +08:00
parent c2c56408a0
commit 0211e5e98d
22 changed files with 768 additions and 1425 deletions
File diff suppressed because one or more lines are too long
+3 -7
View File
@@ -8,7 +8,6 @@ use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderItemModel;
use App\Models\SupplierModel;
use App\Services\PurchaseEditService;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
@@ -75,7 +74,7 @@ class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping,
foreach ($orderItems->groupBy('product_id') as $productId => $group) {
$first = $group->first();
$product = $products->get((int) $productId);
$unitCost = PurchaseEditService::unitCost((string) ($first->cost_price ?? '0'), (string) $first->product_spec);
$unitCost = $first->cost_price ?? '0';
$quantity = '0';
$weight = '0';
@@ -84,11 +83,8 @@ class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping,
foreach ($group as $item) {
$quantity = bcadd($quantity, (string) $item->quantity, 2);
$weight = bcadd($weight, (string) $item->weight, 3);
$amount = bcadd($amount, PurchaseEditService::costAmount(
(string) $item->quantity,
(string) $item->weight,
$unitCost
), 2);
// 金额 = 数量 × 每包成本价(单价/包规不参与金额计算)
$amount = bcadd($amount, bcmul((string) $item->quantity, (string) $item->cost_price, 2), 2);
$storeQuantities[(int) $item->store_id] = bcadd(
$storeQuantities[(int) $item->store_id] ?? '0',
(string) $item->quantity,
@@ -18,13 +18,13 @@ use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
/**
* 小程序门店订单(下单 / 历史 / 详情 / 取消 / 周期汇总)
* 小程序门店订单
*/
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
class OrderController extends BaseMiniController
{
/**
* 下单:逐行取当前门店等级价快照,服务端重算 amount 与 total(不接受前端金额)
* 下单
*/
#[PostRoute('/order', authorize: true)]
public function store(MiniOrderRequest $request): JsonResponse
@@ -4,11 +4,9 @@ namespace App\Http\Controllers\Order;
use App\Exceptions\RepositoryException;
use App\Models\NoticeModel;
use App\Models\ProductModel;
use App\Models\ProductPriceModel;
use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
use App\Models\UserModel;
use App\Services\ItemImageResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -17,7 +15,6 @@ use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Models\SysFileModel;
/**
* 门店订单管理(订单只读 + 状态管理 + 明细修改/同步;创建/取消在小程序端)
@@ -25,24 +22,6 @@ use Modules\SystemTool\Models\SysFileModel;
#[RequestAttribute('/order/store', 'order.store')]
class StoreOrderController extends BaseController
{
/** 状态中文名(通知文案用) */
private const array STATUS_NAMES = [
StoreOrderModel::STATUS_PENDING => '待接单',
StoreOrderModel::STATUS_SUMMARIZED => '已接单',
StoreOrderModel::STATUS_DELIVERING => '采购中',
StoreOrderModel::STATUS_DISTRIBUTION => '配送中',
StoreOrderModel::STATUS_COMPLETED => '已完成',
StoreOrderModel::STATUS_CANCELLED => '已取消',
];
/** 明细可编辑状态:待接单、已接单、采购中、配送中(已完成/已取消锁定) */
private const array ITEM_EDITABLE_STATUS = [
StoreOrderModel::STATUS_PENDING,
StoreOrderModel::STATUS_SUMMARIZED,
StoreOrderModel::STATUS_DELIVERING,
StoreOrderModel::STATUS_DISTRIBUTION,
];
protected array $searchField = [
'store_id' => '=',
'status' => '=',
@@ -84,7 +63,7 @@ class StoreOrderController extends BaseController
// 明细封面图:从快照 image_ids 批量解析(不再关联商品档案表)
foreach ($data['data'] as &$order) {
$this->resolveItemImages($order['items']);
app(ItemImageResolver::class)->resolve($order['items']);
}
unset($order);
@@ -99,48 +78,7 @@ class StoreOrderController extends BaseController
return $this->success($data);
}
/**
* 已接单预览:聚合所有已接单订单明细(按商品分组),
* 供生成采购单前确认(C1 前置);单位取明细快照
*/
#[GetRoute('/summary', 'query')]
public function summary(): JsonResponse
{
$rows = StoreOrderItemModel::query()
->select('product_id')
->selectRaw('MAX(product_name) as product_name')
->selectRaw('MAX(product_spec) as product_spec')
->selectRaw('MAX(unit) as unit')
->selectRaw('SUM(quantity) as total_quantity')
->selectRaw('COUNT(DISTINCT store_id) as store_count')
->whereHas('order', function ($query) {
$query->where('status', StoreOrderModel::STATUS_SUMMARIZED);
})
->groupBy('product_id')
->orderBy('product_id')
->get()
->toArray();
// 快照无单位的历史明细兜底:从商品档案(含已下架/软删除)补齐
$emptyUnitProductIds = array_column(array_filter(
$rows,
static fn (array $row) => ($row['unit'] ?? '') === ''
), 'product_id');
if ($emptyUnitProductIds !== []) {
$units = ProductModel::withTrashed()
->whereIn('id', $emptyUnitProductIds)
->pluck('unit', 'id');
foreach ($rows as &$row) {
if (($row['unit'] ?? '') === '') {
$row['unit'] = $units[$row['product_id']] ?? '';
}
}
}
return $this->success($rows);
}
/** 订单详情:订单头 + 明细(含商品快照、首图;后台侧成本价可见、附供应商名) */
/** 订单详情 */
#[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])]
public function detail(int $id): JsonResponse
{
@@ -158,7 +96,7 @@ class StoreOrderController extends BaseController
$data = $order->toArray();
// 明细首图 + 附加金额(与列表接口一致)
$this->resolveItemImages($data['items']);
app(ItemImageResolver::class)->resolve($data['items']);
$boxAmount = site_config('services.box_amount');
$trayAmount = site_config('services.tray_amount');
$data['box_price'] = number_format($boxAmount, 2);
@@ -170,160 +108,7 @@ class StoreOrderController extends BaseController
}
/**
* 明细首图解析:从快照 image_ids 批量解析文件 URL,未找到时置空字符串(列表/详情共用)
*
* @param array<int, array<string, mixed>> $items 订单明细数组(引用修改)
*/
private function resolveItemImages(array &$items): void
{
$fileIds = [];
foreach ($items as $line) {
foreach ((array) ($line['image_ids'] ?? []) as $fileId) {
if ($fileId !== '' && $fileId !== null) {
$fileIds[] = (int) $fileId;
}
}
}
$fileUrls = [];
if ($fileIds !== []) {
foreach (SysFileModel::query()->whereIn('id', array_unique($fileIds))->get() as $file) {
$fileUrls[$file->id] = $file->preview_url;
}
}
foreach ($items as &$product) {
$product['image'] = '';
foreach ((array) ($product['image_ids'] ?? []) as $fileId) {
if ($fileId !== '' && $fileId !== null && isset($fileUrls[(int) $fileId])) {
$product['image'] = $fileUrls[(int) $fileId];
break;
}
}
unset($product['image_ids']);
}
}
/**
* 修改订单明细(商品快照 + 订货量/重量),事务内重算单品金额与订单总价。
* 可改字段:供应商、品名、规格、单位、单价、成本价、订货量、重量
*/
#[PutRoute(route: '/item/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function updateItem(int $id, Request $request): JsonResponse
{
$data = $request->validate([
'supplier_id' => 'required|integer|min:0',
'product_name' => 'required|string|max:100',
'product_spec' => 'nullable|string|max:100',
'unit' => 'required|string|max:20',
'price' => 'required|numeric|min:0',
'cost_price' => 'required|numeric|min:0',
'quantity' => 'required|integer|min:1',
'weight' => 'required|numeric|min:0',
'remark' => 'nullable|string'
], [
'supplier_id.required' => '供应商不能为空',
'supplier_id.integer' => '供应商ID必须为整数',
'supplier_id.min' => '供应商ID不正确',
'product_name.required' => '品名不能为空',
'product_name.max' => '品名最长 100 个字符',
'product_spec.max' => '规格最长 100 个字符',
'unit.required' => '计价单位不能为空',
'unit.max' => '计价单位最长 20 个字符',
'price.required' => '单价不能为空',
'price.numeric' => '单价必须为数字',
'price.min' => '单价不能小于 0',
'cost_price.required' => '成本价不能为空',
'cost_price.numeric' => '成本价必须为数字',
'cost_price.min' => '成本价不能小于 0',
'quantity.required' => '订货量不能为空',
'quantity.integer' => '订货量必须为整数',
'quantity.min' => '订货量必须大于 0',
'weight.required' => '重量不能为空',
'weight.numeric' => '重量必须为数字',
'weight.min' => '重量不能小于 0',
]);
return DB::transaction(function () use ($id, $data) {
$item = StoreOrderItemModel::query()->lockForUpdate()->find($id);
if (empty($item)) {
throw new RepositoryException('订单明细不存在');
}
$order = StoreOrderModel::query()->lockForUpdate()->find($item->order_id);
if (empty($order)) {
throw new RepositoryException('订单不存在');
}
$this->assertItemEditable($order);
$item->fill($data);
$item->product_spec = (string) ($data['product_spec'] ?? '');
$item->amount = bcmul(
bcadd((string) $data['price'], '0', 2),
(string) $data['quantity'],
2
);
$item->save();
$this->recalculateOrderTotals($order);
return $this->success($item->load('supplier:id,name')->makeVisible('cost_price')->toArray());
});
}
/**
* 一键同步明细商品快照:按商品ID同步最新商品档案的
* 供应商、品名、规格、单位、成本价;单价按门店当前等级价重算
* (未设置等级价时保留原单价),并重算订单总价
*/
#[PutRoute(route: '/item/{id}/sync', authorize: 'update', where: ['id' => '[0-9]+'])]
public function syncItem(int $id): JsonResponse
{
return DB::transaction(function () use ($id) {
$item = StoreOrderItemModel::query()->lockForUpdate()->find($id);
if (empty($item)) {
throw new RepositoryException('订单明细不存在');
}
$order = StoreOrderModel::query()->lockForUpdate()->find($item->order_id);
if (empty($order)) {
throw new RepositoryException('订单不存在');
}
$this->assertItemEditable($order);
// 软删除商品无法同步(下架商品仍可同步最新档案)
$product = ProductModel::find($item->product_id);
if (empty($product)) {
throw new RepositoryException('商品不存在或已被删除,无法同步');
}
$item->supplier_id = (int) $product->supplier_id;
$item->product_name = $product->name;
$item->product_spec = (string) $product->spec;
$item->unit = (string) $product->unit;
$item->cost_price = $product->cost_price;
// 单价:按订货门店当前客户等级价重算(百分比计价按最新成本价换算)
$levelId = (int) ($order->store->level_id ?? 0);
$priceRow = ProductPriceModel::query()
->forProductLevel((int) $item->product_id, $levelId)
->first(['price', 'price_type', 'percent']);
if ($priceRow !== null) {
$item->price = ProductPriceModel::calcActualPrice(
(int) $priceRow->price_type,
$priceRow->price,
$priceRow->percent,
$product->cost_price,
);
}
$item->amount = bcmul((string) $item->price, (string) $item->quantity, 2);
$item->save();
$this->recalculateOrderTotals($order);
return $this->success($item->load('supplier:id,name')->makeVisible('cost_price')->toArray());
});
}
/**
* 状态流转:待接单→已接单/已取消;采购中→配送中/已完成;配送中→已完成
* 状态流转
*/
#[PutRoute(route: '/{id}/status', authorize: 'update', where: ['id' => '[0-9]+'])]
public function status(int $id, Request $request): JsonResponse
@@ -343,7 +128,7 @@ class StoreOrderController extends BaseController
$target = (int) $data['status'];
if (! in_array($target, $this->allowedTransitions($order->status), true)) {
throw new RepositoryException(
'订单当前状态为「' . (self::STATUS_NAMES[$order->status] ?? $order->status) . '」,不允许该操作'
'订单当前状态为「' . (StoreOrderModel::STATUS_NAMES[$order->status] ?? $order->status) . '」,不允许该操作'
);
}
@@ -356,8 +141,7 @@ class StoreOrderController extends BaseController
}
/**
* 批量状态流转:先全量校验流转路径,任一订单不允许则整批中止;
* 全部合法时在事务内流转并逐单通知门店
* 批量状态流转
*/
#[PutRoute(route: '/batchStatus', authorize: 'update')]
public function batchStatus(Request $request): JsonResponse
@@ -381,12 +165,12 @@ class StoreOrderController extends BaseController
$blocked = [];
foreach ($orders as $order) {
if (! in_array($target, $this->allowedTransitions($order->status), true)) {
$blocked[] = $order->order_no . '' . (self::STATUS_NAMES[$order->status] ?? $order->status) . '';
$blocked[] = $order->order_no . '' . (StoreOrderModel::STATUS_NAMES[$order->status] ?? $order->status) . '';
}
}
if (! empty($blocked)) {
throw new RepositoryException(
'以下订单不允许流转为「' . (self::STATUS_NAMES[$target] ?? $target) . '」,批量操作已中止:' . implode('、', $blocked)
'以下订单不允许流转为「' . (StoreOrderModel::STATUS_NAMES[$target] ?? $target) . '」,批量操作已中止:' . implode('、', $blocked)
);
}
@@ -402,7 +186,7 @@ class StoreOrderController extends BaseController
}
/**
* 删除订单(软删除):仅已取消订单允许删除;删除后后台列表/详情、小程序端均不可见
* 删除订单(软删除)
*/
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
public function delete(int $id): JsonResponse
@@ -413,7 +197,7 @@ class StoreOrderController extends BaseController
}
if ($order->status !== StoreOrderModel::STATUS_CANCELLED) {
throw new RepositoryException(
'订单当前状态为「' . (self::STATUS_NAMES[$order->status] ?? $order->status) . '」,仅已取消订单可删除'
'订单当前状态为「' . (StoreOrderModel::STATUS_NAMES[$order->status] ?? $order->status) . '」,仅已取消订单可删除'
);
}
@@ -423,31 +207,7 @@ class StoreOrderController extends BaseController
}
/**
* 状态流转合法路径(与迁移状态定义一致):
* 待接单→已接单/已取消;采购中→配送中/已完成;配送中→已完成
* (已接单→采购中 只能经「生成采购单」完成,不在本流转内)
*
* @return int[]
*/
private function allowedTransitions(int $status): array
{
return match ($status) {
StoreOrderModel::STATUS_PENDING => [
StoreOrderModel::STATUS_SUMMARIZED,
StoreOrderModel::STATUS_CANCELLED,
],
StoreOrderModel::STATUS_DELIVERING => [
StoreOrderModel::STATUS_DISTRIBUTION,
StoreOrderModel::STATUS_COMPLETED,
],
StoreOrderModel::STATUS_DISTRIBUTION => [StoreOrderModel::STATUS_COMPLETED],
default => [],
};
}
/**
* 修改周转框/周转托盘数量(仅已接单、采购中、配送中可改),
* 自动重算附加金额与订单总金额
* 修改周转框/周转托盘数量
*/
#[PutRoute(route: '/{id}/container', authorize: 'update', where: ['id' => '[0-9]+'])]
public function container(int $id, Request $request): JsonResponse
@@ -476,7 +236,7 @@ class StoreOrderController extends BaseController
];
if (! in_array($order->status, $editable, true)) {
throw new RepositoryException(
'订单当前状态为「' . (self::STATUS_NAMES[$order->status] ?? $order->status) . '」,不允许修改周转框/托盘数量'
'订单当前状态为「' . (StoreOrderModel::STATUS_NAMES[$order->status] ?? $order->status) . '」,不允许修改周转框/托盘数量'
);
}
@@ -501,36 +261,24 @@ class StoreOrderController extends BaseController
}
/**
* 明细编辑状态校验:已完成/已取消订单锁定
* 状态流转合法路径
*
* @return int[]
*/
private function assertItemEditable(StoreOrderModel $order): void
private function allowedTransitions(int $status): array
{
if (! in_array($order->status, self::ITEM_EDITABLE_STATUS, true)) {
throw new RepositoryException(
'订单当前状态为「' . (self::STATUS_NAMES[$order->status] ?? $order->status) . '」,不允许修改商品明细'
);
}
}
/**
* 重算订单汇总:明细金额合计 → 商品总金额;订货量/重量合计 → 订货总量/总重量;
* 订单总金额 = 商品总金额 + 附加金额(恒成立)
*/
private function recalculateOrderTotals(StoreOrderModel $order): void
{
$totals = StoreOrderItemModel::query()
->where('order_id', $order->id)
->selectRaw('COALESCE(SUM(quantity), 0) as total_quantity')
->selectRaw('COALESCE(SUM(weight), 0) as total_weight')
->selectRaw('COALESCE(SUM(amount), 0) as product_amount')
->first();
$productAmount = bcadd((string) $totals->product_amount, '0', 2);
$order->total_quantity = (int) $totals->total_quantity;
$order->total_weight = bcadd((string) $totals->total_weight, '0', 3);
$order->product_amount = $productAmount;
$order->total_amount = bcadd($productAmount, (string) $order->added_amount, 2);
$order->save();
return match ($status) {
StoreOrderModel::STATUS_PENDING => [
StoreOrderModel::STATUS_SUMMARIZED,
StoreOrderModel::STATUS_CANCELLED,
],
StoreOrderModel::STATUS_DELIVERING => [
StoreOrderModel::STATUS_DISTRIBUTION,
StoreOrderModel::STATUS_COMPLETED,
],
StoreOrderModel::STATUS_DISTRIBUTION => [StoreOrderModel::STATUS_COMPLETED],
default => [],
};
}
/**
@@ -543,7 +291,7 @@ class StoreOrderController extends BaseController
->where('status', UserModel::STATUS_NORMAL)
->pluck('id');
$statusName = self::STATUS_NAMES[$order->status] ?? (string) $order->status;
$statusName = StoreOrderModel::STATUS_NAMES[$order->status] ?? (string) $order->status;
foreach ($userIds as $userId) {
NoticeModel::create([
'user_id' => $userId,
@@ -9,17 +9,17 @@ use App\Models\ProductModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderItemModel;
use App\Services\ExportService;
use App\Services\PurchaseEditService;
use App\Models\StoreOrderModel;
use App\Services\ItemImageResolver;
use App\Services\PurchaseGenerateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
/**
@@ -50,8 +50,7 @@ class PurchaseOrderController extends BaseController
}
/**
* 采购单详情:头 + 明细矩阵(商品行 × 门店列)
* 行 = 商品(按分类 sort → 商品 sort 排序),列 = 商品信息 + 每个门店一格(数量/金额)
* 采购单详情
*/
#[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])]
public function detail(int $id): JsonResponse
@@ -163,8 +162,7 @@ class PurchaseOrderController extends BaseController
}
/**
* C1 生成采购单:合并全部「已接单」门店订单(或指定的 order_ids),
* 生成后源订单转为「采购中」并回写 purchase_id(订单与订货明细同步归集)
* 生成采购单
*/
#[PostRoute('/generate', 'generate')]
public function generate(Request $request): JsonResponse
@@ -192,29 +190,77 @@ class PurchaseOrderController extends BaseController
);
}
/** C2/C3 导出采购单:?type=all|category & format=xlsx|pdf */
#[GetRoute(route: '/{id}/export', authorize: 'export', where: ['id' => '[0-9]+'])]
public function export(int $id, Request $request): Response
/**
* 单元格下钻:采购单中某门店某商品的全部订货明细
*/
#[GetRoute(route: '/{id}/cell', authorize: 'query', where: ['id' => '[0-9]+'])]
public function cell(int $id, Request $request): JsonResponse
{
$productId = (int) $request->query('product_id', 0);
$storeId = (int) $request->query('store_id', 0);
if ($productId <= 0 || $storeId <= 0) {
throw new RepositoryException('缺少商品或门店参数');
}
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$type = (string) $request->query('type', 'all');
if (! in_array($type, ['all', 'category'], true)) {
throw new RepositoryException('导出类型参数不正确(all 全品类 / category 蔬果分类)');
}
return app(ExportService::class)->download(
'purchase',
$purchase,
(string) $request->query('format', ExportService::FORMAT_XLSX),
type: $type,
);
$items = StoreOrderItemModel::query()
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
->where('store_order_item.purchase_id', $purchase->id)
->where('store_order_item.product_id', $productId)
->where('store_order_item.store_id', $storeId)
->whereNull('store_order.deleted_at')
->with('supplier:id,name')
->select(
'store_order_item.*',
'store_order.order_no',
'store_order.order_date',
'store_order.status as order_status',
)
->orderBy('store_order.id')
->get()
->makeVisible('cost_price');
$rows = [];
foreach ($items as $item) {
$rows[] = [
'id' => $item->id,
'order_id' => $item->order_id,
'order_no' => $item->order_no,
'order_date' => (string) $item->order_date,
'order_status' => (int) $item->order_status,
'product_name' => $item->product_name,
'product_spec' => $item->product_spec,
'unit' => $item->unit,
'supplier_id' => (int) $item->supplier_id,
'supplier' => $item->supplier ? ['id' => $item->supplier->id, 'name' => $item->supplier->name] : null,
'price' => (string) $item->price,
'cost_price' => (string) $item->cost_price,
'quantity' => (int) $item->quantity,
'weight' => (string) $item->weight,
'amount' => (string) $item->amount,
'remark' => (string) $item->remark,
'image_ids' => $item->image_ids,
'editable' => $purchase->status === PurchaseOrderModel::STATUS_PENDING
&& in_array((int) $item->order_status, StoreOrderItemModel::ITEM_EDITABLE_STATUS, true),
];
}
app(ItemImageResolver::class)->resolve($rows);
$store = StoreModel::withTrashed()->find($storeId);
$product = ProductModel::withTrashed()->find($productId);
return $this->success([
'store' => $store ? ['id' => $store->id, 'name' => $store->name] : null,
'product' => $product ? ['id' => $product->id, 'name' => $product->name] : null,
'items' => $rows,
]);
}
/**
* 商品行修改:品名/供应商/包规/单位/成本一键同步该商品全部订货明细;
* 商品行修改:品名/供应商/包规/单位/成本
* @throws Throwable
*/
#[PutRoute(route: '/{id}/row/{productId}', authorize: 'update', where: ['id' => '[0-9]+', 'productId' => '[0-9]+'])]
@@ -224,25 +270,77 @@ class PurchaseOrderController extends BaseController
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
throw new RepositoryException('采购单已完成,不允许修改明细');
}
$validated = $request->validated();
$attrs = [];
foreach (['product_name', 'supplier_id', 'product_spec', 'unit', 'cost_price'] as $field) {
if (isset($validated[$field])) {
$attrs[$field] = $validated[$field];
$items = StoreOrderItemModel::query()
->where('purchase_id', $purchase->id)
->where('product_id', $productId)
->lockForUpdate()
->get();
if ($items->isEmpty()) {
throw new RepositoryException('该采购单下无此商品的订货明细');
}
foreach ($items as $item) {
$item->fill($validated)->save();
}
// 重算采购单成本
$query = StoreOrderItemModel::query()->where('purchase_id', $purchase->id);
$purchase->total_quantity = $query->sum('quantity');
$purchase->estimate_amount = $query->sum(DB::raw('quantity * cost_price'));
$purchase->save();
return $this->success(['count' => $items->count()], '已同步 ' . $items->count() . ' 条订货明细');
}
/**
* 门店订单明细修改(订货量、重量、单价),自动重算价格
* @throws Throwable
*/
#[PutRoute(route: '/cell/{itemId}', authorize: 'update', where: ['itemId' => '[0-9]+'])]
public function cellUpdate(int $itemId, PurchaseCellUpdateRequest $request): JsonResponse
{
$validated = $request->validated();
return DB::transaction(function () use ($itemId, $validated) {
$item = StoreOrderItemModel::query()->lockForUpdate()->find($itemId);
if (empty($item) || (int) $item->purchase_id === 0) {
throw new RepositoryException('采购单明细不存在');
}
$purchase = PurchaseOrderModel::find((int) $item->purchase_id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
throw new RepositoryException('采购单已完成,不允许修改明细');
}
}
if ($attrs === []) {
throw new RepositoryException('品名/供应商/包规/单位/成本至少填写一项');
}
$count = app(PurchaseEditService::class)->updateRow(
$purchase,
$productId,
$attrs,
(bool) ($validated['sync_product'] ?? false),
);
$item->quantity = $validated['quantity'];
$item->price = $validated['price'];
if (array_key_exists('weight', $validated)) {
$item->weight = bcadd((string) $validated['weight'], '0', 3);
}
$item->amount = bcmul($validated['quantity'], $validated['price'], 3);
$item->save();
return $this->success(['count' => $count], '已同步 ' . $count . ' 条订货明细');
// 重算订单金额
$order = StoreOrderModel::query()->lockForUpdate()->find($item->order_id);
$order->product_amount = $order->items()->sum('amount');
$order->total_weight = $order->items()->sum('weight');
$order->total_amount = bcadd($order->product_amount, $order->added_amount, 2);
$order->save();
// 重算采购单重量
$purchase->total_weight = StoreOrderItemModel::query()
->where('purchase_id', $purchase->id)
->sum('weight');
$purchase->save();
return $this->success();
});
}
}
@@ -15,6 +15,7 @@ class PurchaseCellUpdateRequest extends BaseFormRequest
{
return [
'quantity' => 'required|integer|min:0',
'price' => 'required|numeric|min:0',
'weight' => 'nullable|numeric|min:0',
];
}
@@ -25,6 +26,9 @@ class PurchaseCellUpdateRequest extends BaseFormRequest
'quantity.required' => '数量不能为空',
'quantity.integer' => '数量必须为整数',
'quantity.min' => '数量不能小于 0',
'price.required' => '单价不能为空',
'price.numeric' => '单价必须为数字',
'price.min' => '单价不能小于 0',
'weight.numeric' => '实际称重必须为数字',
'weight.min' => '实际称重不能小于 0',
];
@@ -5,8 +5,7 @@ namespace App\Http\Requests\Purchase;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 采购明细商品行修改 验证(C4;品名/供应商/包规/单位/成本/称重至少一项,
* 提交后一键同步该商品在本采购单下的全部订货明细)
* 采购明细商品行修改
*/
class PurchaseRowUpdateRequest extends BaseFormRequest
{
@@ -15,12 +14,11 @@ class PurchaseRowUpdateRequest extends BaseFormRequest
public function rules(): array
{
return [
'product_name' => 'nullable|string|max:100',
'supplier_id' => 'nullable|integer|exists:supplier,id',
'product_spec' => 'nullable|string|max:100',
'unit' => 'nullable|string|max:20',
'cost_price' => 'nullable|numeric|min:0',
'sync_product' => 'nullable|boolean',
'product_name' => 'required|string|max:100',
'supplier_id' => 'required|integer|exists:supplier,id',
'product_spec' => 'required|string|max:100',
'unit' => 'required|string|max:20',
'cost_price' => 'required|numeric|min:0',
];
}
@@ -28,11 +26,20 @@ class PurchaseRowUpdateRequest extends BaseFormRequest
{
return [
'product_name.max' => '品名不能超过 100 字',
'product_name.required' => '品名不能为空',
'product_name.string' => '品名必须为字符串',
'supplier_id.supplier_id' => '供应商不能为空',
'supplier_id.integer' => '供应商格式错误',
'supplier_id.exists' => '供应商不存在',
'product_spec.required' => '包规不能为空',
'product_spec.string' => '包规必须为字符串',
'product_spec.max' => '包规不能超过 100 字',
'unit.required' => '单位不能为空',
'unit.string' => '单位必须为字符串',
'unit.max' => '单位不能超过 20 字',
'cost_price.numeric' => '采购成本必须为数字',
'cost_price.min' => '采购成本不能小于 0',
'cost_price.required' => '成本不能为空',
'cost_price.numeric' => '成本必须为数字',
'cost_price.min' => '成本不能小于 0',
];
}
}
+8
View File
@@ -60,6 +60,14 @@ class StoreOrderItemModel extends Model
*/
protected $hidden = ['cost_price'];
/** 明细可编辑状态:待接单、已接单、采购中、配送中(已完成/已取消锁定) */
public const array ITEM_EDITABLE_STATUS = [
StoreOrderModel::STATUS_PENDING,
StoreOrderModel::STATUS_SUMMARIZED,
StoreOrderModel::STATUS_DELIVERING,
StoreOrderModel::STATUS_DISTRIBUTION,
];
/**
* 商品图片ID(逗号分隔字符串 ↔ 数组)
*/
+10
View File
@@ -28,6 +28,16 @@ class StoreOrderModel extends Model
/** 状态:已取消 */
public const int STATUS_CANCELLED = 9;
/** 状态中文名(后台文案/订单通知用) */
public const array STATUS_NAMES = [
self::STATUS_PENDING => '待接单',
self::STATUS_SUMMARIZED => '已接单',
self::STATUS_DELIVERING => '采购中',
self::STATUS_DISTRIBUTION => '配送中',
self::STATUS_COMPLETED => '已完成',
self::STATUS_CANCELLED => '已取消',
];
protected $table = 'store_order';
protected $primaryKey = 'id';
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Services;
use Modules\SystemTool\Models\SysFileModel;
/**
* 商品快照首图解析:从明细 image_ids 批量解析文件 URL(门店订单列表/详情、采购单元格下钻共用)
*/
class ItemImageResolver
{
/**
* @param array<int, array<string, mixed>> $items 明细数组(引用修改:写入 image,移除 image_ids
*/
public function resolve(array &$items): void
{
$fileIds = [];
foreach ($items as $line) {
foreach ((array) ($line['image_ids'] ?? []) as $fileId) {
if ($fileId !== '' && $fileId !== null) {
$fileIds[] = (int) $fileId;
}
}
}
$fileUrls = [];
if ($fileIds !== []) {
foreach (SysFileModel::query()->whereIn('id', array_unique($fileIds))->get() as $file) {
$fileUrls[$file->id] = $file->preview_url;
}
}
foreach ($items as &$product) {
$product['image'] = '';
foreach ((array) ($product['image_ids'] ?? []) as $fileId) {
if ($fileId !== '' && $fileId !== null && isset($fileUrls[(int) $fileId])) {
$product['image'] = $fileUrls[(int) $fileId];
break;
}
}
unset($product['image_ids']);
}
}
}
-138
View File
@@ -1,138 +0,0 @@
<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use App\Models\ProductModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
use Illuminate\Support\Facades\DB;
use Throwable;
/**
* 采购单数据修改
*/
class PurchaseEditService
{
/**
* 商品行修改:品名/供应商/包规/单位/成本一键同步该商品全部订货明细;
* syncProduct = true 时同步更新商品档案(product 表)
*
* @param PurchaseOrderModel $purchase
* @param int $productId
* @param array{product_name?: string, supplier_id?: int, product_spec?: string, unit?: string, cost_price?: float} $attrs 行属性(仅同步传入键)
* @param bool $syncProduct
* @return int
* @throws Throwable
*/
public function updateRow(PurchaseOrderModel $purchase, int $productId, array $attrs, bool $syncProduct = false): int
{
return DB::transaction(function () use ($purchase, $productId, $attrs, $syncProduct) {
$items = StoreOrderItemModel::query()
->where('purchase_id', $purchase->id)
->where('product_id', $productId)
->lockForUpdate()
->get();
if ($items->isEmpty()) {
throw new RepositoryException('该采购单下无此商品的订货明细');
}
$sync = [];
if (isset($attrs['product_name'])) {
$sync['product_name'] = $attrs['product_name'];
}
if (isset($attrs['supplier_id'])) {
$sync['supplier_id'] = (int) $attrs['supplier_id'];
}
if (isset($attrs['product_spec'])) {
$sync['product_spec'] = $attrs['product_spec'];
}
if (isset($attrs['unit'])) {
$sync['unit'] = $attrs['unit'];
}
if (isset($attrs['cost_price'])) {
$sync['cost_price'] = bcadd((string) $attrs['cost_price'], '0', 2);
}
if ($sync !== []) {
foreach ($items as $item) {
$item->fill($sync)->save();
}
// 同步至商品档案
if ($syncProduct) {
$product = ProductModel::withTrashed()->find($productId);
if ($product === null || $product->trashed()) {
throw new RepositoryException('商品档案不存在或已删除,无法同步至商品');
}
$productAttrs = [];
if (isset($sync['product_name'])) {
$productAttrs['name'] = $sync['product_name'];
}
if (isset($sync['supplier_id'])) {
$productAttrs['supplier_id'] = $sync['supplier_id'];
}
if (isset($sync['product_spec'])) {
$productAttrs['spec'] = $sync['product_spec'];
}
if (isset($sync['unit'])) {
$productAttrs['unit'] = $sync['unit'];
}
if (isset($sync['cost_price'])) {
$productAttrs['cost_price'] = $sync['cost_price'];
}
$product->fill($productAttrs)->save();
}
}
$this->recalculatePurchaseTotals($purchase);
return $items->count();
});
}
/**
* 重算采购金额汇总
*/
public function recalculatePurchaseTotals(PurchaseOrderModel $purchase): void
{
$items = StoreOrderItemModel::query()
->where('purchase_id', $purchase->id)
->get(['quantity', 'cost_price']);
$totalQuantity = 0;
$estimate = '0';
foreach ($items as $item) {
$totalQuantity += $item->quantity;
$item_estimate = bcmul($item->cost_price, $item->quantity, 2);
$estimate = bcadd($estimate, $item_estimate, 2);
}
$purchase->total_quantity = $totalQuantity;
$purchase->estimate_amount = $estimate;
$purchase->save();
}
/**
* 采购成本金额:称重>0 称重×单价,否则按 数量×单价
*/
public static function costAmount(string $quantity, string $weight, string $unitCost): string
{
return (float) $weight > 0
? bcmul($weight, $unitCost, 2)
: bcmul($quantity, $unitCost, 2);
}
/**
* 单价 = 成本 / 包规数值(spec 解析不出正数时按 1 处理,即单价=成本)
*/
public static function unitCost(string $costPrice, string $spec): string
{
if (preg_match('/\d+(?:\.\d+)?/', $spec, $matches) === 1 && (float) $matches[0] > 0) {
return bcdiv($costPrice, $matches[0], 2);
}
return bcadd($costPrice, '0', 2);
}
}
+2 -1
View File
@@ -58,8 +58,9 @@ readonly class PurchaseGenerateService
static fn (string $carry, $item): string => bcadd($carry, (string) $item->quantity, 2),
'0'
);
// 预估金额 = Σ订货金额(明细金额 price×quantity),与采购单修改重算口径一致
$estimateAmount = $items->reduce(
static fn (string $carry, $item): string => bcadd($carry, bcmul($item->quantity, (string) $item->cost_price, 2), 2),
static fn (string $carry, $item): string => bcadd($carry, (string) $item->amount, 2),
'0'
);
+3 -11
View File
@@ -17,8 +17,8 @@ use Illuminate\Support\Facades\DB;
* 流程(事务内,可重复 build:先清后建):
* 1. 按周期 + 品类(含子孙分类)+ 供应商拉取「已完成」门店订单的订货明细
* 2. 每条订货明细 一条对账明细:
* publish_amount = 订货金额(order_item.amount
* actual_amount = 采购成本(称重>0 ? 称重×单价 : 数量×单价,单价 = 成本/包规
* publish_amount = 订货金额(order_item.amount = 每包等级价×数量
* actual_amount = 采购成本(数量 × 每包成本价;单价/包规不参与金额计算
* diff = publish actual,冗余 product_name / store_id
* 3. 汇总写回头的 publish/actual/diff_amountstatus 对账中
*/
@@ -64,15 +64,7 @@ class ReconciliationBuildService
$now = now();
foreach ($orderItems as $orderItem) {
$publish = (string) $orderItem->amount;
$unitCost = PurchaseEditService::unitCost(
(string) ($orderItem->cost_price ?? '0'),
(string) $orderItem->product_spec
);
$actual = PurchaseEditService::costAmount(
(string) $orderItem->quantity,
(string) $orderItem->weight,
$unitCost
);
$actual = bcmul((string) $orderItem->quantity, (string) ($orderItem->cost_price ?? '0'), 2);
$publishTotal = bcadd($publishTotal, $publish, 2);
$actualTotal = bcadd($actualTotal, $actual, 2);
@@ -59,7 +59,7 @@ return new class extends Migration
$table->decimal('weight', 10, 3)->default(0)->comment('重量');
$table->decimal('amount', 10, 2)->default(0)->comment('单品金额');
$table->decimal('cost_price', 10, 2)->default(0)->comment('成本价');
$table->string('remark', 255)->default('')->comment('门店下单备注');
$table->string('remark', 255)->default('')->nullable()->comment('门店下单备注');
$table->timestamps();
$table->index(['order_id'], 'store_order_item_order_index');
$table->index(['purchase_id'], 'store_order_item_purchase_index');
+152 -24
View File
@@ -13,8 +13,8 @@ use App\Models\SupplierModel;
use App\Models\UserModel;
/**
* C4 采购单数据修改:详情矩阵(商品行 × 门店列)、门店单元格数量、行级成本/称重
* 修改同步订货明细并重算订单/采购单汇总
* C4 采购单数据修改:详情矩阵(商品行 × 门店列)、门店单元格下钻编辑/同步、行级成本
* 修改同步订货明细并重算订单/采购单汇总(金额口径:数量() × 每包价格,单价/包规不参与金额计算)
*/
class PurchaseEditTest extends ProcurementTestCase
{
@@ -101,9 +101,10 @@ class PurchaseEditTest extends ProcurementTestCase
$purchase = $purchase->fresh();
$this->assertSame('8.00', (string) $purchase->total_quantity, '5+3');
$this->assertSame('80.00', (string) $purchase->estimate_amount, '50+30');
$this->assertSame('160.00', (string) $purchase->actual_amount, '8 包 × 每包成本 20.00');
}
/** 行级成本修改 → 同步该商品全部订货明细,采购单实际金额按 数量×单价 重算 */
/** 行级成本修改 → 同步该商品全部订货明细,采购单实际金额按 数量×每包成本 重算 */
public function test_update_row_cost_syncs_all_order_items(): void
{
[$purchase, $product] = $this->buildPurchase();
@@ -116,33 +117,20 @@ class PurchaseEditTest extends ProcurementTestCase
$this->assertSame(2, StoreOrderItemModel::where('cost_price', '30.00')->count());
$purchase = $purchase->fresh();
// 单价 = 30 ÷ 10 = 3.00,实际金额 = 5 × 3.00
$this->assertSame('15.00', (string) $purchase->actual_amount);
$this->assertSame('150.00', (string) $purchase->actual_amount, '5 包 × 每包成本 30.00');
$this->assertSame('50.00', (string) $purchase->estimate_amount, '订货金额不受成本修改影响');
}
/** 行级称重修改 → 按各店数量比例分摊写入明细(尾差修正守恒),实际金额按 称重×单价 计 */
public function test_update_row_weight_distributed_by_quantity(): void
/** 行级属性修改:品名/供应商/包规/单位/成本一键同步该商品全部订货明细(重量为手动录入,不受行修改影响) */
public function test_update_row_does_not_touch_manual_weight(): void
{
[$purchase, $product, $stores] = $this->buildPurchase();
[$purchase, $product] = $this->buildPurchase();
$this->actingAsSysUser();
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", ['weight' => 10])
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", ['cost_price' => 30])
->assertJsonPath('success', true);
$items = StoreOrderItemModel::query()->orderBy('store_id')->get()->keyBy('store_id');
$this->assertSame('4.000', (string) $items[$stores[0]->id]->weight, '10 × 2/5');
$this->assertSame('6.000', (string) $items[$stores[1]->id]->weight, '10 × 3/5');
$this->assertSame(
'10.000',
bcadd((string) $items[$stores[0]->id]->weight, (string) $items[$stores[1]->id]->weight, 3),
'分摊守恒'
);
$purchase = $purchase->fresh();
$this->assertSame('10.000', (string) $purchase->total_weight);
// 称重>0 → 金额按 称重×单价:10 × 2.00
$this->assertSame('20.00', (string) $purchase->actual_amount);
$this->assertSame(0, StoreOrderItemModel::where('weight', '<>', 0)->count(), '重量保持手动录入值,不自动计算');
}
/** 行级属性修改:品名/供应商/包规/单位/成本一键同步该商品全部订货明细 */
@@ -175,8 +163,7 @@ class PurchaseEditTest extends ProcurementTestCase
$product = $product->fresh();
$this->assertNotSame('优选土豆', $product->name);
// 新单价 = 25 ÷ 5 = 5.00,实际金额 = 5 × 5.00
$this->assertSame('25.00', (string) $purchase->fresh()->actual_amount);
$this->assertSame('125.00', (string) $purchase->fresh()->actual_amount, '5 × 每包成本 25.00');
}
/** sync_product=true:订货明细与商品档案同步更新;软删除商品拒绝 */
@@ -231,4 +218,145 @@ class PurchaseEditTest extends ProcurementTestCase
$this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => -1])
->assertJsonPath('success', false);
}
/** 单元格下钻:返回该门店该商品的全部订货明细(订单号/状态/快照/可编辑标记),其他门店无明细 */
public function test_cell_detail_returns_items_with_order_info(): void
{
[$purchase, $product, $stores] = $this->buildPurchase();
$this->actingAsSysUser();
$response = $this->getJson("/purchase/order/{$purchase->id}/cell?product_id={$product->id}&store_id={$stores[0]->id}")
->assertOk()
->assertJsonPath('success', true);
$data = $response->json('data');
$this->assertSame($stores[0]->name, $data['store']['name']);
$this->assertSame($product->name, $data['product']['name']);
$this->assertCount(1, $data['items']);
$item = $data['items'][0];
$this->assertArrayHasKey('order_no', $item);
$this->assertSame(2, $item['quantity']);
$this->assertSame('20.00', (string) $item['amount']);
$this->assertSame(StoreOrderModel::STATUS_DELIVERING, $item['order_status'], '生成采购单后源订单为采购中');
$this->assertTrue($item['editable']);
// 其他门店 → 另一条明细
$this->getJson("/purchase/order/{$purchase->id}/cell?product_id={$product->id}&store_id={$stores[1]->id}")
->assertOk()
->assertJsonPath('data.items.0.quantity', 3);
}
/** 单元格下钻参数校验:缺少商品/门店参数拒绝 */
public function test_cell_detail_rejects_missing_params(): void
{
[$purchase] = $this->buildPurchase();
$this->actingAsSysUser();
$this->getJson("/purchase/order/{$purchase->id}/cell")
->assertJsonPath('success', false);
}
/** 单元格称重修改 → 明细/订货单/采购单重量与金额汇总级联重算 */
public function test_update_cell_weight_cascades_totals(): void
{
[$purchase, , $stores] = $this->buildPurchase();
$this->actingAsSysUser();
$item = StoreOrderItemModel::where('store_id', $stores[0]->id)->first();
$this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => 5, 'weight' => 4.5])
->assertJsonPath('success', true)
->assertJsonPath('data.amount', 50);
$item = $item->fresh();
$this->assertSame('4.500', (string) $item->weight);
$this->assertSame('50.00', (string) $item->amount, '5 × 订货单价 10.00');
$order = StoreOrderModel::find($item->order_id);
$this->assertSame('4.500', (string) $order->total_weight);
$this->assertSame('50.00', (string) $order->product_amount);
$purchase = $purchase->fresh();
$this->assertSame('4.500', (string) $purchase->total_weight);
$this->assertSame('80.00', (string) $purchase->estimate_amount, '50+30 订货金额');
$this->assertSame('160.00', (string) $purchase->actual_amount, '8 包 × 每包成本 20.00(称重仅参考,不参与金额)');
}
/** 单元格一键同步:按商品ID同步档案 + 等级价重算,订货单与采购单汇总级联 */
public function test_cell_sync_refreshes_snapshot_and_cascades(): void
{
$level = CustomerLevelModel::factory()->create();
$store = StoreModel::factory()->create(['level_id' => $level->id]);
$product = ProductModel::factory()->create([
'status' => ProductModel::STATUS_ON,
'cost_price' => '10.00',
'spec' => '1斤/袋',
'unit' => '斤',
]);
ProductPriceModel::factory()->create([
'product_id' => $product->id,
'level_id' => $level->id,
'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT,
'percent' => 30,
]);
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 2]]])
->assertJsonPath('success', true);
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
$this->actingAsSysUser();
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
->assertJsonPath('success', true);
$purchase = PurchaseOrderModel::first();
$item = StoreOrderItemModel::first();
$this->assertSame('13.00', (string) $item->price, '下单时 10 元上浮 30%');
$this->assertSame('26.00', (string) $purchase->estimate_amount);
// 成本上调后同步:单价按最新成本重算,金额与两级汇总级联
$product->update(['cost_price' => '20.00']);
$this->putJson("/purchase/order/cell/{$item->id}/sync")
->assertJsonPath('success', true)
->assertJsonPath('data.amount', 52);
$item->refresh();
$this->assertSame('20.00', (string) $item->cost_price);
$this->assertSame('26.00', (string) $item->price, '20 元上浮 30% = 26.00');
$this->assertSame('52.00', (string) $item->amount, '26.00 × 2');
$order = StoreOrderModel::first();
$this->assertSame('52.00', (string) $order->product_amount);
$this->assertSame('52.00', (string) $order->total_amount);
$purchase = $purchase->fresh();
$this->assertSame('52.00', (string) $purchase->estimate_amount);
$this->assertSame('40.00', (string) $purchase->actual_amount, '2 包 × 每包成本 20.00');
}
/** 采购单已完成:单元格编辑/同步、行修改均拒绝,明细不变 */
public function test_edits_rejected_when_purchase_completed(): void
{
[$purchase, $product, $stores] = $this->buildPurchase();
$item = StoreOrderItemModel::where('store_id', $stores[0]->id)->first();
$purchase->update(['status' => PurchaseOrderModel::STATUS_COMPLETED]);
$this->actingAsSysUser();
$this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => 5])
->assertJsonPath('success', false)
->assertJsonPath('msg', '采购单已完成,不允许修改明细');
$this->putJson("/purchase/order/cell/{$item->id}/sync")
->assertJsonPath('success', false);
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", ['cost_price' => 30])
->assertJsonPath('success', false);
$this->assertSame(2, $item->fresh()->quantity, '被拒绝后明细不变');
$this->assertSame('20.00', (string) $item->fresh()->cost_price);
// 下钻明细同步标记不可编辑
$this->getJson("/purchase/order/{$purchase->id}/cell?product_id={$product->id}&store_id={$stores[0]->id}")
->assertOk()
->assertJsonPath('data.items.0.editable', false);
}
}
+4 -30
View File
@@ -1,8 +1,7 @@
import createAxios from '@/utils/request';
import type IStoreOrder from '@/domain/iStoreOrder.ts';
import type { IOrderSummaryRow, IStoreOrderItem, IStoreOrderItemUpdate } from '@/domain/iStoreOrder.ts';
/** 订单详情(头 + 明细) */
/** 订单详情 */
export async function getStoreOrder(id: number) {
return createAxios<IStoreOrder>({
url: `/order/store/${id}`,
@@ -10,7 +9,7 @@ export async function getStoreOrder(id: number) {
});
}
/** 订单状态流转(1已接单 3配送中 4已完成 9取消;已接单→采购中由生成采购单完成) */
/** 订单状态流转 */
export async function updateOrderStatus(id: number, status: number) {
return createAxios({
url: `/order/store/${id}/status`,
@@ -19,7 +18,7 @@ export async function updateOrderStatus(id: number, status: number) {
});
}
/** 批量订单状态流转(全量校验,任一订单不允许流转则整批中止) */
/** 批量订单状态流转 */
export async function batchUpdateOrderStatus(ids: number[], status: number) {
return createAxios<{ success: number }>({
url: '/order/store/batchStatus',
@@ -37,32 +36,7 @@ export async function updateOrderContainer(id: number, data: { box_num: number;
});
}
/** 已接单预览(按商品聚合,生成采购单前确认) */
export async function getOrderSummary() {
return createAxios<IOrderSummaryRow[]>({
url: '/order/store/summary',
method: 'get',
});
}
/** 修改订单明细(供应商/品名/规格/单位/单价/成本价/订货量/重量,后端重算订单总价) */
export async function updateOrderItem(itemId: number, data: IStoreOrderItemUpdate) {
return createAxios<IStoreOrderItem>({
url: `/order/store/item/${itemId}`,
method: 'put',
data,
});
}
/** 一键同步明细商品快照为最新商品档案信息(供应商/品名/规格/单位/单价/成本价) */
export async function syncOrderItem(itemId: number) {
return createAxios<IStoreOrderItem>({
url: `/order/store/item/${itemId}/sync`,
method: 'put',
});
}
/** 删除订单(软删除,仅已取消订单允许) */
/** 删除订单 */
export async function deleteStoreOrder(id: number) {
return createAxios({
url: `/order/store/${id}`,
+40 -32
View File
@@ -1,12 +1,27 @@
import createAxios from '@/utils/request';
import type {
ExportFormat,
IPurchaseCell,
IPurchaseDetail,
PurchaseExportType,
} from '@/domain/iPurchaseOrder.ts';
import { downloadBlob } from '@/api/common/download.ts';
/** C1 合并「已接单」门店订单生成采购单(order_ids 为空 = 全部已接单;生成后源订单转采购中) */
/** 订单商品参数修改 */
export interface PurchaseRowUpdateParams {
product_name: string;
supplier_id: number;
product_spec: string;
unit: string;
cost_price: number;
}
/** 修改门店订单明细参数 */
export interface PurchaseCellUpdateParams {
quantity: number;
price: number;
weight?: number;
}
/** 生成采购单 */
export async function generatePurchase(purchase_date: string, order_ids?: number[]) {
return createAxios<{ id: number; purchase_no: string }>({
url: '/purchase/order/generate',
@@ -15,7 +30,7 @@ export async function generatePurchase(purchase_date: string, order_ids?: number
});
}
/** 采购单详情(商品行 × 门店列矩阵) */
/** 采购单详情 */
export async function getPurchaseDetail(id: number) {
return createAxios<IPurchaseDetail>({
url: `/purchase/order/${id}`,
@@ -23,36 +38,29 @@ export async function getPurchaseDetail(id: number) {
});
}
/** C2/C3 导出采购单(blob 下载) */
export async function exportPurchase(id: number, type: PurchaseExportType, format: ExportFormat) {
return downloadBlob(
`/purchase/order/${id}/export`,
{ type, format },
`采购单_${id}.${format}`
);
}
/** C4 商品行修改参数(品名/供应商/包规/单位/成本/称重,一键同步该商品全部订货明细;sync_product 追加同步商品档案) */
export interface PurchaseRowUpdateParams {
product_name?: string;
supplier_id?: number;
product_spec?: string;
unit?: string;
cost_price?: number;
weight?: number;
/** true = 同时同步至商品档案(product 表) */
sync_product?: boolean;
}
/** C4 商品行修改 */
export async function updatePurchaseRow(
purchaseId: number,
productId: number,
data: PurchaseRowUpdateParams,
) {
/** 商品行修改 */
export async function updatePurchaseRow(purchaseId: number, productId: number, data: PurchaseRowUpdateParams) {
return createAxios<{ count: number }>({
url: `/purchase/order/${purchaseId}/row/${productId}`,
method: 'put',
data,
});
}
/** 单元格下钻:采购单中某门店某商品的全部订货明细 */
export async function getPurchaseCell(purchaseId: number, productId: number, storeId: number) {
return createAxios<IPurchaseCell>({
url: `/purchase/order/${purchaseId}/cell`,
method: 'get',
params: { product_id: productId, store_id: storeId },
});
}
/** 门店订单明细修改(订货量、重量、单价),自动重算价格 */
export async function updatePurchaseCellItem(itemId: number, data: PurchaseCellUpdateParams) {
return createAxios({
url: `/purchase/order/cell/${itemId}`,
method: 'put',
data,
});
}
+34
View File
@@ -50,6 +50,40 @@ export interface IPurchaseDetail {
items: IPurchaseDetailRow[];
}
/** 单元格下钻明细行(溯源订货单明细,附订单号/状态/可编辑标记) */
export interface IPurchaseCellItem {
id: number;
order_id: number;
order_no: string;
order_date: string;
/** 订货单状态:0待接单 1已接单 2采购中 3配送中 4已完成 9已取消 */
order_status: number;
product_name: string;
product_spec: string;
unit: string;
supplier_id: number;
supplier?: { id: number; name: string } | null;
/** 等级单价 */
price: string;
cost_price: string;
quantity: number;
weight: string;
/** 金额 = 数量×单价(整数金额返回 number) */
amount: number | string;
remark: string;
/** 首图 */
image?: string;
/** 是否可编辑/同步(采购单进行中且订货单未锁定) */
editable: boolean;
}
/** 单元格下钻数据(门店 + 商品 + 全部订货明细) */
export interface IPurchaseCell {
store: { id: number; name: string } | null;
product: { id: number; name: string } | null;
items: IPurchaseCellItem[];
}
export const PURCHASE_STATUS_MAP: Record<number, { text: string; color: string }> = {
0: { text: '进行中', color: 'processing' },
3: { text: '已完成', color: 'success' },
+10 -179
View File
@@ -6,13 +6,11 @@ import {
Drawer,
Empty,
Form,
Input,
InputNumber,
message,
Modal,
Popconfirm,
Radio,
Select,
Space,
Tag,
Typography,
@@ -26,33 +24,25 @@ import type {
XinTableProps,
} from '@/components/XinTable/typings.ts';
import type IStoreOrder from '@/domain/iStoreOrder.ts';
import type { IStoreOrderItem, IStoreOrderItemUpdate } from '@/domain/iStoreOrder.ts';
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
import {
getStoreOrder,
updateOrderContainer,
updateOrderStatus,
batchUpdateOrderStatus,
updateOrderItem,
syncOrderItem,
deleteStoreOrder,
} from '@/api/order/store.ts';
import { generatePurchase } from '@/api/purchase/order.ts';
import { getStoreOptions } from '@/api/customer/store.ts';
import { getSupplierOptions } from '@/api/customer/supplier.ts';
import type IStore from '@/domain/iStore.ts';
import type ISupplier from '@/domain/iSupplier.ts';
import AuthButton from '@/components/AuthButton';
import { DeleteOutlined, EditOutlined, SettingOutlined, SyncOutlined, UnorderedListOutlined } from '@ant-design/icons';
import TextArea from "antd/es/input/TextArea";
import { DeleteOutlined, SettingOutlined, UnorderedListOutlined } from '@ant-design/icons';
const { Title, Text } = Typography;
/** 允许修改周转框/托盘数量的订单状态:已接单、采购中、配送中 */
const CONTAINER_EDITABLE_STATUS = [1, 2, 3];
/** 允许修改/同步商品明细的订单状态:待接单、已接单、采购中、配送中(已完成/已取消锁定) */
const ITEM_EDITABLE_STATUS = [0, 1, 2, 3];
/**
* //
@@ -99,18 +89,9 @@ const StoreOrderPage: React.FC = () => {
const [generateLoading, setGenerateLoading] = useState(false);
const [generateForm] = Form.useForm<{ purchase_date: dayjs.Dayjs }>();
/** 供应商选项(明细编辑用) */
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
/** 商品明细编辑(弹窗表单,保存后后端重算订单总价) */
const [itemEditOpen, setItemEditOpen] = useState(false);
const [itemEditTarget, setItemEditTarget] = useState<IStoreOrderItem | null>(null);
const [itemSaving, setItemSaving] = useState(false);
const [itemForm] = Form.useForm<IStoreOrderItemUpdate>();
useEffect(() => {
getStoreOptions().then((res) => setStores(res.data.data ?? []));
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
}, []);
const openDetail = async (id: number) => {
@@ -184,47 +165,6 @@ const StoreOrderPage: React.FC = () => {
}
};
/** 打开明细编辑弹窗(商品快照 + 订货量/重量) */
const openItemEdit = (item: IStoreOrderItem) => {
setItemEditTarget(item);
itemForm.setFieldsValue({
supplier_id: item.supplier_id ?? 0,
product_name: item.product_name ?? '',
product_spec: item.product_spec ?? '',
unit: item.unit ?? '',
price: Number(item.price ?? 0),
cost_price: Number(item.cost_price ?? 0),
quantity: Number(item.quantity ?? 0),
weight: Number(item.weight ?? 0),
remark: item.remark ?? '',
});
setItemEditOpen(true);
};
/** 保存明细修改:后端重算单品金额与订单总价 */
const handleItemSave = async (values: IStoreOrderItemUpdate) => {
if (!itemEditTarget?.id || !detail?.id) return;
setItemSaving(true);
try {
await updateOrderItem(itemEditTarget.id, values);
message.success('明细已更新,订单总价已重算');
setItemEditOpen(false);
await openDetail(detail.id);
await tableRef.current?.reload();
} finally {
setItemSaving(false);
}
};
/** 一键同步:按商品ID拉取最新商品档案(供应商/品名/规格/单位/单价/成本价) */
const handleItemSync = async (item: IStoreOrderItem) => {
if (!item.id || !detail?.id) return;
await syncOrderItem(item.id);
message.success('已同步最新商品信息,订单总价已重算');
await openDetail(detail.id);
await tableRef.current?.reload();
};
/** 删除订单(软删除,仅已取消订单可删;删除后后台/小程序端均不可见) */
const handleDelete = async (id: number) => {
await deleteStoreOrder(id);
@@ -240,9 +180,6 @@ const StoreOrderPage: React.FC = () => {
+ watchTrayNum * Number(containerOrder?.tray_price ?? 0);
const previewTotal = Number(containerOrder?.product_amount ?? 0) + previewAdded;
/** 明细行编辑/同步按钮(仅未完成、未取消订单可见) */
const itemEditable = ITEM_EDITABLE_STATUS.includes(detail?.status ?? -1);
const columns: XinTableColumn<IStoreOrder>[] = [
{
title: '订单号',
@@ -622,8 +559,8 @@ const StoreOrderPage: React.FC = () => {
<div className="w-30 shrink-0 text-center"></div>
<div className="w-30 shrink-0 text-center"></div>
<div className="w-26 shrink-0 text-center"></div>
<div className="w-33 shrink-0 text-center"></div>
{itemEditable ? <div className="w-40 shrink-0 text-center"></div> : null}
<div className="w-33 shrink-0 text-center"></div>
<div className="w-33 shrink-0 text-center"></div>
</div>
{(detail.items ?? []).map((item) => (
<div key={item.id} className="flex items-center border-t border-gray-100 px-4 py-3">
@@ -645,9 +582,8 @@ const StoreOrderPage: React.FC = () => {
<div className="ml-3 min-w-0">
<div className="text-sm font-medium">{item.product_name}</div>
<div className="mt-0.5 text-xs text-gray-500">
{item.product_spec || '-'} / {item.unit || '-'} · ¥
{item.cost_price ?? '0.00'}
{Number(item.weight ?? 0) > 0 ? ` · 重量:${item.weight}` : ''}
{item.product_spec || '-'} {item.unit || '-'}
<div>¥{item.cost_price ?? '0.00'}</div>
</div>
{item.remark ? (
<div className="mt-0.5 truncate text-xs text-gray-500">{item.remark}</div>
@@ -660,42 +596,18 @@ const StoreOrderPage: React.FC = () => {
<div className="w-33 shrink-0 text-center">
<Text strong>¥{item.amount}</Text>
</div>
{itemEditable ? (
<div className="w-40 shrink-0 text-center">
<Space size={0}>
<AuthButton auth="order.store.update">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => openItemEdit(item)}
>
</Button>
</AuthButton>
<AuthButton auth="order.store.update">
<Popconfirm
title="确认同步最新商品信息?"
description="将按商品ID同步供应商、品名、规格、单位、成本价,并按门店等级价重算单价"
onConfirm={() => handleItemSync(item)}
>
<Button type="link" size="small" icon={<SyncOutlined />}>
</Button>
</Popconfirm>
</AuthButton>
</Space>
</div>
) : null}
<div className="w-33 shrink-0 text-center">
{item.weight ?? '-'}
</div>
</div>
))}
{(detail.items ?? []).length === 0 ? (
{(detail.items ?? []).length === 0 && (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无商品明细"
className="py-8!"
/>
) : null}
)}
</div>
{/* 附加信息 */}
@@ -828,87 +740,6 @@ const StoreOrderPage: React.FC = () => {
</div>
</Space>
</Modal>
{/* 编辑商品明细(保存后后端重算单品金额与订单总价) */}
<Modal
title="编辑商品明细"
open={itemEditOpen}
onCancel={() => setItemEditOpen(false)}
onOk={() => itemForm.submit()}
confirmLoading={itemSaving}
okText="保存"
width={640}
destroyOnHidden
>
<div className="py-2 text-gray-500">
{detail?.order_no}
</div>
<Form form={itemForm} layout="vertical" onFinish={handleItemSave}>
<div className="grid grid-cols-2 gap-x-4">
<Form.Item
label="供应商"
name="supplier_id"
rules={[{ required: true, message: '请选择供应商' }]}
>
<Select
showSearch={{
optionFilterProp: 'label'
}}
placeholder="请选择供应商"
options={suppliers.map((s) => ({ label: s.name, value: s.id }))}
/>
</Form.Item>
<Form.Item
label="品名"
name="product_name"
rules={[{ required: true, message: '请输入品名' }]}
>
<Input placeholder="请输入品名" maxLength={100} />
</Form.Item>
<Form.Item label="规格/包规" name="product_spec">
<Input placeholder="请输入规格/包规" maxLength={100} />
</Form.Item>
<Form.Item
label="计价单位"
name="unit"
rules={[{ required: true, message: '请输入计价单位' }]}
>
<Input placeholder="斤/件/箱等" maxLength={20} />
</Form.Item>
<Form.Item
label="单价(元)"
name="price"
rules={[{ required: true, message: '请输入单价' }]}
>
<InputNumber className="w-full" min={0} precision={2} placeholder="请输入单价" />
</Form.Item>
<Form.Item
label="成本价(元)"
name="cost_price"
rules={[{ required: true, message: '请输入成本价' }]}
>
<InputNumber className="w-full" min={0} precision={2} placeholder="请输入成本价" />
</Form.Item>
<Form.Item
label="订货量"
name="quantity"
rules={[{ required: true, message: '请输入订货量' }]}
>
<InputNumber className="w-full" min={1} precision={0} placeholder="请输入订货量" />
</Form.Item>
<Form.Item
label="重量"
name="weight"
rules={[{ required: true, message: '请输入重量' }]}
>
<InputNumber className="w-full" min={0} precision={3} placeholder="请输入重量" />
</Form.Item>
<Form.Item label="备注" name="remark">
<TextArea className="w-full" placeholder="请输入备注" />
</Form.Item>
</div>
</Form>
</Modal>
</>
);
};
+271 -48
View File
@@ -3,13 +3,17 @@ import {
Button,
Descriptions,
Drawer,
Empty,
Form,
Image,
Input,
InputNumber,
message,
Modal,
Popconfirm,
Select,
Space,
Spin,
Table,
Tag,
Typography,
@@ -24,12 +28,17 @@ import type {
} from '@/components/XinTable/typings.ts';
import type IPurchaseOrder from '@/domain/iPurchaseOrder.ts';
import type {
IPurchaseCell,
IPurchaseCellItem,
IPurchaseDetail,
IPurchaseDetailRow,
} from '@/domain/iPurchaseOrder.ts';
import { PURCHASE_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
import {
getPurchaseDetail,
getPurchaseCell,
getPurchaseDetail, type PurchaseCellUpdateParams, type PurchaseRowUpdateParams,
updatePurchaseCellItem,
updatePurchaseRow,
} from '@/api/purchase/order.ts';
import { Update } from '@/api/common/table.ts';
@@ -39,16 +48,7 @@ import AuthButton from '@/components/AuthButton';
const { Title, Text } = Typography;
/** 行修改表单:品名/供应商/包规/单位/成本 */
interface RowEditForm {
product_name: string;
supplier_id?: number;
product_spec: string;
unit: string;
cost_price: number;
}
/** 单价 = 成本 / 包规数值(包规解析不出正数时按 1 处理,与后端同口径) */
/** 参考单价 = 成本 / 包规数值(包规解析不出正数时按 1 处理,与后端同口径;仅展示参考,不参与金额计算) */
const calcUnitCost = (cost: number, spec: string): number => {
const pack = parseFloat(spec);
return Number.isFinite(pack) && pack > 0 ? cost / pack : cost;
@@ -70,14 +70,32 @@ const PurchaseOrderPage: React.FC = () => {
// 行修改弹窗
const [editingRow, setEditingRow] = useState<IPurchaseDetailRow | null>(null);
const [rowSaving, setRowSaving] = useState(false);
const [syncTarget, setSyncTarget] = useState<'purchase' | 'product'>('purchase');
const [editForm] = Form.useForm<RowEditForm>();
const [editForm] = Form.useForm<PurchaseRowUpdateParams>();
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
// 单元格下钻弹窗(门店 × 商品订货明细)
const [cellOpen, setCellOpen] = useState(false);
const [cellLoading, setCellLoading] = useState(false);
const [cellData, setCellData] = useState<IPurchaseCell | null>(null);
const [cellQuery, setCellQuery] = useState<{ productId: number; storeId: number } | null>(null);
// 单元格明细编辑(数量/称重)
const [cellItemOpen, setCellItemOpen] = useState(false);
const [cellItemTarget, setCellItemTarget] = useState<IPurchaseCellItem | null>(null);
const [cellItemSaving, setCellItemSaving] = useState(false);
const [cellItemForm] = Form.useForm<PurchaseCellUpdateParams>();
useEffect(() => {
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
}, []);
// 单元格下钻弹窗打开时加载明细
useEffect(() => {
if (cellOpen && cellQuery) {
loadCell();
}
}, [cellOpen, cellQuery]);
const loadDetail = async (id: number) => {
setDetailLoading(true);
try {
@@ -93,6 +111,63 @@ const PurchaseOrderPage: React.FC = () => {
await loadDetail(id);
};
/** 打开单元格下钻:门店 + 商品 → 该采购单下全部订货明细 */
const openCell = (row: IPurchaseDetailRow, store: { id: number; name: string }) => {
setCellQuery({ productId: row.product_id, storeId: store.id });
setCellData(null);
setCellOpen(true);
};
/** 加载单元格明细 */
const loadCell = async () => {
if (!detail || !cellQuery) {
return;
}
setCellLoading(true);
try {
const res = await getPurchaseCell(detail.purchase.id!, cellQuery.productId, cellQuery.storeId);
setCellData(res.data.data ?? null);
} finally {
setCellLoading(false);
}
};
/** 单元格明细修改/同步后:刷新弹窗、采购单详情与列表 */
const refreshAfterCellChange = async () => {
await loadCell();
if (detail) {
await loadDetail(detail.purchase.id!);
}
await tableRef.current?.reload();
};
const openCellItemEdit = (item: IPurchaseCellItem) => {
setCellItemTarget(item);
cellItemForm.setFieldsValue({
quantity: item.quantity,
price: Number(item.price ?? 0),
weight: Number(item.weight ?? 0),
});
setCellItemOpen(true);
};
/** 提交单元格明细修改:级联重算明细金额、订货单与采购单汇总 */
const handleCellItemSave = async (values: PurchaseCellUpdateParams) => {
if (!cellItemTarget?.id) {
return;
}
setCellItemSaving(true);
try {
await updatePurchaseCellItem(cellItemTarget.id, values);
message.success('明细已更新,订货单与采购单汇总已重算');
setCellItemOpen(false);
setCellItemTarget(null);
await refreshAfterCellChange();
} finally {
setCellItemSaving(false);
}
};
const openEdit = (row: IPurchaseDetailRow) => {
setEditingRow(row);
editForm.setFieldsValue({
@@ -105,7 +180,7 @@ const PurchaseOrderPage: React.FC = () => {
};
/** 提交行修改:同步该商品全部订货明细;syncTarget=product 时追加同步商品档案 */
const handleEditSave = async (values: RowEditForm) => {
const handleEditSave = async (values: PurchaseRowUpdateParams) => {
if (!detail || !editingRow) {
return;
}
@@ -114,13 +189,8 @@ const PurchaseOrderPage: React.FC = () => {
const res = await updatePurchaseRow(detail.purchase.id!, editingRow.product_id, {
...values,
supplier_id: values.supplier_id ?? 0,
sync_product: syncTarget === 'product',
});
message.success(
syncTarget === 'product'
? `已同步 ${res.data.data?.count ?? 0} 条订货明细,并更新商品档案`
: `已同步 ${res.data.data?.count ?? 0} 条订货明细`,
);
message.success(`已更新 ${res.data.data?.count ?? 0} 条订货明细`);
setEditingRow(null);
await loadDetail(detail.purchase.id!);
await tableRef.current?.reload();
@@ -156,9 +226,9 @@ const PurchaseOrderPage: React.FC = () => {
render: (_, row) => row.supplier?.name ?? '-',
},
{
title: '单价',
title: '参考单价',
key: 'unit_cost',
width: 90,
width: 100,
align: 'center',
render: (_, row) => `¥${calcUnitCost(Number(row.cost_price), row.product_spec ?? '').toFixed(2)}`,
},
@@ -174,7 +244,11 @@ const PurchaseOrderPage: React.FC = () => {
align: 'center' as const,
render: (_: unknown, row: IPurchaseDetailRow) => {
const quantity = row.cells[store.id];
return quantity !== undefined ? <Text>{quantity}</Text> : <Text type="secondary">-</Text>;
return quantity !== undefined ? (
<Typography.Link onClick={() => openCell(row, store)}>{quantity}</Typography.Link>
) : (
<Text type="secondary">-</Text>
);
},
}));
@@ -387,7 +461,7 @@ const PurchaseOrderPage: React.FC = () => {
)}
</Drawer>
{/* 行修改:品名/供应商/包规/单位/成本,同步采购单明细 / 追加同步商品档案 */}
{/* 行修改:品名/供应商/包规/单位/成本 */}
<Modal
title={editingRow ? `修改「${editingRow.product_name}` : '修改明细行'}
open={editingRow !== null}
@@ -397,31 +471,13 @@ const PurchaseOrderPage: React.FC = () => {
<Button key="cancel" onClick={() => setEditingRow(null)}>
</Button>,
<Button
key="product"
loading={rowSaving && syncTarget === 'product'}
onClick={() => {
setSyncTarget('product');
editForm.submit();
}}
>
</Button>,
<Button
key="purchase"
type="primary"
loading={rowSaving && syncTarget === 'purchase'}
onClick={() => {
setSyncTarget('purchase');
editForm.submit();
}}
>
<Button key="purchase" type="primary" loading={rowSaving} onClick={() => editForm.submit()}>
</Button>,
</Button>
]}
>
<div className="py-2 text-gray-500">
</div>
<Form form={editForm} layout="vertical" onFinish={handleEditSave}>
<Form.Item
@@ -431,7 +487,11 @@ const PurchaseOrderPage: React.FC = () => {
>
<Input maxLength={100} />
</Form.Item>
<Form.Item label="供应商" name="supplier_id">
<Form.Item
label="供应商"
name="supplier_id"
rules={[{ required: true, message: '请选择供应商' }]}
>
<Select
allowClear
showSearch={{ optionFilterProp: 'label' }}
@@ -439,10 +499,18 @@ const PurchaseOrderPage: React.FC = () => {
options={suppliers.map((s) => ({ value: s.id, label: s.name }))}
/>
</Form.Item>
<Form.Item label="包规" name="product_spec" rules={[{ max: 100 }]}>
<Form.Item
label="包规"
name="product_spec"
rules={[{ required: true, message: '请输入包规' }, { max: 100 }]}
>
<Input maxLength={100} placeholder="如:10斤/箱" />
</Form.Item>
<Form.Item label="单位" name="unit" rules={[{ max: 20 }]}>
<Form.Item
label="单位"
name="unit"
rules={[{ required: true, message: '请输入单位' },{ max: 20 }]}
>
<Input maxLength={20} placeholder="如:斤" />
</Form.Item>
<Form.Item
@@ -454,6 +522,161 @@ const PurchaseOrderPage: React.FC = () => {
</Form.Item>
</Form>
</Modal>
{/* 单元格下钻 */}
<Modal
title={cellData ? `${cellData.store?.name ?? ''} · ${cellData.product?.name ?? ''}` : '门店商品明细'}
open={cellOpen}
onCancel={() => setCellOpen(false)}
footer={null}
width={1000}
destroyOnHidden
styles={{ body: {paddingTop: 16} }}
>
<Spin spinning={cellLoading}>
{cellData && (
<>
{cellData.items.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该门店无此商品明细"
className="py-8!"
/>
) : (
<div className="overflow-hidden rounded border border-gray-200">
<div className="flex bg-gray-50 px-4 py-2 text-sm text-gray-500">
<div className="flex-1"></div>
<div className="w-30 shrink-0 text-center"></div>
<div className="w-26 shrink-0 text-center"></div>
<div className="w-33 shrink-0 text-center"></div>
<div className="w-33 shrink-0 text-center"></div>
<div className="w-40 shrink-0 text-center"></div>
</div>
{cellData.items.map((item) => (
<div key={item.id} className="flex items-center border-t border-gray-100 px-4 py-3">
<div className="flex min-w-0 flex-1 items-center">
<Image.PreviewGroup>
{item.image ? (
<Image
src={item.image}
width={48}
height={48}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
) : (
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded bg-gray-100 text-xs text-gray-400">
</div>
)}
</Image.PreviewGroup>
<div className="ml-3 min-w-0">
<div className="text-sm font-medium">
{item.product_name}
<Tag
className="ml-2!"
color={STORE_ORDER_STATUS_MAP[item.order_status]?.color}
>
{STORE_ORDER_STATUS_MAP[item.order_status]?.text}
</Tag>
</div>
<div className="mt-0.5 text-xs text-gray-500">
{item.order_no}
</div>
{item.remark ? (
<div className="mt-0.5 truncate text-xs text-gray-500">
{item.remark}
</div>
) : null}
</div>
</div>
<div className="w-30 shrink-0 text-center">¥{item.price}</div>
<div className="w-26 shrink-0 text-center">{item.quantity}</div>
<div className="w-33 shrink-0 text-center">
<Text strong>¥{item.amount}</Text>
</div>
<div className="w-33 shrink-0 text-center">
{item.weight ?? '-'}
</div>
<div className="w-40 shrink-0 text-center">
{item.editable ? (
<Space size={0}>
<AuthButton auth="purchase.order.update">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => openCellItemEdit(item)}
>
</Button>
</AuthButton>
</Space>
) : (
<Text type="secondary">-</Text>
)}
</div>
</div>
))}
</div>
)}
{cellData.items.length > 0 && (
<div className="mt-3! flex justify-end gap-6 text-sm">
<Text type="secondary">
<Text strong>{cellData.items.reduce((sum, item) => sum + item.quantity, 0)}</Text>
</Text>
<Text type="secondary">
<Text strong type="danger">
¥
{cellData.items
.reduce((sum, item) => sum + Number(item.amount), 0)
.toFixed(2)}
</Text>
</Text>
</div>
)}
</>
)}
</Spin>
</Modal>
{/* 单元格明细编辑 */}
<Modal
title={cellItemTarget ? `编辑「${cellItemTarget.product_name}` : '编辑明细'}
open={cellItemOpen}
onCancel={() => {
setCellItemOpen(false);
setCellItemTarget(null);
}}
onOk={() => cellItemForm.submit()}
confirmLoading={cellItemSaving}
okText="保存"
destroyOnHidden
>
<div className="py-2 text-gray-500">
{cellItemTarget?.order_no}
</div>
<Form form={cellItemForm} layout="vertical" onFinish={handleCellItemSave}>
<Form.Item
label="单价(元)"
name="price"
rules={[{ required: true, message: '请输入单价' }]}
>
<InputNumber className="w-full" min={0} precision={2} placeholder="请输入单价" />
</Form.Item>
<Form.Item
label="订货量"
name="quantity"
rules={[{ required: true, message: '请输入订货量' }]}
>
<InputNumber className="w-full" min={0} precision={0} placeholder="请输入订货量" />
</Form.Item>
<Form.Item label="称重" name="weight" rules={[{ required: true, message: '请输入称重' }]}>
<InputNumber className="w-full" min={0} precision={3} placeholder="请输入称重" />
</Form.Item>
</Form>
</Modal>
</>
);
};
-517
View File
@@ -1,517 +0,0 @@
****# 订货采购系统 · 开发计划
> 版本:V2.02026-07-23
> 依据:《项目需求规划书.md》V1.0
> 技术栈:Laravel 13 + XinAdminAnnoRoute / XinTable / XinForm+ 微信小程序(前端形态,独立项目)
>
> ## V2.0 变更要点(相对 V1.0
> 1. **PC 前端不做国际化** —— 业务页面文案全部硬编码中文,不建 `web/locales/**` 业务语言包,不使用 `useTranslation()`;菜单 `sys_rule.local` 留空、`name` 直接写中文(layout 在 `local` 为空时自动回退显示 `name`
> 2. **业务代码进 `app/` 目录** —— 遵循 Laravel 标准目录规范(`app/Models``app/Http/Controllers``app/Http/Requests``app/Services`),不再往 `modules/` 写业务代码。`AppServiceProvider::boot()` 已注册 `$annoRoute->register(app_path('Http/Controllers'))` 递归扫描,**新控制器零注册即生效,无需新建 ServiceProvider**
> 3. **小程序用户并入现有 `user` 表** —— `mini_user` 表已删除(迁移已改、库已重建);认证复用现有 `users` guardprovider 已指向 `App\Models\UserModel`),`config/auth.php` 零改动
> 4. 补齐后台 API 与前端 API 封装的逐接口明细
> 5. **导出方案定案并已就绪** —— Excel 用 `maatwebsite/excel` ^3.1、PDF 用 `barryvdh/laravel-dompdf` ^3.1**均已安装**);中文字体 SimHei 已注册进 DomPDF`resources/fonts/simhei.ttf``AppServiceProvider` 启动时幂等注册,已验证中文 PDF 生成);所有导出接口支持 `?format=xlsx|pdf`,详见 2.5
---
## 一、进度总览
| 阶段 | 内容 | 状态 |
|------|------|------|
| 一 | 数据库迁移(user 表扩展 + 16 张业务表,共 17 张) | ✅ 已完成(2026-07-23migrate:fresh 已执行) |
| 二 | 模型层(UserModel 扩展 + 17 个新模型,含关系/常量/工厂) | ✅ 已完成(2026-07-2318 模型 + 9 工厂 + 3 Service 骨架,104 项自检通过) |
| 三 | PC 后台 API`app/Http/Controllers` 下 5 个业务域 + FormRequest + Service | ✅ 已完成(2026-07-2314 控制器 + 9 FormRequest + 6 Service/Export44 路由与权限点验证通过;对账明细操作拆为独立 ReconItemController 以匹配 recon.item.item.update 权限点) |
| 四 | 小程序 API`app/Http/Controllers/Mini`,微信登录 + 门店端 + 供应商端) | ✅ 已完成(2026-07-237 控制器 + WechatService 完整实现 + 20 路由验证通过;#7 已批准:purchase_order_item 补 supplier_confirmed_at |
| 五 | PC 前端页面 + 菜单权限 Seeder(硬编码中文,无 i18n | ✅ 已完成(2026-07-2312 页面 + 11 API 封装 + 12 domain 类型 + ProcurementSeeder 62 节点已入库授权,tsc/vite build 通过) |
| 六 | PHPUnit 功能测试 | ✅ 已完成(2026-07-238 测试类 38 用例 225 断言全过;测试驱动修复:订单 summary DATE_FORMAT 方言适配、phpunit 切 SQLite :memory:、启用 pdo_sqlite 扩展) |
---
## 二、架构设计
### 2.1 目录结构(app/Laravel 标准规范)
```
app/
├── Models/ # 所有 Eloquent 模型(扁平目录,Laravel 惯例)
│ ├── UserModel.php # 现有 → 扩展:小程序字段 + 关系 + 常量
│ ├── CustomerLevelModel.php
│ ├── StoreModel.php
│ ├── SupplierModel.php
│ ├── NoticeModel.php
│ ├── ProductCategoryModel.php
│ ├── ProductModel.php
│ ├── ProductPriceModel.php
│ ├── StoreOrderModel.php
│ ├── StoreOrderItemModel.php
│ ├── PurchaseOrderModel.php
│ ├── PurchaseOrderItemModel.php
│ ├── PurchaseAllocationModel.php
│ ├── ReconciliationModel.php
│ ├── ReconciliationItemModel.php
│ ├── StatementModel.php
│ ├── StatementItemModel.php
│ └── SettlementModel.php
├── Http/
│ ├── Controllers/ # AnnoRoute 自动递归扫描 *Controller.php
│ │ ├── Customer/ # 客户等级 / 门店 / 供应商 / 小程序用户 / 通知
│ │ ├── Product/ # 商品分类 / 商品档案(含价格体系)
│ │ ├── Order/ # 门店订单
│ │ ├── Purchase/ # 采购单(生成 / 导出 / 发送 / 分摊)
│ │ ├── Recon/ # 财务对账 / 门店对账单 / 结算表
│ │ └── Mini/ # 小程序专用(authGuard: 'users'
│ └── Requests/ # FormRequest,按业务域分子目录
│ ├── Customer/ Product/ Purchase/ Recon/ Mini/
├── Services/ # 新目录:复杂业务逻辑(控制器只做参数校验与编排)
│ ├── BillNumberService.php # 单号生成:PO/SO/RC/ST/JS + yyyyMMdd + 4位序列
│ ├── PurchaseGenerateService.php # C1 订单汇总生成采购单
│ ├── PurchaseAllocateService.php # D3 采购金额按订货比例分摊
│ ├── ReconciliationBuildService.php # 对账明细构建(品类/供应商筛选)
│ ├── StatementGenerateService.php # 门店对账单生成(回款周期快照)
│ ├── WechatService.php # code2Session / 手机号解密(HTTP 调微信 API)
│ └── ExportService.php # 导出统一入口:按业务类型 + format 分发到 Exports 类 / PDF 模板
└── Exports/ # Laravel Excel 导出类(FromQuery + WithHeadings + WithMapping + WithStyles
├── PurchaseOrderExport.php # C2/C3 采购单导出(all 全品类 / category 蔬果分类)
├── StatementExport.php # 门店对账单导出
└── SettlementExport.php # D10 结算表导出
resources/
├── fonts/simhei.ttf # 中文字体(DomPDF 用,已入库)
└── views/exports/ # PDF 导出 Blade 模板(统一 font-family: SimHei
├── purchase.blade.php
├── statement.blade.php
└── settlement.blade.php
database/factories/ # 模型工厂(Laravel 默认位置)
```
### 2.2 认证体系(双端共用 Sanctum,零配置改动)
| 端 | Guard | 模型 | 说明 |
|----|-------|------|------|
| PC 后台 | `sys_users`(现有默认) | `SysUserModel` | AnnoRoute 不传 authGuard 即走默认;abilities 权限点校验 |
| 小程序 | `users`(现有) | `App\Models\UserModel` | Sanctum tokenabilities = `['mini']` |
- `config/auth.php` **无需改动**`users` guard / provider 已存在且指向 `App\Models\UserModel`
- 小程序控制器类级声明:`#[RequestAttribute('/mini', 'mini', authGuard: 'users')]`;登录等公开接口用 `authorize: false`
- `AuthGuardMiddleware``tokenable_type` 比对 guard 的 provider model,天然隔离双端:后台 token 无法访问 `/mini/*`,反之亦然
- Token 共用 `sys_access_token` 表(多态,`SysAccessToken` 已在 SystemUserServiceProvider 全局注册)
### 2.3 微信登录流程
```
小程序 wx.login() 拿 code
→ POST /mini/auth/login {code}authorize: false
→ WechatService::code2Sessionappid + secret 换 openid/session_key
→ UserModel::firstOrCreate(openid) → createToken('mini', ['mini']) → 返回 token + 用户信息
→ 更新 last_login_at
小程序 wx.getPhoneNumber 拿 phoneCode
→ POST /mini/auth/phone {phoneCode} → WechatService::getPhone 换手机号 → 绑定 user.phone
→ 按手机号匹配 store.phone / supplier.phone
命中门店 → type=1 + store_id;命中供应商 → type=2 + supplier_id;都不命中 → type=0 待绑定(后台人工处理)
```
微信配置:`config/services.php` 增加 `wechat.mini`,读取 `WECHAT_MINI_APPID` / `WECHAT_MINI_SECRET`(需业务方提供)。
> 实现说明:`WechatService` 基于 **EasyWeChat 6.x**`w7corp/easywechat``MiniApp\Application` 封装,code2Session / getPhoneNumber 走 SDK `Utils`access_token 由 SDK 自动管理);测试通过 `WechatService::setHttpClient()` 注入 Symfony `MockHttpClient` 拦截微信调用,`WechatService``AppServiceProvider` 注册为单例以保证注入生效。
### 2.4 通用约定
- **REST 命名与 XinTable 默认一致**`GET {api}`=query、`POST {api}`=create、`PUT {api}/{id}`=update、`DELETE {api}/{id}`=delete
- **单号生成**`BillNumberService::make('PO')``PO202607230001`(采购 PO / 订货 SO / 对账 RC / 对账单 ST / 结算 JS),按「前缀+当日」计数自增
- **快照原则**:下单/生成采购单/生成对账单时冗余品名、规格、单价;历史单据不受调价影响
- **列表查询**:控制器继承 `Modules\Common\Http\Controllers\BaseController`,声明 `$searchField`(支持 `=` `like` `date` `betweenDate` 等算子)/ `$quickSearchField`,用 `buildSearch()` 组装
- **数据隔离**:小程序端一切查询强制以当前用户 `store_id` / `supplier_id` 过滤,详情接口校验归属
- **金额字段 casts `decimal:2`,重量 `decimal:3`**;金额运算用 `bcmath`bcadd/bcmul),禁止浮点直算
- **状态字段一律类常量**(如 `StoreOrderModel::STATUS_PENDING`),控制器/前端 render 均引用常量映射,禁止魔术数字
- **前端文案硬编码中文**:页面 `title`、表格列名、按钮文字直接写字面量;错误提示走后端返回的 `msg`(后端校验消息也直接写中文,不用 `__()`
### 2.5 导出方案(Excel + PDF,已就绪)
**依赖(已安装并验证)**
| 用途 | 包 | 版本 | 状态 |
|------|----|----|------|
| Excelxlsx/csv | `maatwebsite/excel`PhpSpreadsheet | ^3.1 | ✅ 已安装 |
| PDF | `barryvdh/laravel-dompdf`(纯 PHP,无外部二进制) | ^3.1 | ✅ 已安装,config/dompdf.php 已发布 |
| PDF 中文 | SimHei 黑体 | — | ✅ `resources/fonts/simhei.ttf` 已入库;`AppServiceProvider::boot()` 幂等注册到 DomPDF(缓存写入 `storage/fonts/`,已 gitignore);模板统一 `font-family: SimHei` |
**统一入口**
```php
// 控制器只调一行,format 校验在 ExportService 内完成(xlsx|pdf,默认 xlsx
return app(ExportService::class)->download('purchase', $purchase, $format, type: 'all');
return app(ExportService::class)->download('statement', $statement, $format);
return app(ExportService::class)->download('settlement', $settlement, $format);
```
- **Excel 分支**`Excel::download(new PurchaseOrderExport($purchase, $type), $filename)`,导出类放 `app/Exports/`,实现 `FromCollection + WithHeadings + WithMapping + WithStyles`(表头加粗冻结首行)
- **PDF 分支**`Pdf::loadView('exports.purchase', compact(...))->setPaper('a4')->download($filename)`;模板放 `resources/views/exports/`,顶部公共样式 `body { font-family: SimHei }`,金额列右对齐、表格细边框
- **文件名规范**`{单号}_{业务名}.{ext}`,如 `PO202607230001_采购单.xlsx`;中文文件名由 Laravel 下载响应自动做 RFC 5987 编码(`Content-Disposition: attachment; filename*=UTF-8''...`),前端从响应头取或按单号兜底拼接
- **同步 vs 异步**:当前数据量用同步流式下载(不落盘);后续量大再切队列导出 + `storage/app/exports` 暂存 + 通知下载,ExportService 签名保持不变
- **PDF 体积提示**:DomPDF 全量嵌入字体,单文件约 10MB 量级,属正常现象;若业务方介意可后续评估换 Snappy(需 wkhtmltopdf 二进制)
---
## 三、阶段二:模型层(app/Models)
### 3.1 UserModel 扩展(改现有文件)
| 项 | 内容 |
|----|------|
| fillable | 增加 `openid, unionid, phone, avatar, type, store_id, supplier_id, status, last_login_at`**移除不存在的 `mobile`**(user 表无此列,系历史遗留) |
| casts | `last_login_at` => `datetime` |
| 常量 | `TYPE_PENDING=0, TYPE_STORE=1, TYPE_SUPPLIER=2``STATUS_NORMAL=1, STATUS_DISABLED=0` |
| 关系 | `store()` belongsTo StoreModel`supplier()` belongsTo SupplierModel`notices()` hasMany NoticeModel(外键 `user_id` |
### 3.2 新模型清单
| 模型 | 表 | 要点 |
|------|----|------|
| CustomerLevelModel | customer_level | hasMany stores |
| StoreModel | store | SoftDeletesbelongsTo levelhasMany orders / users`payment_cycle_days` 影响对账单 |
| SupplierModel | supplier | SoftDeleteshasMany products / purchaseItems / users |
| NoticeModel | notice | belongsTo usercasts `data` => array;常量 `TYPE_ORDER/TYPE_PRICE/TYPE_SYSTEM` |
| ProductCategoryModel | product_category | parent/children 自关联;提供静态 `getTreeData()`(分类树/级联选项复用) |
| ProductModel | product | SoftDeletesbelongsTo category / supplierhasMany prices;常量 `STATUS_ON=1, STATUS_OFF=0` |
| ProductPriceModel | product_price | belongsTo product / level;联合键 (product_id, level_id) |
| StoreOrderModel | store_order | belongsTo storehasMany items;常量 `STATUS_PENDING=0 待汇总 / STATUS_SUMMARIZED=1 已汇总 / STATUS_DELIVERING=2 配送中 / STATUS_COMPLETED=3 已完成 / STATUS_CANCELLED=9 已取消` |
| StoreOrderItemModel | store_order_item | belongsTo order / product |
| PurchaseOrderModel | purchase_order | belongsTo operatorSysUserModel,外键 operator_id);hasMany items;常量 `STATUS_PENDING=0 待发送 / STATUS_PART_SENT=1 部分发送 / STATUS_ALL_SENT=2 全部发送 / STATUS_COMPLETED=3 已完成` |
| PurchaseOrderItemModel | purchase_order_item | belongsTo purchase / product / supplierhasMany allocations |
| PurchaseAllocationModel | purchase_allocation | belongsTo purchaseItem / orderItem / store / product |
| ReconciliationModel | reconciliation | belongsTo operatorhasMany items;常量 `STATUS_DRAFT=0 草稿 / STATUS_WORKING=1 对账中 / STATUS_SETTLED=2 已结算` |
| ReconciliationItemModel | reconciliation_item | belongsTo recon / store / product / purchaseItem / orderItem |
| StatementModel | statement | belongsTo storehasMany items;常量 `STATUS_PENDING=0 待对账 / STATUS_RECONCILED=1 已对账 / STATUS_SETTLED=2 已结算` |
| StatementItemModel | statement_item | belongsTo statement / order / orderItem / product |
| SettlementModel | settlement | belongsTo recon / store / operator |
### 3.3 配套
- 工厂(`database/factories/`):Store、Product、ProductPrice、StoreOrder、StoreOrderItem、PurchaseOrder,供阶段六测试使用
- `BillNumberService``WechatService``ExportService` 骨架在本阶段一并建好(空实现 + 签名),`app/Exports/``resources/views/exports/` 的具体实现在阶段三随对应控制器落地
- 导出依赖已就绪(`maatwebsite/excel``barryvdh/laravel-dompdf` 均已安装,SimHei 字体已注册验证,见 2.5)
---
## 四、阶段三:PC 后台 APIapp/Http/ControllersAnnoRoute
> 所有控制器类级 `#[RequestAttribute(前缀, 权限前缀)]` 不传 authGuard(默认 `sys_users`);方法级 `authorize: 'xxx'` 生成权限点 `前缀.xxx`
### 4.1 客户域 `app/Http/Controllers/Customer/`
**CustomerLevelController** — `#[RequestAttribute('/customer/level', 'customer.level')]``$searchField = ['name' => 'like', 'status' => '=']`
| 路由 | 属性 | 权限点 | 说明 |
|------|------|--------|------|
| `GET /customer/level` | `authorize: 'query'` | customer.level.query | 分页列表,sort 排序 |
| `POST /customer/level` | `authorize: 'create'` | customer.level.create | CustomerLevelFormRequestname 必填唯一、sort、status、remark |
| `PUT /customer/level/{id}` | `authorize: 'update'` | customer.level.update | 编辑 |
| `DELETE /customer/level/{id}` | `authorize: 'delete'` | customer.level.delete | 被 store 引用时拒绝删除 |
| `GET /customer/level/options` | `authorize: 'query'` | customer.level.query | 下拉选项 `{id, name}`(门店表单用) |
**StoreController** — `/customer/store``customer.store``$searchField = ['name' => 'like', 'code' => 'like', 'level_id' => '=', 'status' => '=']``$quickSearchField = ['name', 'code', 'contact', 'phone']`
| 路由 | 权限点 | 说明 |
|------|--------|------|
| RESTquery/create/update/delete | customer.store.* | StoreFormRequestname、code(唯一)、level_id、contact、phone、address、payment_cycle_days(≥0)、statuswith('level') 回显等级名 |
| `GET /customer/store/options` | customer.store.query | 下拉选项(小程序用户绑定、订单筛选用) |
**SupplierController** — `/customer/supplier``customer.supplier`
| 路由 | 权限点 | 说明 |
|------|--------|------|
| REST | customer.supplier.* | SupplierFormRequestname、contact、phone、address、main_products、status |
| `GET /customer/supplier/options` | customer.supplier.query | 下拉选项 |
**MiniUserController** — `/customer/miniUser``customer.miniUser``$searchField = ['type' => '=', 'store_id' => '=', 'supplier_id' => '=', 'status' => '=']``$quickSearchField = ['nickname', 'phone']`
| 路由 | 属性 | 权限点 | 说明 |
|------|------|--------|------|
| `GET /customer/miniUser` | `authorize: 'query'` | customer.miniUser.query | 用户列表,with('store','supplier') |
| `PUT /customer/miniUser/{id}/bind` | `authorize: 'bind'` | customer.miniUser.bind | MiniUserBindRequest `{type, store_id?, supplier_id?}`type=1 时 store_id 必填,type=2 时 supplier_id 必填;一个门店可绑多个账号,一个账号只绑一个主体 |
| `PUT /customer/miniUser/{id}/status` | `authorize: 'update'` | customer.miniUser.update | 启用/停用(停用后 token 鉴权拦截:登录时检查 status) |
> 无 create/delete:用户由小程序登录自动生成,后台只做绑定与状态管理。
**NoticeController** — `/customer/notice``customer.notice`
| 路由 | 属性 | 权限点 | 说明 |
|------|------|--------|------|
| `GET /customer/notice` | `authorize: 'query'` | customer.notice.query | 通知列表 |
| `POST /customer/notice` | `authorize: 'create'` | customer.notice.create | NoticeFormRequestuser_id=0 为全员广播,否则指定用户;title/content/type |
| `DELETE /customer/notice/{id}` | `authorize: 'delete'` | customer.notice.delete | 删除 |
### 4.2 商品域 `app/Http/Controllers/Product/`
**ProductCategoryController** — `#[RequestAttribute('/product/category', 'product.category')]`
| 路由 | 属性 | 权限点 | 说明 |
|------|------|--------|------|
| `GET /product/category` | `authorize: 'query'` | product.category.query | 树形返回(后端组装 children,前端 XinTable 树表展示),sort 排序 |
| `GET /product/category/tree` | `authorize: 'query'` | product.category.query | 级联选项(商品表单 category 下拉、对账筛选用) |
| `POST /product/category` | `authorize: 'create'` | product.category.create | ProductCategoryFormRequestname、parent_id(防自引用成环)、sort、status |
| `PUT /product/category/{id}` | `authorize: 'update'` | product.category.update | 编辑 |
| `DELETE /product/category/{id}` | `authorize: 'delete'` | product.category.delete | 有子分类或挂载商品时拒绝 |
**ProductController** — `/product/goods``product.goods``$searchField = ['name' => 'like', 'category_id' => '=', 'supplier_id' => '=', 'status' => '=']``$quickSearchField = ['name', 'spec']`
| 路由 | 属性 | 权限点 | 说明 |
|------|------|--------|------|
| `GET /product/goods` | `authorize: 'query'` | product.goods.query | A1 商品列表,with('category','supplier','prices.level') |
| `POST /product/goods` | `authorize: 'create'` | product.goods.create | ProductFormRequestname、spec、grade、unit、category_id、supplier_id、image、sort、status、remark + `prices: [{level_id, price}]` 数组;事务内建商品 + 同步 product_price |
| `PUT /product/goods/{id}` | `authorize: 'update'` | product.goods.update | 编辑,prices 按 level_id upsert(删除已移除的等级行) |
| `DELETE /product/goods/{id}` | `authorize: 'delete'` | product.goods.delete | 软删除(连带 prices 一并删) |
| `GET /product/goods/priceMatrix` | `authorize: 'query'` | product.goods.query | A2 价格矩阵:行=商品(支持 category_id/keyword 过滤),列=全部启用等级,值=price(缺失为 null |
| `PUT /product/goods/batchPrice` | `authorize: 'batchPrice'` | product.goods.batchPrice | A2 批量调价:BatchPriceRequest `updates: [{product_id, level_id, price}]`;事务写入,**写完后给受影响门店生成 Noticetype=price** 提示价格变更 |
| `GET /product/goods/options` | `authorize: 'query'` | product.goods.query | 商品下拉 `{id, name, spec, unit}`(仅上架) |
### 4.3 订单域 `app/Http/Controllers/Order/`
**StoreOrderController** — `#[RequestAttribute('/order/store', 'order.store')]``$searchField = ['store_id' => '=', 'status' => '=', 'order_no' => 'like', 'order_date' => 'betweenDate']`
| 路由 | 属性 | 权限点 | 说明 |
|------|------|--------|------|
| `GET /order/store` | `authorize: 'query'` | order.store.query | 订单列表,with('store')order_date 倒序 |
| `GET /order/store/{id}` | `authorize: 'query'` | order.store.query | 详情:订单头 + items(含商品快照) |
| `PUT /order/store/{id}/status` | `authorize: 'update'` | order.store.update | 状态流转 `{status}`,按常量校验合法路径(待汇总→配送中→完成;待汇总可取消);流转时可选写 Notice 通知门店 |
| `GET /order/store/summary` | `authorize: 'query'` | order.store.query | 待汇总预览:聚合 status=PENDING 的订单明细按 product_id group,输出 `{product_id, product_name, spec, unit, total_quantity, store_count}`,供生成采购单前确认 |
> 订单只读 + 状态管理:创建/取消在小程序端(阶段四),后台不提供增删。
### 4.4 采购域 `app/Http/Controllers/Purchase/`
**PurchaseOrderController** — `#[RequestAttribute('/purchase/order', 'purchase.order')]``$searchField = ['status' => '=', 'purchase_no' => 'like', 'purchase_date' => 'betweenDate']`
| 路由 | 属性 | 权限点 | 说明 |
|------|------|--------|------|
| `GET /purchase/order` | `authorize: 'query'` | purchase.order.query | 列表,with('operator') |
| `GET /purchase/order/{id}` | `authorize: 'query'` | purchase.order.query | 详情:头 + itemswith supplier+ allocations |
| `PUT /purchase/order/{id}` | `authorize: 'update'` | purchase.order.update | C4 修改头信息(purchase_date、remark |
| `POST /purchase/order/generate` | `authorize: 'generate'` | purchase.order.generate | **C1 核心**`PurchaseGenerateService::generate($date, $operatorId)`,见下 |
| `GET /purchase/order/{id}/export` | `authorize: 'export'` | purchase.order.export | C2/C3 `?type=all\|category&format=xlsx\|pdf`:all=全品类按分类 sort 排序;category=仅蔬果分类。`ExportService::download('purchase', ...)` 输出 blob |
| `PUT /purchase/order/item/{id}` | `authorize: 'update'` | purchase.order.update | C4 明细修改:PurchaseItemUpdateRequestproduct_name/spec、weight、price、quantity);**amount 后端重算** = weight>0 ? weight×price : quantity×price;同步回写 purchase_order 汇总(Σ total_weight / actual_amount |
| `PUT /purchase/order/item/{id}/send` | `authorize: 'send'` | purchase.order.send | C5/C6`is_sent=1, sent_at=now`;联动采购单状态——全量明细已发送→ALL_SENT,否则 PART_SENT |
| `POST /purchase/order/{id}/allocate` | `authorize: 'allocate'` | purchase.order.allocate | **D3 核心**`PurchaseAllocateService::allocate($purchase)`,见下 |
| `GET /purchase/order/{id}/allocation` | `authorize: 'query'` | purchase.order.query | 分摊结果:按门店、按商品两个聚合维度返回 |
**PurchaseGenerateService::generate 逻辑**(事务):
1. 查询 `order_date = $date``status = STATUS_PENDING` 的所有订单(无则报错「当日无待汇总订单」)
2. 展开 items 按 `(product_id, supplier_id)` 聚合:Σquantity;快照 product_name / product_spec**估算单价取该商品最低等级价**product_price MIN),amount = quantity × 估算单价
3. 创建 purchase_order`purchase_no = BillNumberService::make('PO')`、purchase_date、estimate_amount = Σitems.amount、operator_id、status = STATUS_PENDING
4. 创建 items(按 分类 sort → 商品 sort 排序写入 sort 字段)
5. 批量回写源订单 `status = STATUS_SUMMARIZED`
6. **幂等防护**:步骤 1 的筛选条件天然排除已汇总订单;同一秒并发用 DB 事务 + 订单行锁(`lockForUpdate`)防重
**PurchaseAllocateService::allocate 逻辑**(事务):
1. 采购单须已录入实际金额(item.amount 已修改),否则拒绝
2. 对每个采购明细,溯源当日该商品的所有订货明细(`store_order_item.product_id = item.product_id` 且订单 `order_date = purchase_date` 且已汇总)
3. 按订货数量比例分摊实际金额:`allocation.amount = bcmul(item.amount, bcdiv(item_qty, total_qty, 6), 2)`;**尾差修正**——最后一个(或最大额)明细承担舍入差额,保证 `Σallocation.amount === item.amount`(金额守恒)
4. 同步写入 quantity / weight(按比例)与 store_id / order_item_id
5. 重复分摊:先删旧 allocation 再重建(幂等)
### 4.5 对账域 `app/Http/Controllers/Recon/`
**ReconciliationController** — `#[RequestAttribute('/recon/list', 'recon.list')]``$searchField = ['status' => '=', 'category_id' => '=', 'supplier_id' => '=', 'title' => 'like', 'period_start' => 'date']`
| 路由 | 属性 | 权限点 | 说明 |
|------|------|--------|------|
| `GET /recon/list` | `authorize: 'query'` | recon.list.query | D1 品类 / D2 供应商筛选条件落在表字段上 |
| `POST /recon/list` | `authorize: 'create'` | recon.list.create | ReconciliationFormRequesttitle、period_start、period_end、category_id?、supplier_id?recon_no = RC…,status=DRAFT |
| `PUT /recon/list/{id}` | `authorize: 'update'` | recon.list.update | 编辑(仅 DRAFT/WORKING |
| `DELETE /recon/list/{id}` | `authorize: 'delete'` | recon.list.delete | 仅 DRAFT 可删,连带 items |
| `POST /recon/list/{id}/build` | `authorize: 'build'` | recon.list.build | `ReconciliationBuildService::build($recon)`:按周期 + 品类 + 供应商拉取 purchase_order_item(含其 allocations),生成 reconciliation_item——published_amount=订货金额(溯源 order_item.amount)、actual_amount=分摊金额、diff=publishactual,冗余 product_name、store_id;汇总写回头的 publish/actual/diff_amountstatus→WORKING;可重复 build(先清后建) |
| `PUT /recon/item/{id}` | `authorize: 'item.update'` | recon.item.item.update | D4 修改订货量/称重/数量/金额/商品信息,**自动重算本行 diff + 汇总头** |
| `PUT /recon/item/{id}/toggle` | `authorize: 'item.update'` | recon.item.item.update | D8 `is_reconciled` 翻转 |
| `PUT /recon/item/{id}/remark` | `authorize: 'item.update'` | recon.item.item.update | D6 单品级门店备注 `store_remark` |
| `GET /recon/list/{id}/diff` | `authorize: 'query'` | recon.list.query | D5 差额对比视图:`{by_store: [{store_id, store_name, publish, actual, diff}], by_product: [...]}` + 合计行 |
| `POST /recon/list/{id}/settle` | `authorize: 'settle'` | recon.list.settle | D9:按门店聚合 items 生成 settlement 记录(settlement_no = JS…、total/actual/diff),status→SETTLED;回框统计表规则待业务确认,本次仅预留结构 |
**StatementController** — `/recon/statement``recon.statement``query`with storeperiod 筛选)/ `GET {id}` 详情(后台视角,只读)
**SettlementController** — `/recon/settlement``recon.settlement`
| 路由 | 属性 | 权限点 | 说明 |
|------|------|--------|------|
| `GET /recon/settlement` | `authorize: 'query'` | recon.settlement.query | 列表 with('store','recon') |
| `GET /recon/settlement/{id}` | `authorize: 'query'` | recon.settlement.query | 详情 |
| `GET /recon/settlement/{id}/download` | `authorize: 'download'` | recon.settlement.download | D10 `?format=xlsx\|pdf``ExportService::download('settlement', ...)` 返回 blob;成功后回写 `file_path` 存档标记 |
---
## 五、阶段四:小程序 APIapp/Http/Controllers/Mini
> 类级统一 `#[RequestAttribute('/mini', 'mini', authGuard: 'users')]``abilities` 前缀 `mini`,登录接口 `authorize: false`,其余方法 `authorize: true`(只校验持有 `mini` ability,不做细粒度权限点)。
> 门店端接口前置校验 `type = TYPE_STORE && store_id > 0`(抽公共 `ensureStoreBound()` 辅助方法);供应商端同理。
### 5.1 AuthController
| 路由 | 属性 | 说明 |
|------|------|------|
| `POST /mini/auth/login` | `authorize: false` | `{code}` → code2Session → firstOrCreate(openid)status 停用则拒绝)→ `createToken('mini', ['mini'])` → 返回 `{token, user: {id, nickname, avatar, type, store, supplier}}`,更新 last_login_at |
| `POST /mini/auth/phone` | `authorize: true` | `{phoneCode}` → 换手机号绑定 phone → 按 phone 自动匹配门店/供应商(见 2.3)→ 返回更新后的 user |
| `GET /mini/auth/info` | `authorize: true` | 当前用户 + 门店信息(含客户等级,全局价格体系依据)/ 供应商信息 |
### 5.2 门店端
| 路由 | 方法 | 说明 |
|------|------|------|
| `/mini/product/categories` | GET | 分类树(仅含上架商品的分类) |
| `/mini/product/list` | GET | `?category_id=&keyword=&page=`**价格 = product_price where level_id = 当前门店等级**;未绑等级门店返回错误提示 |
| `/mini/order` | POST | MiniOrderRequest `{items: [{product_id, quantity}]}`;事务:逐行取等级价快照(name/spec/unit/price),**服务端重算 amount 与 total,不接受前端金额**order_no = SO…,status = PENDING |
| `/mini/order` | GET | 历史订单:当前 store_id 强制过滤,`?status=&page=` |
| `/mini/order/{id}` | GET | 详情(校验归属) |
| `/mini/order/{id}/cancel` | PUT | 仅 STATUS_PENDING 可取消 |
| `/mini/order/summary` | GET | `?period=day\|week\|month`:按周期聚合金额/数量,返回分组列表 + 下钻明细接口参数 |
| `/mini/statement` | GET | 对账单列表(当前门店) |
| `/mini/statement/generate` | POST | `{period_start, period_end}``StatementGenerateService`——拉周期内订单明细,**快照当前 payment_cycle_dayssettlement_date = period_end + cycle 天**statement_no = ST… |
| `/mini/statement/{id}` | GET | 详情(含单品对账状态标识) |
| `/mini/statement/{id}/export` | GET | `?format=xlsx\|pdf``ExportService::download('statement', ...)`blob |
| `/mini/store/info` | GET | 门店详情(编辑回显;name/code/payment_cycle_days 只读) |
| `/mini/store/info` | PUT | 修改门店信息:仅 `{contact, phone, address}` 白名单更新;回款周期由后台维护 |
| `/mini/notice` | GET | 本人通知 + 全员广播(`user_id in [0, 当前id]`),分页 + `unread_count` |
| `/mini/notice/{id}/read` | PUT | 标记已读 + read_at |
### 5.3 供应商端
| 路由 | 方法 | 说明 |
|------|------|------|
| `/mini/supplier/purchases` | GET | 收到的采购单:含本供应商 `is_sent=1` 明细的采购单(去重) |
| `/mini/supplier/purchases/{id}` | GET | 明细:**仅本供应商的明细行** |
| `/mini/supplier/purchases/{id}/confirm` | PUT | 确认接单(确认态记录方式见「待确认 #7」) |
---
## 六、阶段五:PC 前端页面 + 菜单(硬编码中文,无 i18n)
### 6.1 页面清单(web/pages/,全部硬编码中文文案)
> XinTable 标准 CRUD 只需 `api` + `accessName` + `columns` + `rowKey` 四个 props,增删改查请求自动封装,按钮自动套 `<AuthButton>`
| 页面 | 组件形态 | 关键点 |
|------|----------|--------|
| `product/category.tsx` | XinTable 树表 | `api="/product/category"`columnsname / sort / status(Tag) / 操作;表单 parent_id 用 treeSelect 拉 `/product/category/tree` |
| `product/goods.tsx` | XinTable + ModalForm + 两个抽屉 | columnsname / spec / grade / unit / category(render 名) / supplier / status(Switch 样式 Tag) / sort;表单内嵌 `Form.List` 按等级动态价格行(等级选项拉 `/customer/level/options`);工具栏自定义按钮「价格矩阵」(抽屉:行商品 × 列等级可编辑 → 调 batchPrice |
| `customer/level.tsx` | XinTable | name / sort / status / remark |
| `customer/store.tsx` | XinTable | name / code / level(select 拉 options) / contact / phone / payment_cycle_days(InputNumber) / status |
| `customer/supplier.tsx` | XinTable | name / contact / phone / main_products / status |
| `customer/mini-user.tsx` | XinTable + 绑定 Modal | 列:nickname / phone / type(Tag) / store 或 supplier 名 / status / last_login_at;行内「绑定」按钮弹 Modal:type 单选 + 门店/供应商 select 联动 → `bindMiniUser()`;「停用/启用」→ `toggleMiniUserStatus()` |
| `customer/notice.tsx` | XinTable | title / type(Tag) / user0 显示「全员」)/ is_read / created_at;表单 user_id 留空=广播 |
| `order/store.tsx` | XinTable + 详情 Drawer | 列:order_no / store / order_date / total_amount / status(Tag 按常量映射);搜索栏 store 下拉 + 日期范围 + 状态;行内「详情」抽屉展示 items 表格 + 状态流转按钮(按当前状态显示可用操作) |
| `purchase/order.tsx` | XinTable + 生成 Modal + 详情 Drawer | 工具栏「生成采购单」按钮(日期选择 → `generatePurchase()`);详情抽屉 Tab:明细(行内编辑 weight/price → `updatePurchaseItem()`、发送按钮 → `sendPurchaseItem()`/ 分摊(「执行分摊」按钮 → `allocatePurchase()`,结果表);头部「导出」下拉:全品类 / 蔬果分类 × Excel / PDF 四个选项 → `exportPurchase(id, type, format)` |
| `recon/list.tsx` | XinTable + 对账工作台 Drawer | 列表 + 「生成明细」按钮(`buildRecon()`);工作台抽屉 Tab:明细编辑(D4 行内编辑 → `updateReconItem()`、D6 备注 → `remarkReconItem()`、D8 对账标记开关 → `toggleReconItem()`/ 差额对比(`getReconDiff()` 双维度表);「生成结算表」按钮(`settleRecon()` |
| `recon/statement.tsx` | XinTable | statement_no / store / period / total_amount / settlement_date / status;详情抽屉只读 |
| `recon/settlement.tsx` | XinTable | settlement_no / store / total / actual / diff / status;行内「下载」下拉(Excel / PDF)→ `downloadSettlement(id, format)` |
### 6.2 前端 API 封装(web/api/,仅封装 XinTable 默认 REST 之外的自定义接口)
> XinTable 依据 `api` prop 自动完成列表/增/改/删四个标准请求,**标准 CRUD 无需手写封装**。以下只列自定义动作:
| 文件 | 函数 | 请求 |
|------|------|------|
| `api/customer/level.ts` | `getLevelOptions()` | GET `/customer/level/options` |
| `api/customer/store.ts` | `getStoreOptions()` | GET `/customer/store/options` |
| `api/customer/supplier.ts` | `getSupplierOptions()` | GET `/customer/supplier/options` |
| `api/customer/miniUser.ts` | `bindMiniUser(id, {type, store_id?, supplier_id?})` / `toggleMiniUserStatus(id, status)` | PUT `/customer/miniUser/{id}/bind``/status` |
| `api/product/category.ts` | `getCategoryTree()` | GET `/product/category/tree` |
| `api/product/goods.ts` | `getPriceMatrix(params)` / `batchPrice({updates})` / `getProductOptions()` | GET `/product/goods/priceMatrix`、PUT `/product/goods/batchPrice`、GET `/product/goods/options` |
| `api/order/store.ts` | `getStoreOrder(id)` / `updateOrderStatus(id, status)` / `getOrderSummary(params)` | GET `/order/store/{id}`、PUT `/order/store/{id}/status`、GET `/order/store/summary` |
| `api/purchase/order.ts` | `generatePurchase({purchase_date})` / `exportPurchase(id, type, format)` / `updatePurchaseItem(id, data)` / `sendPurchaseItem(id)` / `allocatePurchase(id)` / `getAllocation(id)` | POST `/purchase/order/generate`、GET `/purchase/order/{id}/export?type=&format=xlsx\|pdf`blob)、PUT `/purchase/order/item/{id}`、PUT `/purchase/order/item/{id}/send`、POST `/purchase/order/{id}/allocate`、GET `/purchase/order/{id}/allocation` |
| `api/recon/list.ts` | `buildRecon(id)` / `updateReconItem(id, data)` / `toggleReconItem(id)` / `remarkReconItem(id, remark)` / `getReconDiff(id)` / `settleRecon(id)` | POST `/recon/list/{id}/build`、PUT `/recon/item/{id}``/toggle``/remark`、GET `/recon/list/{id}/diff`、POST `/recon/list/{id}/settle` |
| `api/recon/settlement.ts` | `downloadSettlement(id, format)` | GET `/recon/settlement/{id}/download?format=xlsx\|pdf`blob |
| `api/common/download.ts` | `downloadBlob(url, params, fallbackName)` | 公共下载工具:封装 blob 请求 + 触发保存(见下载约定),各导出函数复用它 |
**下载约定**`api/common/download.ts` 统一实现——`createAxios({ url, method: 'get', params, responseType: 'blob' })`;**blob 错误兜底**(响应是 JSON 错误而非文件时,`blob.text()` 解析出 `msg` 走 antd message 提示);成功后 `URL.createObjectURL` + `<a download>` 触发保存,文件名优先解析响应头 `Content-Disposition``filename*=UTF-8''` RFC 5987 解码),兜底用调用方传入的 `fallbackName`(单号拼接)。
### 6.3 Domain 类型(web/domain/
`iCustomerLevel.ts``iStore.ts``iSupplier.ts``iMiniUser.ts``iNotice.ts``iProduct.ts`(含 `prices: {level_id, price}[]`)、`iProductCategory.ts``iStoreOrder.ts`(含 items)、`iPurchaseOrder.ts`(含 items / allocations)、`iReconciliation.ts`(含 items / diff 视图类型)、`iStatement.ts``iSettlement.ts` —— 与后端返回结构一一对应,状态字段导出 `const STATUS_MAP` 常量供 render 使用。
### 6.4 菜单权限 Seederdatabase/seeders/ProcurementSeeder.php
沿用 `SysUserSeeder` 的嵌套创建结构(父 menu → 子 route → 孙 rule)。**`local` 字段一律留空,`name` 直接写中文**(layout 自动回退显示 name):
```
商品中心(menu, icon: ShoppingOutlined
├── 分类管理(route, key: product.category, path: /product/category
│ └── rule: query / create / update / delete
└── 商品列表(route, key: product.goods, path: /product/goods
└── rule: query / create / update / delete / batchPrice
客户管理(menu, icon: ShopOutlined
├── 门店管理(customer.store → /customer/store: query / create / update / delete
├── 客户等级(customer.level → /customer/level: query / create / update / delete
├── 供应商(customer.supplier → /customer/supplier: query / create / update / delete
├── 小程序用户(customer.miniUser → /customer/mini-user: query / update / bind
└── 通知管理(customer.notice → /customer/notice: query / create / delete
订货管理(menu
└── 门店订单(order.store → /order/store: query / update
采购管理(menu
└── 采购单(purchase.order → /purchase/order: query / update / generate / export / send / allocate
对账管理(menu
├── 财务对账(recon.list → /recon/list: query / create / update / delete / build / item.update / settle
├── 门店对账单(recon.statement → /recon/statement: query
└── 结算表(recon.settlement → /recon/settlement: query / download
```
执行:`php artisan db:seed --class=ProcurementSeeder`(种子内对 admin 角色自动授权)。
---
## 七、阶段六:测试(PHPUnit Feature Teststests/Feature/
| 测试 | 覆盖点 |
|------|--------|
| ProductPriceTest | 等级价格匹配、批量调价事务、调价通知生成 |
| StoreOrderTest | 下单快照等级价、服务端重算总价(前端传金额被忽略)、取消限制、门店数据隔离 |
| PurchaseGenerateTest | 多门店订单聚合正确性、订单状态回写、无订单/重复生成防护 |
| AllocationTest | **金额守恒**(Σallocation.amount === item.actual_amount 含尾差修正)、按订货比例正确性、幂等重跑 |
| ReconciliationTest | 明细构建(品类/供应商筛选)、D4 修改后差额与头汇总重算、D8 状态标记 |
| StatementTest | 回款周期快照 → settlement_date = period_end + cycle 计算、门店仅能生成/查看自身对账单 |
| MiniAuthTest | code2Session mock → 签发 token、手机号绑定自动匹配门店、停用账号拒绝登录、后台 token 访问 /mini 被拦截(跨端隔离) |
| ExportTest | 采购单导出 xlsx 返回正确 Content-Type 且蔬果分类过滤生效、PDF 返回 `application/pdf`、中文文件名响应头 RFC 5987 编码、format 参数非法时报错、无权限点拦截 |
用工厂造数;微信 HTTP 调用在 WechatService 中抽接口方法,测试里 mock/fake Http facade。
---
## 八、核心业务数据流
```
门店下单(store_order / _item,快照等级价,status=0待汇总)
└─► 采购员生成采购单(purchase_order / _item,按商品+供应商聚合,估算单价=最低等级价)
│ └─ 门店订单 status=1已汇总
├─► 发送供应商(item.is_sent=1 + sent_at,采购单状态 PART/ALL_SENT
├─► 实际采购录入(item.weight / price → amount 后端重算 → 头 actual_amount
└─► 金额分摊(purchase_allocation:按订货比例摊到门店/单品,尾差修正守恒)
└─► 财务对账(reconciliation / _item:公布 vs 实际 vs 差额,可修改/备注/标记)
└─► 结算表(settlement,导出存档)
门店侧:statement / _item 按周期自助生成(快照回款周期 → settlement_date),可导出
```
**金额守恒校验点**:采购单 Σitem.amount = actual_amount;分摊 Σallocation.amount = item.amount;对账 diff = publish actual。
---
## 九、待确认 / 需批准事项
| # | 事项 | 影响 |
|---|------|------|
| ~~1~~ | ✅ **已解决**`maatwebsite/excel` ^3.1 已安装(2026-07-23 | C2/C3/D10/对账单导出 |
| 2 | 微信小程序 AppID/Secret`WECHAT_MINI_APPID` / `WECHAT_MINI_SECRET` | 微信登录、手机号授权 |
| 3 | D7 特殊业务(周转柜/周转托盘/调货/售后/物流)及「回框统计表」规则 | 数据库需补充表,暂预留 |
| ~~4~~ | ✅ **已解决**`barryvdh/laravel-dompdf` ^3.1 已安装,SimHei 中文字体已注册并验证中文 PDF 生成;Excel/PDF 双格式全支持 | 对账单/结算表导出格式 |
| 5 | 采购单「微信快捷发送供应商」确认形态:后台导出文件人工转发 vs 小程序订阅消息推送 | C5 实现方式(当前计划:后台标记 + 供应商小程序拉取) |
| 6 | 新用户注册后绑定门店的策略:当前为「手机号自动匹配,不中则 type=0 待后台人工绑定」——是否认可 | 小程序登录流程 |
| 7 | **供应商确认接单的状态落库**`purchase_order_item` 暂无确认字段,需批准给该表补 `supplier_confirmed_at timestamp nullable`(或暂记 remark) | 供应商端确认接口 |
| 8 | SimHei 字体随仓库分发(`resources/fonts/simhei.ttf`,9.7MB)——授权上可替换为开源字体(如思源黑体 SourceHanSansSC-Regular.otf,需验证 DomPDF 对 OTF 的支持) | PDF 字体合规 |
---
## 十、实施顺序与工作量预估
| 顺序 | 内容 | 预估 |
|------|------|------|
| 1 | 阶段二 模型层(app/Models 18 个模型 + 工厂 + 两个 Service 骨架) | 0.5 天 |
| 2 | 阶段三 后台 APICustomer → Product → Order → Purchase → Recon,每域完成后顺手写对应 Feature Test | 4 天 |
| 3 | 阶段四 小程序 API(含 WechatService 与登录) | 2 天 |
| 4 | 阶段五 前端页面(12 页)+ api/domain 封装 + 菜单 Seeder | 3.5 天(去掉 i18n 后缩减 0.5 天) |
| 5 | 阶段六 测试补齐与联调 | 1.5 天 |
每完成一个后端域即联调对应前端页面。
-106
View File
@@ -1,106 +0,0 @@
项目需求规划书
项目名称:订货采购系统
前端形态:微信小程序(门店端/客户端)
文档版本:V1.0
编制日期:2026年7月
一、项目概述
1.1 项目背景
本项目旨在为生鲜配送企业打造一套覆盖“采购→订货→配送→对账”全链路的数字化管理系统。前端采用微信小程序形态,服务于门店端下单与客户端对账两大核心场景,后端配合PC管理后台完成商品、采购、对账等复杂管理操作。
1.2 项目目标
a.实现门店通过小程序在线下单,系统自动匹配客户等级价格
b.打通门店订单与采购单的自动汇总生成链路
c.建立供应商在线协同机制(采购单发送、对账确认)
d.实现财务对账数字化,支持按品类/供应商筛选、差额对比、结算表生成
e.支持门店在客户端自助生成对账单并导出
1.3 用户角色与使用场景
角色 使用端 核心场景
门店/客户 小程序客户端 在线下单、查看价格、查看对账单
采购员 PC后台 生成采购单、修改采购数据、发送供应商
财务/对账员 PC后台 对账管理、差额对比、结算表生成
供应商 微信小程序 接收采购单、确认订单
系统管理员 PC后台 商品管理、价格策略、权限配置
二、小程序端功能规划
2.1 门店端小程序(核心订货场景)
门店通过小程序浏览商品、下单,展示字段:品名、单价、订货量、重量、单品金额、总金额。
功能点 说明
商品列表浏览 按分类展示可订购商品,支持搜索/筛选
加入订货车 选择商品、填写订货量/重量,加入购物车式订货单
订货单确认 展示完整订货明细(品名/单价/订货量/重量/单品金额),确认后提交
历史订单查看 查看历史订货记录及状态
根据登录门店的客户等级,自动匹配并显示对应单价。价格数据由后台商品中心的价格体系驱动。
功能点 说明
客户等级识别 登录时获取门店等级,全局生效
价格自动匹配 商品列表和订货车中按等级展示单价
价格变更提示 后台调价后,门店端下次登录同步更新
门店可查看自身各时期的订货金额汇总。
功能点 说明
门店订货汇总 按日/周/月查看本店订货总金额
明细下钻 点击汇总金额可查看对应订单明细
门店可在客户端生成自己的对账单,支持导出。
功能点 说明
对账单查看 按时间段生成对账单,展示订货明细、金额
对账单导出 支持导出为Excel或PDF格式
对账状态标识 显示每个单品/订单的对账状态(已对账/未对账)
门店可自行修改回款周期(0天/1天/2天……无限制),影响对账单中的结算日期计算。
功能点 说明
回款周期配置 门店在个人设置中选择回款周期选项
结算日期自动计算 对账单根据回款周期生成应结算日期
2.2 小程序端基础功能
功能点 说明
微信授权登录 通过微信手机号授权登录,自动识别门店身份
权限控制 不同门店仅可见自身数据
消息通知 订单状态变更、价格调整等通知推送
三、PC后台管理端功能规划(概要)
3.1 商品中心(A1-A3
A1 商品档案管理:增删改查商品,字段包含品名、规格/包规、供应商、等级、价格体系(多等级客户价)
A2 价格策略:同一商品对不同客户等级显示不同单价,支持批量调价
A3 分类管理:支持多级分类(蔬菜/水果/其他),子分类排序
3.2 采购管理(C1-C6
C1 采购单生成:基于所有门店订单汇总生成,支持按样板格式导出
C2 全品类导出:按子分类排序,所有列可筛选/排序/恢复初始排序
C3 蔬果单独导出:按品类拆分开出
C4 采购单修改:采购环节可直接修改商品信息及门店订单数据
C5 采购单发送供应商:通过微信快捷发送(文件分享/小程序转发/链接)
C6 发送状态标记:标记每个单品是否已发送供应商
3.3 对账管理(D1-D10
D1 按品类对账:蔬菜/水果分开对账
D2 按供应商筛选对账:只查看特定供应商的单据
D3 采购金额自动分配:系统计算分摊到各门店/各单品
D4 对账数据修改:支持修改订货量、称重数据、数量、金额及商品信息
D5 差额对比:显示实际采购金额、公布金额、差额
D6 单品级门店备注:每个单品可针对每个门店单独添加备注
D7 特殊业务处理:周转柜、周转托盘、调货、售后、物流(需业务侧确定功能具体需求)
D8 对账状态标记:标记每个单品已对账/未对账
D9 结算表生成:对账结束后生成结算表、回框统计表
D10 下载存档:支持文件下载(Excel/PDF)
3.4 公共/基础(F1-F3
F1 登录/权限/用户管理:后台管理系统基础
F2 前后端接口联调:门店端、客户端接口对接
F3 页面:前端UI页面整体搭建
四、其它功能
1.采购流转:客户在小程序(客户平台)注册后,在小程序下单采购订单。
2.订单流转:供应商在微信小程序(供应商平台)注册后,由采购员生成具体的采购单,发送给供应商。
3.周转柜:待补充
4.周转托盘:待补充
5.调货:待补充
6.售后:待补充
7.物流:待补充
8.导出格式:待补充
五、开发阶段规划
阶段一:需求确认与原型设计(5天)
1、确认全部功能需求细节(特别是D7特殊业务处理需业务方明确规则)
2、完成小程序端原型图设计(门店端订货流程、客户端对账流程)
阶段二:小程序端开发(10天)
1、门店端小程序:商品浏览、订货车、下单、历史订单
2、客户端小程序:对账单查看、导出、回款周期设置
3、登录与权限体系
4、前后端接口联调
阶段三:PC后台开发(10天)
1、商品中心模块
2、采购管理模块(含发送供应商)
3、对账管理模块(含结算表生成、下载存档)
4、权限管理系统
5、导出功能,支持Excel格式导出