diff --git a/app/Console/Commands/ImportDewuSettlementBills.php b/app/Console/Commands/ImportDewuSettlementBills.php new file mode 100644 index 00000000..9816c4a3 --- /dev/null +++ b/app/Console/Commands/ImportDewuSettlementBills.php @@ -0,0 +1,72 @@ +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; + } +} diff --git a/database/migrations/2026_09_22_010000_create_dewu_settlement_bill_tables.php b/database/migrations/2026_09_22_010000_create_dewu_settlement_bill_tables.php new file mode 100644 index 00000000..adc15393 --- /dev/null +++ b/database/migrations/2026_09_22_010000_create_dewu_settlement_bill_tables.php @@ -0,0 +1,119 @@ +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'); + } +}; diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index c34c7269..4d4d082b 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -25,7 +25,7 @@ | 页面已切换 | 店铺收款账单的淘系支付宝账户读取 `operations_replica.rpt.jst_originalBillInfo`,并关联 `rpt.jst_shopInfo` 展示店铺;分页、筛选、汇总和导出统一读取共享业务镜像。 | | 抖音按月校准 | 抖音账户对已核对的完整月份读取 `douyin_authoritative_bills` 中胜算原始明细,`douyin_authoritative_periods` 原子选择有效版本;该月份不再叠加聚水潭拆分行。未校准月份继续读取原始账单镜像。导入命令为 `business-mirror:import-douyin-authoritative --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` 的工作平台价格记录混为同一来源。 diff --git a/docs/得物完整账单取数方案留档.md b/docs/得物完整账单取数方案留档.md new file mode 100644 index 00000000..3ff73fbc --- /dev/null +++ b/docs/得物完整账单取数方案留档.md @@ -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 与控制数,再调用受控导入命令。源文件未生成、下载失败、会话失效、控制数不一致、生产连接不可用或迁移未完成时停止,不使用旧镜像补数。 + +上线前需单独完成生产备份、迁移授权、发布以及首次导入授权。回滚时使用已归档的上一版源文件及其控制数重新导入相同账期。 diff --git a/modules/EcommerceFinance/Http/Controllers/DewuAccountBills.php b/modules/EcommerceFinance/Http/Controllers/DewuAccountBills.php new file mode 100644 index 00000000..89e2cf1d --- /dev/null +++ b/modules/EcommerceFinance/Http/Controllers/DewuAccountBills.php @@ -0,0 +1,178 @@ +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> */ + 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 $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, '当前账号没有店铺收款账户账单权限。'); + } +} diff --git a/modules/EcommerceFinance/Repositories/DewuSettlementBillRepository.php b/modules/EcommerceFinance/Repositories/DewuSettlementBillRepository.php new file mode 100644 index 00000000..e2105465 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/DewuSettlementBillRepository.php @@ -0,0 +1,322 @@ +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,bill_items:list} */ + 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>,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 */ + 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> */ + 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 $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 */ + 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; + } +} diff --git a/modules/EcommerceFinance/Services/DewuSettlementBillImporter.php b/modules/EcommerceFinance/Services/DewuSettlementBillImporter.php new file mode 100644 index 00000000..9b3a12be --- /dev/null +++ b/modules/EcommerceFinance/Services/DewuSettlementBillImporter.php @@ -0,0 +1,731 @@ + ['记录编码'], + '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,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,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 */ + 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 $cells @return array */ + 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 */ + 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 */ + 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; + } +} diff --git a/resources/assets/js/components/business/AccountBills.vue b/resources/assets/js/components/business/AccountBills.vue index b656287a..9d04b62a 100644 --- a/resources/assets/js/components/business/AccountBills.vue +++ b/resources/assets/js/components/business/AccountBills.vue @@ -16,16 +16,16 @@ -
+
{{ ['jd-transactions','jd-wallet'].includes(billType) ? '费用结算时间' : '发生时间' }}
- + 至 - + @@ -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} diff --git a/routes/admin.php b/routes/admin.php index 12f65156..60474ac4 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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']) diff --git a/tests/Feature/Business/DewuReceivingBillsTest.php b/tests/Feature/Business/DewuReceivingBillsTest.php index f5ad4f15..9aba5542 100644 --- a/tests/Feature/Business/DewuReceivingBillsTest.php +++ b/tests/Feature/Business/DewuReceivingBillsTest.php @@ -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 diff --git a/tests/Feature/Console/DewuSettlementBillImporterTest.php b/tests/Feature/Console/DewuSettlementBillImporterTest.php new file mode 100644 index 00000000..da5e41ca --- /dev/null +++ b/tests/Feature/Console/DewuSettlementBillImporterTest.php @@ -0,0 +1,373 @@ + */ + 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 */ + 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> $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; + } +} diff --git a/tests/Unit/EcommerceFinance/DewuSettlementAccountBillsRepositoryTest.php b/tests/Unit/EcommerceFinance/DewuSettlementAccountBillsRepositoryTest.php new file mode 100644 index 00000000..fdc96812 --- /dev/null +++ b/tests/Unit/EcommerceFinance/DewuSettlementAccountBillsRepositoryTest.php @@ -0,0 +1,171 @@ + [ + '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' => '聚胜算', + ]); + } +} diff --git a/tests/Unit/View/AccountBillsTableTest.php b/tests/Unit/View/AccountBillsTableTest.php index 321f0af4..39a171d4 100644 --- a/tests/Unit/View/AccountBillsTableTest.php +++ b/tests/Unit/View/AccountBillsTableTest.php @@ -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); diff --git a/tests/scripts/account-bills-filter-export.test.cjs b/tests/scripts/account-bills-filter-export.test.cjs index d1eca47c..8d077527 100644 --- a/tests/scripts/account-bills-filter-export.test.cjs +++ b/tests/scripts/account-bills-filter-export.test.cjs @@ -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/); });