feat: 新增唯品会JX期初余额与账期导入 (#984)
This commit is contained in:
parent
31de7272e3
commit
f41554e63e
196
app/Console/Commands/ImportVipshopJxActualBills.php
Normal file
196
app/Console/Commands/ImportVipshopJxActualBills.php
Normal 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)];
|
||||
}
|
||||
}
|
||||
77
app/Console/Commands/StoreVipshopJxOpeningBalance.php
Normal file
77
app/Console/Commands/StoreVipshopJxOpeningBalance.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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"
|
||||
]
|
||||
|
||||
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
/* 导出进度弹窗:和财务规则 › 科目映射表的「操作记录」同一个观感 */
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user