318 lines
13 KiB
PHP
318 lines
13 KiB
PHP
<?php
|
||
|
||
namespace Tests\Feature;
|
||
|
||
use App\Exports\ContainerReturnExport;
|
||
use App\Models\BillModel;
|
||
use App\Models\ContainerReturnModel;
|
||
use App\Models\CustomerLevelModel;
|
||
use App\Models\ProductModel;
|
||
use App\Models\PurchaseOrderModel;
|
||
use App\Models\StoreModel;
|
||
use App\Models\StoreOrderModel;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Maatwebsite\Excel\Facades\Excel;
|
||
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, 'cost_price' => '5.00']);
|
||
|
||
$this->actingAsMiniStore($store);
|
||
$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);
|
||
}
|
||
|
||
/** 造一条压回筐记录(直接入库并指定记录时间,用于导出区间/合并断言) */
|
||
private function makeRecord(StoreModel $store, string $amount, string $createdAt): void
|
||
{
|
||
DB::table('container_return')->insert([
|
||
'store_id' => $store->id,
|
||
'bill_id' => 0,
|
||
'box_num' => 0,
|
||
'tray_num' => 0,
|
||
'box_price' => '5.00',
|
||
'tray_price' => '20.00',
|
||
'amount' => $amount,
|
||
'operator_id' => 0,
|
||
'created_at' => $createdAt,
|
||
'updated_at' => $createdAt,
|
||
]);
|
||
}
|
||
|
||
/** 汇总导出:行=日期(区间内逐日),列=门店,同日记录合并,无记录填 0,含行/列合计 */
|
||
public function test_export_matrix_merges_same_day_and_zero_fills(): void
|
||
{
|
||
$storeA = StoreModel::factory()->create();
|
||
$storeB = StoreModel::factory()->create();
|
||
|
||
// 08-01:A 两条同日记录合并(+55 压筐、-10 回筐 = 45),B 一条 -20
|
||
$this->makeRecord($storeA, '55.00', '2026-08-01 09:00:00');
|
||
$this->makeRecord($storeA, '-10.00', '2026-08-01 15:30:00');
|
||
$this->makeRecord($storeB, '-20.00', '2026-08-01 10:00:00');
|
||
// 08-02:仅 A +5,B 无记录填 0
|
||
$this->makeRecord($storeA, '5.00', '2026-08-02 08:00:00');
|
||
// 区间外记录不参与
|
||
$this->makeRecord($storeB, '30.00', '2026-08-04 08:00:00');
|
||
|
||
$this->actingAsSysUser();
|
||
Excel::fake();
|
||
$this->get('/recon/container-return/export?start_date=2026-08-01&end_date=2026-08-03')->assertOk();
|
||
|
||
Excel::assertDownloaded(
|
||
'回筐记录_2026-08-01_2026-08-03.xlsx',
|
||
static function (ContainerReturnExport $export) use ($storeA, $storeB): bool {
|
||
$rows = $export->collection()->values();
|
||
// 标题1 + 范围1 + 空行1 + 列头1 + 日期3 + 合计1 = 8 行
|
||
if ($rows->count() !== 8) {
|
||
return false;
|
||
}
|
||
// 列头:日期 + 门店(未选门店时取区间内有记录的门店,ID 升序)+ 合计
|
||
if ($rows[3] !== ['日期', $storeA->name, $storeB->name, '合计']) {
|
||
return false;
|
||
}
|
||
// 08-01:同日合并 55-10=45,B -20,行合计 25
|
||
if ($rows[4] !== ['2026-08-01', 45.0, -20.0, 25.0]) {
|
||
return false;
|
||
}
|
||
// 08-02:A 5、B 无记录填 0,行合计 5
|
||
if ($rows[5] !== ['2026-08-02', 5.0, 0.0, 5.0]) {
|
||
return false;
|
||
}
|
||
// 08-03:当日全部无记录,整行填 0
|
||
if ($rows[6] !== ['2026-08-03', 0.0, 0.0, 0.0]) {
|
||
return false;
|
||
}
|
||
// 合计行:A 列 50、B 列 -20、总计 30
|
||
return $rows[7] === ['合计', 50.0, -20.0, 30.0];
|
||
}
|
||
);
|
||
}
|
||
|
||
/** 指定门店导出:仅所选门店成列,未选门店记录忽略;所选门店无记录整列填 0 */
|
||
public function test_export_filters_by_selected_stores(): void
|
||
{
|
||
$storeA = StoreModel::factory()->create();
|
||
$storeB = StoreModel::factory()->create();
|
||
$storeC = StoreModel::factory()->create();
|
||
|
||
$this->makeRecord($storeA, '55.00', '2026-08-01 09:00:00');
|
||
$this->makeRecord($storeB, '-20.00', '2026-08-01 10:00:00');
|
||
// C 区间内无记录,但被选中 → 整列填 0
|
||
|
||
$this->actingAsSysUser();
|
||
Excel::fake();
|
||
$this->get('/recon/container-return/export?start_date=2026-08-01&end_date=2026-08-02&store_ids=' . $storeA->id . ',' . $storeC->id)
|
||
->assertOk();
|
||
|
||
Excel::assertDownloaded(
|
||
'回筐记录_2026-08-01_2026-08-02.xlsx',
|
||
static function (ContainerReturnExport $export) use ($storeA, $storeC): bool {
|
||
$rows = $export->collection()->values();
|
||
// 列头仅含所选门店(B 的记录被忽略)
|
||
if ($rows[3] !== ['日期', $storeA->name, $storeC->name, '合计']) {
|
||
return false;
|
||
}
|
||
// 08-01:A 55、C 填 0;08-02 全 0
|
||
if ($rows[4] !== ['2026-08-01', 55.0, 0.0, 55.0]) {
|
||
return false;
|
||
}
|
||
if ($rows[5] !== ['2026-08-02', 0.0, 0.0, 0.0]) {
|
||
return false;
|
||
}
|
||
|
||
return $rows[6] === ['合计', 55.0, 0.0, 55.0];
|
||
}
|
||
);
|
||
}
|
||
|
||
/** 参数校验:缺少日期 / 结束早于开始 / 区间超 366 天 → 拒绝 */
|
||
public function test_export_invalid_params_rejected(): void
|
||
{
|
||
$this->actingAsSysUser();
|
||
|
||
$this->get('/recon/container-return/export')
|
||
->assertJsonPath('success', false);
|
||
$this->get('/recon/container-return/export?start_date=2026-08-10&end_date=2026-08-01')
|
||
->assertJsonPath('success', false);
|
||
$this->get('/recon/container-return/export?start_date=2026-01-01&end_date=2027-06-30')
|
||
->assertJsonPath('success', false);
|
||
}
|
||
|
||
/** 区间内无记录且未选门店 → 拒绝 */
|
||
public function test_export_empty_range_rejected(): void
|
||
{
|
||
$this->actingAsSysUser();
|
||
|
||
$this->get('/recon/container-return/export?start_date=2026-08-01&end_date=2026-08-03')
|
||
->assertJsonPath('success', false);
|
||
}
|
||
|
||
/** 无 recon.containerReturn.export 权限点 → 拦截 */
|
||
public function test_export_requires_export_permission(): void
|
||
{
|
||
// 先建一个占位用户:每个测试方法内首个系统用户自增 id=1,
|
||
// SysAccessToken::can() 对 tokenable_id==1(超管)放行全部权限,会绕过 abilities 校验
|
||
$this->actingAsSysUser();
|
||
// 仅持有查询权限的用户
|
||
$this->actingAsSysUser(['recon.containerReturn.query']);
|
||
|
||
$response = $this->get('/recon/container-return/export?start_date=2026-08-01&end_date=2026-08-03');
|
||
$this->assertFalse($response->json('success'), '缺少权限点应被拦截');
|
||
}
|
||
}
|