diff --git a/app/Console/Commands/ImportVipshopJxActualBills.php b/app/Console/Commands/ImportVipshopJxActualBills.php new file mode 100644 index 00000000..51400369 --- /dev/null +++ b/app/Console/Commands/ImportVipshopJxActualBills.php @@ -0,0 +1,196 @@ +argument('file')); + if ($path === false || ! is_file($path) || ! is_readable($path)) { + $this->error('唯品会JX账期明细文件不存在或不可读。'); + + return self::FAILURE; + } + $period = trim((string) $this->option('period')); + if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $period) !== 1) { + $this->error('账单期间必须是 YYYY-MM。'); + + return self::FAILURE; + } + $companyId = (int) $this->option('company'); + if ($companyId < 1) { + $this->error('公司 ID 无效。'); + + return self::FAILURE; + } + + try { + $rows = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + $this->error('唯品会JX账期明细不是有效 JSON。'); + + return self::FAILURE; + } + if (! is_array($rows) || $rows === []) { + $this->error('唯品会JX账期明细为空。'); + + return self::FAILURE; + } + + try { + [$income, $expense] = $this->validateRows($rows, $period); + } catch (RuntimeException $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + + $existing = DB::table('ec_account_bills') + ->where('company_id', $companyId) + ->where('bill_type', 'vipshop-jx') + ->where('payload->settle_period', $period) + ->whereNull('deleted_at') + ->count(); + + $this->table(['账期', '状态', '明细行数', '收入', '支出', '净额', '现有行数'], [[ + $period, + $this->option('dry-run') ? 'validated' : 'ready', + count($rows), + number_format($income, 2, '.', ''), + number_format($expense, 2, '.', ''), + number_format($income - $expense, 2, '.', ''), + $existing, + ]]); + + if ($existing > 0) { + $this->error("{$period} 已有 {$existing} 行唯品会JX账单,为避免重复写入已停止。"); + + return self::FAILURE; + } + if ($this->option('dry-run')) { + return self::SUCCESS; + } + + $lock = Cache::lock("vipshop-jx-import-{$companyId}-{$period}", 900); + if (! $lock->get()) { + $this->error('唯品会JX账单导入正在运行,请稍后重试。'); + + return self::FAILURE; + } + + try { + DB::transaction(function () use ($rows, $companyId, $period): void { + $now = now(); + foreach (array_chunk($rows, 500) as $chunk) { + $payload = array_map(static function (array $row) use ($companyId, $period, $now): array { + return [ + 'company_id' => $companyId, + 'bill_type' => 'vipshop-jx', + 'record_code' => (string) $row['record_code'], + 'store_name' => (string) $row['store_name'], + 'order_no' => (string) ($row['order_no'] ?? ''), + 'amount' => (string) $row['amount'], + 'bill_item' => (string) $row['bill_item'], + 'occurred_at' => filled($row['occurred_at'] ?? null) ? (string) $row['occurred_at'] : null, + 'income' => (string) $row['income'], + 'expense' => (string) $row['expense'], + 'balance' => '0', + 'payload' => json_encode( + $row['payload'], + JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ), + 'modifier' => '唯品会JX账单导入', + 'created_from' => 'vipshop-jx:import', + 'created_at' => $now, + 'updated_at' => $now, + ]; + }, $chunk); + DB::table('ec_account_bills')->insert($payload); + } + }); + } catch (Throwable $exception) { + report($exception); + $this->error('唯品会JX账单写入失败,详细原因已记录到受保护日志。'); + + return self::FAILURE; + } finally { + $lock->release(); + } + + $inserted = DB::table('ec_account_bills') + ->where('company_id', $companyId) + ->where('bill_type', 'vipshop-jx') + ->where('payload->settle_period', $period) + ->whereNull('deleted_at') + ->selectRaw('COUNT(*) AS rows_count, COALESCE(SUM(income), 0) AS income, COALESCE(SUM(expense), 0) AS expense') + ->first(); + + $this->table(['账期', '状态', '明细行数', '收入', '支出', '净额'], [[ + $period, + 'succeeded', + (int) $inserted->rows_count, + number_format((float) $inserted->income, 2, '.', ''), + number_format((float) $inserted->expense, 2, '.', ''), + number_format((float) $inserted->income - (float) $inserted->expense, 2, '.', ''), + ]]); + + return self::SUCCESS; + } + + /** + * @param list $rows + * @return array{0:float,1:float} + */ + private function validateRows(array $rows, string $period): array + { + $recordCodes = []; + $income = 0.0; + $expense = 0.0; + foreach ($rows as $index => $row) { + if (! is_array($row)) { + throw new RuntimeException('第 ' . ($index + 1) . ' 行不是有效对象。'); + } + $recordCode = trim((string) ($row['record_code'] ?? '')); + if ($recordCode === '' || isset($recordCodes[$recordCode])) { + throw new RuntimeException('第 ' . ($index + 1) . ' 行记录编码为空或重复。'); + } + $recordCodes[$recordCode] = true; + if (($row['payload']['settle_period'] ?? null) !== $period) { + throw new RuntimeException("第 " . ($index + 1) . " 行账单期间不是 {$period}。"); + } + + $amountRaw = $row['amount'] ?? ''; + $incomeRaw = $row['income'] ?? ''; + $expenseRaw = $row['expense'] ?? ''; + if (! is_numeric($amountRaw) || ! is_numeric($incomeRaw) || ! is_numeric($expenseRaw)) { + throw new RuntimeException('第 ' . ($index + 1) . ' 行金额无效。'); + } + $amount = (float) $amountRaw; + $rowIncome = (float) $incomeRaw; + $rowExpense = (float) $expenseRaw; + if (abs($amount - ($rowIncome - $rowExpense)) > 0.000001) { + throw new RuntimeException('第 ' . ($index + 1) . ' 行金额与收支不一致。'); + } + $income += $rowIncome; + $expense += $rowExpense; + } + + return [round($income, 6), round($expense, 6)]; + } +} diff --git a/app/Console/Commands/StoreVipshopJxOpeningBalance.php b/app/Console/Commands/StoreVipshopJxOpeningBalance.php new file mode 100644 index 00000000..09b36884 --- /dev/null +++ b/app/Console/Commands/StoreVipshopJxOpeningBalance.php @@ -0,0 +1,77 @@ +argument('period-end')); + $date = CarbonImmutable::createFromFormat('!Y-m-d', $periodEnd); + if ($date === false || $date->format('Y-m-d') !== $periodEnd || $date->format('Y-m-t') !== $periodEnd) { + $this->error('余额截止日期必须是有效月末,格式 YYYY-MM-DD。'); + + return self::FAILURE; + } + + $rawBalance = str_replace([',', ',', '¥', '¥', ' '], '', (string) $this->argument('balance')); + if ($rawBalance === '' || ! is_numeric($rawBalance)) { + $this->error('余额金额无效。'); + + return self::FAILURE; + } + + $connection = $databases->connection( + (string) config('upstream_mirrors.connection', 'business_mirror'), + ); + if (! $connection->getSchemaBuilder()->hasTable(VipshopJxOpeningBalanceRepository::TABLE)) { + $this->error('唯品会JX期初余额表尚未完成迁移。'); + + return self::FAILURE; + } + + $balance = number_format((float) $rawBalance, 6, '.', ''); + $source = mb_substr(trim((string) $this->option('source')), 0, 255); + if ($source === '') { + $this->error('余额来源说明不能为空。'); + + return self::FAILURE; + } + + if (! $this->option('dry-run')) { + $now = now(); + $connection->table(VipshopJxOpeningBalanceRepository::TABLE)->updateOrInsert( + ['period_end' => $periodEnd], + [ + 'balance' => $balance, + 'source' => $source, + 'imported_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ], + ); + } + + $this->table(['截止日期', '状态', '余额', '来源'], [[ + $periodEnd, + $this->option('dry-run') ? 'validated' : 'succeeded', + $balance, + $source, + ]]); + + return self::SUCCESS; + } +} diff --git a/database/migrations/2026_11_07_120000_create_vipshop_jx_opening_balance_snapshots.php b/database/migrations/2026_11_07_120000_create_vipshop_jx_opening_balance_snapshots.php new file mode 100644 index 00000000..d3d910ee --- /dev/null +++ b/database/migrations/2026_11_07_120000_create_vipshop_jx_opening_balance_snapshots.php @@ -0,0 +1,39 @@ +connection()); + if ($schema->hasTable(self::TABLE)) { + return; + } + + $schema->create(self::TABLE, function (Blueprint $table): void { + $table->id(); + $table->date('period_end'); + $table->decimal('balance', 20, 6); + $table->string('source'); + $table->dateTime('imported_at', 6); + $table->timestamps(6); + + $table->unique('period_end', 'vipshop_jx_opening_period_uq'); + }); + } + + public function down(): void + { + Schema::connection($this->connection())->dropIfExists(self::TABLE); + } + + private function connection(): string + { + return (string) config('upstream_mirrors.connection', 'business_mirror'); + } +}; diff --git a/deploy/finance-batch-imports.json b/deploy/finance-batch-imports.json index a38b883c..8ad2085b 100644 --- a/deploy/finance-batch-imports.json +++ b/deploy/finance-batch-imports.json @@ -39,6 +39,7 @@ "taobao-wechat-bills:import-authoritative", "vipshop-mp:import", "vipshop-mp:import-opening-balances", + "vipshop-jx:import", "xiaohongshu-bills:import", "business-mirror:import-third-party-warehouse-anchor" ] diff --git a/modules/EcommerceFinance/Http/Controllers/AccountBills.php b/modules/EcommerceFinance/Http/Controllers/AccountBills.php index 10eef2fe..2f84900b 100644 --- a/modules/EcommerceFinance/Http/Controllers/AccountBills.php +++ b/modules/EcommerceFinance/Http/Controllers/AccountBills.php @@ -16,6 +16,7 @@ use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorReposito use Modules\EcommerceFinance\Repositories\StoreReceivingDewuBillMirrorRepository; use Modules\EcommerceFinance\Repositories\StoreReceivingPinduoduoBillMirrorRepository; use Modules\EcommerceFinance\Repositories\PromotionMarketingExpenseRepository; +use Modules\EcommerceFinance\Repositories\VipshopJxOpeningBalanceRepository; use Modules\EcommerceFinance\Repositories\VipshopMpOpeningBalanceRepository; use RuntimeException; use Throwable; @@ -101,6 +102,10 @@ class AccountBills extends Controller $request->get('start'), $request->get('end'), ), + 'vipshop-jx' => app(VipshopJxOpeningBalanceRepository::class)->openingBalance( + $request->get('start'), + $request->get('end'), + ), default => ['balance' => null, 'date' => null, 'source' => null], }; $latestBalance = in_array($billType, ['vipshop', 'vipshop-jx', 'xiaohongshu'], true) @@ -125,8 +130,8 @@ class AccountBills extends Controller 'opening_balance_source' => $openingBalance['source'], 'balance' => match (true) { $billType === 'vipshop' => round($openingBalance['balance'] + $income - $expense, 2), + $billType === 'vipshop-jx' => round($openingBalance['balance'] + $income - $expense, 2), $billType === 'xiaohongshu' => round($openingBalance['balance'] + $income - $expense, 2), - $billType === 'vipshop-jx' => round($income - $expense, 2), default => $latestBalance !== null ? (float) $latestBalance : 0, }, ]; diff --git a/modules/EcommerceFinance/Repositories/VipshopJxOpeningBalanceRepository.php b/modules/EcommerceFinance/Repositories/VipshopJxOpeningBalanceRepository.php new file mode 100644 index 00000000..496dec24 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/VipshopJxOpeningBalanceRepository.php @@ -0,0 +1,81 @@ +mirror = $databases->connection( + (string) config('upstream_mirrors.connection', 'business_mirror'), + ); + $this->bills = $databases->connection(); + } + + /** @return array{balance:float,date:?string,source:string} */ + public function openingBalance(?string $startDate, ?string $endDate): array + { + $start = $this->monthStart($startDate) ?? $this->monthStart($endDate); + $baselineMonth = CarbonImmutable::create(2026, 7, 1)->startOfDay(); + if ($start === null || $start->lt($baselineMonth)) { + return ['balance' => 0.0, 'date' => null, 'source' => 'none']; + } + if (! $this->mirror->getSchemaBuilder()->hasTable(self::TABLE)) { + return ['balance' => 0.0, 'date' => null, 'source' => 'none']; + } + + $baseline = $this->mirror->table(self::TABLE) + ->where('period_end', self::BASELINE_PERIOD_END) + ->value('balance'); + if ($baseline === null) { + return ['balance' => 0.0, 'date' => null, 'source' => 'none']; + } + + $balance = (float) $baseline; + if ($start->gt($baselineMonth)) { + $movement = $this->bills->table('ec_account_bills') + ->where('company_id', (int) (company_id() ?: 1)) + ->where('bill_type', 'vipshop-jx') + ->whereNull('deleted_at') + ->where('payload->settle_period', '>=', '2026-07') + ->where('payload->settle_period', '<', $start->format('Y-m')) + ->selectRaw('COALESCE(SUM(income), 0) AS income, COALESCE(SUM(expense), 0) AS expense') + ->first(); + $balance += (float) ($movement->income ?? 0) - (float) ($movement->expense ?? 0); + } + + return [ + 'balance' => round($balance, 2), + 'date' => $start->subDay()->format('Y-m-d'), + 'source' => $start->equalTo($baselineMonth) ? 'june_ending' : 'previous_period', + ]; + } + + private function monthStart(?string $value): ?CarbonImmutable + { + $date = trim((string) $value); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) { + return null; + } + + try { + $parsed = CarbonImmutable::createFromFormat('!Y-m-d', $date); + } catch (\Throwable) { + return null; + } + + return $parsed instanceof CarbonImmutable && $parsed->format('Y-m-d') === $date + ? $parsed->startOfMonth() + : null; + } +} diff --git a/resources/assets/js/components/business/AccountBills.vue b/resources/assets/js/components/business/AccountBills.vue index aac01047..d04143c5 100644 --- a/resources/assets/js/components/business/AccountBills.vue +++ b/resources/assets/js/components/business/AccountBills.vue @@ -10,6 +10,7 @@ 'tw-stats--pinduoduo': billType === 'pinduoduo', 'tw-stats--douyin': billType === 'douyin', 'tw-stats--vipshop': billType === 'vipshop', + 'tw-stats--vipshop-jx': billType === 'vipshop-jx', 'tw-stats--xiaohongshu': billType === 'xiaohongshu', }" > @@ -393,6 +394,22 @@ export default { {label:'交易笔数',value:this.summary.count,note:'当前筛选结果',className:''}, ]; } + if(this.billType==='vipshop-jx'){ + const openingSource=this.summary.opening_balance_source; + const openingDate=this.summary.opening_balance_date; + const openingNote=openingSource==='none' + ?'未找到 2026-06-30 平台期末余额' + :(openingSource==='june_ending' + ?`截至 ${openingDate} 平台历史账单累计` + :`截至 ${openingDate} 上一账期期末`); + return [ + {label:'期初余额',value:this.moneyWan(this.summary.opening_balance),note:openingNote,className:''}, + {label:'收入合计',value:this.moneyWan(this.summary.income),note:`${this.summary.count} 笔交易`,className:'is-income'}, + {label:'支出合计',value:this.moneyWan(this.summary.expense),note:'按当前筛选',className:'is-expense'}, + {label:'期末余额',value:this.moneyWan(this.summary.balance),note:'期初余额 + 收入合计 - 支出合计',className:''}, + {label:'交易笔数',value:this.summary.count,note:'当前筛选结果',className:''}, + ]; + } if(this.billType==='xiaohongshu'){ const openingSource=this.summary.opening_balance_source; const openingDate=this.summary.opening_balance_date; @@ -407,7 +424,7 @@ export default { {label:'交易笔数',value:this.summary.count,note:'当前筛选结果',className:''}, ]; } - const netLabel=JD_BILL_TABS.concat(['vipshop-jx']).indexOf(this.billType)!==-1; + const netLabel=JD_BILL_TABS.indexOf(this.billType)!==-1; const items=[ {label:'收入合计',value:this.moneyWan(this.summary.income),note:`${this.summary.count} 笔交易`,className:'is-income'}, {label:'支出合计',value:this.moneyWan(this.summary.expense),note:'按当前筛选',className:'is-expense'}, @@ -554,15 +571,18 @@ export default { .tw-tools { flex: 1 1 24rem; min-width: 0; } .tw-stats--douyin { grid-template-columns: repeat(5, minmax(0, 1fr)); } .tw-stats--vipshop { grid-template-columns: repeat(5, minmax(0, 1fr)); } +.tw-stats--vipshop-jx { grid-template-columns: repeat(5, minmax(0, 1fr)); } .tw-stats--xiaohongshu { grid-template-columns: repeat(5, minmax(0, 1fr)); } @media (max-width: 900px) { .tw-stats--douyin { grid-template-columns: repeat(2, minmax(0, 1fr)); } .tw-stats--vipshop { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .tw-stats--vipshop-jx { grid-template-columns: repeat(2, minmax(0, 1fr)); } .tw-stats--xiaohongshu { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 520px) { .tw-stats--douyin { grid-template-columns: 1fr; } .tw-stats--vipshop { grid-template-columns: 1fr; } + .tw-stats--vipshop-jx { grid-template-columns: 1fr; } .tw-stats--xiaohongshu { grid-template-columns: 1fr; } } /* 导出进度弹窗:和财务规则 › 科目映射表的「操作记录」同一个观感 */ diff --git a/tests/Feature/Business/VipshopJxReceivingBillsTest.php b/tests/Feature/Business/VipshopJxReceivingBillsTest.php index ca952aa9..0466329b 100644 --- a/tests/Feature/Business/VipshopJxReceivingBillsTest.php +++ b/tests/Feature/Business/VipshopJxReceivingBillsTest.php @@ -3,9 +3,12 @@ namespace Tests\Feature\Business; use App\Models\Auth\Permission; +use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap; use Modules\EcommerceFinance\Models\AccountBill; +use Modules\EcommerceFinance\Repositories\VipshopJxOpeningBalanceRepository; use Tests\Feature\FeatureTestCase; /** @@ -31,7 +34,7 @@ class VipshopJxReceivingBillsTest extends FeatureTestCase ->assertJsonPath('summary.count', 2) ->assertJsonPath('summary.income', 82.48) ->assertJsonPath('summary.expense', 68.60) - // 该页签没有账户余额列,汇总口径为收支净额。 + // 未选择账期时没有期初基准,汇总仍等于本月收支净额。 ->assertJsonPath('summary.balance', 13.88) // 日期按 Y-m-d H:i:s 返回,前端直接展示,不再出现 ISO8601(UTC)。 ->assertJsonPath('data.0.occurred_at', '2026-07-03 00:00:00') @@ -46,6 +49,37 @@ class VipshopJxReceivingBillsTest extends FeatureTestCase ); } + public function test_vipshop_jx_summary_uses_the_rolled_opening_balance_for_the_selected_month(): void + { + $this->allowPage(); + $this->prepareOpeningBalanceTable(); + DB::connection('business_mirror')->table(VipshopJxOpeningBalanceRepository::TABLE)->insert([ + 'period_end' => '2026-06-30', + 'balance' => '491948.53', + 'source' => '唯品会结算平台历史账单累计', + 'imported_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + $this->bill('vipshop-jx', 'JUL-ROLL', '销售明细', '2026-07-09 00:00:00', 300, 50, '订单', '2026-07'); + $this->bill('vipshop-jx', 'AUG-VIEW', '销售明细', '2026-08-09 00:00:00', 100, 20, '订单', '2026-08'); + + $this->loginAs() + ->getJson(route('ecommerce-finance.account-bills.index', [ + 'bill_type' => 'vipshop-jx', + 'start' => '2026-08-01', + 'end' => '2026-08-31', + 'limit' => 20, + ])) + ->assertOk() + ->assertJsonPath('summary.income', 100) + ->assertJsonPath('summary.expense', 20) + ->assertJsonPath('summary.opening_balance', 492198.53) + ->assertJsonPath('summary.opening_balance_date', '2026-07-31') + ->assertJsonPath('summary.opening_balance_source', 'previous_period') + ->assertJsonPath('summary.balance', 492278.53); + } + public function test_vipshop_jx_filter_options_and_export_stay_inside_the_tab(): void { $this->allowPage(); @@ -121,7 +155,16 @@ class VipshopJxReceivingBillsTest extends FeatureTestCase /** * @param string $billItemRemark 页面「账单项目」列取值(payload.second_remark),JX 页签的账单项目筛选按它过滤 */ - private function bill(string $billType, string $orderNo, string $item, string $occurredAt, float $income, float $expense, string $billItemRemark = '订单'): void + private function bill( + string $billType, + string $orderNo, + string $item, + string $occurredAt, + float $income, + float $expense, + string $billItemRemark = '订单', + string $period = '2026-07', + ): void { AccountBill::create([ 'company_id' => $this->company->id, @@ -134,10 +177,35 @@ class VipshopJxReceivingBillsTest extends FeatureTestCase 'occurred_at' => $occurredAt, 'income' => $income, 'expense' => $expense, - 'payload' => ['second_remark' => $billItemRemark, 'settle_period' => '2026-07'], + 'payload' => ['second_remark' => $billItemRemark, 'settle_period' => $period], ]); } + private function prepareOpeningBalanceTable(): void + { + config([ + 'database.connections.business_mirror' => [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ], + 'upstream_mirrors.connection' => 'business_mirror', + ]); + DB::purge('business_mirror'); + $schema = Schema::connection('business_mirror'); + if (! $schema->hasTable(VipshopJxOpeningBalanceRepository::TABLE)) { + $schema->create(VipshopJxOpeningBalanceRepository::TABLE, function (Blueprint $table): void { + $table->id(); + $table->date('period_end'); + $table->decimal('balance', 20, 6); + $table->string('source'); + $table->dateTime('imported_at', 6); + $table->timestamps(6); + $table->unique('period_end'); + }); + } + } + private function allowPage(): void { $this->user->attachPermission( diff --git a/tests/Unit/EcommerceFinance/VipshopJxOpeningBalanceRepositoryTest.php b/tests/Unit/EcommerceFinance/VipshopJxOpeningBalanceRepositoryTest.php new file mode 100644 index 00000000..7c051913 --- /dev/null +++ b/tests/Unit/EcommerceFinance/VipshopJxOpeningBalanceRepositoryTest.php @@ -0,0 +1,88 @@ + [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ], + 'upstream_mirrors.connection' => 'business_mirror', + ]); + DB::purge('business_mirror'); + (require database_path('migrations/2026_11_07_120000_create_vipshop_jx_opening_balance_snapshots.php'))->up(); + } + + public function test_opening_balance_rolls_from_june_ending_by_settlement_period(): void + { + $companyId = (int) DB::table('companies')->value('id'); + DB::connection('business_mirror')->table(VipshopJxOpeningBalanceRepository::TABLE)->insert([ + 'period_end' => '2026-06-30', + 'balance' => '491948.53', + 'source' => '唯品会结算平台历史账单累计', + 'imported_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + $this->bill($companyId, 'JUL-A', '2026-07', '300', '50'); + $this->bill($companyId, 'AUG-A', '2026-08', '100', '20'); + + $repository = app(VipshopJxOpeningBalanceRepository::class); + $july = $repository->openingBalance('2026-07-01', '2026-07-31'); + $august = $repository->openingBalance('2026-08-01', '2026-08-31'); + $september = $repository->openingBalance('2026-09-01', '2026-09-30'); + + $this->assertSame(491948.53, $july['balance']); + $this->assertSame('2026-06-30', $july['date']); + $this->assertSame('june_ending', $july['source']); + $this->assertSame(492198.53, $august['balance']); + $this->assertSame('2026-07-31', $august['date']); + $this->assertSame('previous_period', $august['source']); + $this->assertSame(492278.53, $september['balance']); + } + + public function test_it_returns_no_opening_balance_before_the_june_baseline(): void + { + $result = app(VipshopJxOpeningBalanceRepository::class) + ->openingBalance('2026-06-01', '2026-06-30'); + + $this->assertSame(0.0, $result['balance']); + $this->assertNull($result['date']); + $this->assertSame('none', $result['source']); + } + + private function bill( + int $companyId, + string $recordCode, + string $period, + string $income, + string $expense, + ): void { + DB::table('ec_account_bills')->insert([ + 'company_id' => $companyId, + 'bill_type' => 'vipshop-jx', + 'record_code' => $recordCode, + 'store_name' => '唯品会JITX_亿家达', + 'order_no' => 'ORDER-' . $recordCode, + 'bill_item' => '销售明细', + 'occurred_at' => $period . '-15 10:00:00', + 'income' => $income, + 'expense' => $expense, + 'amount' => bcsub($income, $expense, 2), + 'balance' => 0, + 'payload' => json_encode(['settle_period' => $period], JSON_THROW_ON_ERROR), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/tests/Unit/View/AccountBillsTableTest.php b/tests/Unit/View/AccountBillsTableTest.php index 71aeca19..6817926a 100644 --- a/tests/Unit/View/AccountBillsTableTest.php +++ b/tests/Unit/View/AccountBillsTableTest.php @@ -79,7 +79,7 @@ class AccountBillsTableTest extends TestCase $this->assertStringContainsString("const DATE_FILTER_LABELS = {'jd-transactions':'费用结算时间','jingxi':'费用结算时间','jd-wallet':'费用结算时间','douyin':'结算时间','vipshop':'账单期间','vipshop-jx':'账单期间','xiaohongshu':'结算时间','promotion-marketing':'交易时间'};", $source); $this->assertStringContainsString('{{ dateFilterLabel }}', $source); $this->assertStringContainsString('v-if="isJdBillTab && sync"', $source); - $this->assertStringContainsString("netLabel=JD_BILL_TABS.concat(['vipshop-jx']).indexOf(this.billType)!==-1", $source); + $this->assertStringContainsString("netLabel=JD_BILL_TABS.indexOf(this.billType)!==-1", $source); } public function test_taobao_wechat_places_alipay_balances_before_income(): void @@ -189,6 +189,26 @@ class AccountBillsTableTest extends TestCase ); } + public function test_vipshop_jx_places_rolled_opening_balance_before_income(): void + { + $source = file_get_contents(__DIR__ . '/../../../resources/assets/js/components/business/AccountBills.vue'); + + $this->assertStringContainsString("'tw-stats--vipshop-jx': billType === 'vipshop-jx'", $source); + $this->assertStringContainsString("if(this.billType==='vipshop-jx')", $source); + $this->assertStringContainsString('平台历史账单累计', $source); + $this->assertStringContainsString('上一账期期末', $source); + $this->assertStringContainsString("label:'期末余额'", $source); + $this->assertStringContainsString("note:'期初余额 + 收入合计 - 支出合计'", $source); + $this->assertLessThan( + strpos($source, "label:'收入合计'"), + strpos($source, "label:'期初余额'"), + ); + $this->assertStringContainsString( + '.tw-stats--vipshop-jx { grid-template-columns: repeat(5, minmax(0, 1fr)); }', + $source, + ); + } + public function test_promotion_marketing_card_opens_the_store_detail_modal(): void { $source = file_get_contents(__DIR__ . '/../../../resources/assets/js/components/business/AccountBills.vue');