first version

This commit is contained in:
liu
2026-07-23 20:41:25 +08:00
parent 00a0938a1b
commit 10cff754a4
211 changed files with 11577 additions and 842 deletions
+144
View File
@@ -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.00quantity=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());
}
}
+121
View File
@@ -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'), '缺少权限点应被拦截');
}
}
+156
View File
@@ -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);
}
}
+67
View File
@@ -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 tokenwithToken 注入 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 避开 1SysAccessToken::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;
}
}
+103
View File
@@ -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]);
}
}
+102
View File
@@ -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(), '第二次生成应被拒绝,不产生新采购单');
}
}
+177
View File
@@ -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);
}
}
+105
View File
@@ -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());
}
}
+132
View File
@@ -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);
}
}