feat: 新增唯品会JX期初余额与账期导入 (#984)

This commit is contained in:
qiuxiang 2026-11-07 16:48:01 +08:00
parent 31de7272e3
commit f41554e63e
10 changed files with 601 additions and 6 deletions

View File

@ -0,0 +1,196 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use JsonException;
use RuntimeException;
use Throwable;
class ImportVipshopJxActualBills extends Command
{
protected $signature = 'vipshop-jx:import
{file : 唯品会JX账期明细 JSON}
{--period= : 账单期间,格式 YYYY-MM}
{--company=1 : 公司 ID}
{--dry-run : 只校验,不写数据库}';
protected $description = '导入唯品会JX账期明细,按账单期间写入账户账单';
public function handle(): int
{
$path = realpath((string) $this->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<mixed> $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)];
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace App\Console\Commands;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Database\DatabaseManager;
use Modules\EcommerceFinance\Repositories\VipshopJxOpeningBalanceRepository;
class StoreVipshopJxOpeningBalance extends Command
{
protected $signature = 'vipshop-jx:store-opening-balance
{period-end : 余额截止日期,格式 YYYY-MM-DD}
{balance : 余额金额}
{--source=唯品会结算平台历史账单累计 : 余额来源说明}
{--dry-run : 只校验,不写数据库}';
protected $description = '登记唯品会JX账户月末余额,作为后续月份期初余额的滚动基准';
public function handle(DatabaseManager $databases): int
{
$periodEnd = trim((string) $this->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;
}
}

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private const TABLE = 'vipshop_jx_opening_balance_snapshots';
public function up(): void
{
$schema = Schema::connection($this->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');
}
};

View File

@ -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"
]

View File

@ -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,
},
];

View File

@ -0,0 +1,81 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
class VipshopJxOpeningBalanceRepository
{
public const TABLE = 'vipshop_jx_opening_balance_snapshots';
public const BASELINE_PERIOD_END = '2026-06-30';
private readonly ConnectionInterface $mirror;
private readonly ConnectionInterface $bills;
public function __construct(DatabaseManager $databases)
{
$this->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;
}
}

View File

@ -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; }
}
/* 导出进度弹窗:和财务规则 › 科目映射表的「操作记录」同一个观感 */

View File

@ -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(

View File

@ -0,0 +1,88 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\VipshopJxOpeningBalanceRepository;
use Tests\TestCase;
class VipshopJxOpeningBalanceRepositoryTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'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(),
]);
}
}

View File

@ -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');