first version
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\PurchaseAllocationModel;
|
||||
use App\Models\PurchaseOrderItemModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* D3 采购金额分摊:金额守恒(含尾差修正)、按订货比例、幂等重跑
|
||||
*/
|
||||
class AllocationTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 构造可分摊的采购单:各门店下单 → 生成采购单 → 录入实际金额
|
||||
*
|
||||
* @param array<int, string> $quantities 各门店订货量
|
||||
* @return array{0: PurchaseOrderModel, 1: PurchaseOrderItemModel}
|
||||
*/
|
||||
private function buildAllocatablePurchase(array $quantities, string $actualPrice = '10.00'): array
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => '10.00']);
|
||||
|
||||
foreach ($quantities as $qty) {
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]])
|
||||
->assertJsonPath('success', true);
|
||||
}
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$purchase = PurchaseOrderModel::first();
|
||||
$item = $purchase->items->first();
|
||||
|
||||
// 录入实际单价(weight=0 → amount = quantity × price)
|
||||
$this->putJson("/purchase/order/item/{$item->id}", [
|
||||
'price' => $actualPrice,
|
||||
'quantity' => $item->quantity,
|
||||
'weight' => 0,
|
||||
])->assertJsonPath('success', true);
|
||||
|
||||
return [$purchase->fresh(), $item->fresh()];
|
||||
}
|
||||
|
||||
/** 金额守恒:Σallocation.amount === item.amount,尾差由最后一行承担 */
|
||||
public function test_allocation_conserves_amount_with_tail_correction(): void
|
||||
{
|
||||
[$purchase, $item] = $this->buildAllocatablePurchase(['1', '1', '1']);
|
||||
|
||||
// 把实际金额调成不可被 3 整除的 100.00(quantity=10 × price=10)
|
||||
$this->actingAsSysUser();
|
||||
$this->putJson("/purchase/order/item/{$item->id}", [
|
||||
'price' => '10.00',
|
||||
'quantity' => '10.00',
|
||||
'weight' => 0,
|
||||
])->assertJsonPath('success', true);
|
||||
$item = $item->fresh();
|
||||
$this->assertSame('100.00', (string) $item->amount);
|
||||
|
||||
$this->postJson("/purchase/order/{$purchase->id}/allocate")
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$allocations = PurchaseAllocationModel::where('purchase_item_id', $item->id)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
$this->assertCount(3, $allocations);
|
||||
|
||||
$sum = $allocations->reduce(
|
||||
static fn (string $carry, $a): string => bcadd($carry, (string) $a->amount, 2),
|
||||
'0'
|
||||
);
|
||||
$this->assertSame('100.00', $sum, '分摊总额必须守恒');
|
||||
|
||||
// 三等分尾差修正:33.33 / 33.33 / 33.34
|
||||
$this->assertSame('33.33', (string) $allocations[0]->amount);
|
||||
$this->assertSame('33.33', (string) $allocations[1]->amount);
|
||||
$this->assertSame('33.34', (string) $allocations[2]->amount);
|
||||
}
|
||||
|
||||
/** 按订货数量比例分摊 */
|
||||
public function test_allocation_follows_order_ratio(): void
|
||||
{
|
||||
[$purchase, $item] = $this->buildAllocatablePurchase(['1', '3']);
|
||||
// quantity=4,实际金额 4×10=40 → 1:3 → 10.00 / 30.00
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', true);
|
||||
|
||||
$amounts = PurchaseAllocationModel::where('purchase_item_id', $item->id)
|
||||
->pluck('amount')
|
||||
->map(static fn ($v) => (string) $v)
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
$this->assertSame(['10.00', '30.00'], $amounts);
|
||||
}
|
||||
|
||||
/** 幂等:重复分摊先删旧记录再重建,数量不变 */
|
||||
public function test_allocation_is_idempotent(): void
|
||||
{
|
||||
[$purchase] = $this->buildAllocatablePurchase(['2', '3']);
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', true);
|
||||
$first = PurchaseAllocationModel::count();
|
||||
$this->assertGreaterThan(0, $first);
|
||||
|
||||
$this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', true);
|
||||
$this->assertSame($first, PurchaseAllocationModel::count(), '重复分摊不应产生重复记录');
|
||||
}
|
||||
|
||||
/** 未录入实际金额时拒绝分摊 */
|
||||
public function test_allocation_rejected_without_actual_amount(): void
|
||||
{
|
||||
$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' => '10.00']);
|
||||
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]]);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()]);
|
||||
$purchase = PurchaseOrderModel::first();
|
||||
|
||||
// 模拟未录入实际金额
|
||||
$purchase->items->first()->update(['amount' => 0, 'price' => 0]);
|
||||
|
||||
$this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', false);
|
||||
$this->assertSame(0, PurchaseAllocationModel::count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Exports\PurchaseOrderExport;
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\UserModel;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
|
||||
/**
|
||||
* 采购单导出:xlsx Content-Type / 蔬果分类过滤 / PDF / format 非法拒绝 / 权限拦截 /
|
||||
* 中文文件名 RFC 5987 编码
|
||||
*/
|
||||
class ExportTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 构造含蔬果 + 肉禽两类商品的采购单(下单 → 汇总生成)
|
||||
*/
|
||||
private function buildPurchaseWithItems(): PurchaseOrderModel
|
||||
{
|
||||
$vegRoot = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]);
|
||||
$meatRoot = ProductCategoryModel::create(['name' => '肉禽蛋', 'parent_id' => 0, 'sort' => 1, 'status' => 1]);
|
||||
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$veg = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'category_id' => $vegRoot->id]);
|
||||
$meat = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'category_id' => $meatRoot->id]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $veg->id, 'level_id' => $level->id, 'price' => '5.00']);
|
||||
ProductPriceModel::factory()->create(['product_id' => $meat->id, 'level_id' => $level->id, 'price' => '20.00']);
|
||||
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
$this->postJson('/mini/order', ['items' => [
|
||||
['product_id' => $veg->id, 'quantity' => 2],
|
||||
['product_id' => $meat->id, 'quantity' => 1],
|
||||
]])->assertJsonPath('success', true);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
return PurchaseOrderModel::first();
|
||||
}
|
||||
|
||||
/** xlsx 导出:正确 Content-Type + 中文文件名 RFC 5987 编码 */
|
||||
public function test_export_xlsx_with_encoded_chinese_filename(): void
|
||||
{
|
||||
$purchase = $this->buildPurchaseWithItems();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$response = $this->get("/purchase/order/{$purchase->id}/export?type=all&format=xlsx");
|
||||
$response->assertOk();
|
||||
|
||||
$this->assertStringContainsString(
|
||||
'spreadsheetml',
|
||||
(string) $response->headers->get('Content-Type'),
|
||||
'xlsx 应返回电子表格 Content-Type'
|
||||
);
|
||||
|
||||
$disposition = (string) $response->headers->get('Content-Disposition');
|
||||
// Symfony 输出小写 utf-8''(RFC 5987),大小写不敏感断言
|
||||
$this->assertMatchesRegularExpression("/filename\*=utf-8''/i", $disposition, '中文文件名应走 RFC 5987 编码');
|
||||
$this->assertStringContainsString(rawurlencode('采购单'), $disposition);
|
||||
}
|
||||
|
||||
/** 蔬果分类过滤:type=category 仅导出蔬菜/水果顶级分类商品 */
|
||||
public function test_export_category_filters_to_vegetables(): void
|
||||
{
|
||||
$purchase = $this->buildPurchaseWithItems();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
Excel::fake();
|
||||
$this->get("/purchase/order/{$purchase->id}/export?type=category&format=xlsx")->assertOk();
|
||||
|
||||
Excel::assertDownloaded(
|
||||
$purchase->purchase_no . '_采购单.xlsx',
|
||||
static function (PurchaseOrderExport $export): bool {
|
||||
$items = $export->collection();
|
||||
// 仅蔬菜商品一行,肉禽被过滤
|
||||
return $items->count() === 1;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** PDF 导出:application/pdf */
|
||||
public function test_export_pdf(): void
|
||||
{
|
||||
$purchase = $this->buildPurchaseWithItems();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$response = $this->get("/purchase/order/{$purchase->id}/export?format=pdf");
|
||||
$response->assertOk();
|
||||
$this->assertStringContainsString('application/pdf', (string) $response->headers->get('Content-Type'));
|
||||
$this->assertStringStartsWith('%PDF', (string) $response->getContent());
|
||||
}
|
||||
|
||||
/** format 参数非法 → 拒绝 */
|
||||
public function test_export_invalid_format_rejected(): void
|
||||
{
|
||||
$purchase = $this->buildPurchaseWithItems();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->get("/purchase/order/{$purchase->id}/export?format=doc")
|
||||
->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
/** 无 purchase.order.export 权限点 → 拦截 */
|
||||
public function test_export_requires_export_permission(): void
|
||||
{
|
||||
$purchase = $this->buildPurchaseWithItems();
|
||||
// 仅持有查询权限的用户
|
||||
$this->actingAsSysUser(['purchase.order.query']);
|
||||
|
||||
$response = $this->get("/purchase/order/{$purchase->id}/export?format=xlsx");
|
||||
$this->assertFalse($response->json('success'), '缺少权限点应被拦截');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Services\WechatService;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
/**
|
||||
* 小程序认证:EasyWeChat MockHttpClient 拦截微信调用、
|
||||
* 登录自动注册、手机号绑定自动匹配、停用拒绝、双端 token 隔离
|
||||
*/
|
||||
class MiniAuthTest extends ProcurementTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
// 测试环境注入微信配置(生产由 WECHAT_MINI_APPID/SECRET 提供)
|
||||
config(['services.wechat.mini' => ['appid' => 'test_appid', 'secret' => 'test_secret']]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 向 WechatService 注入 MockHttpClient,按 URL 分发微信接口 mock 响应
|
||||
*
|
||||
* @param callable(string, string): MockResponse $responder
|
||||
*/
|
||||
private function mockWechat(callable $responder): void
|
||||
{
|
||||
app(WechatService::class)->setHttpClient(
|
||||
new MockHttpClient($responder, 'https://api.weixin.qq.com')
|
||||
);
|
||||
}
|
||||
|
||||
/** wx.login code → 自动注册用户并签发 token */
|
||||
public function test_login_creates_user_and_issues_token(): void
|
||||
{
|
||||
$this->mockWechat(fn () => new MockResponse((string) json_encode([
|
||||
'openid' => 'openid_test_001',
|
||||
'session_key' => 'session_key_x',
|
||||
])));
|
||||
|
||||
$response = $this->postJson('/mini/auth/login', ['code' => 'wx_code']);
|
||||
$response->assertOk()
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonStructure(['data' => ['token', 'user' => ['id', 'type', 'store', 'supplier']]]);
|
||||
|
||||
$this->assertNotEmpty($response->json('data.token'));
|
||||
|
||||
$user = UserModel::where('openid', 'openid_test_001')->first();
|
||||
$this->assertNotNull($user, '应按 openid 自动创建用户');
|
||||
$this->assertSame(UserModel::TYPE_PENDING, $user->type);
|
||||
$this->assertNotNull($user->last_login_at);
|
||||
}
|
||||
|
||||
/** 停用账号拒绝登录 */
|
||||
public function test_disabled_user_cannot_login(): void
|
||||
{
|
||||
UserModel::factory()->disabled()->create(['openid' => 'openid_disabled']);
|
||||
$this->mockWechat(fn () => new MockResponse((string) json_encode([
|
||||
'openid' => 'openid_disabled',
|
||||
'session_key' => 'sk',
|
||||
])));
|
||||
|
||||
$this->postJson('/mini/auth/login', ['code' => 'c'])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
/** 微信接口报错时登录失败(错误被转译为业务异常,不泄露原始报文结构) */
|
||||
public function test_login_fails_when_wechat_rejects_code(): void
|
||||
{
|
||||
$this->mockWechat(fn () => new MockResponse((string) json_encode([
|
||||
'errcode' => 40029,
|
||||
'errmsg' => 'invalid code',
|
||||
])));
|
||||
|
||||
$this->postJson('/mini/auth/login', ['code' => 'bad_code'])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', false);
|
||||
$this->assertSame(0, UserModel::count());
|
||||
}
|
||||
|
||||
/** 手机号绑定:按手机号自动匹配门店 */
|
||||
public function test_phone_binding_matches_store(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create(['phone' => '13800138000']);
|
||||
$user = UserModel::factory()->create();
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$this->mockWechat(function (string $method, string $url): MockResponse {
|
||||
if (str_contains($url, '/cgi-bin/token')) {
|
||||
return new MockResponse((string) json_encode([
|
||||
'access_token' => 'mock_access_token',
|
||||
'expires_in' => 7200,
|
||||
]));
|
||||
}
|
||||
|
||||
return new MockResponse((string) json_encode([
|
||||
'errcode' => 0,
|
||||
'phone_info' => ['phoneNumber' => '13800138000'],
|
||||
]));
|
||||
});
|
||||
|
||||
$this->postJson('/mini/auth/phone', ['phoneCode' => 'phone_code'])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$user = $user->fresh();
|
||||
$this->assertSame('13800138000', $user->phone);
|
||||
$this->assertSame(UserModel::TYPE_STORE, $user->type);
|
||||
$this->assertSame($store->id, $user->store_id);
|
||||
}
|
||||
|
||||
/** 手机号无匹配主体 → 保持待绑定 */
|
||||
public function test_phone_no_match_stays_pending(): void
|
||||
{
|
||||
$user = UserModel::factory()->create();
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$this->mockWechat(function (string $method, string $url): MockResponse {
|
||||
if (str_contains($url, '/cgi-bin/token')) {
|
||||
return new MockResponse((string) json_encode([
|
||||
'access_token' => 'mock_access_token',
|
||||
'expires_in' => 7200,
|
||||
]));
|
||||
}
|
||||
|
||||
return new MockResponse((string) json_encode([
|
||||
'errcode' => 0,
|
||||
'phone_info' => ['phoneNumber' => '19999999999'],
|
||||
]));
|
||||
});
|
||||
|
||||
$this->postJson('/mini/auth/phone', ['phoneCode' => 'phone_code'])
|
||||
->assertJsonPath('success', true);
|
||||
$this->assertSame(UserModel::TYPE_PENDING, $user->fresh()->type);
|
||||
}
|
||||
|
||||
/** 跨端隔离:后台 token 访问小程序接口 → 401 */
|
||||
public function test_sys_token_cannot_access_mini(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->getJson('/mini/auth/info')->assertStatus(401);
|
||||
}
|
||||
|
||||
/** 跨端隔离:小程序 token 访问后台接口 → 401 */
|
||||
public function test_mini_token_cannot_access_admin_api(): void
|
||||
{
|
||||
$this->actingAsMiniUser(UserModel::factory()->create());
|
||||
|
||||
$this->getJson('/customer/level')->assertStatus(401);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\UserModel;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Modules\SystemUser\Models\SysUserModel;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 订货采购系统功能测试基类
|
||||
*
|
||||
* - SQLite :memory: + RefreshDatabase,每个测试方法独立迁移
|
||||
* - 双端认证辅助:createToken 签发 Sanctum token,withToken 注入 Authorization 头,
|
||||
* 走真实链路(auth:sanctum → AuthGuardMiddleware 双端隔离 → abilities 校验)
|
||||
*/
|
||||
abstract class ProcurementTestCase extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private static int $userSeq = 0;
|
||||
|
||||
/**
|
||||
* 创建后台系统用户并以该用户身份发起后续请求
|
||||
*
|
||||
* @param array<int, string> $abilities 权限点列表,['*'] 为全部权限
|
||||
*/
|
||||
protected function actingAsSysUser(array $abilities = ['*'], array $attributes = []): SysUserModel
|
||||
{
|
||||
// 清除 guard 缓存:Sanctum RequestGuard 会在 app 单例上缓存已认证用户,
|
||||
// 同一测试内切换身份时必须重置,否则后续请求仍用旧用户做 abilities 校验
|
||||
$this->app['auth']->forgetGuards();
|
||||
|
||||
$seq = ++self::$userSeq;
|
||||
$user = SysUserModel::create(array_merge([
|
||||
// id 避开 1:SysAccessToken::can() 对 tokenable_id==1(超管)直接放行全部权限,
|
||||
// 测试用户从 101 起编号,保证 abilities 校验真实生效
|
||||
'id' => 100 + $seq,
|
||||
'username' => 'tester' . str_pad((string) $seq, 4, '0', STR_PAD_LEFT) . random_int(10, 99),
|
||||
'password' => bcrypt('password'),
|
||||
'nickname' => '测试员' . $seq,
|
||||
'email' => 'tester' . $seq . '_' . random_int(100, 999) . '@example.com',
|
||||
'mobile' => '',
|
||||
'dept_id' => 0,
|
||||
'sex' => 0,
|
||||
'status' => 1,
|
||||
], $attributes));
|
||||
|
||||
$this->withToken($user->createToken('testing', $abilities)->plainTextToken);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 以小程序用户身份发起后续请求(token abilities 默认 ['mini'])
|
||||
*
|
||||
* @param array<int, string> $abilities
|
||||
*/
|
||||
protected function actingAsMiniUser(UserModel $user, array $abilities = ['mini']): UserModel
|
||||
{
|
||||
$this->app['auth']->forgetGuards();
|
||||
|
||||
$this->withToken($user->createToken('mini', $abilities)->plainTextToken);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\NoticeModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* A2 等级价格体系:等级价匹配、批量调价事务与调价通知
|
||||
*/
|
||||
class ProductPriceTest extends ProcurementTestCase
|
||||
{
|
||||
/** 小程序商品列表返回当前门店等级对应的价格 */
|
||||
public function test_product_list_returns_price_for_store_level(): void
|
||||
{
|
||||
$levelA = CustomerLevelModel::factory()->create();
|
||||
$levelB = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $levelA->id]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $levelA->id, 'price' => 5.50]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $levelB->id, 'price' => 3.00]);
|
||||
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
|
||||
$response = $this->getJson('/mini/product/list');
|
||||
$response->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$row = collect($response->json('data.data'))->firstWhere('id', $product->id);
|
||||
$this->assertNotNull($row, '商品应出现在列表中');
|
||||
$this->assertSame(5.50, (float) $row['price'], '应返回门店所在等级的价格');
|
||||
}
|
||||
|
||||
/** 门店未设置客户等级时拒绝展示价格 */
|
||||
public function test_store_without_level_is_rejected(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create(['level_id' => 0]);
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
|
||||
$this->getJson('/mini/product/list')
|
||||
->assertOk()
|
||||
->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
/** 批量调价:事务写入 + 通知受影响门店用户(不影响无关门店) */
|
||||
public function test_batch_price_updates_and_notifies_affected_stores(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$affectedStore = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$unaffectedStore = StoreModel::factory()->create(['level_id' => CustomerLevelModel::factory()->create()->id]);
|
||||
$affectedUser = UserModel::factory()->forStore($affectedStore->id)->create();
|
||||
$unaffectedUser = UserModel::factory()->forStore($unaffectedStore->id)->create();
|
||||
|
||||
$product = ProductModel::factory()->create();
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 5.00]);
|
||||
|
||||
$this->putJson('/product/goods/batchPrice', [
|
||||
'updates' => [
|
||||
['product_id' => $product->id, 'level_id' => $level->id, 'price' => 8.80],
|
||||
],
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$this->assertSame(
|
||||
8.80,
|
||||
(float) ProductPriceModel::forProductLevel($product->id, $level->id)->first()->price,
|
||||
'等级价格应已更新'
|
||||
);
|
||||
|
||||
$this->assertSame(
|
||||
1,
|
||||
NoticeModel::where('user_id', $affectedUser->id)->where('type', NoticeModel::TYPE_PRICE)->count(),
|
||||
'受影响门店用户应收到价格变更通知'
|
||||
);
|
||||
$this->assertSame(
|
||||
0,
|
||||
NoticeModel::where('user_id', $unaffectedUser->id)->count(),
|
||||
'无关门店用户不应收到通知'
|
||||
);
|
||||
}
|
||||
|
||||
/** 价格矩阵:行=商品 × 列=启用等级 */
|
||||
public function test_price_matrix_structure(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$product = ProductModel::factory()->create();
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 6.00]);
|
||||
|
||||
$response = $this->getJson('/product/goods/priceMatrix');
|
||||
$response->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$this->assertNotEmpty($response->json('data.levels'));
|
||||
$row = collect($response->json('data.rows'))->firstWhere('id', $product->id);
|
||||
$this->assertNotNull($row);
|
||||
$this->assertSame(6.0, (float) $row['price_' . $level->id]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* C1 订单汇总生成采购单:多门店聚合、订单状态回写、无订单/重复生成防护
|
||||
*/
|
||||
class PurchaseGenerateTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 造当日待汇总订单:门店A 两单各 3 件 + 门店B 一单 4 件(同一商品)
|
||||
* 商品设两个等级价 5.00 / 4.00,估算单价应取最低 4.00
|
||||
*/
|
||||
private function seedPendingOrders(): ProductModel
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$levelLow = CustomerLevelModel::factory()->create();
|
||||
$storeA = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$storeB = 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]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $levelLow->id, 'price' => 4.00]);
|
||||
|
||||
foreach ([[$storeA, 3], [$storeA, 3], [$storeB, 4]] as [$store, $qty]) {
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]])
|
||||
->assertJsonPath('success', true);
|
||||
}
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
/** 多门店多订单按商品聚合,估算单价取最低等级价 */
|
||||
public function test_generate_aggregates_orders_by_product(): void
|
||||
{
|
||||
$this->seedPendingOrders();
|
||||
$admin = $this->actingAsSysUser();
|
||||
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$purchase = PurchaseOrderModel::first();
|
||||
$this->assertNotNull($purchase);
|
||||
$this->assertStringStartsWith('PO', $purchase->purchase_no);
|
||||
$this->assertSame(now()->toDateString(), $purchase->purchase_date->toDateString());
|
||||
$this->assertSame($admin->id, $purchase->operator_id);
|
||||
$this->assertSame(PurchaseOrderModel::STATUS_PENDING, $purchase->status);
|
||||
|
||||
$this->assertCount(1, $purchase->items, '单一商品应聚合为一行');
|
||||
$item = $purchase->items->first();
|
||||
$this->assertSame('10.00', (string) $item->quantity, '3+3+4');
|
||||
$this->assertSame('4.00', (string) $item->price, '估算单价取最低等级价');
|
||||
$this->assertSame('40.00', (string) $item->amount, '10 × 4.00');
|
||||
$this->assertSame('40.00', (string) $purchase->estimate_amount);
|
||||
}
|
||||
|
||||
/** 源订单状态回写为已汇总 */
|
||||
public function test_generate_writes_back_order_status(): void
|
||||
{
|
||||
$this->seedPendingOrders();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$this->assertSame(0, StoreOrderModel::where('status', StoreOrderModel::STATUS_PENDING)->count());
|
||||
$this->assertSame(3, StoreOrderModel::where('status', StoreOrderModel::STATUS_SUMMARIZED)->count());
|
||||
}
|
||||
|
||||
/** 当日无待汇总订单 → 报错 */
|
||||
public function test_generate_without_pending_orders_fails(): void
|
||||
{
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', false);
|
||||
$this->assertSame(0, PurchaseOrderModel::count());
|
||||
}
|
||||
|
||||
/** 幂等:已汇总订单不会被重复归集 */
|
||||
public function test_generate_is_idempotent(): void
|
||||
{
|
||||
$this->seedPendingOrders();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', true);
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', false);
|
||||
|
||||
$this->assertSame(1, PurchaseOrderModel::count(), '第二次生成应被拒绝,不产生新采购单');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\ReconciliationItemModel;
|
||||
use App\Models\ReconciliationModel;
|
||||
use App\Models\SettlementModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\SupplierModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* 财务对账:明细构建(品类/供应商筛选)、D4 修改后差额与头汇总重算、D8 标记、D9 结算
|
||||
*/
|
||||
class ReconciliationTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 构造已分摊的完整链路:2 门店下单(2/3 件)→ 生成采购单 → 录入实际金额 → 分摊
|
||||
*
|
||||
* @return array{0: PurchaseOrderModel, 1: array<int, StoreModel>, 2: SupplierModel}
|
||||
*/
|
||||
private function buildAllocatedChain(): array
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$supplier = SupplierModel::factory()->create();
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'supplier_id' => $supplier->id,
|
||||
]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => '10.00']);
|
||||
|
||||
$stores = [];
|
||||
foreach (['2.00', '3.00'] as $qty) {
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$stores[] = $store;
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]])
|
||||
->assertJsonPath('success', true);
|
||||
}
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', true);
|
||||
$purchase = PurchaseOrderModel::first();
|
||||
$item = $purchase->items->first();
|
||||
|
||||
$this->putJson("/purchase/order/item/{$item->id}", [
|
||||
'price' => '10.00',
|
||||
'quantity' => $item->quantity,
|
||||
'weight' => 0,
|
||||
])->assertJsonPath('success', true);
|
||||
$this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', true);
|
||||
|
||||
return [$purchase->fresh(), $stores, $supplier];
|
||||
}
|
||||
|
||||
private function createRecon(array $extra = []): int
|
||||
{
|
||||
$response = $this->postJson('/recon/list', array_merge([
|
||||
'title' => '测试对账',
|
||||
'period_start' => now()->toDateString(),
|
||||
'period_end' => now()->toDateString(),
|
||||
], $extra));
|
||||
$response->assertJsonPath('success', true);
|
||||
|
||||
return (int) $response->json('data.id');
|
||||
}
|
||||
|
||||
/** 构建明细:publish=订货金额,actual=分摊金额,diff=publish-actual,头汇总回写 */
|
||||
public function test_build_creates_reconciliation_items(): void
|
||||
{
|
||||
[, $stores] = $this->buildAllocatedChain();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$reconId = $this->createRecon();
|
||||
$this->postJson("/recon/list/{$reconId}/build")->assertJsonPath('success', true);
|
||||
|
||||
$items = ReconciliationItemModel::where('recon_id', $reconId)->get();
|
||||
$this->assertCount(2, $items, '两门店分摊 → 两条对账明细');
|
||||
|
||||
$byStore = $items->keyBy('store_id');
|
||||
$this->assertSame('20.00', (string) $byStore[$stores[0]->id]->publish_amount, '订货金额 2×10');
|
||||
$this->assertSame('20.00', (string) $byStore[$stores[0]->id]->actual_amount, '分摊金额');
|
||||
$this->assertSame('0.00', (string) $byStore[$stores[0]->id]->diff_amount);
|
||||
|
||||
$recon = ReconciliationModel::find($reconId);
|
||||
$this->assertSame('50.00', (string) $recon->publish_amount);
|
||||
$this->assertSame('50.00', (string) $recon->actual_amount);
|
||||
$this->assertSame('0.00', (string) $recon->diff_amount);
|
||||
$this->assertSame(ReconciliationModel::STATUS_WORKING, $recon->status);
|
||||
}
|
||||
|
||||
/** 供应商筛选:仅拉取该供应商的采购数据 */
|
||||
public function test_build_filters_by_supplier(): void
|
||||
{
|
||||
[, , $supplier] = $this->buildAllocatedChain();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
// 无关供应商 → 无数据报错
|
||||
$other = SupplierModel::factory()->create();
|
||||
$reconId = $this->createRecon(['supplier_id' => $other->id]);
|
||||
$this->postJson("/recon/list/{$reconId}/build")->assertJsonPath('success', false);
|
||||
|
||||
// 正确供应商 → 构建成功
|
||||
$reconId2 = $this->createRecon(['supplier_id' => $supplier->id]);
|
||||
$this->postJson("/recon/list/{$reconId2}/build")->assertJsonPath('success', true);
|
||||
$this->assertSame(2, ReconciliationItemModel::where('recon_id', $reconId2)->count());
|
||||
}
|
||||
|
||||
/** D4 修改明细:自动重算本行 diff 与对账单头汇总 */
|
||||
public function test_update_item_recalculates_diff_and_header(): void
|
||||
{
|
||||
$this->buildAllocatedChain();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$reconId = $this->createRecon();
|
||||
$this->postJson("/recon/list/{$reconId}/build");
|
||||
|
||||
$item = ReconciliationItemModel::where('recon_id', $reconId)->orderBy('id')->first();
|
||||
$this->putJson("/recon/item/{$item->id}", ['actual_amount' => '25.00'])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$item = $item->fresh();
|
||||
$this->assertSame('-5.00', (string) $item->diff_amount, '20.00 - 25.00');
|
||||
|
||||
$recon = ReconciliationModel::find($reconId);
|
||||
$this->assertSame('55.00', (string) $recon->actual_amount, '25 + 30');
|
||||
$this->assertSame('-5.00', (string) $recon->diff_amount, '50 - 55');
|
||||
}
|
||||
|
||||
/** D8 对账状态标记翻转 */
|
||||
public function test_toggle_reconciled_flag(): void
|
||||
{
|
||||
$this->buildAllocatedChain();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$reconId = $this->createRecon();
|
||||
$this->postJson("/recon/list/{$reconId}/build");
|
||||
$item = ReconciliationItemModel::where('recon_id', $reconId)->first();
|
||||
$this->assertSame(0, $item->is_reconciled);
|
||||
|
||||
$this->putJson("/recon/item/{$item->id}/toggle")->assertJsonPath('success', true);
|
||||
$this->assertSame(1, $item->fresh()->is_reconciled);
|
||||
|
||||
$this->putJson("/recon/item/{$item->id}/toggle")->assertJsonPath('success', true);
|
||||
$this->assertSame(0, $item->fresh()->is_reconciled);
|
||||
}
|
||||
|
||||
/** D9 结算:按门店生成结算表,对账单转为已结算且不可重复结算 */
|
||||
public function test_settle_creates_settlements_per_store(): void
|
||||
{
|
||||
[, $stores] = $this->buildAllocatedChain();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$reconId = $this->createRecon();
|
||||
$this->postJson("/recon/list/{$reconId}/build");
|
||||
|
||||
$this->postJson("/recon/list/{$reconId}/settle")->assertJsonPath('success', true);
|
||||
|
||||
$settlements = SettlementModel::where('recon_id', $reconId)->get();
|
||||
$this->assertCount(2, $settlements, '按门店各生成一张结算表');
|
||||
|
||||
$byStore = $settlements->keyBy('store_id');
|
||||
$this->assertSame('20.00', (string) $byStore[$stores[0]->id]->total_amount);
|
||||
$this->assertSame('20.00', (string) $byStore[$stores[0]->id]->actual_amount);
|
||||
$this->assertStringStartsWith('JS', $byStore[$stores[0]->id]->settlement_no);
|
||||
|
||||
$this->assertSame(ReconciliationModel::STATUS_SETTLED, ReconciliationModel::find($reconId)->status);
|
||||
|
||||
// 已结算不可重复结算
|
||||
$this->postJson("/recon/list/{$reconId}/settle")->assertJsonPath('success', false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\StatementModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* 门店对账单:回款周期快照(settlement_date = period_end + cycle)、门店数据隔离、防重复入账
|
||||
*/
|
||||
class StatementTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* @return array{0: StoreModel, 1: UserModel, 2: ProductModel}
|
||||
*/
|
||||
private function makeStoreWithOrder(string $qty = '2.00', int $cycleDays = 7): array
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create([
|
||||
'level_id' => $level->id,
|
||||
'payment_cycle_days' => $cycleDays,
|
||||
]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => '10.00']);
|
||||
|
||||
$user = UserModel::factory()->forStore($store->id)->create();
|
||||
$this->actingAsMiniUser($user);
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
return [$store, $user, $product];
|
||||
}
|
||||
|
||||
/** 回款周期快照:settlement_date = period_end + cycle 天 */
|
||||
public function test_generate_snapshots_payment_cycle(): void
|
||||
{
|
||||
[$store] = $this->makeStoreWithOrder('2.00', 7);
|
||||
$today = now()->toDateString();
|
||||
|
||||
$response = $this->postJson('/mini/statement/generate', [
|
||||
'period_start' => $today,
|
||||
'period_end' => $today,
|
||||
]);
|
||||
$response->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$statement = StatementModel::where('store_id', $store->id)->first();
|
||||
$this->assertNotNull($statement);
|
||||
$this->assertStringStartsWith('ST', $statement->statement_no);
|
||||
$this->assertSame(7, $statement->payment_cycle_days, '快照生成时的回款周期');
|
||||
$this->assertSame(now()->addDays(7)->toDateString(), $statement->settlement_date->toDateString());
|
||||
$this->assertSame('20.00', (string) $statement->total_amount);
|
||||
$this->assertSame(1, $statement->items()->count());
|
||||
|
||||
// 生成后门店修改回款周期,不影响已生成的对账单(快照语义)
|
||||
$store->update(['payment_cycle_days' => 30]);
|
||||
$this->assertSame(7, $statement->fresh()->payment_cycle_days);
|
||||
}
|
||||
|
||||
/** 门店隔离:只能查看与生成本店对账单 */
|
||||
public function test_statement_isolated_between_stores(): void
|
||||
{
|
||||
[$storeA] = $this->makeStoreWithOrder();
|
||||
$today = now()->toDateString();
|
||||
$this->postJson('/mini/statement/generate', ['period_start' => $today, 'period_end' => $today])
|
||||
->assertJsonPath('success', true);
|
||||
$statementOfA = StatementModel::where('store_id', $storeA->id)->first();
|
||||
|
||||
// 门店 B 用户
|
||||
$storeB = StoreModel::factory()->create(['level_id' => $storeA->level_id]);
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($storeB->id)->create());
|
||||
|
||||
$this->getJson('/mini/statement')->assertJsonPath('data.total', 0);
|
||||
$this->getJson("/mini/statement/{$statementOfA->id}")->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
/** 已取消订单不计入;重复生成时已入账明细被排除 */
|
||||
public function test_generate_excludes_cancelled_and_used_items(): void
|
||||
{
|
||||
[$store] = $this->makeStoreWithOrder('2.00');
|
||||
// 再下一单并取消
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => ProductModel::first()->id, 'quantity' => 5]]]);
|
||||
$cancelledOrder = StoreOrderModel::where('store_id', $store->id)
|
||||
->orderBy('id', 'desc')
|
||||
->first();
|
||||
$this->putJson("/mini/order/{$cancelledOrder->id}/cancel")->assertJsonPath('success', true);
|
||||
|
||||
$today = now()->toDateString();
|
||||
$this->postJson('/mini/statement/generate', ['period_start' => $today, 'period_end' => $today])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$statement = StatementModel::where('store_id', $store->id)->first();
|
||||
$this->assertSame('20.00', (string) $statement->total_amount, '已取消订单不计入');
|
||||
$this->assertSame(1, $statement->items()->count());
|
||||
|
||||
// 同周期重复生成 → 明细已全部入账,拒绝
|
||||
$this->postJson('/mini/statement/generate', ['period_start' => $today, 'period_end' => $today])
|
||||
->assertJsonPath('success', false);
|
||||
$this->assertSame(1, StatementModel::where('store_id', $store->id)->count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* 小程序下单:等级价快照、服务端重算总价、取消限制、门店数据隔离
|
||||
*/
|
||||
class StoreOrderTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* @return array{0: StoreModel, 1: ProductModel, 2: UserModel}
|
||||
*/
|
||||
private function makeStoreWithProduct(string $price = '5.00'): 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' => $price,
|
||||
]);
|
||||
|
||||
return [$store, $product, UserModel::factory()->forStore($store->id)->create()];
|
||||
}
|
||||
|
||||
/** 下单快照等级价,服务端重算单品金额与订单总价 */
|
||||
public function test_place_order_snapshots_level_price_and_recalculates(): void
|
||||
{
|
||||
[$store, $product, $user] = $this->makeStoreWithProduct('5.50');
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$this->postJson('/mini/order', [
|
||||
'items' => [['product_id' => $product->id, 'quantity' => 3]],
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$order = StoreOrderModel::where('store_id', $store->id)->first();
|
||||
$this->assertNotNull($order);
|
||||
$this->assertSame(StoreOrderModel::STATUS_PENDING, $order->status);
|
||||
$this->assertStringStartsWith('SO', $order->order_no);
|
||||
$this->assertSame('16.50', (string) $order->total_amount, '5.50 × 3');
|
||||
|
||||
$item = $order->items->first();
|
||||
$this->assertSame('5.50', (string) $item->price, '明细快照等级价');
|
||||
$this->assertSame('16.50', (string) $item->amount);
|
||||
$this->assertSame($product->name, $item->product_name, '明细快照商品名');
|
||||
}
|
||||
|
||||
/** 前端传入的金额字段一律被忽略 */
|
||||
public function test_client_supplied_amount_is_ignored(): void
|
||||
{
|
||||
[$store, $product, $user] = $this->makeStoreWithProduct('5.00');
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$this->postJson('/mini/order', [
|
||||
'items' => [[
|
||||
'product_id' => $product->id,
|
||||
'quantity' => 2,
|
||||
'price' => 0.01,
|
||||
'amount' => 0.02,
|
||||
]],
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$order = StoreOrderModel::where('store_id', $store->id)->first();
|
||||
$this->assertSame('10.00', (string) $order->total_amount, '应按等级价 5.00×2 计算,忽略前端金额');
|
||||
}
|
||||
|
||||
/** 商品未设置门店等级价格时拒绝下单 */
|
||||
public function test_product_without_level_price_rejected(): void
|
||||
{
|
||||
[, $product, $user] = $this->makeStoreWithProduct('5.00');
|
||||
ProductPriceModel::where('product_id', $product->id)->delete();
|
||||
$this->actingAsMiniUser($user);
|
||||
|
||||
$this->postJson('/mini/order', [
|
||||
'items' => [['product_id' => $product->id, 'quantity' => 1]],
|
||||
])->assertOk()->assertJsonPath('success', false);
|
||||
|
||||
$this->assertSame(0, StoreOrderModel::count());
|
||||
}
|
||||
|
||||
/** 取消:仅待汇总订单可取消 */
|
||||
public function test_cancel_only_pending_orders(): void
|
||||
{
|
||||
[$store, $product, $user] = $this->makeStoreWithProduct();
|
||||
$this->actingAsMiniUser($user);
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]]);
|
||||
$order = StoreOrderModel::where('store_id', $store->id)->first();
|
||||
|
||||
$this->putJson("/mini/order/{$order->id}/cancel")->assertJsonPath('success', true);
|
||||
$this->assertSame(StoreOrderModel::STATUS_CANCELLED, $order->fresh()->status);
|
||||
|
||||
// 已取消不可重复取消
|
||||
$this->putJson("/mini/order/{$order->id}/cancel")->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
/** 已汇总订单不可取消 */
|
||||
public function test_summarized_order_cannot_be_cancelled(): void
|
||||
{
|
||||
[$store, $product, $user] = $this->makeStoreWithProduct();
|
||||
$this->actingAsMiniUser($user);
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]]);
|
||||
$order = StoreOrderModel::where('store_id', $store->id)->first();
|
||||
$order->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||
|
||||
$this->putJson("/mini/order/{$order->id}/cancel")->assertJsonPath('success', false);
|
||||
$this->assertSame(StoreOrderModel::STATUS_SUMMARIZED, $order->fresh()->status);
|
||||
}
|
||||
|
||||
/** 门店数据隔离:只能查看本店订单 */
|
||||
public function test_order_data_isolated_between_stores(): void
|
||||
{
|
||||
[$storeA, $product, $userA] = $this->makeStoreWithProduct();
|
||||
$this->actingAsMiniUser($userA);
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]]);
|
||||
$orderOfA = StoreOrderModel::where('store_id', $storeA->id)->first();
|
||||
|
||||
// 门店 B 用户访问门店 A 的订单
|
||||
$storeB = StoreModel::factory()->create(['level_id' => $storeA->level_id]);
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($storeB->id)->create());
|
||||
|
||||
$this->getJson("/mini/order/{$orderOfA->id}")->assertJsonPath('success', false);
|
||||
$this->getJson('/mini/order')->assertJsonPath('data.total', 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user