期初余额取所选月份开始前聚水潭京东钱包每店铺账户最后一笔余额;期末余额统一按期初加收入减支出计算,并增加余额查询索引与定向测试。 > 开工时标题使用 `WIP: <任务名称>` 并先填写“任务占用与边界”。Codex 完成独立合并前自审后补齐其余内容并移除 `WIP:`。 ## 任务占用与边界 - 任务名称: - 实际开发者: - 任务来源/团队分工: - 目标结果: - 预计修改路径: - 将写入的公共契约(路由、Controller 方法、Repository、表/视图、`source_key`、`scope`、权限名、菜单键或共享组件): - 仅复用、明确不修改的公共契约: - 数据源、表/视图和字段: - 上游 `source_key` / `scope`: - 权限名、菜单键或共享组件: - 明确不修改的范围: ## 开工碰撞检查 - [ ] Codex 已执行 `scripts/codex-task-preflight.ps1` - [ ] 已检查最新 `main` 和所有远程任务分支 - [ ] 已搜索主线既有页面、接口、Repository 和数据查询 - [ ] 已把“将写入”和“仅复用、不修改”的公共契约分开;仅复用项不作为单独阻断条件 - [ ] 未发现实际写入重叠;或已在下方写明相关分支、具体重叠项和协调结论 相关分支、重叠项与协调结论: ## 实际改动 - 完成内容: - Codex 实际修改的文件: - 新增或变更的公共契约: - 与原计划的差异: ## 验证证据 - 正常流程: - 空数据/错误输入/无权限场景: - 受影响旧功能: - 自动测试及结果: - 实际页面、接口或只读业务查询结果: ## 风险检查 - [ ] 不涉及金额、权限、审批、数据库迁移、生产配置或数据修复 - [ ] 如涉及上述高风险内容,已在下方写明影响、回滚和所需授权 - [ ] 未提交 `.env`、密码、Token、私钥、真实财务数据、生产日志或构建产物 - [ ] 前端改动符合 `DESIGN.md`;无前端改动则写“不适用” 高风险影响、回滚和授权状态: ## 合并前复查 - [ ] 已重新拉取远程引用并用实际修改范围再次运行碰撞检查 - [ ] PR 不再是 `WIP:`,所有测试结果已经补齐 - [ ] Codex 已重新读取原始需求和验收标准,并逐文件审核 `origin/main...任务分支` 的完整 diff - [ ] Codex 自审确认无越界修改、重复实现、敏感内容、未处理冲突和未验证结果 - [ ] 合并顺序不会覆盖其他未合并任务 - [ ] 已授权当前任务的 Codex 执行 Squash merge、验证 `main` 并清理任务分支 - [ ] 如涉及生产迁移、数据修复、删除、覆盖配置或部署,已另行取得对应高风险授权 Reviewed-on: #901
222 lines
12 KiB
PHP
222 lines
12 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\EcommerceFinance;
|
|
|
|
use Illuminate\Support\Facades\DB;
|
|
use Modules\EcommerceFinance\AccountBillColumns;
|
|
use Modules\EcommerceFinance\Repositories\StoreReceivingJdWalletBillRepository;
|
|
use Modules\EcommerceFinance\Services\JdWalletBillImporter;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
class JdWalletBillTest extends TestCase
|
|
{
|
|
private array $files = [];
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$app = require dirname(__DIR__, 3) . '/bootstrap/app.php';
|
|
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
|
config(['database.connections.business_mirror' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']]);
|
|
DB::purge('business_mirror');
|
|
(require database_path('migrations/2026_09_18_160000_create_jd_wallet_bill_mirror_tables.php'))->up();
|
|
(require database_path('migrations/2026_11_05_100000_index_jd_wallet_opening_balances.php'))->up();
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
foreach ($this->files as $file) unlink($file);
|
|
parent::tearDown();
|
|
}
|
|
|
|
public function test_complete_month_preserves_identifiers_and_unifies_paging_filters_and_export(): void
|
|
{
|
|
$rows = [$this->row('2000000000000000001', '40.12'), $this->row('2000000000000000002', '0.00', '2.03')];
|
|
$this->import($rows, 2, '40.12', '2.03');
|
|
$repo = app(StoreReceivingJdWalletBillRepository::class);
|
|
$filters = ['start' => '2026-08-01', 'end' => '2026-08-31'];
|
|
$first = $repo->paginate($filters, 'record_code', 'asc', 1, 1);
|
|
$second = $repo->paginate($filters, 'record_code', 'asc', 2, 1);
|
|
$this->assertSame(2, $first['total']);
|
|
$this->assertSame(40.12, $first['summary']['income']);
|
|
$this->assertSame(2.03, $first['summary']['expense']);
|
|
$this->assertSame(-45.15, $first['summary']['opening_balance']);
|
|
$this->assertSame('first_row_estimate', $first['summary']['opening_balance_source']);
|
|
$this->assertSame(-7.06, $first['summary']['balance']);
|
|
$this->assertSame('2000000000000000001', $first['data'][0]['record_code']);
|
|
$this->assertSame('10100126080000000000000001', $first['data'][0]['payload']['serial_no']);
|
|
$this->assertSame('2026-07-31 10:00:00', $first['data'][0]['payload']['bill_date']);
|
|
$this->assertSame([$first['data'][0], $second['data'][0]], iterator_to_array($repo->exportRows($filters, 'record_code', 'asc')));
|
|
$this->assertSame(1, $repo->paginate(['keyword' => '2000000000000000002'], 'occurred_at', 'asc', 1, 10)['total']);
|
|
$this->assertSame(0, $repo->paginate(['start' => '2026-09-01'], 'occurred_at', 'asc', 1, 10)['total']);
|
|
$this->assertSame(2, $repo->paginate(['store_name' => '测试京东店', 'bill_item' => '货款'], 'income', 'desc', 1, 10)['total']);
|
|
$this->assertSame(['测试京东店'], $repo->filterOptions()['shops']);
|
|
$this->assertSame(['2026-08'], $first['sync']['periods']);
|
|
$this->assertContains('balance', array_column(AccountBillColumns::for('jd-wallet')['columns'], 'field'));
|
|
}
|
|
|
|
public function test_invalid_import_does_not_replace_active_month(): void
|
|
{
|
|
$row = $this->row('2000000000000000001', '10.00');
|
|
$initial = $this->import([$row], 1, '10.00', '0.00');
|
|
$cases = [
|
|
[[$row], 2, '10.00', '0.00'], [[$row], 1, '11.00', '0.00'],
|
|
[[$row, $row], 2, '20.00', '0.00'],
|
|
[[array_replace($row, ['费用结算时间' => '2026-09-01 00:00:00'])], 1, '10.00', '0.00'],
|
|
[[array_replace($row, ['费用结算时间' => '2026-08-32 00:00:00'])], 1, '10.00', '0.00'],
|
|
[[array_replace($row, ['收入金额' => '1.005'])], 1, '1.00', '0.00'],
|
|
[[array_replace($row, ['币种' => 'USD'])], 1, '10.00', '0.00'],
|
|
[[array_replace($row, ['记录编码' => '2E+18'])], 1, '10.00', '0.00'],
|
|
];
|
|
foreach ($cases as $case) {
|
|
$caught = false;
|
|
try { $this->import(...$case); } catch (\InvalidArgumentException|\Illuminate\Database\QueryException) { $caught = true; }
|
|
$this->assertTrue($caught);
|
|
$this->assertSame($initial['active_generation'], DB::connection('business_mirror')->table('jd_wallet_bill_periods')->value('active_generation'));
|
|
$this->assertSame(1, DB::connection('business_mirror')->table('jd_wallet_bill_rows')->count());
|
|
}
|
|
}
|
|
|
|
public function test_reimport_is_atomic_and_keeps_old_version(): void
|
|
{
|
|
$first = $this->import([$this->row('2000000000000000001', '10.00')], 1, '10.00', '0.00');
|
|
$second = $this->import([$this->row('2000000000000000001', '12.00')], 1, '12.00', '0.00');
|
|
$this->assertNotSame($first['active_generation'], $second['active_generation']);
|
|
$this->assertSame(2, DB::connection('business_mirror')->table('jd_wallet_bill_rows')->count());
|
|
$this->assertSame(12.0, app(StoreReceivingJdWalletBillRepository::class)->paginate([], 'occurred_at', 'asc', 1, 10)['summary']['income']);
|
|
}
|
|
|
|
public function test_month_to_date_import_rejects_shorter_replacement_and_out_of_range_rows(): void
|
|
{
|
|
$row = array_replace($this->row('2000000000000000001', '10.00'), ['费用结算时间' => '2026-08-23 12:00:00']);
|
|
$first = $this->import([$row], 1, '10.00', '0.00', '2026-08-23');
|
|
$this->assertSame('2026-08-24', $first['period_end']);
|
|
|
|
try {
|
|
$earlier = array_replace($row, ['费用结算时间' => '2026-08-22 12:00:00']);
|
|
$this->import([$earlier], 1, '10.00', '0.00', '2026-08-22');
|
|
$this->fail('A shorter file must not replace the active range.');
|
|
} catch (\InvalidArgumentException $e) {
|
|
$this->assertNotSame('', $e->getMessage());
|
|
}
|
|
$this->assertSame($first['active_generation'], DB::connection('business_mirror')->table('jd_wallet_bill_periods')->value('active_generation'));
|
|
|
|
$later = array_replace($row, ['记录编码' => '2000000000000000002', '费用结算时间' => '2026-08-24 12:00:00']);
|
|
$this->expectException(\InvalidArgumentException::class);
|
|
$this->import([$row, $later], 2, '20.00', '0.00', '2026-08-23');
|
|
}
|
|
|
|
public function test_month_to_date_import_can_extend_a_verified_range(): void
|
|
{
|
|
$row = array_replace($this->row('2000000000000000001', '10.00'), ['费用结算时间' => '2026-08-23 12:00:00']);
|
|
$first = $this->import([$row], 1, '10.00', '0.00', '2026-08-23');
|
|
$later = array_replace($row, ['记录编码' => '2000000000000000002', '费用结算时间' => '2026-08-24 12:00:00']);
|
|
$second = $this->import([$row, $later], 2, '20.00', '0.00', '2026-08-24');
|
|
$this->assertSame('2026-08-25', $second['period_end']);
|
|
$this->assertNotSame($first['active_generation'], $second['active_generation']);
|
|
$this->assertSame(2, app(StoreReceivingJdWalletBillRepository::class)->paginate(['start' => '2026-08-01', 'end' => '2026-08-24'], 'occurred_at', 'asc', 1, 10)['total']);
|
|
}
|
|
|
|
public function test_uninitialized_mirror_is_not_reported_as_empty_success(): void
|
|
{
|
|
$this->expectExceptionMessage('jd_wallet_bill_mirror_not_ready');
|
|
app(StoreReceivingJdWalletBillRepository::class)->paginate([], 'occurred_at', 'asc', 1, 10);
|
|
}
|
|
|
|
public function test_july_opening_uses_june_last_balance_and_closing_uses_the_period_formula(): void
|
|
{
|
|
$this->import([
|
|
$this->row('1', '0.00', '0.00', [
|
|
'费用结算时间' => '2026-06-15 12:00:00',
|
|
'账户余额' => '90.00',
|
|
]),
|
|
$this->row('2', '0.00', '0.00', [
|
|
'费用结算时间' => '2026-06-30 23:59:59',
|
|
'账户余额' => '100.00',
|
|
]),
|
|
$this->row('3', '0.00', '0.00', [
|
|
'费用结算时间' => '2026-06-30 23:59:59',
|
|
'账户余额' => '101.00',
|
|
]),
|
|
$this->row('4', '0.00', '0.00', [
|
|
'费用结算时间' => '2026-06-30 18:00:00',
|
|
'账户名称' => '另一账户',
|
|
'账户余额' => '20.00',
|
|
]),
|
|
$this->row('5', '0.00', '0.00', [
|
|
'店铺' => '另一店',
|
|
'费用结算时间' => '2026-06-30 17:00:00',
|
|
'账户余额' => '30.00',
|
|
]),
|
|
], 5, '0.00', '0.00', null, '2026-06');
|
|
$this->import([
|
|
$this->row('7', '15.00', '3.00', [
|
|
'费用结算时间' => '2026-07-15 12:00:00',
|
|
'账户余额' => '999.00',
|
|
]),
|
|
], 1, '15.00', '3.00', null, '2026-07');
|
|
|
|
$repo = app(StoreReceivingJdWalletBillRepository::class);
|
|
$allStores = $repo->paginate([
|
|
'start' => '2026-07-01',
|
|
'end' => '2026-07-31',
|
|
], 'occurred_at', 'desc', 1, 10);
|
|
$this->assertSame(151.0, $allStores['summary']['opening_balance']);
|
|
$this->assertSame('2026-06-30', $allStores['summary']['opening_balance_date']);
|
|
$this->assertSame('previous_period', $allStores['summary']['opening_balance_source']);
|
|
$this->assertSame(15.0, $allStores['summary']['income']);
|
|
$this->assertSame(3.0, $allStores['summary']['expense']);
|
|
$this->assertSame(163.0, $allStores['summary']['balance']);
|
|
|
|
$oneStore = $repo->paginate([
|
|
'store_name' => '测试京东店',
|
|
'start' => '2026-07-01',
|
|
'end' => '2026-07-31',
|
|
], 'occurred_at', 'desc', 1, 10);
|
|
$this->assertSame(121.0, $oneStore['summary']['opening_balance']);
|
|
$this->assertSame(133.0, $oneStore['summary']['balance']);
|
|
}
|
|
|
|
public function test_equations_keep_negative_balances_and_do_not_add_snapshot_rows(): void
|
|
{
|
|
$rows = [
|
|
array_replace($this->row('9', '10.00'), ['账户余额' => '100.00']),
|
|
array_replace($this->row('10', '20.00'), ['账户余额' => '-5.03']),
|
|
array_replace($this->row('11', '0.00', '2.00'), ['店铺' => '另一店', '账户余额' => '20.00']),
|
|
array_replace($this->row('12', '0.00'), ['账户名称' => '另一账户', '账户余额' => '7.00']),
|
|
];
|
|
$this->import($rows, 4, '30.00', '2.00');
|
|
$repo = app(StoreReceivingJdWalletBillRepository::class);
|
|
$this->assertSame(28.0, $repo->paginate([], 'income', 'asc', 1, 1)['summary']['balance']);
|
|
$this->assertSame(30.0, $repo->paginate(['store_name' => '测试京东店'], 'income', 'desc', 1, 10)['summary']['balance']);
|
|
$this->assertSame(21.97, $repo->paginate(['start' => '2026-09-01'], 'occurred_at', 'desc', 1, 10)['summary']['balance']);
|
|
}
|
|
|
|
private function import(
|
|
array $rows,
|
|
int $count,
|
|
string $income,
|
|
string $expense,
|
|
?string $through = null,
|
|
string $month = '2026-08',
|
|
): array
|
|
{
|
|
$path = tempnam(sys_get_temp_dir(), 'jd-daily-');
|
|
$this->files[] = $path;
|
|
$file = fopen($path, 'wb');
|
|
fputcsv($file, array_keys($rows[0]));
|
|
foreach ($rows as $row) fputcsv($file, array_values($row));
|
|
fclose($file);
|
|
return app(JdWalletBillImporter::class)->import($path, $month, $count, $income, $expense, $through);
|
|
}
|
|
|
|
private function row(string $code, string $income, string $expense = '0.00', array $overrides = []): array
|
|
{
|
|
return array_replace(array_fill_keys(array_values(JdWalletBillImporter::PAYLOAD_FIELDS), ''), [
|
|
'记录编码' => $code, '店铺' => '测试京东店', '账单项目' => '货款', '交易订单号' => '3606404000000001',
|
|
'流水号' => '10100126080000000000000001', '费用结算时间' => '2026-08-31 23:59:59',
|
|
'账单日期' => '2026-07-31 10:00:00', '收入金额' => $income, '支出金额' => $expense, '币种' => 'CNY', '账户余额' => '-5.03', '账户名称' => '测试账户',
|
|
], $overrides);
|
|
}
|
|
}
|