回筐逻辑修改

This commit is contained in:
liu
2026-08-14 23:47:49 +08:00
parent 4bdaf2a219
commit 7c3f6147ac
20 changed files with 325 additions and 342 deletions
File diff suppressed because one or more lines are too long
@@ -182,7 +182,7 @@ class OrderController extends BaseMiniController
$previewMap = []; $previewMap = [];
$flatItems = []; $flatItems = [];
foreach ($paginator->getCollection() as $order) { foreach ($paginator->getCollection() as $order) {
foreach ($order->items as $item) { foreach ($order->items->take(3) as $item) {
$flatItems[] = [ $flatItems[] = [
'order_id' => $order->id, 'order_id' => $order->id,
'product_name' => $item->product_name, 'product_name' => $item->product_name,
@@ -2,124 +2,41 @@
namespace App\Http\Controllers\Recon; namespace App\Http\Controllers\Recon;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Recon\ContainerReturnFormRequest;
use App\Models\ContainerReturnModel; use App\Models\ContainerReturnModel;
use App\Models\StoreModel;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute; use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute; use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController; use Modules\Common\Http\Controllers\BaseController;
use Throwable;
/** /**
* 回筐记录(压筐=生成账单自动写入只读;回筐=门店退回手动登记,扣减门店待回数量 * 回筐记录(周转筐/托盘跟账单走,生成账单自动写入完整快照,只读)
*
* 数量正数=压筐(附加金额),负数=回筐(抵扣金额)
*/ */
#[RequestAttribute('/recon/container-return', 'recon.containerReturn')] #[RequestAttribute('/recon/container-return', 'recon.containerReturn')]
class ContainerReturnController extends BaseController class ContainerReturnController extends BaseController
{ {
protected array $searchField = [ protected array $searchField = [
'store_id' => '=', 'store_id' => '=',
'type' => '=', 'bill_id' => '=',
'return_date' => 'betweenDate', 'created_at' => 'betweenDate',
]; ];
/** 回筐记录列表 */ /** 回筐记录列表 */
#[GetRoute(authorize: 'query')] #[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse public function query(Request $request): JsonResponse
{ {
$params = $request->all(); $params = $request->all();
$pageSize = $params['pageSize'] ?? 10; $pageSize = $params['pageSize'] ?? 10;
$data = $this->buildSearch($params, ContainerReturnModel::query()->with([ $data = $this->buildSearch($params, ContainerReturnModel::query()->with([
'store:id,name,pending_box_num,pending_tray_num', 'store:id,name',
'bill:id,bill_no', 'bill:id,bill_no,bill_date',
'operator:id,nickname', 'operator:id,nickname',
])) ]))
->orderBy('return_date', 'desc')
->orderBy('id', 'desc') ->orderBy('id', 'desc')
->paginate($pageSize) ->paginate($pageSize)
->toArray(); ->toArray();
return $this->success($data); return $this->success($data);
} }
/**
* 回筐登记:门店退回周转筐/托盘,扣减门店待回数量(超回拒绝)
* @throws Throwable
*/
#[PostRoute(authorize: 'create')]
public function create(ContainerReturnFormRequest $request): JsonResponse
{
$data = $request->validated();
$boxNum = (int) $data['box_num'];
$trayNum = (int) $data['tray_num'];
if ($boxNum === 0 && $trayNum === 0) {
throw new RepositoryException('周转筐与周转托盘数量不能同时为 0');
}
return DB::transaction(function () use ($data, $boxNum, $trayNum, $request) {
$store = StoreModel::query()->lockForUpdate()->find((int) $data['store_id']);
if (empty($store)) {
throw new RepositoryException('门店不存在');
}
if ($boxNum > (int) $store->pending_box_num) {
throw new RepositoryException(
'回筐数量超过该门店待回筐数量(当前待回 ' . (int) $store->pending_box_num . ' 个)'
);
}
if ($trayNum > (int) $store->pending_tray_num) {
throw new RepositoryException(
'回托盘数量超过该门店待回托盘数量(当前待回 ' . (int) $store->pending_tray_num . ' 个)'
);
}
$store->pending_box_num = (int) $store->pending_box_num - $boxNum;
$store->pending_tray_num = (int) $store->pending_tray_num - $trayNum;
$store->save();
ContainerReturnModel::create([
'store_id' => $store->id,
'bill_id' => 0,
'type' => ContainerReturnModel::TYPE_RETURN,
'box_num' => $boxNum,
'tray_num' => $trayNum,
'return_date' => $data['return_date'],
'operator_id' => (int) $request->user()->id,
'remark' => (string) ($data['remark'] ?? ''),
]);
return $this->success([], '回筐已登记');
});
}
/**
* 删除回筐记录(仅手动登记的回筐记录可删,删除后恢复门店待回数量;压筐记录随账单生成不允许删除)
* @throws Throwable
*/
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
public function delete(int $id): JsonResponse
{
$record = ContainerReturnModel::find($id);
if (empty($record)) {
throw new RepositoryException('回筐记录不存在');
}
if ($record->type !== ContainerReturnModel::TYPE_RETURN) {
throw new RepositoryException('压筐记录由账单生成,不允许删除');
}
return DB::transaction(function () use ($record) {
$store = StoreModel::query()->lockForUpdate()->find($record->store_id);
if ($store !== null) {
$store->pending_box_num = (int) $store->pending_box_num + (int) $record->box_num;
$store->pending_tray_num = (int) $store->pending_tray_num + (int) $record->tray_num;
$store->save();
}
$record->delete();
return $this->success([], '回筐记录已删除,门店待回数量已恢复');
});
}
} }
@@ -5,7 +5,8 @@ namespace App\Http\Requests\Purchase;
use Modules\Common\Http\Requests\BaseFormRequest; use Modules\Common\Http\Requests\BaseFormRequest;
/** /**
* 采购单生成账单 验证(按门店提交配送费/周转筐/托盘数量,金额由系统汇总不可修改 * 采购单生成账单 验证(按门店提交配送费/周转筐/托盘数量,金额由系统汇总不可修改
* 筐/托盘数量正数=压筐附加金额,负数=回筐抵扣金额)
*/ */
class PurchaseBillGenerateRequest extends BaseFormRequest class PurchaseBillGenerateRequest extends BaseFormRequest
{ {
@@ -17,8 +18,8 @@ class PurchaseBillGenerateRequest extends BaseFormRequest
'stores' => 'required|array|min:1', 'stores' => 'required|array|min:1',
'stores.*.store_id' => 'required|integer|distinct', 'stores.*.store_id' => 'required|integer|distinct',
'stores.*.delivery_fee' => 'required|numeric|min:0', 'stores.*.delivery_fee' => 'required|numeric|min:0',
'stores.*.box_num' => 'required|integer|min:0', 'stores.*.box_num' => 'required|integer',
'stores.*.tray_num' => 'required|integer|min:0', 'stores.*.tray_num' => 'required|integer',
]; ];
} }
@@ -35,11 +36,9 @@ class PurchaseBillGenerateRequest extends BaseFormRequest
'stores.*.delivery_fee.numeric' => '配送费格式错误', 'stores.*.delivery_fee.numeric' => '配送费格式错误',
'stores.*.delivery_fee.min' => '配送费不能小于 0', 'stores.*.delivery_fee.min' => '配送费不能小于 0',
'stores.*.box_num.required' => '周转筐数量不能为空', 'stores.*.box_num.required' => '周转筐数量不能为空',
'stores.*.box_num.integer' => '周转筐数量必须为整数', 'stores.*.box_num.integer' => '周转筐数量必须为整数(正数=压筐,负数=回筐)',
'stores.*.box_num.min' => '周转筐数量不能小于 0',
'stores.*.tray_num.required' => '周转托盘数量不能为空', 'stores.*.tray_num.required' => '周转托盘数量不能为空',
'stores.*.tray_num.integer' => '周转托盘数量必须为整数', 'stores.*.tray_num.integer' => '周转托盘数量必须为整数(正数=压筐,负数=回筐)',
'stores.*.tray_num.min' => '周转托盘数量不能小于 0',
]; ];
} }
} }
@@ -1,41 +0,0 @@
<?php
namespace App\Http\Requests\Recon;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 回筐登记 验证(门店退回周转筐/托盘,扣减门店待回数量)
*/
class ContainerReturnFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'store_id' => 'required|integer|exists:store,id',
'box_num' => 'required|integer|min:0',
'tray_num' => 'required|integer|min:0',
'return_date' => 'required|date_format:Y-m-d',
'remark' => 'nullable|string|max:255',
];
}
public function messages(): array
{
return [
'store_id.required' => '请选择门店',
'store_id.exists' => '门店不存在',
'box_num.required' => '周转筐数量不能为空',
'box_num.integer' => '周转筐数量必须为整数',
'box_num.min' => '周转筐数量不能小于 0',
'tray_num.required' => '周转托盘数量不能为空',
'tray_num.integer' => '周转托盘数量必须为整数',
'tray_num.min' => '周转托盘数量不能小于 0',
'return_date.required' => '请选择退回日期',
'return_date.date_format' => '退回日期格式为 Y-m-d',
'remark.max' => '备注超过最大长度',
];
}
}
+10 -18
View File
@@ -8,44 +8,36 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\SystemUser\Models\SysUserModel; use Modules\SystemUser\Models\SysUserModel;
/** /**
* 门店回筐记录模型(压筐=生成账单时自动写入并累加门店待回;回筐=门店退回手动登记并扣减待回 * 门店回筐记录模型(周转筐/托盘跟账单走,生成账单时写入完整快照
*
* 数量正数=压筐(附加金额),负数=回筐(抵扣金额);数量为 0 不写记录
*/ */
class ContainerReturnModel extends Model class ContainerReturnModel extends Model
{ {
use HasFactory; use HasFactory;
/** 类型:压筐(账单生成压出,系统写入只读) */
public const int TYPE_PRESS = 1;
/** 类型:回筐(门店退回,后台手动登记) */
public const int TYPE_RETURN = 2;
/** 类型中文名 */
public const array TYPE_NAMES = [
self::TYPE_PRESS => '压筐',
self::TYPE_RETURN => '回筐',
];
protected $table = 'container_return'; protected $table = 'container_return';
protected $primaryKey = 'id'; protected $primaryKey = 'id';
protected $fillable = [ protected $fillable = [
'store_id', 'store_id',
'bill_id', 'bill_id',
'type',
'box_num', 'box_num',
'tray_num', 'tray_num',
'return_date', 'box_price',
'tray_price',
'amount',
'operator_id', 'operator_id',
'remark',
]; ];
protected $casts = [ protected $casts = [
'store_id' => 'integer', 'store_id' => 'integer',
'bill_id' => 'integer', 'bill_id' => 'integer',
'type' => 'integer',
'box_num' => 'integer', 'box_num' => 'integer',
'tray_num' => 'integer', 'tray_num' => 'integer',
'return_date' => 'date:Y-m-d', 'box_price' => 'decimal:2',
'tray_price' => 'decimal:2',
'amount' => 'decimal:2',
'operator_id' => 'integer', 'operator_id' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s', 'created_at' => 'datetime:Y-m-d H:i:s',
]; ];
@@ -59,7 +51,7 @@ class ContainerReturnModel extends Model
} }
/** /**
* 关联账单(压筐记录) * 关联账单
*/ */
public function bill(): BelongsTo public function bill(): BelongsTo
{ {
-2
View File
@@ -38,8 +38,6 @@ class StoreModel extends Model
protected $casts = [ protected $casts = [
'level_id' => 'integer', 'level_id' => 'integer',
'payment_cycle_days' => 'integer', 'payment_cycle_days' => 'integer',
'pending_box_num' => 'integer',
'pending_tray_num' => 'integer',
'total_purchase_amount' => 'decimal:2', 'total_purchase_amount' => 'decimal:2',
'status' => 'integer', 'status' => 'integer',
]; ];
+9 -10
View File
@@ -18,8 +18,10 @@ use Throwable;
* 流程(事务内): * 流程(事务内):
* 1. 锁定采购单全部有效订单,按门店分组,校验提交门店完整覆盖 * 1. 锁定采购单全部有效订单,按门店分组,校验提交门店完整覆盖
* 2. 商品金额 = 门店订单商品金额汇总(快照,生成后不可修改) * 2. 商品金额 = 门店订单商品金额汇总(快照,生成后不可修改)
* 3. 附加金额 = 周转筐数量×筐单价 + 托盘数量×托盘单价(单价取站点配置快照 * 3. 附加金额 = 周转筐数量×筐单价 + 托盘数量×托盘单价(单价取站点配置快照
* 数量正数=压筐附加金额,负数=回筐抵扣金额)
* 4. 总金额 = 商品金额 + 配送费 + 附加金额;回写门店订单 bill_id 完成关联,订单状态置为已完成 * 4. 总金额 = 商品金额 + 配送费 + 附加金额;回写门店订单 bill_id 完成关联,订单状态置为已完成
* 5. 压回筐记录:筐/托盘数量非 0 时写入完整快照(数量/单价/金额均可为负)
*/ */
readonly class BillGenerateService readonly class BillGenerateService
{ {
@@ -122,22 +124,19 @@ readonly class BillGenerateService
->whereIn('order_id', $storeOrders->pluck('id')) ->whereIn('order_id', $storeOrders->pluck('id'))
->update(['bill_id' => $bill->id]); ->update(['bill_id' => $bill->id]);
// 压筐:累加门店待回筐/托盘(行锁防并发),并写入压筐记录 // 压回筐记录:数量为 0 不写记录;正数=压筐附加金额,负数=回筐抵扣金额
$store = StoreModel::query()->lockForUpdate()->find((int) $storeId); if ((int) $row['box_num'] !== 0 || (int) $row['tray_num'] !== 0) {
if ($store !== null) {
$store->pending_box_num = (int) $store->pending_box_num + (int) $row['box_num'];
$store->pending_tray_num = (int) $store->pending_tray_num + (int) $row['tray_num'];
$store->save();
}
ContainerReturnModel::create([ ContainerReturnModel::create([
'store_id' => (int) $storeId, 'store_id' => (int) $storeId,
'bill_id' => $bill->id, 'bill_id' => $bill->id,
'type' => ContainerReturnModel::TYPE_PRESS,
'box_num' => (int) $row['box_num'], 'box_num' => (int) $row['box_num'],
'tray_num' => (int) $row['tray_num'], 'tray_num' => (int) $row['tray_num'],
'return_date' => $billDate, 'box_price' => bcadd($boxPrice, '0', 2),
'tray_price' => bcadd($trayPrice, '0', 2),
'amount' => $addedAmount,
'operator_id' => $operatorId, 'operator_id' => $operatorId,
]); ]);
}
$bills[] = $bill; $bills[] = $bill;
} }
@@ -60,8 +60,6 @@ return new class extends Migration
$table->string('phone', 20)->default('')->comment('联系电话'); $table->string('phone', 20)->default('')->comment('联系电话');
$table->string('address', 255)->default('')->comment('门店地址'); $table->string('address', 255)->default('')->comment('门店地址');
$table->integer('payment_cycle_days')->default(0)->comment('回款周期(天)'); $table->integer('payment_cycle_days')->default(0)->comment('回款周期(天)');
$table->integer('pending_box_num')->default(0)->comment('待回筐数量(生成账单压筐累加,回筐登记扣减)');
$table->integer('pending_tray_num')->default(0)->comment('待回托盘数量');
$table->decimal('total_purchase_amount', 12, 2)->default(0)->comment('总采购金额(只统计商品金额,账单支付后累加)'); $table->decimal('total_purchase_amount', 12, 2)->default(0)->comment('总采购金额(只统计商品金额,账单支付后累加)');
$table->integer('status')->default(1)->comment('状态(1正常 0停用)'); $table->integer('status')->default(1)->comment('状态(1正常 0停用)');
$table->string('remark', 255)->nullable()->default('')->comment('备注'); $table->string('remark', 255)->nullable()->default('')->comment('备注');
@@ -8,7 +8,8 @@ return new class extends Migration
{ {
/** /**
* Run the migrations. * Run the migrations.
* 门店回筐台账:压筐(生成账单时自动写入)+ 回筐(门店退回,后台手动登记) * 门店回筐记录:周转筐/托盘跟账单走,生成账单时写入完整快照;
* 数量正数=压筐(附加金额),负数=回筐(抵扣金额),数量为 0 不写记录
*/ */
public function up(): void public function up(): void
{ {
@@ -16,17 +17,17 @@ return new class extends Migration
Schema::create('container_return', function (Blueprint $table) { Schema::create('container_return', function (Blueprint $table) {
$table->increments('id')->comment('记录ID'); $table->increments('id')->comment('记录ID');
$table->integer('store_id')->comment('门店ID'); $table->integer('store_id')->comment('门店ID');
$table->integer('bill_id')->default(0)->comment('关联账单ID0=手动回筐登记)'); $table->integer('bill_id')->comment('关联账单ID');
$table->integer('type')->comment('类型(1压筐-账单生成 2回筐-退回登记'); $table->integer('box_num')->default(0)->comment('压(回)筐数量(正数=压筐,负数=回筐');
$table->integer('box_num')->default(0)->comment('周转筐数量'); $table->integer('tray_num')->default(0)->comment('压(回)托盘数量(正数=压筐,负数=回筐)');
$table->integer('tray_num')->default(0)->comment('周转托盘数量'); $table->decimal('box_price', 10, 2)->default(0)->comment('周转筐单价(生成账单时快照)');
$table->date('return_date')->comment('记录日期(压筐=账单日期,回筐=退回日期'); $table->decimal('tray_price', 10, 2)->default(0)->comment('周转托盘单价(生成账单时快照');
$table->decimal('amount', 10, 2)->default(0)->comment('抵扣(附加)金额 = 筐×筐单价 + 托盘×托盘单价(正=压筐附加,负=回筐抵扣)');
$table->integer('operator_id')->default(0)->comment('操作人(后台系统用户ID'); $table->integer('operator_id')->default(0)->comment('操作人(后台系统用户ID');
$table->string('remark', 255)->default('')->comment('备注');
$table->timestamps(); $table->timestamps();
$table->index(['store_id', 'type'], 'container_return_store_type_index'); $table->index(['store_id'], 'container_return_store_index');
$table->index(['bill_id'], 'container_return_bill_index'); $table->index(['bill_id'], 'container_return_bill_index');
$table->comment('门店回筐记录表(压筐=生成账单压出,回筐=门店退回登记'); $table->comment('门店回筐记录表(随账单生成:正数=压筐附加金额,负数=回筐抵扣金额');
}); });
} }
} }
-2
View File
@@ -223,8 +223,6 @@ class PermissionSeeder extends Seeder
'path' => '/recon/container-return', 'path' => '/recon/container-return',
'children' => [ 'children' => [
['type' => 'rule', 'key' => 'recon.containerReturn.query', 'name' => '查询'], ['type' => 'rule', 'key' => 'recon.containerReturn.query', 'name' => '查询'],
['type' => 'rule', 'key' => 'recon.containerReturn.create', 'name' => '回筐登记'],
['type' => 'rule', 'key' => 'recon.containerReturn.delete', 'name' => '删除'],
], ],
], ],
[ [
+185
View File
@@ -0,0 +1,185 @@
<?php
namespace Tests\Feature;
use App\Models\BillModel;
use App\Models\ContainerReturnModel;
use App\Models\CustomerLevelModel;
use App\Models\ProductModel;
use App\Models\ProductPriceModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderModel;
use App\Models\UserModel;
use Illuminate\Support\Facades\DB;
use Modules\SystemTool\Services\SysSiteConfigService;
/**
* 压回筐记录:周转筐/托盘跟账单走(生成账单时写入完整快照,只读);
* 数量正数=压筐(附加金额),负数=回筐(抵扣金额),数量为 0 不写记录;
* 门店表不再记录待回数量
*/
class ContainerReturnTest extends ProcurementTestCase
{
/** 注入站点配置:筐单价 5.00、托盘单价 20.00 */
private function seedContainerConfig(): void
{
DB::table('sys_site_config_group')->insert([
'id' => 100,
'title' => '业务配置',
'key' => 'services',
'remark' => '',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('sys_site_config_items')->insert([
[
'group_id' => 100, 'key' => 'box_amount', 'title' => '周转筐金额', 'describe' => '',
'values' => '5.00', 'type' => 'InputNumber', 'options' => null, 'props' => null, 'sort' => 0,
'created_at' => now(), 'updated_at' => now(),
],
[
'group_id' => 100, 'key' => 'tray_amount', 'title' => '周转托盘金额', 'describe' => '',
'values' => '20.00', 'type' => 'InputNumber', 'options' => null, 'props' => null, 'sort' => 1,
'created_at' => now(), 'updated_at' => now(),
],
]);
SysSiteConfigService::refreshSiteConfig();
}
/**
* 完整业务链造账单(商品 5.00 × 4 = 20.00,配送费 0),按指定筐/托盘数量生成
*
* @return array{0: StoreModel, 1: BillModel}
*/
private function makeBillWithContainers(int $boxNum, int $trayNum): array
{
$level = CustomerLevelModel::factory()->create();
$store = StoreModel::factory()->create(['level_id' => $level->id]);
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
ProductPriceModel::factory()->create([
'product_id' => $product->id,
'level_id' => $level->id,
'price' => '5.00',
]);
$user = UserModel::factory()->forStore($store->id)->create();
$this->actingAsMiniUser($user);
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 4]]])
->assertJsonPath('success', true);
$order = StoreOrderModel::where('store_id', $store->id)->first();
$this->actingAsSysUser();
$this->putJson("/order/store/{$order->id}/status", ['status' => StoreOrderModel::STATUS_SUMMARIZED])
->assertJsonPath('success', true);
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
->assertJsonPath('success', true);
$purchase = PurchaseOrderModel::first();
$this->putJson("/purchase/order/{$purchase->id}/finish")->assertJsonPath('success', true);
$this->postJson("/purchase/order/{$purchase->id}/bill", [
'stores' => [[
'store_id' => $store->id,
'delivery_fee' => 0,
'box_num' => $boxNum,
'tray_num' => $trayNum,
]],
])->assertJsonPath('success', true);
return [$store, BillModel::where('store_id', $store->id)->first()];
}
/** 压筐(正数):附加金额增加账单总额,写入完整快照记录 */
public function test_press_containers_adds_amount_and_writes_record(): void
{
$this->seedContainerConfig();
[$store, $bill] = $this->makeBillWithContainers(3, 2);
// 附加 = 3×5 + 2×20 = 55;总额 = 20 + 55 = 75
$this->assertSame('55.00', (string) $bill->added_amount);
$this->assertSame('75.00', (string) $bill->total_amount);
$record = ContainerReturnModel::where('bill_id', $bill->id)->first();
$this->assertNotNull($record);
$this->assertSame($store->id, $record->store_id);
$this->assertSame(3, $record->box_num);
$this->assertSame(2, $record->tray_num);
$this->assertSame('5.00', (string) $record->box_price, '筐单价快照');
$this->assertSame('20.00', (string) $record->tray_price, '托盘单价快照');
$this->assertSame('55.00', (string) $record->amount);
}
/** 回筐(负数):抵扣账单金额,记录为负 */
public function test_return_containers_deducts_amount(): void
{
$this->seedContainerConfig();
[, $bill] = $this->makeBillWithContainers(-2, 0);
// 附加 = -2×5 + 0 = -10;总额 = 20 - 10 = 10
$this->assertSame('-10.00', (string) $bill->added_amount);
$this->assertSame('10.00', (string) $bill->total_amount);
$record = ContainerReturnModel::where('bill_id', $bill->id)->first();
$this->assertNotNull($record);
$this->assertSame(-2, $record->box_num);
$this->assertSame(0, $record->tray_num);
$this->assertSame('-10.00', (string) $record->amount);
}
/** 混合:筐回(负)+ 托盘压(正),金额按代数和计 */
public function test_mixed_sign_containers(): void
{
$this->seedContainerConfig();
[, $bill] = $this->makeBillWithContainers(-3, 1);
// 附加 = -3×5 + 1×20 = 5;总额 = 20 + 5 = 25
$this->assertSame('5.00', (string) $bill->added_amount);
$this->assertSame('25.00', (string) $bill->total_amount);
$record = ContainerReturnModel::where('bill_id', $bill->id)->first();
$this->assertNotNull($record);
$this->assertSame(-3, $record->box_num);
$this->assertSame(1, $record->tray_num);
$this->assertSame('5.00', (string) $record->amount);
}
/** 数量为 0:不写压回筐记录 */
public function test_zero_containers_writes_no_record(): void
{
$this->seedContainerConfig();
[, $bill] = $this->makeBillWithContainers(0, 0);
$this->assertSame('20.00', (string) $bill->total_amount);
$this->assertSame(0, ContainerReturnModel::where('bill_id', $bill->id)->count());
}
/** 筐/托盘数量非整数 → 拒绝 */
public function test_non_integer_containers_rejected(): void
{
$level = CustomerLevelModel::factory()->create();
$store = StoreModel::factory()->create(['level_id' => $level->id]);
$this->actingAsSysUser();
$purchase = PurchaseOrderModel::factory()->create(['status' => PurchaseOrderModel::STATUS_COMPLETED]);
$this->postJson("/purchase/order/{$purchase->id}/bill", [
'stores' => [[
'store_id' => $store->id,
'delivery_fee' => 0,
'box_num' => 1.5,
'tray_num' => 0,
]],
])->assertJsonPath('success', false);
}
/** 压回筐记录只读:手动登记/删除端点已下线 */
public function test_manual_create_and_delete_routes_removed(): void
{
$this->actingAsSysUser();
$this->postJson('/recon/container-return', [
'store_id' => 1,
'box_num' => 1,
'tray_num' => 0,
])->assertJsonPath('success', false);
$this->deleteJson('/recon/container-return/1')->assertJsonPath('success', false);
}
}
+12 -15
View File
@@ -1,26 +1,23 @@
/** 门店回筐记录(压筐=生成账单自动写入只读;回筐=门店退回手动登记 */ /** 门店回筐记录(随账单生成:数量正数=压筐附加金额,负数=回筐抵扣金额 */
export default interface IContainerReturn { export default interface IContainerReturn {
id?: number; id?: number;
store_id?: number; store_id?: number;
/** 关联账单ID0=手动回筐登记) */ /** 关联账单ID */
bill_id?: number; bill_id?: number;
/** 类型:1压筐 2回筐 */ /** 压(回)筐数量(正数=压筐,负数=回筐 */
type?: number;
box_num?: number; box_num?: number;
/** 压(回)托盘数量(正数=压筐,负数=回筐) */
tray_num?: number; tray_num?: number;
/** 记录日期(压筐=账单日期,回筐=退回日期 */ /** 周转筐单价(生成账单时快照 */
return_date?: string; box_price?: string;
/** 周转托盘单价(生成账单时快照) */
tray_price?: string;
/** 抵扣(附加)金额(正=压筐附加,负=回筐抵扣) */
amount?: string;
operator_id?: number; operator_id?: number;
remark?: string;
created_at?: string; created_at?: string;
/** 列表接口附带 */ /** 列表接口附带 */
store?: { id: number; name: string; pending_box_num?: number; pending_tray_num?: number } | null; store?: { id: number; name: string } | null;
bill?: { id: number; bill_no: string } | null; bill?: { id: number; bill_no: string; bill_date?: string } | null;
operator?: { id: number; nickname: string } | null; operator?: { id: number; nickname: string } | null;
} }
/** 回筐记录类型映射 */
export const CONTAINER_TYPE_MAP: Record<number, { text: string; color: string }> = {
1: { text: '压筐', color: 'processing' },
2: { text: '回筐', color: 'success' },
};
-4
View File
@@ -14,10 +14,6 @@ export default interface IStore {
address?: string; address?: string;
/** 回款周期(天) */ /** 回款周期(天) */
payment_cycle_days?: number; payment_cycle_days?: number;
/** 待回筐数量(生成账单压筐累加,回筐登记扣减) */
pending_box_num?: number;
/** 待回托盘数量 */
pending_tray_num?: number;
/** 总采购金额(只统计商品金额,账单支付后累加) */ /** 总采购金额(只统计商品金额,账单支付后累加) */
total_purchase_amount?: string; total_purchase_amount?: string;
status?: number; status?: number;
+2 -2
View File
@@ -52,8 +52,8 @@ const CustomerLevelPage: React.FC = () => {
return ( return (
<Image <Image
src={url} src={url}
width={60} width={30}
height={60} height={30}
style={{ objectFit: 'cover', borderRadius: 4 }} style={{ objectFit: 'cover', borderRadius: 4 }}
/> />
); );
-24
View File
@@ -86,30 +86,6 @@ const StorePage: React.FC = () => {
fieldProps: { min: 0, precision: 0 }, fieldProps: { min: 0, precision: 0 },
align: 'center', align: 'center',
}, },
{
title: '待回筐数量',
dataIndex: 'pending_box_num',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) => (
<Text strong type={Number(record.pending_box_num) > 0 ? 'warning' : undefined}>
{record.pending_box_num ?? 0}
</Text>
),
},
{
title: '待回托盘数量',
dataIndex: 'pending_tray_num',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) => (
<Text strong type={Number(record.pending_tray_num) > 0 ? 'warning' : undefined}>
{record.pending_tray_num ?? 0}
</Text>
),
},
{ {
title: '总采购金额', title: '总采购金额',
dataIndex: 'total_purchase_amount', dataIndex: 'total_purchase_amount',
+1
View File
@@ -96,6 +96,7 @@ const SupplierPage: React.FC = () => {
grid: true, grid: true,
colProps: { span: 12 }, colProps: { span: 12 },
layout: 'vertical', layout: 'vertical',
rowProps: {gutter: 24}
}, },
modalProps: { width: 720 }, modalProps: { width: 720 },
}; };
-15
View File
@@ -329,10 +329,6 @@ const StoreOrderPage: React.FC = () => {
{record.bill.pay_state_name ?? BILL_PAY_STATE_MAP[record.bill.pay_state ?? 0]?.text} {record.bill.pay_state_name ?? BILL_PAY_STATE_MAP[record.bill.pay_state ?? 0]?.text}
</Tag> </Tag>
</div> </div>
<div>
<Text type={'secondary'}></Text>
<span className={'text-[red]'}>{record.bill.total_amount} </span>
</div>
</Space> </Space>
) : '-' ) : '-'
}, },
@@ -524,17 +520,6 @@ const StoreOrderPage: React.FC = () => {
{detail.bill.pay_state_name ?? BILL_PAY_STATE_MAP[detail.bill.pay_state ?? 0]?.text} {detail.bill.pay_state_name ?? BILL_PAY_STATE_MAP[detail.bill.pay_state ?? 0]?.text}
</Tag> </Tag>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="商品金额">¥{detail.bill.product_amount ?? '-'}</Descriptions.Item>
<Descriptions.Item label="配送费">¥{detail.bill.delivery_fee ?? '-'}</Descriptions.Item>
<Descriptions.Item label="附加金额">
¥{detail.bill.added_amount ?? '0.00'}
<Text type={'secondary'} className={'ml-1 text-[12px]'}>
{detail.bill.box_num ?? 0}/{detail.bill.tray_num ?? 0}
</Text>
</Descriptions.Item>
<Descriptions.Item label="账单总金额">
<Text strong className={'text-[red]'}>¥{detail.bill.total_amount}</Text>
</Descriptions.Item>
<Descriptions.Item label="付款时间">{detail.bill.paid_at ?? '-'}</Descriptions.Item> <Descriptions.Item label="付款时间">{detail.bill.paid_at ?? '-'}</Descriptions.Item>
<Descriptions.Item label="付款备注">{detail.bill.pay_remark || '-'}</Descriptions.Item> <Descriptions.Item label="付款备注">{detail.bill.pay_remark || '-'}</Descriptions.Item>
</Descriptions> </Descriptions>
+5 -7
View File
@@ -1094,7 +1094,7 @@ const PurchaseOrderPage: React.FC = () => {
{billPrepare && ( {billPrepare && (
<> <>
<div className="py-2 text-gray-500"> <div className="py-2 text-gray-500">
/ /==
{billAllGenerated ? '该采购单已全部生成账单,仅可查看。' : '生成后采购单中的全部订单将关联到对应门店账单。'} {billAllGenerated ? '该采购单已全部生成账单,仅可查看。' : '生成后采购单中的全部订单将关联到对应门店账单。'}
</div> </div>
<Form form={billForm} layout="vertical" onFinish={handleBillSave}> <Form form={billForm} layout="vertical" onFinish={handleBillSave}>
@@ -1105,8 +1105,8 @@ const PurchaseOrderPage: React.FC = () => {
<div className="w-40 shrink-0"></div> <div className="w-40 shrink-0"></div>
<div className="w-28 shrink-0 text-center"></div> <div className="w-28 shrink-0 text-center"></div>
<div className="w-32 shrink-0 text-center"></div> <div className="w-32 shrink-0 text-center"></div>
<div className="w-32 shrink-0 text-center">¥{billPrepare.stores[0]?.box_price ?? '0.00'}</div> <div className="w-32 shrink-0 text-center">¥{billPrepare.stores[0]?.box_price ?? '0.00'}/</div>
<div className="w-32 shrink-0 text-center">¥{billPrepare.stores[0]?.tray_price ?? '0.00'}</div> <div className="w-32 shrink-0 text-center">¥{billPrepare.stores[0]?.tray_price ?? '0.00'}/</div>
<div className="w-28 shrink-0 text-center"></div> <div className="w-28 shrink-0 text-center"></div>
<div className="flex-1 text-center"></div> <div className="flex-1 text-center"></div>
</div> </div>
@@ -1148,10 +1148,9 @@ const PurchaseOrderPage: React.FC = () => {
> >
<InputNumber <InputNumber
className="w-full" className="w-full"
min={0}
precision={0} precision={0}
disabled={billed} disabled={billed}
placeholder="周转筐数量" placeholder="正压负回"
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
@@ -1161,10 +1160,9 @@ const PurchaseOrderPage: React.FC = () => {
> >
<InputNumber <InputNumber
className="w-full" className="w-full"
min={0}
precision={0} precision={0}
disabled={billed} disabled={billed}
placeholder="周转托盘数量" placeholder="正压负回"
/> />
</Form.Item> </Form.Item>
<div className="w-28 shrink-0 text-center"> <div className="w-28 shrink-0 text-center">
+69 -85
View File
@@ -1,18 +1,40 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { message, Tag, Typography } from 'antd'; import { Typography } from 'antd';
import dayjs from 'dayjs';
import XinTable from '@/components/XinTable'; import XinTable from '@/components/XinTable';
import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts'; import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts';
import type IContainerReturn from '@/domain/iContainerReturn.ts'; import type IContainerReturn from '@/domain/iContainerReturn.ts';
import { CONTAINER_TYPE_MAP } from '@/domain/iContainerReturn.ts';
import { getStoreOptions } from '@/api/customer/store.ts'; import { getStoreOptions } from '@/api/customer/store.ts';
import { Create } from '@/api/common/table.ts';
import type IStore from '@/domain/iStore.ts'; import type IStore from '@/domain/iStore.ts';
const { Title, Text } = Typography; const { Title, Text } = Typography;
/** 有符号数量展示:正数=压筐(橙色 +N),负数=回筐(绿色 N),0 置灰 */
const SignedNum: React.FC<{ value?: number }> = ({ value }) => {
const num = Number(value ?? 0);
if (num > 0) {
return <Text strong type="warning">+{num}</Text>;
}
if (num < 0) {
return <Text strong type="success">{num}</Text>;
}
return <Text type="secondary">0</Text>;
};
/** 有符号金额展示:正=压筐附加(橙),负=回筐抵扣(绿) */
const SignedAmount: React.FC<{ value?: string }> = ({ value }) => {
const num = Number(value ?? 0);
if (num > 0) {
return <Text strong type="warning">+¥{num.toFixed(2)}</Text>;
}
if (num < 0) {
return <Text strong type="success">-¥{Math.abs(num).toFixed(2)}</Text>;
}
return <Text type="secondary">¥0.00</Text>;
};
/** /**
* ==退 * /
* ==
*/ */
const ContainerReturnPage: React.FC = () => { const ContainerReturnPage: React.FC = () => {
const [stores, setStores] = useState<IStore[]>([]); const [stores, setStores] = useState<IStore[]>([]);
@@ -26,7 +48,7 @@ const ContainerReturnPage: React.FC = () => {
title: '门店', title: '门店',
dataIndex: 'store_id', dataIndex: 'store_id',
valueType: 'select', valueType: 'select',
rules: [{ required: true, message: '请选择门店' }], hideInForm: true,
fieldProps: { fieldProps: {
options: stores.map((s) => ({ label: s.name, value: s.id })), options: stores.map((s) => ({ label: s.name, value: s.id })),
showSearch: true, showSearch: true,
@@ -34,81 +56,57 @@ const ContainerReturnPage: React.FC = () => {
}, },
render: (_, record) => record.store?.name ?? `门店#${record.store_id}`, render: (_, record) => record.store?.name ?? `门店#${record.store_id}`,
}, },
{
title: '类型',
dataIndex: 'type',
valueType: 'select',
hideInForm: true,
align: 'center',
fieldProps: {
options: Object.entries(CONTAINER_TYPE_MAP).map(([value, item]) => ({
value: Number(value),
label: item.text,
})),
},
render: (_, record) => {
const item = CONTAINER_TYPE_MAP[record.type ?? 0];
return <Tag color={item?.color}>{item?.text ?? '-'}</Tag>;
},
},
{ {
title: '关联账单', title: '关联账单',
dataIndex: 'bill_id', dataIndex: 'bill_id',
hideInForm: true, hideInForm: true,
hideInSearch: true, hideInSearch: true,
align: 'center', render: (_, record) => record.bill ? (
render: (_, record) => <>
record.bill ? <Text copyable={{ text: record.bill.bill_no }}>{record.bill.bill_no}</Text> : '-', <Text copyable={{ text: record.bill.bill_no }}>{record.bill.bill_no}</Text>
<div className={'text-[12px] text-[#999]'}>{record.bill.bill_date}</div>
</>
) : '-',
}, },
{ {
title: '周转筐数量', title: '压(回)筐数量',
dataIndex: 'box_num', dataIndex: 'box_num',
valueType: 'digit', hideInForm: true,
hideInSearch: true, hideInSearch: true,
initialValue: 0,
rules: [{ required: true, message: '请输入周转筐数量' }],
fieldProps: { min: 0, precision: 0 },
align: 'center', align: 'center',
render: (_, record) => <Text strong>{record.box_num}</Text>, render: (_, record) => <SignedNum value={record.box_num} />,
}, },
{ {
title: '周转托盘数量', title: '压(回)托盘数量',
dataIndex: 'tray_num', dataIndex: 'tray_num',
valueType: 'digit',
hideInSearch: true,
initialValue: 0,
rules: [{ required: true, message: '请输入周转托盘数量' }],
fieldProps: { min: 0, precision: 0 },
align: 'center',
render: (_, record) => <Text strong>{record.tray_num}</Text>,
},
{
title: '当前待回(筐/托盘)',
key: 'pending',
hideInForm: true, hideInForm: true,
hideInSearch: true, hideInSearch: true,
align: 'center', align: 'center',
render: (_, record) => render: (_, record) => <SignedNum value={record.tray_num} />,
record.store
? `${record.store.pending_box_num ?? 0} / ${record.store.pending_tray_num ?? 0}`
: '-',
}, },
{ {
title: '退回日期', title: '筐单价',
dataIndex: 'return_date', dataIndex: 'box_price',
valueType: 'dateRange',
hideInTable: true,
hideInForm: true, hideInForm: true,
hideInSearch: true,
align: 'center', align: 'center',
render: (_, record) => `¥${Number(record.box_price ?? 0).toFixed(2)}`,
}, },
{ {
title: '退回日期', title: '托盘单价',
dataIndex: 'return_date', dataIndex: 'tray_price',
valueType: 'date', hideInForm: true,
hideInSearch: true, hideInSearch: true,
initialValue: dayjs(),
rules: [{ required: true, message: '请选择退回日期' }],
align: 'center', align: 'center',
render: (_, record) => `¥${Number(record.tray_price ?? 0).toFixed(2)}`,
},
{
title: '抵扣(附加)金额',
dataIndex: 'amount',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) => <SignedAmount value={record.amount} />,
}, },
{ {
title: '操作人', title: '操作人',
@@ -119,20 +117,20 @@ const ContainerReturnPage: React.FC = () => {
render: (_, record) => record.operator?.nickname ?? '-', render: (_, record) => record.operator?.nickname ?? '-',
}, },
{ {
title: '记时间', title: '记时间',
dataIndex: 'created_at',
valueType: 'dateRange',
hideInForm: true,
hideInTable: true,
align: 'center',
},
{
title: '记录时间',
dataIndex: 'created_at', dataIndex: 'created_at',
hideInForm: true, hideInForm: true,
hideInSearch: true, hideInSearch: true,
align: 'center', align: 'center',
}, },
{
title: '备注',
dataIndex: 'remark',
valueType: 'textarea',
hideInSearch: true,
fieldProps: { rows: 2 },
render: (v) => v || '-',
},
]; ];
const tableProps: XinTableProps<IContainerReturn> = { const tableProps: XinTableProps<IContainerReturn> = {
@@ -140,25 +138,11 @@ const ContainerReturnPage: React.FC = () => {
columns, columns,
rowKey: 'id', rowKey: 'id',
accessName: 'recon.containerReturn', accessName: 'recon.containerReturn',
// 压筐记录账单生成,仅回筐记录可删除;均不允许编辑 // 记录账单生成,只读:不新增、不编辑、不删除
addShow: false,
editShow: false, editShow: false,
deleteShow: (record) => record.type === 2, deleteShow: false,
// 自定义提交:DatePicker 值为 dayjs 对象,格式化为 Y-m-d 后再提交 formProps: false,
handleFinish: async (values) => {
await Create('/recon/container-return', {
...values,
return_date: dayjs(values.return_date).format('YYYY-MM-DD'),
});
message.success('回筐已登记');
return true;
},
formProps: {
grid: true,
colProps: { span: 12 },
rowProps: { gutter: 20 },
layout: 'vertical',
},
modalProps: { width: 640, title: '回筐登记' },
}; };
return ( return (
@@ -166,7 +150,7 @@ const ContainerReturnPage: React.FC = () => {
<div className="mb-5"> <div className="mb-5">
<Title level={3}></Title> <Title level={3}></Title>
<Text type="secondary"> <Text type="secondary">
退/ /== 0
</Text> </Text>
</div> </div>
<XinTable<IContainerReturn> {...tableProps} /> <XinTable<IContainerReturn> {...tableProps} />