parent
e5ad1b2390
commit
af10f3c972
72
app/Console/Commands/ImportDewuSettlementBills.php
Normal file
72
app/Console/Commands/ImportDewuSettlementBills.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\DewuSettlementBillImporter;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ImportDewuSettlementBills extends Command
|
||||
{
|
||||
protected $signature = 'dewu-bills:import-authoritative
|
||||
{path : 聚胜算导出的得物完整账单 XLSX}
|
||||
{--from= : 账期开始日期,格式 YYYY-MM-DD}
|
||||
{--to= : 账期结束日期,格式 YYYY-MM-DD}
|
||||
{--expected-count= : 完整账单权威笔数}
|
||||
{--expected-income= : 完整账单权威收入}
|
||||
{--expected-expense= : 完整账单权威支出}
|
||||
{--batch=1000 : 分批写入行数}';
|
||||
|
||||
protected $description = '导入并核验聚胜算得物完整账单(保留全部渠道)';
|
||||
|
||||
public function handle(DewuSettlementBillImporter $importer): int
|
||||
{
|
||||
foreach (['from', 'to', 'expected-count', 'expected-income', 'expected-expense'] as $option) {
|
||||
if (trim((string) $this->option($option)) === '') {
|
||||
$this->error("缺少 --{$option},完整账单导入必须提供账期和三项权威控制数。");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $importer->import(
|
||||
(string) $this->argument('path'),
|
||||
(string) $this->option('from'),
|
||||
(string) $this->option('to'),
|
||||
(int) $this->option('expected-count'),
|
||||
(string) $this->option('expected-income'),
|
||||
(string) $this->option('expected-expense'),
|
||||
(int) $this->option('batch'),
|
||||
);
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error('得物完整账单导入失败,详细原因已记录到受保护日志。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(['状态', '账单行数', '跳过行数', '店铺数', '账单项目数', '收入', '支出', '净额', '账期'], [[
|
||||
$result['status'],
|
||||
$result['row_count'],
|
||||
$result['excluded_rows'],
|
||||
$result['store_count'],
|
||||
$result['bill_item_count'],
|
||||
$result['income_total'],
|
||||
$result['expense_total'],
|
||||
$result['net_total'],
|
||||
$result['period_start'] . ' 至 ' . $result['period_end'],
|
||||
]]);
|
||||
$this->line('渠道:' . collect($result['channel_counts'])
|
||||
->map(fn (int $count, string $channel): string => "{$channel} {$count}")
|
||||
->implode(';'));
|
||||
$this->line('SHA-256: ' . $result['source_sha256']);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (app()->environment('testing')
|
||||
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema = Schema::connection($this->connection());
|
||||
|
||||
if (! $schema->hasTable('dewu_settlement_bill_imports')) {
|
||||
$schema->create('dewu_settlement_bill_imports', function (Blueprint $table): void {
|
||||
$table->uuid('generation')->primary();
|
||||
$table->string('source_filename');
|
||||
$table->char('source_sha256', 64)->unique();
|
||||
$table->date('period_start');
|
||||
$table->date('period_end');
|
||||
$table->string('status', 32)->default('running');
|
||||
$table->unsignedBigInteger('expected_row_count');
|
||||
$table->decimal('expected_income_total', 20, 2);
|
||||
$table->decimal('expected_expense_total', 20, 2);
|
||||
$table->unsignedBigInteger('row_count')->default(0);
|
||||
$table->unsignedBigInteger('excluded_rows')->default(0);
|
||||
$table->unsignedInteger('store_count')->default(0);
|
||||
$table->unsignedInteger('bill_item_count')->default(0);
|
||||
$table->json('channel_counts')->nullable();
|
||||
$table->decimal('income_total', 20, 2)->default(0);
|
||||
$table->decimal('expense_total', 20, 2)->default(0);
|
||||
$table->decimal('net_total', 20, 2)->default(0);
|
||||
$table->string('last_error_code', 120)->nullable();
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['status', 'period_start', 'period_end'], 'dewu_bill_import_period_idx');
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasTable('dewu_settlement_bill_staging')) {
|
||||
$schema->create('dewu_settlement_bill_staging', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->uuid('generation');
|
||||
$this->billColumns($table);
|
||||
$table->unique(['generation', 'record_code'], 'dewu_bill_stage_generation_record_uq');
|
||||
$table->index(['generation', 'bill_date'], 'dewu_bill_stage_generation_time_idx');
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasTable('dewu_settlement_bill_rows')) {
|
||||
$schema->create('dewu_settlement_bill_rows', function (Blueprint $table): void {
|
||||
$table->string('record_code', 191)->primary();
|
||||
$this->billColumns($table, false);
|
||||
$table->uuid('source_generation');
|
||||
$table->char('source_sha256', 64);
|
||||
$table->timestamp('imported_at');
|
||||
|
||||
$table->index(['bill_date', 'record_code'], 'dewu_bill_time_record_idx');
|
||||
$table->index(['store_name', 'bill_date'], 'dewu_bill_store_time_idx');
|
||||
$table->index(['bill_item', 'bill_date'], 'dewu_bill_item_time_idx');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (app()->environment('testing')
|
||||
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema = Schema::connection($this->connection());
|
||||
$schema->dropIfExists('dewu_settlement_bill_rows');
|
||||
$schema->dropIfExists('dewu_settlement_bill_staging');
|
||||
$schema->dropIfExists('dewu_settlement_bill_imports');
|
||||
}
|
||||
|
||||
private function billColumns(Blueprint $table, bool $includeRecordCode = true): void
|
||||
{
|
||||
if ($includeRecordCode) {
|
||||
$table->string('record_code', 191);
|
||||
}
|
||||
$table->string('order_no', 191)->nullable();
|
||||
$table->string('online_order_no', 191)->nullable();
|
||||
$table->string('custom_remark')->nullable();
|
||||
$table->string('bill_item')->nullable();
|
||||
$table->string('store_name')->nullable();
|
||||
$table->string('order_type')->nullable();
|
||||
$table->string('goods_name', 1000)->nullable();
|
||||
$table->string('goods_no', 191)->nullable();
|
||||
$table->string('quantity', 64)->nullable();
|
||||
$table->string('channel')->nullable();
|
||||
$table->string('business_type')->nullable();
|
||||
$table->decimal('income_amount', 20, 2)->default(0);
|
||||
$table->decimal('expense_amount', 20, 2)->default(0);
|
||||
$table->decimal('signed_amount', 20, 2)->default(0);
|
||||
$table->string('online_real_store')->nullable();
|
||||
$table->dateTime('order_created_at')->nullable();
|
||||
$table->dateTime('paid_refunded_at')->nullable();
|
||||
$table->dateTime('ship_return_at')->nullable();
|
||||
$table->dateTime('bill_date');
|
||||
$table->string('settle_status')->nullable();
|
||||
$table->string('pay_period')->nullable();
|
||||
$table->dateTime('pulled_at')->nullable();
|
||||
$table->dateTime('source_updated_at')->nullable();
|
||||
$table->string('source_modified_by')->nullable();
|
||||
$table->unsignedBigInteger('source_row_number');
|
||||
}
|
||||
};
|
||||
@ -25,7 +25,7 @@
|
||||
| 页面已切换 | 店铺收款账单的淘系支付宝账户读取 `operations_replica.rpt.jst_originalBillInfo`,并关联 `rpt.jst_shopInfo` 展示店铺;分页、筛选、汇总和导出统一读取共享业务镜像。 |
|
||||
| 抖音按月校准 | 抖音账户对已核对的完整月份读取 `douyin_authoritative_bills` 中胜算原始明细,`douyin_authoritative_periods` 原子选择有效版本;该月份不再叠加聚水潭拆分行。未校准月份继续读取原始账单镜像。导入命令为 `business-mirror:import-douyin-authoritative <CSV路径> <YYYY-MM> --count=<核对笔数> --income=<收入> --expense=<支出>`,只接受完整月份和精确核对值,保留旧版本、文件摘要及原镜像供回滚。生产执行前备份状态与受影响月份镜像,并使用 `/run/caiwu-upstream-sync.lock`。实际财务数据只放私有目录,不进入 Git。 |
|
||||
| 已接入待首次同步 | 店铺收款账单的拼多多账户读取 `jiansun.dbo.jst_originalBillInfo` 中固定 `channel = PinduoduoImport` 的独立切片镜像 `js_jst_pdd_receiving_bill_rows`;不使用慢一天的 `operations_replica` 副本或营销账户 `pdd_spread_bills`。 |
|
||||
| 已接入待首次同步 | 店铺收款账单的得物账户读取 `jiansun.dbo.jst_originalBillInfo` 中固定 `channel IN (Dewu, DewuTK, DewuKJ)` 的独立切片镜像 `js_jst_dewu_receiving_bill_rows`;主账单、退货退款订单和扣减其他费用明细统一展示,`Dewubzj` 保证金继续排除。 |
|
||||
| 已接入待首次导入 | 店铺收款账单的得物账户读取聚胜算权威 XLSX 经受控导入生成的 `dewu_settlement_bill_rows`;保留得物、退货退款订单和扣减其他费用明细三类渠道,不再回退 `jst_originalBillInfo` 辅助镜像。 |
|
||||
| 已完成首次导入 | 提现管理的每日店铺余额读取聚水潭“平台原始账单[新] → 店铺余额表”接口 JSON/XLSX、多平台“平台资金账户余额”导出及“京东商城 → 京东钱包”流水,进入 `jushuitan_shop_balance_snapshots`。京东钱包按前一天作为截止日,每个店铺取最新费用结算时间的最后一笔账户余额,同一时间以最大记录编码消歧;每天 05:00 原子覆盖各来源范围,异常或空数据保留上一快照。页面按店铺名称精确匹配并强制使用平台消歧,未匹配和多匹配不会显示为零。 |
|
||||
| 已接入待首次同步 | 店铺营销账户余额的京东账户读取 `js_data_middle_platform.dbo.jd_jzt_finance_records` 镜像 `jsm_jd_jzt_finance_records`;按 `id` 稳定断点、`updated_at` 重叠增量,页面不直连上游。 |
|
||||
| 本次接入 | 普通商品资料的工作平台采购价格接入 `jiansun.dbo.pm_priceAdjustment`(镜像 `js_pm_price_adjustment`):按 `skuCode` 关联 `jst_sku.sku_id`,取 `effectTime` 不晚于当前时间的最新 `purchasePrice`,并在页面和导出中标记“工作平台价格”;源表没有可靠更新时间,按 `Id` 稳定键追加,首次全量完成前只显示普通商品自身采购价。 |
|
||||
@ -76,7 +76,7 @@
|
||||
| 快递资料 | `dbo.wp_express`、`dbo.wp_express_expNet`、`dbo.wp_express_account`、`dbo.wp_express_extension` |
|
||||
| 聚水潭小额打款工单 | `dbo.jst_workOrderInfo`(稳定键 `Id`、增量字段 `updateTime`)与 `dbo.jst_small_payment_order_map`(稳定键 `woNo`、增量字段 `updated_at`) |
|
||||
| 拼多多货款账户账单 | `dbo.jst_originalBillInfo`(固定 `channel = PinduoduoImport`,镜像 `js_jst_pdd_receiving_bill_rows`) |
|
||||
| 得物货款账户账单 | `dbo.jst_originalBillInfo`(固定 `channel IN (Dewu, DewuTK, DewuKJ)`,镜像 `js_jst_dewu_receiving_bill_rows`;排除 `Dewubzj`) |
|
||||
| 得物完整账户账单 | 聚胜算平台原始账单(新)XLSX(受控导入 `dewu_settlement_bill_rows`;保留得物、退货退款订单、扣减其他费用明细) |
|
||||
|
||||
## JSDataMiddlePlatform 中台库(js_data_middle_platform)
|
||||
|
||||
@ -99,7 +99,7 @@
|
||||
9. 店铺维度报表是已批准的中央专用数据集:生产节点按 `Id` 断点推进并回看最近 50000 行;原始账单同步后增量维护按店铺/日期汇总的 `store_report_daily_rows`,页面不得现场扫描千万级原始账单。开发机不运行刷新 worker。
|
||||
10. `operations_replica.rpt.jst_originalBillInfo` 是供多个业务功能复用的原始账单镜像。首次同步由 Codex 备份后自主安排在生产同步节点低峰期分批续传,无需另行授权;页面请求和普通开发机不启动刷新 worker。同步完成后,开发者通过固定 Repository 查询 `ops_jst_original_bill_info`,不得各自创建整表快照。 淘系支付宝账户账单当前仍读取此 MariaDB 镜像,统一调度须持续增量,不因 ClickHouse 尚未启用而暂停。账单冲减则复用“订单数据 → 原始数据 → 订单售后费用明细”,不按该表费用名称推断 500112。
|
||||
11. 拼多多货款账户账单使用 `jiansun.dbo.jst_originalBillInfo.pinduoduo_receiving` 数据集。同步器必须以参数绑定固定 `channel = PinduoduoImport`,只复制该渠道切片;页面 Repository 只读 `js_jst_pdd_receiving_bill_rows`。首次全量按 `Id` 断点推进,后续按 `updateTime` 保留重叠窗口,不得切回落后一天的 `operations_replica` 副本。
|
||||
12. 得物货款账户账单使用 `jiansun.dbo.jst_originalBillInfo.dewu_receiving` 数据集。同步器必须以参数绑定固定 `channel IN (Dewu, DewuTK, DewuKJ)`,页面 Repository 对列表、分页、筛选、汇总和导出使用相同渠道范围,并保留原始渠道代码用于审计;`Dewubzj` 保证金不得混入。
|
||||
12. 得物完整账单使用聚胜算权威 XLSX,通过 `dewu-bills:import-authoritative` 写入 `dewu_settlement_bill_rows`。导入必须核验文件 SHA-256、账期、记录编码唯一性、行数、收入和支出控制数,并保留得物、退货退款订单、扣减其他费用明细三类渠道;页面 Repository 不得回退 `jiansun.dbo.jst_originalBillInfo.dewu_receiving`。详细流程见 `docs/得物完整账单取数方案留档.md`。
|
||||
13. 聚水潭进销存首次全量按头表 `Id` 区间小批断点续传,头表保存全部仓库中 `io_date >= 2026-08-01` 的数据,不激活半份数据;日常增量以头表 `(modified, Id)` 作稳定游标并保留 48 小时时间重叠,由命中头单驱动整单明细替换。全量和增量都先写待发布代次,库存抓取完成后再把流水追平到库存抓取之后的固定源时间边界,三部分成功后才一次切换;运行中或失败时三方仓页面继续读取上一成功代次,全仓报表在首份完整全仓代次发布前明确提示同步中。页面只统计 `Confirmed` 的 12 类进销存流水,数量按上游 `qty * qty_type` 保留符号。库存同时保存上游 `source_modified_at` 和实际 `captured_at`,历史余额以每个“仓库 + SKU”的抓取时间为回推锚点;三方仓页面仍固定 `wms_co_id = 15045364` 并按“日期 + 商品”输出,全仓报表按“月份 + 仓库 + 商品”汇总。每天 `00:00` 执行增量同步,失败时继续展示上一成功代次。
|
||||
14. 聚水潭商品操作日志按最多 7 天的固定时间窗和接口页码稳定断点同步,每页最多 50 条,首次全量从 `2000-01-01` 起扫描;只保留备注内容含“采购价”的记录,并从“采购价:旧值->新值”解析前后价格和差额。日常同步回看 48 小时,按接口记录字段生成稳定键幂等更新;接口失败时保留上一版镜像。页面不得把这些聚水潭记录与 `jiansun.dbo.pm_priceAdjustment` 的工作平台价格记录混为同一来源。
|
||||
|
||||
|
||||
59
docs/得物完整账单取数方案留档.md
Normal file
59
docs/得物完整账单取数方案留档.md
Normal file
@ -0,0 +1,59 @@
|
||||
# 得物完整账单取数方案留档
|
||||
|
||||
## 目标
|
||||
|
||||
财务平台“账单流水数据 / 店铺收款账户账单 / 得物账户”以聚胜算导出的平台原始账单为权威来源,不再回退到 `rpt.jst_originalBillInfo` 或其得物辅助镜像。
|
||||
|
||||
页面现有表头和列顺序保持不变,完整保留以下渠道:
|
||||
|
||||
- 得物
|
||||
- 退货退款订单
|
||||
- 扣减其他费用明细
|
||||
|
||||
`Dewubzj` 保证金不属于本账单文件,不混入得物收支账单。
|
||||
|
||||
## 权威文件
|
||||
|
||||
来源固定为聚胜算“处理 / 平台原始账单(新) / 得物”导出的 XLSX。导入要求标准表头完整存在:
|
||||
|
||||
`记录编码、订单号、线上单号、自定义备注、账单项目、店铺、订单类型、商品名称、商品货号、数量、渠道、业务类型、收入金额、支出金额、线上单号对应实际店铺、订单创建时间、支付/退款时间、发货/退货时间、结算状态、收款账期、拉取时间、修改时间、修改人`。
|
||||
|
||||
得物文件没有单独“金额”列,系统以 `收入金额 - 支出金额` 计算净额。账期以“发货/退货时间”为主;该字段为空时才使用“支付/退款时间”。两个原始时间字段均单独保存,页面不会伪造支付/退款时间。
|
||||
|
||||
## 数据表
|
||||
|
||||
共享业务镜像库保存三张专用表:
|
||||
|
||||
- `dewu_settlement_bill_imports`:源文件名、SHA-256、账期、预期和实际控制数、渠道计数、状态。
|
||||
- `dewu_settlement_bill_staging`:当前导入代次的暂存数据。
|
||||
- `dewu_settlement_bill_rows`:页面和导出读取的权威正式数据。
|
||||
|
||||
页面 Repository 只读 `dewu_settlement_bill_rows`。尚未成功导入时返回明确的 503,不回退旧镜像。
|
||||
|
||||
## 导入控制
|
||||
|
||||
导入前必须同时提供账期、行数、收入和支出控制数:
|
||||
|
||||
```bash
|
||||
php artisan dewu-bills:import-authoritative /protected/path/dewu.xlsx \
|
||||
--from=2026-08-01 \
|
||||
--to=2026-08-31 \
|
||||
--expected-count=68449 \
|
||||
--expected-income=1236493.96 \
|
||||
--expected-expense=634104.33
|
||||
```
|
||||
|
||||
导入必须满足:
|
||||
|
||||
1. 文件为可读 XLSX,标准表头完整。
|
||||
2. 记录编码非空且在同一文件中唯一。
|
||||
3. 每行渠道非空,账单时间非空且位于指定账期。
|
||||
4. 实际行数、收入、支出与权威控制数完全一致。
|
||||
5. 同一 SHA-256 已成功导入时幂等跳过;同一文件使用不同账期或控制数时拒绝。
|
||||
6. 先写暂存表并完成核验,再在事务内替换指定账期;任何失败均保留上一版正式数据。
|
||||
|
||||
## 每日运行
|
||||
|
||||
每日北京时间 08:00 处理前一自然日已生成的得物完整账单,不处理当天数据。自动任务必须先归档源文件并记录 SHA-256 与控制数,再调用受控导入命令。源文件未生成、下载失败、会话失效、控制数不一致、生产连接不可用或迁移未完成时停止,不使用旧镜像补数。
|
||||
|
||||
上线前需单独完成生产备份、迁移授权、发布以及首次导入授权。回滚时使用已归档的上一版源文件及其控制数重新导入相同账期。
|
||||
178
modules/EcommerceFinance/Http/Controllers/DewuAccountBills.php
Normal file
178
modules/EcommerceFinance/Http/Controllers/DewuAccountBills.php
Normal file
@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Http\Controllers;
|
||||
|
||||
use App\Utilities\SidebarPermissions;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\EcommerceFinance\AccountBillColumns;
|
||||
use Modules\EcommerceFinance\Repositories\DewuSettlementBillRepository;
|
||||
use RuntimeException;
|
||||
|
||||
class DewuAccountBills extends Controller
|
||||
{
|
||||
public function index(Request $request, DewuSettlementBillRepository $repository)
|
||||
{
|
||||
$this->authorizeStoreReceivingBills();
|
||||
|
||||
if ($request->boolean('export')) {
|
||||
return $this->exportResponse($request, $repository);
|
||||
}
|
||||
|
||||
$validated = $request->validate($this->filterRules() + [
|
||||
'sort' => ['nullable', 'in:occurred_at,income,expense,record_code'],
|
||||
'dir' => ['nullable', 'in:asc,desc'],
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
]);
|
||||
$filters = collect($validated)->only(array_keys($this->filterRules()))->all();
|
||||
|
||||
try {
|
||||
$data = $repository->paginateAccountBills(
|
||||
$filters,
|
||||
(string) ($validated['sort'] ?? 'occurred_at'),
|
||||
(string) ($validated['dir'] ?? 'desc'),
|
||||
(int) ($validated['page'] ?? 1),
|
||||
(int) ($validated['limit'] ?? 10),
|
||||
);
|
||||
} catch (RuntimeException $exception) {
|
||||
if ($exception->getMessage() !== 'dewu_authoritative_bills_not_ready') {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => '得物完整账单尚未完成首次导入,当前不会回退到不完整的聚水潭辅助表。',
|
||||
'data' => [],
|
||||
'total' => 0,
|
||||
'current_page' => 1,
|
||||
'last_page' => 1,
|
||||
'per_page' => (int) ($validated['limit'] ?? 10),
|
||||
'summary' => ['income' => 0, 'expense' => 0, 'balance' => 0, 'count' => 0],
|
||||
'columns' => AccountBillColumns::for('dewu'),
|
||||
], 503);
|
||||
}
|
||||
|
||||
$data['columns'] = AccountBillColumns::for('dewu');
|
||||
|
||||
return response()->json($data);
|
||||
}
|
||||
|
||||
public function filterOptions(DewuSettlementBillRepository $repository)
|
||||
{
|
||||
$this->authorizeStoreReceivingBills();
|
||||
|
||||
try {
|
||||
return response()->json($repository->accountBillFilterOptions());
|
||||
} catch (RuntimeException $exception) {
|
||||
if ($exception->getMessage() !== 'dewu_authoritative_bills_not_ready') {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => '得物完整账单尚未完成首次导入。',
|
||||
'shops' => [],
|
||||
'bill_items' => [],
|
||||
], 503);
|
||||
}
|
||||
}
|
||||
|
||||
public function export(Request $request, DewuSettlementBillRepository $repository)
|
||||
{
|
||||
$this->authorizeStoreReceivingBills();
|
||||
|
||||
return $this->exportResponse($request, $repository);
|
||||
}
|
||||
|
||||
private function exportResponse(Request $request, DewuSettlementBillRepository $repository)
|
||||
{
|
||||
$validated = $request->validate($this->filterRules() + [
|
||||
'sort' => ['nullable', 'in:occurred_at,income,expense,record_code'],
|
||||
'dir' => ['nullable', 'in:asc,desc'],
|
||||
]);
|
||||
$filters = collect($validated)->only(array_keys($this->filterRules()))->all();
|
||||
$definition = AccountBillColumns::for('dewu');
|
||||
$available = collect($definition['columns'] ?? [])->keyBy('field');
|
||||
$requested = $request->query('columns', []);
|
||||
if (is_string($requested)) {
|
||||
$requested = array_filter(array_map('trim', explode(',', $requested)));
|
||||
}
|
||||
if (! is_array($requested) || count($requested) > 40) {
|
||||
$requested = [];
|
||||
}
|
||||
$requested = collect($requested)
|
||||
->filter(static fn (mixed $value): bool => is_scalar($value))
|
||||
->map(static fn (mixed $value): string => mb_substr(trim((string) $value), 0, 128))
|
||||
->filter()
|
||||
->unique();
|
||||
$columns = $requested->isEmpty()
|
||||
? $available->values()
|
||||
: $requested->map(fn (string $field) => $available->get($field))->filter()->values();
|
||||
abort_if($columns->isEmpty(), 422, '至少选择一个可导出的字段。');
|
||||
|
||||
try {
|
||||
$rows = $repository->exportAccountBills(
|
||||
$filters,
|
||||
(string) ($validated['sort'] ?? 'occurred_at'),
|
||||
(string) ($validated['dir'] ?? 'asc'),
|
||||
);
|
||||
} catch (RuntimeException $exception) {
|
||||
if ($exception->getMessage() !== 'dewu_authoritative_bills_not_ready') {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return response()->json(['message' => '得物完整账单尚未完成首次导入。'], 503);
|
||||
}
|
||||
|
||||
return response()->streamDownload(function () use ($rows, $columns): void {
|
||||
$csv = fopen('php://output', 'w');
|
||||
fputs($csv, "\xEF\xBB\xBF");
|
||||
fputcsv($csv, $columns->pluck('label')->all());
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($csv, $columns->map(
|
||||
fn (array $column): string => $this->exportValue($row, $column)
|
||||
)->all());
|
||||
}
|
||||
fclose($csv);
|
||||
}, 'dewu_account_bills.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string,array<int,string>> */
|
||||
private function filterRules(): array
|
||||
{
|
||||
return [
|
||||
'store_name' => ['nullable', 'string', 'max:255'],
|
||||
'bill_item' => ['nullable', 'string', 'max:255'],
|
||||
'start' => ['nullable', 'date_format:Y-m-d'],
|
||||
'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'],
|
||||
'keyword' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row */
|
||||
private function exportValue(array $row, array $column): string
|
||||
{
|
||||
$field = (string) ($column['field'] ?? '');
|
||||
$value = str_starts_with($field, 'payload.')
|
||||
? data_get($row['payload'] ?? [], substr($field, 8))
|
||||
: data_get($row, $field);
|
||||
$text = (string) ($value ?? '');
|
||||
if (preg_match('/^\d{12,}$/', $text) === 1) {
|
||||
return '="' . $text . '"';
|
||||
}
|
||||
|
||||
return preg_match('/^[=+\-@]/u', $text) === 1 ? "'" . $text : $text;
|
||||
}
|
||||
|
||||
private function authorizeStoreReceivingBills(): void
|
||||
{
|
||||
$section = collect(SidebarPermissions::navigationFor(user()))
|
||||
->firstWhere('slug', 'billing-transactions');
|
||||
$page = collect($section['pages'] ?? [])
|
||||
->firstWhere('slug', 'store-receiving-bills');
|
||||
|
||||
abort_if(empty($page), 403, '当前账号没有店铺收款账户账单权限。');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,322 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use RuntimeException;
|
||||
|
||||
class DewuSettlementBillRepository
|
||||
{
|
||||
private const IMPORT_TABLE = 'dewu_settlement_bill_imports';
|
||||
private const ROW_TABLE = 'dewu_settlement_bill_rows';
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
public function __construct(DatabaseManager $databases)
|
||||
{
|
||||
$this->connection = $databases->connection(
|
||||
(string) config('upstream_mirrors.connection', 'business_mirror'),
|
||||
);
|
||||
}
|
||||
|
||||
public function ready(): bool
|
||||
{
|
||||
$schema = Schema::connection($this->connection->getName());
|
||||
if (! $schema->hasTable(self::IMPORT_TABLE) || ! $schema->hasTable(self::ROW_TABLE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->connection->table(self::IMPORT_TABLE)->where('status', 'succeeded')->exists();
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public function storeOptions(): array
|
||||
{
|
||||
return $this->query()->whereNotNull('store_name')->where('store_name', '<>', '')
|
||||
->distinct()->orderBy('store_name')->pluck('store_name')->map('strval')->all();
|
||||
}
|
||||
|
||||
/** @return array{shops:list<string>,bill_items:list<string>} */
|
||||
public function accountBillFilterOptions(): array
|
||||
{
|
||||
$shops = $this->query()->whereNotNull('store_name')->where('store_name', '<>', '')
|
||||
->distinct()->orderBy('store_name')->pluck('store_name')
|
||||
->map(static fn (mixed $value): string => (string) $value)->all();
|
||||
$billItems = $this->query()->whereNotNull('bill_item')->where('bill_item', '<>', '')
|
||||
->distinct()->orderBy('bill_item')->pluck('bill_item')
|
||||
->map(static fn (mixed $value): string => (string) $value)->all();
|
||||
|
||||
return ['shops' => $shops, 'bill_items' => $billItems];
|
||||
}
|
||||
|
||||
/** @return array{rows:list<array<string,mixed>>,total:int} */
|
||||
public function paginate(
|
||||
?string $storeName,
|
||||
?string $fromDate,
|
||||
?string $toDate,
|
||||
?string $flowType,
|
||||
int $page,
|
||||
int $pageSize,
|
||||
): array {
|
||||
$page = max(1, $page);
|
||||
$pageSize = min(max(1, $pageSize), 500);
|
||||
$query = $this->query();
|
||||
$this->applyFilters($query, $storeName, $fromDate, $toDate, $flowType);
|
||||
$total = (clone $query)->count();
|
||||
|
||||
$rows = $query->orderByDesc('bill_date')->orderByDesc('record_code')
|
||||
->forPage($page, $pageSize)->get()->map(function (object $row): array {
|
||||
$signed = (float) $row->signed_amount;
|
||||
$income = (float) $row->income_amount;
|
||||
$expense = (float) $row->expense_amount;
|
||||
$flowType = $income > 0 ? 1 : ($expense > 0 ? 2 : 0);
|
||||
|
||||
return [
|
||||
'id' => $row->record_code,
|
||||
'shopName' => (string) ($row->store_name ?? ''),
|
||||
'amount' => abs($signed),
|
||||
'balance' => null,
|
||||
'summary' => (string) ($row->bill_item ?? ''),
|
||||
'flowType' => $flowType,
|
||||
'toAdvertiserFundType' => null,
|
||||
'createTime' => (string) $row->bill_date,
|
||||
'source_primary_key' => (string) ($row->order_no ?? ''),
|
||||
'source_record_code' => (string) $row->record_code,
|
||||
'source_channel' => 'DewuSettlementBill',
|
||||
];
|
||||
})->all();
|
||||
|
||||
return ['rows' => $rows, 'total' => $total];
|
||||
}
|
||||
|
||||
/** @return array{income_total:float,expense_total:float,cnt:int} */
|
||||
public function summarize(
|
||||
?string $storeName,
|
||||
?string $fromDate,
|
||||
?string $toDate,
|
||||
?string $flowType,
|
||||
): array {
|
||||
$query = $this->query();
|
||||
$this->applyFilters($query, $storeName, $fromDate, $toDate, $flowType);
|
||||
$row = $query->selectRaw(
|
||||
'COALESCE(SUM(income_amount), 0) AS income_total, '
|
||||
. 'COALESCE(SUM(expense_amount), 0) AS expense_total, COUNT(*) AS cnt'
|
||||
)->first();
|
||||
|
||||
return [
|
||||
'income_total' => (float) ($row->income_total ?? 0),
|
||||
'expense_total' => (float) ($row->expense_total ?? 0),
|
||||
'cnt' => (int) ($row->cnt ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function paginateAccountBills(
|
||||
array $filters,
|
||||
string $sort,
|
||||
string $direction,
|
||||
int $page,
|
||||
int $perPage,
|
||||
): array {
|
||||
$page = max(1, $page);
|
||||
$perPage = min(max(1, $perPage), 100);
|
||||
$query = $this->query();
|
||||
$this->applyAccountBillFilters($query, $filters);
|
||||
|
||||
$summary = (clone $query)->selectRaw(
|
||||
'COALESCE(SUM(income_amount), 0) AS income_total, '
|
||||
. 'COALESCE(SUM(expense_amount), 0) AS expense_total, COUNT(*) AS row_count'
|
||||
)->first();
|
||||
$total = (int) ($summary->row_count ?? 0);
|
||||
$income = (float) ($summary->income_total ?? 0);
|
||||
$expense = (float) ($summary->expense_total ?? 0);
|
||||
|
||||
$sortColumn = $this->accountBillSortColumn($sort);
|
||||
$direction = $direction === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
$query->orderBy($sortColumn, $direction);
|
||||
if ($sortColumn !== 'record_code') {
|
||||
$query->orderBy('record_code', $direction);
|
||||
}
|
||||
|
||||
$rows = $query->forPage($page, $perPage)->get()
|
||||
->map(fn (object $row): array => $this->mapAccountBillRow($row))->all();
|
||||
|
||||
return [
|
||||
'data' => $rows,
|
||||
'total' => $total,
|
||||
'current_page' => $page,
|
||||
'last_page' => max(1, (int) ceil($total / $perPage)),
|
||||
'per_page' => $perPage,
|
||||
'from' => $total === 0 ? null : (($page - 1) * $perPage) + 1,
|
||||
'to' => $total === 0 ? null : min($page * $perPage, $total),
|
||||
'summary' => [
|
||||
'income' => $income,
|
||||
'expense' => $expense,
|
||||
'balance' => round($income - $expense, 2),
|
||||
'count' => $total,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return iterable<int,array<string,mixed>> */
|
||||
public function exportAccountBills(array $filters, string $sort, string $direction): iterable
|
||||
{
|
||||
$query = $this->query();
|
||||
$this->applyAccountBillFilters($query, $filters);
|
||||
$sortColumn = $this->accountBillSortColumn($sort);
|
||||
$direction = $direction === 'desc' ? 'desc' : 'asc';
|
||||
$query->orderBy($sortColumn, $direction);
|
||||
if ($sortColumn !== 'record_code') {
|
||||
$query->orderBy('record_code', $direction);
|
||||
}
|
||||
|
||||
return $query->cursor()->map(fn (object $row): array => $this->mapAccountBillRow($row));
|
||||
}
|
||||
|
||||
private function query(): Builder
|
||||
{
|
||||
if (! $this->ready()) {
|
||||
throw new RuntimeException('dewu_authoritative_bills_not_ready');
|
||||
}
|
||||
|
||||
return $this->connection->table(self::ROW_TABLE);
|
||||
}
|
||||
|
||||
private function applyFilters(
|
||||
Builder $query,
|
||||
?string $storeName,
|
||||
?string $fromDate,
|
||||
?string $toDate,
|
||||
?string $flowType,
|
||||
): void {
|
||||
if ($storeName !== null && $storeName !== '') {
|
||||
$query->where('store_name', $storeName);
|
||||
}
|
||||
$from = $this->date($fromDate);
|
||||
if ($from !== null) {
|
||||
$query->where('bill_date', '>=', $from->format('Y-m-d') . ' 00:00:00');
|
||||
}
|
||||
$to = $this->date($toDate);
|
||||
if ($to !== null) {
|
||||
$query->where('bill_date', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00');
|
||||
}
|
||||
if ($flowType === 'income') {
|
||||
$query->where('income_amount', '>', 0);
|
||||
} elseif ($flowType === 'expense') {
|
||||
$query->where('expense_amount', '>', 0);
|
||||
}
|
||||
}
|
||||
/** @param array<string,mixed> $filters */
|
||||
private function applyAccountBillFilters(Builder $query, array $filters): void
|
||||
{
|
||||
$storeName = trim((string) ($filters['store_name'] ?? ''));
|
||||
if ($storeName !== '') {
|
||||
$query->where('store_name', $storeName);
|
||||
}
|
||||
|
||||
$billItem = trim((string) ($filters['bill_item'] ?? ''));
|
||||
if ($billItem !== '') {
|
||||
$query->where('bill_item', $billItem);
|
||||
}
|
||||
|
||||
$from = $this->date(isset($filters['start']) ? (string) $filters['start'] : null);
|
||||
if ($from !== null) {
|
||||
$query->where('bill_date', '>=', $from->format('Y-m-d') . ' 00:00:00');
|
||||
}
|
||||
|
||||
$to = $this->date(isset($filters['end']) ? (string) $filters['end'] : null);
|
||||
if ($to !== null) {
|
||||
$query->where('bill_date', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00');
|
||||
}
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(function (Builder $nested) use ($keyword): void {
|
||||
$like = '%' . $keyword . '%';
|
||||
$nested->where('record_code', 'like', $like)
|
||||
->orWhere('order_no', 'like', $like)
|
||||
->orWhere('online_order_no', 'like', $like)
|
||||
->orWhere('custom_remark', 'like', $like)
|
||||
->orWhere('goods_name', 'like', $like)
|
||||
->orWhere('goods_no', 'like', $like)
|
||||
->orWhere('channel', 'like', $like)
|
||||
->orWhere('bill_item', 'like', $like);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private function accountBillSortColumn(string $sort): string
|
||||
{
|
||||
return [
|
||||
'occurred_at' => 'bill_date',
|
||||
'income' => 'income_amount',
|
||||
'expense' => 'expense_amount',
|
||||
'record_code' => 'record_code',
|
||||
][$sort] ?? 'bill_date';
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function mapAccountBillRow(object $row): array
|
||||
{
|
||||
$signedAmount = (float) $row->signed_amount;
|
||||
|
||||
return [
|
||||
'id' => (string) $row->record_code,
|
||||
'record_code' => (string) $row->record_code,
|
||||
'store_name' => (string) ($row->store_name ?? ''),
|
||||
'finance_remark' => '',
|
||||
'order_no' => (string) ($row->order_no ?? ''),
|
||||
'amount' => abs($signedAmount),
|
||||
'custom_remark' => (string) ($row->custom_remark ?? ''),
|
||||
'bill_item' => (string) ($row->bill_item ?? ''),
|
||||
'occurred_at' => $row->paid_refunded_at !== null
|
||||
? (string) $row->paid_refunded_at
|
||||
: null,
|
||||
'income' => (float) $row->income_amount,
|
||||
'expense' => (float) $row->expense_amount,
|
||||
'balance' => $signedAmount,
|
||||
'remark' => '',
|
||||
'updated_at' => $row->source_updated_at !== null
|
||||
? (string) $row->source_updated_at
|
||||
: null,
|
||||
'modifier' => (string) ($row->source_modified_by ?? ''),
|
||||
'payload' => [
|
||||
'online_order_no' => (string) ($row->online_order_no ?? ''),
|
||||
'store_2' => (string) ($row->store_name ?? ''),
|
||||
'order_type' => (string) ($row->order_type ?? ''),
|
||||
'goods_name' => (string) ($row->goods_name ?? ''),
|
||||
'goods_no' => (string) ($row->goods_no ?? ''),
|
||||
'quantity' => (string) ($row->quantity ?? ''),
|
||||
'channel' => (string) ($row->channel ?? ''),
|
||||
'source_channel' => match ((string) ($row->channel ?? '')) {
|
||||
'得物' => 'Dewu',
|
||||
'退货退款订单' => 'DewuTK',
|
||||
'扣减其他费用明细' => 'DewuKJ',
|
||||
default => (string) ($row->channel ?? ''),
|
||||
},
|
||||
'business_type' => (string) ($row->business_type ?? ''),
|
||||
'online_real_store' => (string) ($row->online_real_store ?? ''),
|
||||
'order_created_at' => $row->order_created_at !== null ? (string) $row->order_created_at : null,
|
||||
'ship_return_at' => $row->ship_return_at !== null ? (string) $row->ship_return_at : null,
|
||||
'settle_status' => (string) ($row->settle_status ?? ''),
|
||||
'pay_period' => (string) ($row->pay_period ?? ''),
|
||||
'pulled_at' => $row->pulled_at !== null ? (string) $row->pulled_at : null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function date(?string $value): ?DateTimeImmutable
|
||||
{
|
||||
if ($value === null || preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
|
||||
return null;
|
||||
}
|
||||
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
||||
|
||||
return $date !== false && $date->format('Y-m-d') === $value ? $date : null;
|
||||
}
|
||||
}
|
||||
731
modules/EcommerceFinance/Services/DewuSettlementBillImporter.php
Normal file
731
modules/EcommerceFinance/Services/DewuSettlementBillImporter.php
Normal file
@ -0,0 +1,731 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use SimpleXMLElement;
|
||||
use Throwable;
|
||||
use XMLReader;
|
||||
use ZipArchive;
|
||||
|
||||
class DewuSettlementBillImporter
|
||||
{
|
||||
private const IMPORT_TABLE = 'dewu_settlement_bill_imports';
|
||||
private const STAGING_TABLE = 'dewu_settlement_bill_staging';
|
||||
private const ROW_TABLE = 'dewu_settlement_bill_rows';
|
||||
|
||||
private const HEADER_ALIASES = [
|
||||
'record_code' => ['记录编码'],
|
||||
'order_no' => ['订单号'],
|
||||
'online_order_no' => ['线上单号'],
|
||||
'custom_remark' => ['自定义备注'],
|
||||
'bill_item' => ['账单项目'],
|
||||
'store_name' => ['店铺'],
|
||||
'order_type' => ['订单类型'],
|
||||
'goods_name' => ['商品名称'],
|
||||
'goods_no' => ['商品货号'],
|
||||
'quantity' => ['数量'],
|
||||
'channel' => ['渠道'],
|
||||
'business_type' => ['业务类型'],
|
||||
'income_amount' => ['收入金额'],
|
||||
'expense_amount' => ['支出金额'],
|
||||
'online_real_store' => ['线上单号对应实际店铺'],
|
||||
'order_created_at' => ['订单创建时间'],
|
||||
'paid_refunded_at' => ['支付/退款时间'],
|
||||
'ship_return_at' => ['发货/退货时间'],
|
||||
'settle_status' => ['结算状态'],
|
||||
'pay_period' => ['收款账期'],
|
||||
'pulled_at' => ['拉取时间'],
|
||||
'source_updated_at' => ['修改时间'],
|
||||
'source_modified_by' => ['修改人'],
|
||||
];
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
public function __construct(DatabaseManager $databases)
|
||||
{
|
||||
$this->connection = $databases->connection(
|
||||
(string) config('upstream_mirrors.connection', 'business_mirror'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status:string,row_count:int,excluded_rows:int,store_count:int,bill_item_count:int,channel_counts:array<string,int>,income_total:string,expense_total:string,net_total:string,period_start:string,period_end:string,source_sha256:string}
|
||||
*/
|
||||
public function import(
|
||||
string $path,
|
||||
string $fromDate,
|
||||
string $toDate,
|
||||
int $expectedCount,
|
||||
string $expectedIncome,
|
||||
string $expectedExpense,
|
||||
int $batchSize = 1000,
|
||||
): array {
|
||||
$path = $this->validatedPath($path);
|
||||
[$from, $to] = $this->period($fromDate, $toDate);
|
||||
$this->assertSchemaReady();
|
||||
if ($expectedCount <= 0) {
|
||||
throw new RuntimeException('权威笔数控制数必须大于零。');
|
||||
}
|
||||
$expectedIncome = $this->controlAmount($expectedIncome, '收入');
|
||||
$expectedExpense = $this->controlAmount($expectedExpense, '支出');
|
||||
|
||||
$batchSize = min(max($batchSize, 100), 5000);
|
||||
$hash = hash_file('sha256', $path);
|
||||
if (! is_string($hash)) {
|
||||
throw new RuntimeException('无法计算得物完整账单文件摘要。');
|
||||
}
|
||||
|
||||
$existing = $this->connection->table(self::IMPORT_TABLE)
|
||||
->where('source_sha256', $hash)
|
||||
->first();
|
||||
if ($existing !== null && $existing->status === 'succeeded') {
|
||||
$this->assertSameImportRequest(
|
||||
$existing,
|
||||
$from,
|
||||
$to,
|
||||
$expectedCount,
|
||||
$expectedIncome,
|
||||
$expectedExpense,
|
||||
);
|
||||
if ($this->activeRowCount($hash, $from, $to) === (int) $existing->row_count) {
|
||||
return $this->result((array) $existing, 'unchanged');
|
||||
}
|
||||
}
|
||||
|
||||
$generation = $existing !== null ? (string) $existing->generation : (string) Str::uuid();
|
||||
$now = now();
|
||||
$import = [
|
||||
'generation' => $generation,
|
||||
'source_filename' => mb_substr(basename($path), 0, 255),
|
||||
'source_sha256' => $hash,
|
||||
'period_start' => $from->format('Y-m-d'),
|
||||
'period_end' => $to->format('Y-m-d'),
|
||||
'status' => 'running',
|
||||
'expected_row_count' => $expectedCount,
|
||||
'expected_income_total' => $expectedIncome,
|
||||
'expected_expense_total' => $expectedExpense,
|
||||
'row_count' => 0,
|
||||
'excluded_rows' => 0,
|
||||
'store_count' => 0,
|
||||
'bill_item_count' => 0,
|
||||
'channel_counts' => null,
|
||||
'income_total' => '0.00',
|
||||
'expense_total' => '0.00',
|
||||
'net_total' => '0.00',
|
||||
'completed_at' => null,
|
||||
'last_error_code' => null,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
if ($existing === null) {
|
||||
$this->connection->table(self::IMPORT_TABLE)->insert($import + ['created_at' => $now]);
|
||||
} else {
|
||||
$this->connection->table(self::IMPORT_TABLE)->where('generation', $generation)->update($import);
|
||||
}
|
||||
|
||||
$stats = null;
|
||||
try {
|
||||
$stats = $this->stage($path, $generation, $from, $to, $batchSize);
|
||||
$this->assertControls($stats, $expectedCount, $expectedIncome, $expectedExpense);
|
||||
$this->activate($generation, $hash, $from, $to, $stats, $batchSize);
|
||||
|
||||
return $this->result([
|
||||
'row_count' => $stats['row_count'],
|
||||
'excluded_rows' => $stats['excluded_rows'],
|
||||
'store_count' => $stats['store_count'],
|
||||
'bill_item_count' => $stats['bill_item_count'],
|
||||
'channel_counts' => $stats['channel_counts'],
|
||||
'income_total' => $stats['income_total'],
|
||||
'expense_total' => $stats['expense_total'],
|
||||
'net_total' => $stats['net_total'],
|
||||
'period_start' => $from->format('Y-m-d'),
|
||||
'period_end' => $to->format('Y-m-d'),
|
||||
'source_sha256' => $hash,
|
||||
], 'succeeded');
|
||||
} catch (Throwable $exception) {
|
||||
$this->connection->table(self::STAGING_TABLE)->where('generation', $generation)->delete();
|
||||
$failure = [
|
||||
'status' => 'failed',
|
||||
'last_error_code' => mb_substr(class_basename($exception), 0, 120),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
if (is_array($stats)) {
|
||||
$failure += [
|
||||
'row_count' => $stats['row_count'],
|
||||
'excluded_rows' => $stats['excluded_rows'],
|
||||
'store_count' => $stats['store_count'],
|
||||
'bill_item_count' => $stats['bill_item_count'],
|
||||
'channel_counts' => json_encode($stats['channel_counts'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
'income_total' => $stats['income_total'],
|
||||
'expense_total' => $stats['expense_total'],
|
||||
'net_total' => $stats['net_total'],
|
||||
];
|
||||
}
|
||||
$this->connection->table(self::IMPORT_TABLE)->where('generation', $generation)->update($failure);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function validatedPath(string $path): string
|
||||
{
|
||||
$realPath = realpath($path);
|
||||
if ($realPath === false || ! is_file($realPath) || ! is_readable($realPath)) {
|
||||
throw new RuntimeException('得物完整账单文件不存在或不可读。');
|
||||
}
|
||||
if (strtolower((string) pathinfo($realPath, PATHINFO_EXTENSION)) !== 'xlsx') {
|
||||
throw new RuntimeException('聚胜算完整账单导入只支持 XLSX 文件。');
|
||||
}
|
||||
|
||||
return $realPath;
|
||||
}
|
||||
|
||||
/** @return array{0:DateTimeImmutable,1:DateTimeImmutable} */
|
||||
private function period(string $fromDate, string $toDate): array
|
||||
{
|
||||
$from = DateTimeImmutable::createFromFormat('!Y-m-d', $fromDate);
|
||||
$to = DateTimeImmutable::createFromFormat('!Y-m-d', $toDate);
|
||||
if ($from === false || $to === false
|
||||
|| $from->format('Y-m-d') !== $fromDate
|
||||
|| $to->format('Y-m-d') !== $toDate
|
||||
|| $from > $to) {
|
||||
throw new RuntimeException('得物账单导入账期无效。');
|
||||
}
|
||||
|
||||
return [$from, $to];
|
||||
}
|
||||
|
||||
private function assertSchemaReady(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection->getName());
|
||||
foreach ([self::IMPORT_TABLE, self::STAGING_TABLE, self::ROW_TABLE] as $table) {
|
||||
if (! $schema->hasTable($table)) {
|
||||
throw new RuntimeException('得物完整账单表尚未完成迁移:' . $table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{row_count:int,excluded_rows:int,store_count:int,bill_item_count:int,channel_counts:array<string,int>,income_total:string,expense_total:string,net_total:string}
|
||||
*/
|
||||
private function stage(
|
||||
string $path,
|
||||
string $generation,
|
||||
DateTimeImmutable $from,
|
||||
DateTimeImmutable $to,
|
||||
int $batchSize,
|
||||
): array {
|
||||
$archive = new ZipArchive();
|
||||
if ($archive->open($path) !== true) {
|
||||
throw new RuntimeException('无法打开得物完整账单 XLSX 文件。');
|
||||
}
|
||||
|
||||
try {
|
||||
$worksheet = $this->firstWorksheet($archive);
|
||||
$sharedStrings = $this->sharedStrings($path, $archive);
|
||||
} finally {
|
||||
$archive->close();
|
||||
}
|
||||
|
||||
$reader = new XMLReader();
|
||||
if (! $reader->open($this->zipUri($path, $worksheet), null, LIBXML_NONET | LIBXML_COMPACT)) {
|
||||
throw new RuntimeException('无法读取得物完整账单工作表。');
|
||||
}
|
||||
|
||||
$headerMap = null;
|
||||
$batch = [];
|
||||
$rowCount = 0;
|
||||
$incomeTotal = '0.00';
|
||||
$expenseTotal = '0.00';
|
||||
$stores = [];
|
||||
$billItems = [];
|
||||
$channelCounts = [];
|
||||
|
||||
try {
|
||||
while ($reader->read()) {
|
||||
if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'row') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rowNumber = (int) ($reader->getAttribute('r') ?: 0);
|
||||
$cells = $this->rowCells($reader->readOuterXml(), $sharedStrings);
|
||||
if ($headerMap === null) {
|
||||
$headerMap = $this->headerMap($cells);
|
||||
continue;
|
||||
}
|
||||
|
||||
$values = [];
|
||||
foreach ($headerMap as $header => $column) {
|
||||
$values[$header] = trim((string) ($cells[$column] ?? ''));
|
||||
}
|
||||
if (implode('', $values) === '') {
|
||||
continue;
|
||||
}
|
||||
$recordCode = $this->required($values, 'record_code', $rowNumber, '记录编码');
|
||||
$channel = $this->required($values, 'channel', $rowNumber, '渠道');
|
||||
$storeName = $this->nullable($values['store_name']);
|
||||
$billItem = $this->nullable($values['bill_item']);
|
||||
$paidRefundedAt = $this->optionalDateTime($values['paid_refunded_at'], $rowNumber, '支付/退款时间');
|
||||
$shipReturnAt = $this->optionalDateTime($values['ship_return_at'], $rowNumber, '发货/退货时间');
|
||||
$billDate = $shipReturnAt ?? $paidRefundedAt;
|
||||
if ($billDate === null) {
|
||||
throw new RuntimeException("第 {$rowNumber} 行缺少可用于账期核验的发货/退货时间或支付/退款时间。");
|
||||
}
|
||||
if ($billDate < $from->format('Y-m-d') . ' 00:00:00'
|
||||
|| $billDate >= $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00') {
|
||||
throw new RuntimeException("第 {$rowNumber} 行账单时间超出指定账期。");
|
||||
}
|
||||
|
||||
$income = $this->money($values['income_amount'], $rowNumber, '收入金额', false);
|
||||
$expense = $this->money($values['expense_amount'], $rowNumber, '支出金额', true);
|
||||
$signed = bcsub($income, $expense, 2);
|
||||
|
||||
$batch[] = [
|
||||
'generation' => $generation,
|
||||
'record_code' => mb_substr($recordCode, 0, 191),
|
||||
'order_no' => $this->nullable($values['order_no'], 191),
|
||||
'online_order_no' => $this->nullable($values['online_order_no'], 191),
|
||||
'custom_remark' => $this->nullable($values['custom_remark']),
|
||||
'bill_item' => $billItem,
|
||||
'store_name' => $storeName,
|
||||
'order_type' => $this->nullable($values['order_type']),
|
||||
'goods_name' => $this->nullable($values['goods_name'], 1000),
|
||||
'goods_no' => $this->nullable($values['goods_no'], 191),
|
||||
'quantity' => $this->nullable($values['quantity'], 64),
|
||||
'channel' => mb_substr($channel, 0, 255),
|
||||
'business_type' => $this->nullable($values['business_type']),
|
||||
'income_amount' => $income,
|
||||
'expense_amount' => $expense,
|
||||
'signed_amount' => $signed,
|
||||
'online_real_store' => $this->nullable($values['online_real_store']),
|
||||
'order_created_at' => $this->optionalDateTime($values['order_created_at'], $rowNumber, '订单创建时间'),
|
||||
'paid_refunded_at' => $paidRefundedAt,
|
||||
'ship_return_at' => $shipReturnAt,
|
||||
'bill_date' => $billDate,
|
||||
'settle_status' => $this->nullable($values['settle_status']),
|
||||
'pay_period' => $this->nullable($values['pay_period']),
|
||||
'pulled_at' => $this->optionalDateTime($values['pulled_at'], $rowNumber, '拉取时间'),
|
||||
'source_updated_at' => $this->optionalDateTime($values['source_updated_at'], $rowNumber, '修改时间'),
|
||||
'source_modified_by' => $this->nullable($values['source_modified_by']),
|
||||
'source_row_number' => $rowNumber,
|
||||
];
|
||||
$rowCount++;
|
||||
if ($storeName !== null) {
|
||||
$stores[$storeName] = true;
|
||||
}
|
||||
if ($billItem !== null) {
|
||||
$billItems[$billItem] = true;
|
||||
}
|
||||
$channelCounts[$channel] = ($channelCounts[$channel] ?? 0) + 1;
|
||||
$incomeTotal = bcadd($incomeTotal, $income, 2);
|
||||
$expenseTotal = bcadd($expenseTotal, $expense, 2);
|
||||
|
||||
if (count($batch) >= $batchSize) {
|
||||
$this->connection->table(self::STAGING_TABLE)->insert($batch);
|
||||
$batch = [];
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$reader->close();
|
||||
}
|
||||
|
||||
if ($headerMap === null) {
|
||||
throw new RuntimeException('未找到聚胜算得物账单标准表头。');
|
||||
}
|
||||
if ($batch !== []) {
|
||||
$this->connection->table(self::STAGING_TABLE)->insert($batch);
|
||||
}
|
||||
if ($rowCount === 0) {
|
||||
throw new RuntimeException('文件中没有得物完整账单数据。');
|
||||
}
|
||||
|
||||
return [
|
||||
'row_count' => $rowCount,
|
||||
'excluded_rows' => 0,
|
||||
'store_count' => count($stores),
|
||||
'bill_item_count' => count($billItems),
|
||||
'channel_counts' => $channelCounts,
|
||||
'income_total' => $incomeTotal,
|
||||
'expense_total' => $expenseTotal,
|
||||
'net_total' => bcsub($incomeTotal, $expenseTotal, 2),
|
||||
];
|
||||
}
|
||||
|
||||
private function assertControls(array $stats, int $count, string $income, string $expense): void
|
||||
{
|
||||
if ($stats['row_count'] !== $count
|
||||
|| bccomp($stats['income_total'], $income, 2) !== 0
|
||||
|| bccomp($stats['expense_total'], $expense, 2) !== 0) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'权威控制数不一致:实际 %d 笔,收入 %s,支出 %s;期望 %d 笔,收入 %s,支出 %s。',
|
||||
$stats['row_count'],
|
||||
$stats['income_total'],
|
||||
$stats['expense_total'],
|
||||
$count,
|
||||
$income,
|
||||
$expense,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private function assertSameImportRequest(
|
||||
object $existing,
|
||||
DateTimeImmutable $from,
|
||||
DateTimeImmutable $to,
|
||||
int $expectedCount,
|
||||
string $expectedIncome,
|
||||
string $expectedExpense,
|
||||
): void {
|
||||
if ((string) $existing->period_start !== $from->format('Y-m-d')
|
||||
|| (string) $existing->period_end !== $to->format('Y-m-d')
|
||||
|| (int) $existing->expected_row_count !== $expectedCount
|
||||
|| bccomp((string) $existing->expected_income_total, $expectedIncome, 2) !== 0
|
||||
|| bccomp((string) $existing->expected_expense_total, $expectedExpense, 2) !== 0) {
|
||||
throw new RuntimeException('该文件已有成功导入记录,但本次账期或权威控制数与历史记录不一致。');
|
||||
}
|
||||
}
|
||||
|
||||
private function activeRowCount(
|
||||
string $hash,
|
||||
DateTimeImmutable $from,
|
||||
DateTimeImmutable $to,
|
||||
): int {
|
||||
return $this->connection->table(self::ROW_TABLE)
|
||||
->where('source_sha256', $hash)
|
||||
->where('bill_date', '>=', $from->format('Y-m-d') . ' 00:00:00')
|
||||
->where('bill_date', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00')
|
||||
->count();
|
||||
}
|
||||
|
||||
private function activate(
|
||||
string $generation,
|
||||
string $hash,
|
||||
DateTimeImmutable $from,
|
||||
DateTimeImmutable $to,
|
||||
array $stats,
|
||||
int $batchSize,
|
||||
): void {
|
||||
$now = now();
|
||||
$columns = [
|
||||
'order_no', 'online_order_no', 'custom_remark', 'bill_item', 'store_name', 'order_type',
|
||||
'goods_name', 'goods_no', 'quantity', 'channel', 'business_type', 'income_amount',
|
||||
'expense_amount', 'signed_amount', 'online_real_store', 'order_created_at',
|
||||
'paid_refunded_at', 'ship_return_at', 'bill_date', 'settle_status', 'pay_period',
|
||||
'pulled_at', 'source_updated_at', 'source_modified_by',
|
||||
'source_row_number', 'source_generation', 'source_sha256', 'imported_at',
|
||||
];
|
||||
|
||||
$this->connection->transaction(function () use (
|
||||
$generation, $hash, $from, $to, $stats, $batchSize, $columns, $now
|
||||
): void {
|
||||
$this->connection->table(self::ROW_TABLE)
|
||||
->where('bill_date', '>=', $from->format('Y-m-d') . ' 00:00:00')
|
||||
->where('bill_date', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00')
|
||||
->delete();
|
||||
|
||||
$this->connection->table(self::STAGING_TABLE)
|
||||
->where('generation', $generation)
|
||||
->orderBy('id')
|
||||
->chunkById($batchSize, function ($rows) use ($generation, $hash, $columns, $now): void {
|
||||
$payload = [];
|
||||
foreach ($rows as $row) {
|
||||
$payload[] = [
|
||||
'record_code' => $row->record_code,
|
||||
'order_no' => $row->order_no,
|
||||
'online_order_no' => $row->online_order_no,
|
||||
'custom_remark' => $row->custom_remark,
|
||||
'bill_item' => $row->bill_item,
|
||||
'store_name' => $row->store_name,
|
||||
'order_type' => $row->order_type,
|
||||
'goods_name' => $row->goods_name,
|
||||
'goods_no' => $row->goods_no,
|
||||
'quantity' => $row->quantity,
|
||||
'channel' => $row->channel,
|
||||
'business_type' => $row->business_type,
|
||||
'income_amount' => $row->income_amount,
|
||||
'expense_amount' => $row->expense_amount,
|
||||
'signed_amount' => $row->signed_amount,
|
||||
'online_real_store' => $row->online_real_store,
|
||||
'order_created_at' => $row->order_created_at,
|
||||
'paid_refunded_at' => $row->paid_refunded_at,
|
||||
'ship_return_at' => $row->ship_return_at,
|
||||
'bill_date' => $row->bill_date,
|
||||
'settle_status' => $row->settle_status,
|
||||
'pay_period' => $row->pay_period,
|
||||
'pulled_at' => $row->pulled_at,
|
||||
'source_updated_at' => $row->source_updated_at,
|
||||
'source_modified_by' => $row->source_modified_by,
|
||||
'source_row_number' => $row->source_row_number,
|
||||
'source_generation' => $generation,
|
||||
'source_sha256' => $hash,
|
||||
'imported_at' => $now,
|
||||
];
|
||||
}
|
||||
$this->connection->table(self::ROW_TABLE)->upsert($payload, ['record_code'], $columns);
|
||||
});
|
||||
|
||||
$this->connection->table(self::IMPORT_TABLE)->where('generation', $generation)->update([
|
||||
'status' => 'succeeded',
|
||||
'row_count' => $stats['row_count'],
|
||||
'excluded_rows' => $stats['excluded_rows'],
|
||||
'store_count' => $stats['store_count'],
|
||||
'bill_item_count' => $stats['bill_item_count'],
|
||||
'channel_counts' => json_encode($stats['channel_counts'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
'income_total' => $stats['income_total'],
|
||||
'expense_total' => $stats['expense_total'],
|
||||
'net_total' => $stats['net_total'],
|
||||
'completed_at' => $now,
|
||||
'last_error_code' => null,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
});
|
||||
|
||||
$this->connection->table(self::STAGING_TABLE)->where('generation', $generation)->delete();
|
||||
}
|
||||
|
||||
/** @return array<int,string> */
|
||||
private function rowCells(string $xml, array $sharedStrings): array
|
||||
{
|
||||
$row = simplexml_load_string($xml);
|
||||
if (! $row instanceof SimpleXMLElement) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$cells = [];
|
||||
foreach ($row->xpath('./*[local-name()="c"]') ?: [] as $cell) {
|
||||
$reference = (string) ($cell['r'] ?? '');
|
||||
if (preg_match('/^([A-Z]+)\d+$/i', $reference, $matches) !== 1) {
|
||||
continue;
|
||||
}
|
||||
$type = (string) ($cell['t'] ?? '');
|
||||
if ($type === 'inlineStr') {
|
||||
$nodes = $cell->xpath('.//*[local-name()="t"]') ?: [];
|
||||
$value = implode('', array_map(static fn (SimpleXMLElement $node): string => (string) $node, $nodes));
|
||||
} else {
|
||||
$nodes = $cell->xpath('./*[local-name()="v"]') ?: [];
|
||||
$raw = isset($nodes[0]) ? (string) $nodes[0] : '';
|
||||
$value = $type === 's' ? (string) ($sharedStrings[(int) $raw] ?? '') : $raw;
|
||||
}
|
||||
$cells[$this->columnIndex($matches[1])] = $value;
|
||||
}
|
||||
|
||||
return $cells;
|
||||
}
|
||||
|
||||
/** @param array<int,string> $cells @return array<string,int> */
|
||||
private function headerMap(array $cells): ?array
|
||||
{
|
||||
$lookup = [];
|
||||
foreach ($cells as $column => $value) {
|
||||
$lookup[trim($value)] = $column;
|
||||
}
|
||||
|
||||
$mapping = [];
|
||||
foreach (self::HEADER_ALIASES as $field => $aliases) {
|
||||
foreach ($aliases as $alias) {
|
||||
if (array_key_exists($alias, $lookup)) {
|
||||
$mapping[$field] = $lookup[$alias];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (! array_key_exists($field, $mapping)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
private function required(array $values, string $key, int $row, ?string $label = null): string
|
||||
{
|
||||
$value = trim((string) ($values[$key] ?? ''));
|
||||
if ($value === '') {
|
||||
throw new RuntimeException("第 {$row} 行缺少" . ($label ?? $key) . '。');
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function money(
|
||||
string $value,
|
||||
int $row,
|
||||
string $field,
|
||||
bool $absolute = false,
|
||||
bool $allowNegative = false,
|
||||
): string {
|
||||
$value = str_replace([',', ',', '¥', '¥'], '', trim($value));
|
||||
$value = $value === '' ? '0' : $value;
|
||||
if (! is_numeric($value) || (! $allowNegative && ! $absolute && bccomp($value, '0', 6) < 0)) {
|
||||
throw new RuntimeException("第 {$row} 行{$field}不是有效金额。");
|
||||
}
|
||||
if ($absolute && bccomp($value, '0', 6) < 0) {
|
||||
$value = bcmul($value, '-1', 6);
|
||||
}
|
||||
|
||||
return bcadd($value, '0', 2);
|
||||
}
|
||||
|
||||
private function controlAmount(string $value, string $label): string
|
||||
{
|
||||
$value = str_replace([',', ',', '¥', '¥'], '', trim($value));
|
||||
if (! is_numeric($value) || bccomp($value, '0', 6) < 0) {
|
||||
throw new RuntimeException("权威{$label}控制数无效。");
|
||||
}
|
||||
|
||||
return bcadd($value, '0', 2);
|
||||
}
|
||||
|
||||
private function dateTime(string $value, int $row, string $field): string
|
||||
{
|
||||
$parsed = $this->parseDateTime($value);
|
||||
if ($parsed === null) {
|
||||
throw new RuntimeException("第 {$row} 行{$field}不是有效日期时间。");
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
private function optionalDateTime(string $value, int $row, string $field): ?string
|
||||
{
|
||||
if (trim($value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->dateTime($value, $row, $field);
|
||||
}
|
||||
|
||||
private function parseDateTime(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
try {
|
||||
if (is_numeric($value)) {
|
||||
$seconds = (int) round((((float) $value) - 25569) * 86400);
|
||||
|
||||
return Carbon::createFromTimestampUTC($seconds)->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
return Carbon::parse($value)->format('Y-m-d H:i:s');
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function nullable(string $value, int $max = 255): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
return $value === '' ? null : mb_substr($value, 0, $max);
|
||||
}
|
||||
|
||||
private function columnIndex(string $letters): int
|
||||
{
|
||||
$index = 0;
|
||||
foreach (str_split(strtoupper($letters)) as $letter) {
|
||||
$index = ($index * 26) + ord($letter) - 64;
|
||||
}
|
||||
|
||||
return $index - 1;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function sharedStrings(string $path, ZipArchive $archive): array
|
||||
{
|
||||
if ($archive->locateName('xl/sharedStrings.xml') === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$reader = new XMLReader();
|
||||
if (! $reader->open($this->zipUri($path, 'xl/sharedStrings.xml'), null, LIBXML_NONET | LIBXML_COMPACT)) {
|
||||
throw new RuntimeException('无法读取 XLSX 共享文本。');
|
||||
}
|
||||
|
||||
$strings = [];
|
||||
try {
|
||||
while ($reader->read()) {
|
||||
if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'si') {
|
||||
continue;
|
||||
}
|
||||
$item = simplexml_load_string($reader->readOuterXml());
|
||||
$nodes = $item instanceof SimpleXMLElement ? ($item->xpath('.//*[local-name()="t"]') ?: []) : [];
|
||||
$strings[] = implode('', array_map(
|
||||
static fn (SimpleXMLElement $node): string => (string) $node,
|
||||
$nodes,
|
||||
));
|
||||
}
|
||||
} finally {
|
||||
$reader->close();
|
||||
}
|
||||
|
||||
return $strings;
|
||||
}
|
||||
|
||||
private function firstWorksheet(ZipArchive $archive): string
|
||||
{
|
||||
$worksheets = [];
|
||||
for ($index = 0; $index < $archive->numFiles; $index++) {
|
||||
$name = $archive->getNameIndex($index);
|
||||
if (is_string($name) && preg_match('#^xl/worksheets/sheet\d+\.xml$#', $name) === 1) {
|
||||
$worksheets[] = $name;
|
||||
}
|
||||
}
|
||||
sort($worksheets, SORT_NATURAL);
|
||||
if ($worksheets === []) {
|
||||
throw new RuntimeException('XLSX 文件中没有工作表。');
|
||||
}
|
||||
|
||||
return $worksheets[0];
|
||||
}
|
||||
|
||||
private function zipUri(string $path, string $entry): string
|
||||
{
|
||||
return 'zip://' . str_replace('\\', '/', $path) . '#' . $entry;
|
||||
}
|
||||
|
||||
private function result(array $row, string $status): array
|
||||
{
|
||||
return [
|
||||
'status' => $status,
|
||||
'row_count' => (int) ($row['row_count'] ?? 0),
|
||||
'excluded_rows' => (int) ($row['excluded_rows'] ?? 0),
|
||||
'store_count' => (int) ($row['store_count'] ?? 0),
|
||||
'bill_item_count' => (int) ($row['bill_item_count'] ?? 0),
|
||||
'channel_counts' => $this->channelCounts($row['channel_counts'] ?? []),
|
||||
'income_total' => bcadd((string) ($row['income_total'] ?? '0'), '0', 2),
|
||||
'expense_total' => bcadd((string) ($row['expense_total'] ?? '0'), '0', 2),
|
||||
'net_total' => bcadd((string) ($row['net_total'] ?? '0'), '0', 2),
|
||||
'period_start' => (string) ($row['period_start'] ?? ''),
|
||||
'period_end' => (string) ($row['period_end'] ?? ''),
|
||||
'source_sha256' => (string) ($row['source_sha256'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,int> */
|
||||
private function channelCounts(mixed $value): array
|
||||
{
|
||||
if (is_string($value)) {
|
||||
try {
|
||||
$value = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$counts = [];
|
||||
foreach ($value as $channel => $count) {
|
||||
if (is_string($channel) && $channel !== '' && is_numeric($count)) {
|
||||
$counts[$channel] = (int) $count;
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
}
|
||||
@ -16,16 +16,16 @@
|
||||
</el-select>
|
||||
<input v-else v-model.trim="filters.bill_item" placeholder="账单项目" />
|
||||
</label>
|
||||
<div class="tw-filter-field tw-date" :class="{ 'is-pinduoduo-date': isPinduoduo }">
|
||||
<div class="tw-filter-field tw-date" :class="{ 'is-stable-date': usesStableDateControl }">
|
||||
<span class="tw-filter-label">{{ ['jd-transactions','jd-wallet'].includes(billType) ? '费用结算时间' : '发生时间' }}</span>
|
||||
<div class="tw-date-row">
|
||||
<span class="tw-date-control has-picker" @click="isPinduoduo && openDatePicker('startDate')">
|
||||
<span class="tw-date-control has-picker" @click="usesStableDateControl && openDatePicker('startDate')">
|
||||
<flat-picker ref="startDate" :value="filters.start" :config="startDateConfig" aria-label="开始日期" placeholder="开始日期" @on-change="(_, value) => changeDate('start', value)" />
|
||||
<button v-if="filters.start" type="button" class="tw-date-clear" title="清除开始日期" aria-label="清除开始日期" @click.stop="changeDate('start', '')"><span class="material-icons-outlined" aria-hidden="true">close</span></button>
|
||||
<button type="button" class="tw-date-picker" title="选择开始日期" aria-label="选择开始日期" @click.stop="openDatePicker('startDate')"><span class="material-icons-outlined" aria-hidden="true">calendar_today</span></button>
|
||||
</span>
|
||||
<b>至</b>
|
||||
<span class="tw-date-control has-picker" @click="isPinduoduo && openDatePicker('endDate')">
|
||||
<span class="tw-date-control has-picker" @click="usesStableDateControl && openDatePicker('endDate')">
|
||||
<flat-picker ref="endDate" :value="filters.end" :config="endDateConfig" aria-label="结束日期" placeholder="结束日期" @on-change="(_, value) => changeDate('end', value)" />
|
||||
<button v-if="filters.end" type="button" class="tw-date-clear" title="清除结束日期" aria-label="清除结束日期" @click.stop="changeDate('end', '')"><span class="material-icons-outlined" aria-hidden="true">close</span></button>
|
||||
<button type="button" class="tw-date-picker" title="选择结束日期" aria-label="选择结束日期" @click.stop="openDatePicker('endDate')"><span class="material-icons-outlined" aria-hidden="true">calendar_today</span></button>
|
||||
@ -81,7 +81,7 @@ export default {
|
||||
data(){return{loading:false,loadError:false,sync:null,rows:[],sourceColumns:[],configuredColumns:[],columnsTitle:'',sourceGroups:[],summary:{income:0,expense:0,balance:0,count:0},total:0,currentPage:1,lastPage:1,perPage:20,filters:emptyFilters(this.billType),queryFilters:emptyFilters(this.billType),resultFilters:null,filterOptions:{shops:[],bill_items:[]},filterOptionsLoading:false,filterOptionsError:false,requestSerial:0,sort:{key:'occurred_at',dir:'desc'},exportMode:'filtered-visible'};},
|
||||
computed:{
|
||||
isDouyin(){return this.billType==='douyin';},
|
||||
isPinduoduo(){return this.billType==='pinduoduo';},
|
||||
usesStableDateControl(){return ['pinduoduo','dewu'].indexOf(this.billType)!==-1;},
|
||||
usesMirrorOptions(){return ['douyin','pinduoduo','dewu','jd-transactions','jd-wallet'].indexOf(this.billType)!==-1;},
|
||||
invalidDateRange(){return Boolean(this.filters.start&&this.filters.end&&this.filters.start>this.filters.end);},
|
||||
startDateConfig(){return {...dateConfig,maxDate:this.filters.end||null};},
|
||||
@ -130,12 +130,12 @@ export default {
|
||||
.tw-date-picker{flex:none}
|
||||
.tw-date-clear{display:flex;align-items:center;justify-content:center;width:1.5rem;flex:none;padding:0;border:1px solid #d8d2c7;border-left:0;border-right:0;background:#fff;color:#595959;cursor:pointer}
|
||||
.tw-date-clear .material-icons-outlined{font-size:1rem}
|
||||
.tw-date.is-pinduoduo-date .tw-date-control{position:relative;overflow:hidden;height:2.125rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff;cursor:pointer}
|
||||
.tw-date.is-pinduoduo-date .tw-date-control:focus-within{border-color:#856632;box-shadow:0 0 0 3px rgba(133,102,50,.2)}
|
||||
.tw-date.is-pinduoduo-date .tw-date-control.has-picker input{width:100%;height:100%;padding:0 4rem 0 .625rem;border:0;border-radius:0;background:transparent;box-shadow:none;cursor:pointer}
|
||||
.tw-date.is-pinduoduo-date .tw-date-picker,.tw-date.is-pinduoduo-date .tw-date-clear{position:absolute;top:0;height:2rem;border-top:0;border-bottom:0}
|
||||
.tw-date.is-pinduoduo-date .tw-date-picker{right:0;border-right:0;border-radius:0}
|
||||
.tw-date.is-pinduoduo-date .tw-date-clear{right:2.125rem;border-left:0;z-index:1}
|
||||
.tw-date.is-stable-date .tw-date-control{position:relative;overflow:hidden;height:2.125rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff;cursor:pointer}
|
||||
.tw-date.is-stable-date .tw-date-control:focus-within{border-color:#856632;box-shadow:0 0 0 3px rgba(133,102,50,.2)}
|
||||
.tw-date.is-stable-date .tw-date-control.has-picker input{width:100%;height:100%;padding:0 4rem 0 .625rem;border:0;border-radius:0;background:transparent;box-shadow:none;cursor:pointer}
|
||||
.tw-date.is-stable-date .tw-date-picker,.tw-date.is-stable-date .tw-date-clear{position:absolute;top:0;height:2rem;border-top:0;border-bottom:0}
|
||||
.tw-date.is-stable-date .tw-date-picker{right:0;border-right:0;border-radius:0}
|
||||
.tw-date.is-stable-date .tw-date-clear{right:2.125rem;border-left:0;z-index:1}
|
||||
@container(max-width:1200px){
|
||||
.table-workspace .tw-filter{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.table-workspace .tw-date{grid-column:1/-1}
|
||||
|
||||
@ -11,6 +11,7 @@ use App\Http\Controllers\Business\SupplierPayableActions;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\EcommerceFinance\Http\Controllers\AccountBills;
|
||||
use Modules\EcommerceFinance\Http\Controllers\AlipayBills;
|
||||
use Modules\EcommerceFinance\Http\Controllers\DewuAccountBills;
|
||||
use Modules\EcommerceFinance\Http\Controllers\PinduoduoAccountBills;
|
||||
use Modules\EcommerceFinance\Http\Controllers\Api\GoodsReconciliations;
|
||||
use Modules\EcommerceFinance\Http\Controllers\Api\SupplierPayables;
|
||||
@ -34,6 +35,12 @@ Route::get('ecommerce-finance/account-bills/type/pinduoduo/filter-options', [Pin
|
||||
->name('ecommerce-finance.pinduoduo-account-bills.filter-options');
|
||||
Route::get('ecommerce-finance/account-bills/type/pinduoduo/export', [PinduoduoAccountBills::class, 'export'])
|
||||
->name('ecommerce-finance.pinduoduo-account-bills.export');
|
||||
Route::get('ecommerce-finance/account-bills/type/dewu', [DewuAccountBills::class, 'index'])
|
||||
->name('ecommerce-finance.dewu-account-bills.index');
|
||||
Route::get('ecommerce-finance/account-bills/type/dewu/filter-options', [DewuAccountBills::class, 'filterOptions'])
|
||||
->name('ecommerce-finance.dewu-account-bills.filter-options');
|
||||
Route::get('ecommerce-finance/account-bills/type/dewu/export', [DewuAccountBills::class, 'export'])
|
||||
->name('ecommerce-finance.dewu-account-bills.export');
|
||||
Route::get('ecommerce-finance/account-bills/type/{bill_type}', [AccountBills::class, 'index'])
|
||||
->name('ecommerce-finance.account-bills.index');
|
||||
Route::get('ecommerce-finance/account-bills/type/{bill_type}/filter-options', [AccountBills::class, 'filterOptions'])
|
||||
|
||||
@ -4,13 +4,13 @@ namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use Mockery;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingDewuBillMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\DewuSettlementBillRepository;
|
||||
use RuntimeException;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class DewuReceivingBillsTest extends FeatureTestCase
|
||||
{
|
||||
public function test_endpoint_reads_dewu_receiving_mirror_with_all_filters(): void
|
||||
public function test_endpoint_reads_authoritative_dewu_rows_with_all_channels(): void
|
||||
{
|
||||
$this->allowPage();
|
||||
$filters = [
|
||||
@ -20,8 +20,8 @@ class DewuReceivingBillsTest extends FeatureTestCase
|
||||
'end' => '2026-08-31',
|
||||
'keyword' => '110211',
|
||||
];
|
||||
$repository = Mockery::mock(StoreReceivingDewuBillMirrorRepository::class);
|
||||
$repository->shouldReceive('paginate')->once()->with(
|
||||
$repository = Mockery::mock(DewuSettlementBillRepository::class);
|
||||
$repository->shouldReceive('paginateAccountBills')->once()->with(
|
||||
$filters, 'occurred_at', 'desc', 2, 20,
|
||||
)->andReturn([
|
||||
'current_page' => 2,
|
||||
@ -29,34 +29,27 @@ class DewuReceivingBillsTest extends FeatureTestCase
|
||||
'per_page' => 20,
|
||||
'total' => 41,
|
||||
'data' => [[
|
||||
'id' => '14654480',
|
||||
'record_code' => '2000000001248775179',
|
||||
'store_name' => '得物-测试店',
|
||||
'income' => 21.0,
|
||||
'expense' => 0.0,
|
||||
'payload' => [
|
||||
'online_order_no' => '110211311661075313',
|
||||
'channel' => '得物',
|
||||
'source_channel' => 'Dewu',
|
||||
],
|
||||
'payload' => ['channel' => '得物', 'source_channel' => 'Dewu'],
|
||||
], [
|
||||
'id' => '14654481',
|
||||
'record_code' => '2000000001248775180',
|
||||
'store_name' => '得物-测试店',
|
||||
'income' => 0.0,
|
||||
'expense' => 18.07,
|
||||
'payload' => ['channel' => '退货退款订单', 'source_channel' => 'DewuTK'],
|
||||
], [
|
||||
'id' => '14654482',
|
||||
'record_code' => '2000000001248775181',
|
||||
'store_name' => '得物-测试店',
|
||||
'income' => 0.0,
|
||||
'expense' => 3.50,
|
||||
'payload' => ['channel' => '扣减其他费用明细', 'source_channel' => 'DewuKJ'],
|
||||
]],
|
||||
'summary' => ['income' => 21.0, 'expense' => 0.0, 'balance' => 21.0, 'count' => 41],
|
||||
'summary' => ['income' => 21.0, 'expense' => 21.57, 'balance' => -0.57, 'count' => 41],
|
||||
]);
|
||||
$this->app->instance(StoreReceivingDewuBillMirrorRepository::class, $repository);
|
||||
$this->app->instance(DewuSettlementBillRepository::class, $repository);
|
||||
|
||||
$this->withExceptionHandling()
|
||||
->loginAs()
|
||||
@ -67,32 +60,29 @@ class DewuReceivingBillsTest extends FeatureTestCase
|
||||
'limit' => 20,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.payload.channel', '得物')
|
||||
->assertJsonPath('data.0.payload.source_channel', 'Dewu')
|
||||
->assertJsonPath('data.1.payload.channel', '退货退款订单')
|
||||
->assertJsonPath('data.1.payload.source_channel', 'DewuTK')
|
||||
->assertJsonPath('data.2.payload.channel', '扣减其他费用明细')
|
||||
->assertJsonPath('data.2.payload.source_channel', 'DewuKJ')
|
||||
->assertJsonPath('columns.title', '得物账户')
|
||||
->assertJsonPath('summary.count', 41);
|
||||
}
|
||||
|
||||
public function test_filter_options_and_selected_column_export_use_the_same_mirror(): void
|
||||
public function test_filter_options_and_selected_column_export_use_the_authoritative_table(): void
|
||||
{
|
||||
$this->allowPage();
|
||||
$repository = Mockery::mock(StoreReceivingDewuBillMirrorRepository::class);
|
||||
$repository->shouldReceive('filterOptions')->once()->andReturn([
|
||||
$repository = Mockery::mock(DewuSettlementBillRepository::class);
|
||||
$repository->shouldReceive('accountBillFilterOptions')->once()->andReturn([
|
||||
'shops' => ['得物-A店', '得物-B店'],
|
||||
'bill_items' => ['交易收入', '平台服务费'],
|
||||
]);
|
||||
$repository->shouldReceive('exportRows')->once()->with(
|
||||
$repository->shouldReceive('exportAccountBills')->once()->with(
|
||||
['store_name' => '得物-A店'], 'record_code', 'asc',
|
||||
)->andReturn([[
|
||||
'record_code' => '2000000001248775179',
|
||||
'store_name' => '得物-A店',
|
||||
'payload' => ['channel' => '退货退款订单', 'source_channel' => 'DewuTK'],
|
||||
]]);
|
||||
$this->app->instance(StoreReceivingDewuBillMirrorRepository::class, $repository);
|
||||
$this->app->instance(DewuSettlementBillRepository::class, $repository);
|
||||
|
||||
$this->loginAs()
|
||||
->withHeader('X-Requested-With', 'XMLHttpRequest')
|
||||
@ -119,20 +109,37 @@ class DewuReceivingBillsTest extends FeatureTestCase
|
||||
$this->assertStringContainsString('退货退款订单,DewuTK,"=""2000000001248775179""",得物-A店', $csv);
|
||||
}
|
||||
|
||||
public function test_unready_mirror_returns_a_clear_retryable_response(): void
|
||||
public function test_unready_authoritative_table_returns_a_clear_retryable_response(): void
|
||||
{
|
||||
$this->allowPage();
|
||||
$repository = Mockery::mock(StoreReceivingDewuBillMirrorRepository::class);
|
||||
$repository->shouldReceive('paginate')->once()->andThrow(
|
||||
new RuntimeException('store_receiving_alipay_bill_mirror_not_ready')
|
||||
$repository = Mockery::mock(DewuSettlementBillRepository::class);
|
||||
$repository->shouldReceive('paginateAccountBills')->once()->andThrow(
|
||||
new RuntimeException('dewu_authoritative_bills_not_ready')
|
||||
);
|
||||
$this->app->instance(StoreReceivingDewuBillMirrorRepository::class, $repository);
|
||||
$this->app->instance(DewuSettlementBillRepository::class, $repository);
|
||||
|
||||
$this->loginAs()
|
||||
->withHeader('X-Requested-With', 'XMLHttpRequest')
|
||||
->get(route('ecommerce-finance.account-bills.index', ['bill_type' => 'dewu']))
|
||||
->assertStatus(503)
|
||||
->assertJsonPath('message', '得物账户账单共享业务镜像正在首次同步,完成后将自动显示。');
|
||||
->assertJsonPath('message', '得物完整账单尚未完成首次导入,当前不会回退到不完整的聚水潭辅助表。');
|
||||
}
|
||||
|
||||
public function test_invalid_date_range_is_rejected_before_reading_the_repository(): void
|
||||
{
|
||||
$this->allowPage();
|
||||
$repository = Mockery::mock(DewuSettlementBillRepository::class);
|
||||
$repository->shouldNotReceive('paginateAccountBills');
|
||||
$this->app->instance(DewuSettlementBillRepository::class, $repository);
|
||||
|
||||
$this->withExceptionHandling()
|
||||
->loginAs()
|
||||
->getJson(route('ecommerce-finance.account-bills.index', [
|
||||
'bill_type' => 'dewu',
|
||||
'start' => '2026-09-10',
|
||||
'end' => '2026-08-12',
|
||||
]))
|
||||
->assertStatus(422);
|
||||
}
|
||||
|
||||
private function allowPage(): void
|
||||
|
||||
373
tests/Feature/Console/DewuSettlementBillImporterTest.php
Normal file
373
tests/Feature/Console/DewuSettlementBillImporterTest.php
Normal file
@ -0,0 +1,373 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Console;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\DewuSettlementBillRepository;
|
||||
use Modules\EcommerceFinance\Services\DewuSettlementBillImporter;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DewuSettlementBillImporterTest extends TestCase
|
||||
{
|
||||
/** @var list<string> */
|
||||
private array $files = [];
|
||||
|
||||
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');
|
||||
$this->createTables();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ($this->files as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_it_imports_current_jushengsuan_headers_keeps_all_channels_and_is_idempotent(): void
|
||||
{
|
||||
$this->insertActiveRow('OLD-IN-PERIOD', '2026-08-15 12:00:00');
|
||||
$this->insertActiveRow('KEEP-OUTSIDE-PERIOD', '2026-07-31 23:59:59');
|
||||
$path = $this->xlsx([
|
||||
$this->row('REC-INCOME', '2026-08-01 00:00:19', '100.00', '0'),
|
||||
$this->row('REC-EXPENSE', '2026-08-02 12:00:00', '0', '25.00', '退货退款订单', '退款'),
|
||||
$this->row('REC-ZERO', '2026-08-03 12:00:00', '0', '0'),
|
||||
$this->row('REC-FEE', '2026-08-04 12:00:00', '0', '3.00', '扣减其他费用明细'),
|
||||
]);
|
||||
|
||||
$importer = app(DewuSettlementBillImporter::class);
|
||||
$result = $importer->import(
|
||||
$path,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
4,
|
||||
'100.00',
|
||||
'28.00',
|
||||
100,
|
||||
);
|
||||
|
||||
$this->assertSame('succeeded', $result['status']);
|
||||
$this->assertSame(4, $result['row_count']);
|
||||
$this->assertSame(0, $result['excluded_rows']);
|
||||
$this->assertSame(1, $result['store_count']);
|
||||
$this->assertSame(2, $result['bill_item_count']);
|
||||
$this->assertSame([
|
||||
'得物' => 2,
|
||||
'退货退款订单' => 1,
|
||||
'扣减其他费用明细' => 1,
|
||||
], $result['channel_counts']);
|
||||
$this->assertSame('100.00', $result['income_total']);
|
||||
$this->assertSame('28.00', $result['expense_total']);
|
||||
$this->assertSame('72.00', $result['net_total']);
|
||||
$this->assertDatabaseMissing('dewu_settlement_bill_rows', ['record_code' => 'OLD-IN-PERIOD'], 'business_mirror');
|
||||
$this->assertDatabaseHas('dewu_settlement_bill_rows', ['record_code' => 'KEEP-OUTSIDE-PERIOD'], 'business_mirror');
|
||||
$this->assertDatabaseHas('dewu_settlement_bill_rows', [
|
||||
'record_code' => 'REC-FEE',
|
||||
'channel' => '扣减其他费用明细',
|
||||
'signed_amount' => -3,
|
||||
], 'business_mirror');
|
||||
$this->assertDatabaseHas('dewu_settlement_bill_rows', [
|
||||
'record_code' => 'REC-INCOME',
|
||||
'signed_amount' => 100,
|
||||
], 'business_mirror');
|
||||
|
||||
$repository = app(DewuSettlementBillRepository::class);
|
||||
$this->assertTrue($repository->ready());
|
||||
$this->assertSame([
|
||||
'income_total' => 100.0,
|
||||
'expense_total' => 28.0,
|
||||
'cnt' => 4,
|
||||
], $repository->summarize(null, '2026-08-01', '2026-08-31', null));
|
||||
$this->assertSame(1, $repository->paginate(null, '2026-08-01', '2026-08-31', 'income', 1, 25)['total']);
|
||||
$this->assertSame(2, $repository->paginate(null, '2026-08-01', '2026-08-31', 'expense', 1, 25)['total']);
|
||||
$zero = $repository->paginate(null, '2026-08-03', '2026-08-03', null, 1, 25);
|
||||
$this->assertSame(0, $zero['rows'][0]['flowType']);
|
||||
|
||||
$unchanged = $importer->import(
|
||||
$path,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
4,
|
||||
'100.00',
|
||||
'28.00',
|
||||
100,
|
||||
);
|
||||
$this->assertSame('unchanged', $unchanged['status']);
|
||||
$this->assertSame(5, DB::connection('business_mirror')->table('dewu_settlement_bill_rows')->count());
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('本次账期或权威控制数与历史记录不一致');
|
||||
$importer->import($path, '2026-08-01', '2026-08-31', 5, '100.00', '28.00', 100);
|
||||
}
|
||||
|
||||
public function test_control_mismatch_does_not_replace_the_active_period(): void
|
||||
{
|
||||
$this->insertActiveRow('ACTIVE-ROW', '2026-08-15 12:00:00');
|
||||
$path = $this->xlsx([
|
||||
$this->row('NEW-ROW', '2026-08-16 12:00:00', '10.00', '0'),
|
||||
]);
|
||||
|
||||
try {
|
||||
app(DewuSettlementBillImporter::class)->import(
|
||||
$path,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
2,
|
||||
'10.00',
|
||||
'0.00',
|
||||
100,
|
||||
);
|
||||
$this->fail('控制数不一致时必须拒绝激活。');
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->assertStringContainsString('权威控制数不一致', $exception->getMessage());
|
||||
}
|
||||
|
||||
$this->assertDatabaseHas('dewu_settlement_bill_rows', ['record_code' => 'ACTIVE-ROW'], 'business_mirror');
|
||||
$this->assertDatabaseMissing('dewu_settlement_bill_rows', ['record_code' => 'NEW-ROW'], 'business_mirror');
|
||||
$this->assertSame('failed', DB::connection('business_mirror')->table('dewu_settlement_bill_imports')->value('status'));
|
||||
$this->assertSame(2, DB::connection('business_mirror')->table('dewu_settlement_bill_imports')->value('expected_row_count'));
|
||||
$this->assertSame(1, DB::connection('business_mirror')->table('dewu_settlement_bill_imports')->value('row_count'));
|
||||
$this->assertSame(0, DB::connection('business_mirror')->table('dewu_settlement_bill_staging')->count());
|
||||
}
|
||||
|
||||
public function test_duplicate_record_codes_are_rejected_without_replacing_active_rows(): void
|
||||
{
|
||||
$this->insertActiveRow('ACTIVE-ROW', '2026-08-15 12:00:00');
|
||||
$path = $this->xlsx([
|
||||
$this->row('DUPLICATE', '2026-08-16 12:00:00', '10.00', '0'),
|
||||
$this->row('DUPLICATE', '2026-08-17 12:00:00', '20.00', '0'),
|
||||
]);
|
||||
|
||||
$failure = null;
|
||||
try {
|
||||
app(DewuSettlementBillImporter::class)->import(
|
||||
$path,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
2,
|
||||
'30.00',
|
||||
'0.00',
|
||||
100,
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
$failure = $exception;
|
||||
}
|
||||
|
||||
$this->assertNotNull($failure, '重复记录编码必须拒绝导入。');
|
||||
$this->assertDatabaseHas('dewu_settlement_bill_rows', ['record_code' => 'ACTIVE-ROW'], 'business_mirror');
|
||||
$this->assertDatabaseMissing('dewu_settlement_bill_rows', ['record_code' => 'DUPLICATE'], 'business_mirror');
|
||||
$this->assertSame(0, DB::connection('business_mirror')->table('dewu_settlement_bill_staging')->count());
|
||||
}
|
||||
|
||||
public function test_out_of_period_and_missing_bill_date_rows_are_rejected(): void
|
||||
{
|
||||
$outOfPeriod = $this->xlsx([
|
||||
$this->row('OUTSIDE', '2026-09-01 00:00:00', '10.00', '0'),
|
||||
]);
|
||||
$missingDate = $this->xlsx([
|
||||
$this->row('MISSING-DATE', '', '10.00', '0'),
|
||||
]);
|
||||
$importer = app(DewuSettlementBillImporter::class);
|
||||
|
||||
foreach ([
|
||||
[$outOfPeriod, '账单时间超出指定账期'],
|
||||
[$missingDate, '缺少可用于账期核验'],
|
||||
] as [$path, $message]) {
|
||||
try {
|
||||
$importer->import($path, '2026-08-01', '2026-08-31', 1, '10.00', '0.00', 100);
|
||||
$this->fail($message . '时必须拒绝导入。');
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->assertStringContainsString($message, $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame(0, DB::connection('business_mirror')->table('dewu_settlement_bill_rows')->count());
|
||||
$this->assertSame(0, DB::connection('business_mirror')->table('dewu_settlement_bill_staging')->count());
|
||||
}
|
||||
|
||||
public function test_blank_channel_is_rejected(): void
|
||||
{
|
||||
$path = $this->xlsx([
|
||||
$this->row('BLANK-CHANNEL', '2026-08-10 00:00:00', '10.00', '0', ''),
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('缺少渠道');
|
||||
app(DewuSettlementBillImporter::class)->import(
|
||||
$path,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
1,
|
||||
'10.00',
|
||||
'0.00',
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
private function createTables(): void
|
||||
{
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create('dewu_settlement_bill_imports', function (Blueprint $table): void {
|
||||
$table->uuid('generation')->primary();
|
||||
$table->string('source_filename');
|
||||
$table->char('source_sha256', 64)->unique();
|
||||
$table->date('period_start');
|
||||
$table->date('period_end');
|
||||
$table->string('status', 32)->default('running');
|
||||
$table->unsignedBigInteger('expected_row_count');
|
||||
$table->decimal('expected_income_total', 20, 2);
|
||||
$table->decimal('expected_expense_total', 20, 2);
|
||||
$table->unsignedBigInteger('row_count')->default(0);
|
||||
$table->unsignedBigInteger('excluded_rows')->default(0);
|
||||
$table->unsignedInteger('store_count')->default(0);
|
||||
$table->unsignedInteger('bill_item_count')->default(0);
|
||||
$table->json('channel_counts')->nullable();
|
||||
$table->decimal('income_total', 20, 2)->default(0);
|
||||
$table->decimal('expense_total', 20, 2)->default(0);
|
||||
$table->decimal('net_total', 20, 2)->default(0);
|
||||
$table->string('last_error_code', 120)->nullable();
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
$schema->create('dewu_settlement_bill_staging', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->uuid('generation');
|
||||
$this->billColumns($table);
|
||||
$table->unique(['generation', 'record_code']);
|
||||
});
|
||||
$schema->create('dewu_settlement_bill_rows', function (Blueprint $table): void {
|
||||
$table->string('record_code', 191)->primary();
|
||||
$this->billColumns($table, false);
|
||||
$table->uuid('source_generation');
|
||||
$table->char('source_sha256', 64);
|
||||
$table->timestamp('imported_at');
|
||||
});
|
||||
}
|
||||
|
||||
private function billColumns(Blueprint $table, bool $includeRecordCode = true): void
|
||||
{
|
||||
if ($includeRecordCode) {
|
||||
$table->string('record_code', 191);
|
||||
}
|
||||
$table->string('order_no', 191)->nullable();
|
||||
$table->string('online_order_no', 191)->nullable();
|
||||
$table->string('custom_remark')->nullable();
|
||||
$table->string('bill_item')->nullable();
|
||||
$table->string('store_name')->nullable();
|
||||
$table->string('order_type')->nullable();
|
||||
$table->string('goods_name', 1000)->nullable();
|
||||
$table->string('goods_no', 191)->nullable();
|
||||
$table->string('quantity', 64)->nullable();
|
||||
$table->string('channel')->nullable();
|
||||
$table->string('business_type')->nullable();
|
||||
$table->decimal('income_amount', 20, 2)->default(0);
|
||||
$table->decimal('expense_amount', 20, 2)->default(0);
|
||||
$table->decimal('signed_amount', 20, 2)->default(0);
|
||||
$table->string('online_real_store')->nullable();
|
||||
$table->dateTime('order_created_at')->nullable();
|
||||
$table->dateTime('paid_refunded_at')->nullable();
|
||||
$table->dateTime('ship_return_at')->nullable();
|
||||
$table->dateTime('bill_date');
|
||||
$table->string('settle_status')->nullable();
|
||||
$table->string('pay_period')->nullable();
|
||||
$table->dateTime('pulled_at')->nullable();
|
||||
$table->dateTime('source_updated_at')->nullable();
|
||||
$table->string('source_modified_by')->nullable();
|
||||
$table->unsignedBigInteger('source_row_number');
|
||||
}
|
||||
|
||||
private function insertActiveRow(string $recordCode, string $occurredAt): void
|
||||
{
|
||||
DB::connection('business_mirror')->table('dewu_settlement_bill_rows')->insert([
|
||||
'record_code' => $recordCode,
|
||||
'store_name' => '原有店铺',
|
||||
'bill_item' => '原有流水',
|
||||
'channel' => '得物',
|
||||
'ship_return_at' => $occurredAt,
|
||||
'bill_date' => $occurredAt,
|
||||
'income_amount' => 1,
|
||||
'expense_amount' => 0,
|
||||
'signed_amount' => 1,
|
||||
'source_row_number' => 1,
|
||||
'source_generation' => 'existing-generation',
|
||||
'source_sha256' => str_repeat('a', 64),
|
||||
'imported_at' => '2026-09-01 00:00:00',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function row(
|
||||
string $recordCode,
|
||||
string $occurredAt,
|
||||
string $income,
|
||||
string $expense,
|
||||
string $channel = '得物',
|
||||
string $billItem = '技术服务费',
|
||||
): array {
|
||||
return [
|
||||
$recordCode,
|
||||
'ORDER-' . $recordCode,
|
||||
'ONLINE-' . $recordCode,
|
||||
'',
|
||||
$billItem,
|
||||
'得物-测试店铺',
|
||||
'普通订单',
|
||||
'测试商品',
|
||||
'SKU-' . $recordCode,
|
||||
'1',
|
||||
$channel,
|
||||
$income !== '0' ? '收入' : '支出',
|
||||
$income,
|
||||
$expense,
|
||||
'得物-测试店铺',
|
||||
'2026-08-01 08:00:00',
|
||||
'',
|
||||
$occurredAt,
|
||||
'已结算',
|
||||
'2026-08',
|
||||
'2026-09-01 08:00:00',
|
||||
'2026-09-07 17:07:15',
|
||||
'RPA',
|
||||
];
|
||||
}
|
||||
|
||||
/** @param list<list<string>> $rows */
|
||||
private function xlsx(array $rows): string
|
||||
{
|
||||
$temporary = tempnam(sys_get_temp_dir(), 'dewu-authoritative-');
|
||||
$path = $temporary . '.xlsx';
|
||||
rename($temporary, $path);
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$headers = [
|
||||
'记录编码', '订单号', '线上单号', '自定义备注', '账单项目', '店铺',
|
||||
'订单类型', '商品名称', '商品货号', '数量', '渠道', '业务类型',
|
||||
'收入金额', '支出金额', '线上单号对应实际店铺', '订单创建时间',
|
||||
'支付/退款时间', '发货/退货时间', '结算状态', '收款账期', '拉取时间',
|
||||
'修改时间', '修改人',
|
||||
];
|
||||
$spreadsheet->getActiveSheet()->fromArray(array_merge([$headers], $rows));
|
||||
(new Xlsx($spreadsheet))->save($path);
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
$this->files[] = $path;
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\DewuSettlementBillRepository;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DewuSettlementAccountBillsRepositoryTest 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');
|
||||
$this->createTables();
|
||||
}
|
||||
|
||||
public function test_account_bill_page_reads_authoritative_rows_with_filters_and_totals(): void
|
||||
{
|
||||
$this->insertSucceededImport();
|
||||
$this->insertRow('REC-INCOME', '得物一店', '交易收款', '2026-08-01 00:00:00', '100.00', '0.00', '100.00', 'M-100', 'O-100');
|
||||
$this->insertRow('REC-EXPENSE', '得物一店', '退款', '2026-08-31 23:59:59', '0.00', '20.00', '-20.00', 'M-200', 'O-200');
|
||||
$this->insertRow('REC-OTHER', '得物二店', '交易收款', '2026-08-15 12:00:00', '50.00', '0.00', '50.00', 'M-300', 'O-300');
|
||||
|
||||
$result = app(DewuSettlementBillRepository::class)->paginateAccountBills([
|
||||
'store_name' => '得物一店',
|
||||
'start' => '2026-08-01',
|
||||
'end' => '2026-08-31',
|
||||
], 'occurred_at', 'desc', 1, 10);
|
||||
|
||||
$this->assertSame(2, $result['total']);
|
||||
$this->assertSame(100.0, $result['summary']['income']);
|
||||
$this->assertSame(20.0, $result['summary']['expense']);
|
||||
$this->assertSame(80.0, $result['summary']['balance']);
|
||||
$this->assertSame('REC-EXPENSE', $result['data'][0]['record_code']);
|
||||
$this->assertSame('M-200', $result['data'][0]['order_no']);
|
||||
$this->assertSame('O-200', $result['data'][0]['payload']['online_order_no']);
|
||||
$this->assertSame('退货退款订单', $result['data'][0]['payload']['channel']);
|
||||
$this->assertSame('DewuTK', $result['data'][0]['payload']['source_channel']);
|
||||
$this->assertSame('2026-08-31 23:59:59', $result['data'][0]['payload']['ship_return_at']);
|
||||
$this->assertNull($result['data'][0]['occurred_at']);
|
||||
$this->assertSame(20.0, $result['data'][0]['amount']);
|
||||
$this->assertSame(-20.0, $result['data'][0]['balance']);
|
||||
|
||||
$filtered = app(DewuSettlementBillRepository::class)->paginateAccountBills([
|
||||
'bill_item' => '退款',
|
||||
'keyword' => 'O-200',
|
||||
], 'record_code', 'asc', 1, 10);
|
||||
|
||||
$this->assertSame(1, $filtered['total']);
|
||||
$this->assertSame('REC-EXPENSE', $filtered['data'][0]['record_code']);
|
||||
|
||||
$options = app(DewuSettlementBillRepository::class)->accountBillFilterOptions();
|
||||
$this->assertSame(['得物一店', '得物二店'], $options['shops']);
|
||||
$this->assertSame(['交易收款', '退款'], $options['bill_items']);
|
||||
|
||||
$export = iterator_to_array(app(DewuSettlementBillRepository::class)->exportAccountBills([
|
||||
'bill_item' => '退款',
|
||||
], 'record_code', 'asc'));
|
||||
$this->assertCount(1, $export);
|
||||
$this->assertSame('REC-EXPENSE', $export[0]['record_code']);
|
||||
}
|
||||
|
||||
public function test_account_bill_page_fails_closed_until_a_successful_import_exists(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('dewu_authoritative_bills_not_ready');
|
||||
|
||||
app(DewuSettlementBillRepository::class)->paginateAccountBills(
|
||||
[],
|
||||
'occurred_at',
|
||||
'desc',
|
||||
1,
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
private function createTables(): void
|
||||
{
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create('dewu_settlement_bill_imports', function (Blueprint $table): void {
|
||||
$table->uuid('generation')->primary();
|
||||
$table->string('status', 32);
|
||||
});
|
||||
$schema->create('dewu_settlement_bill_rows', function (Blueprint $table): void {
|
||||
$table->string('record_code', 191)->primary();
|
||||
$table->string('order_no', 191)->nullable();
|
||||
$table->string('online_order_no', 191)->nullable();
|
||||
$table->string('custom_remark')->nullable();
|
||||
$table->string('bill_item')->nullable();
|
||||
$table->string('store_name')->nullable();
|
||||
$table->string('order_type')->nullable();
|
||||
$table->string('goods_name', 1000)->nullable();
|
||||
$table->string('goods_no', 191)->nullable();
|
||||
$table->string('quantity', 64)->nullable();
|
||||
$table->string('channel')->nullable();
|
||||
$table->string('business_type')->nullable();
|
||||
$table->decimal('income_amount', 20, 2);
|
||||
$table->decimal('expense_amount', 20, 2);
|
||||
$table->decimal('signed_amount', 20, 2);
|
||||
$table->string('online_real_store')->nullable();
|
||||
$table->dateTime('order_created_at')->nullable();
|
||||
$table->dateTime('paid_refunded_at')->nullable();
|
||||
$table->dateTime('ship_return_at')->nullable();
|
||||
$table->dateTime('bill_date');
|
||||
$table->string('settle_status')->nullable();
|
||||
$table->string('pay_period')->nullable();
|
||||
$table->dateTime('pulled_at')->nullable();
|
||||
$table->dateTime('source_updated_at')->nullable();
|
||||
$table->string('source_modified_by')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
private function insertSucceededImport(): void
|
||||
{
|
||||
DB::connection('business_mirror')->table('dewu_settlement_bill_imports')->insert([
|
||||
'generation' => '11111111-1111-1111-1111-111111111111',
|
||||
'status' => 'succeeded',
|
||||
]);
|
||||
}
|
||||
|
||||
private function insertRow(
|
||||
string $recordCode,
|
||||
string $storeName,
|
||||
string $billItem,
|
||||
string $occurredAt,
|
||||
string $income,
|
||||
string $expense,
|
||||
string $signed,
|
||||
string $orderNo,
|
||||
string $onlineOrderNo,
|
||||
): void {
|
||||
DB::connection('business_mirror')->table('dewu_settlement_bill_rows')->insert([
|
||||
'record_code' => $recordCode,
|
||||
'order_no' => $orderNo,
|
||||
'online_order_no' => $onlineOrderNo,
|
||||
'store_name' => $storeName,
|
||||
'custom_remark' => '自定义备注',
|
||||
'bill_item' => $billItem,
|
||||
'order_type' => '普通订单',
|
||||
'goods_name' => '测试商品',
|
||||
'goods_no' => 'SKU-1',
|
||||
'quantity' => '1',
|
||||
'channel' => $expense === '0.00' ? '得物' : '退货退款订单',
|
||||
'business_type' => $expense === '0.00' ? '收入' : '支出',
|
||||
'income_amount' => $income,
|
||||
'expense_amount' => $expense,
|
||||
'signed_amount' => $signed,
|
||||
'online_real_store' => $storeName,
|
||||
'ship_return_at' => $occurredAt,
|
||||
'bill_date' => $occurredAt,
|
||||
'settle_status' => '已结算',
|
||||
'pay_period' => '2026-08',
|
||||
'pulled_at' => '2026-09-01 07:30:00',
|
||||
'source_updated_at' => '2026-09-01 08:00:00',
|
||||
'source_modified_by' => '聚胜算',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -6,14 +6,14 @@ use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AccountBillsTableTest extends TestCase
|
||||
{
|
||||
public function test_pinduoduo_uses_mirror_filter_options_and_existing_table_interactions(): void
|
||||
public function test_authoritative_bill_pages_use_stable_date_controls_and_existing_table_interactions(): void
|
||||
{
|
||||
$source = file_get_contents(__DIR__ . '/../../../resources/assets/js/components/business/AccountBills.vue');
|
||||
|
||||
$this->assertStringContainsString("isDouyin(){return this.billType==='douyin';}", $source);
|
||||
$this->assertStringContainsString("isPinduoduo(){return this.billType==='pinduoduo';}", $source);
|
||||
$this->assertStringContainsString("{ 'is-pinduoduo-date': isPinduoduo }", $source);
|
||||
$this->assertStringContainsString('.tw-date.is-pinduoduo-date .tw-date-control', $source);
|
||||
$this->assertStringContainsString("usesStableDateControl(){return ['pinduoduo','dewu'].indexOf(this.billType)!==-1;}", $source);
|
||||
$this->assertStringContainsString("{ 'is-stable-date': usesStableDateControl }", $source);
|
||||
$this->assertStringContainsString('.tw-date.is-stable-date .tw-date-control', $source);
|
||||
$this->assertStringContainsString("['douyin','pinduoduo','dewu','jd-transactions','jd-wallet'].indexOf(this.billType)!==-1", $source);
|
||||
$this->assertStringContainsString('v-if="usesMirrorOptions && !filterOptionsError"', $source);
|
||||
$this->assertStringContainsString('page-key="store-receiving-bills"', $source);
|
||||
|
||||
@ -122,11 +122,13 @@ test('calendar selection submits both bounds, resets pagination and preserves da
|
||||
assert.equal(opened, true);
|
||||
});
|
||||
|
||||
test('pinduoduo alone enables the stable full-control date picker treatment', () => {
|
||||
test('authoritative pinduoduo and dewu pages enable the stable full-control date picker treatment', () => {
|
||||
const { instance: pinduoduo } = component(async () => response(), 'pinduoduo');
|
||||
const { instance: dewu } = component(async () => response(), 'dewu');
|
||||
const { instance: douyin } = component(async () => response(), 'douyin');
|
||||
assert.equal(pinduoduo.isPinduoduo, true);
|
||||
assert.equal(douyin.isPinduoduo, false);
|
||||
assert.match(sfc.template.content, /is-pinduoduo-date/);
|
||||
assert.match(source, /\.tw-date\.is-pinduoduo-date \.tw-date-control/);
|
||||
assert.equal(pinduoduo.usesStableDateControl, true);
|
||||
assert.equal(dewu.usesStableDateControl, true);
|
||||
assert.equal(douyin.usesStableDateControl, false);
|
||||
assert.match(sfc.template.content, /is-stable-date/);
|
||||
assert.match(source, /\.tw-date\.is-stable-date \.tw-date-control/);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user