diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 285132de..66974ada 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -26,7 +26,7 @@ | 抖音按月校准 | 抖音账户对已核对的完整月份读取 `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` 保证金继续排除。 | -| 已完成首次导入 | 提现管理的每日店铺余额读取聚水潭“平台原始账单[新] → 店铺余额表”接口 JSON/XLSX 及多平台“平台资金账户余额”导出,进入 `jushuitan_shop_balance_snapshots`;已导入 2026-09-11 的淘系、拼多多和抖音共 164 条余额。页面按店铺名称精确匹配并强制使用平台消歧,未匹配和多匹配不会显示为零。 | +| 已完成首次导入 | 提现管理的每日店铺余额读取聚水潭“平台原始账单[新] → 店铺余额表”接口 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` 稳定键追加,首次全量完成前只显示普通商品自身采购价。 | | 待有效凭据后首次同步 | 普通商品当前成本价、商品类型和更新时间继续读取 `operations_replica.rpt.jst_sku`;`rpt.fys_skuCostPriceUpdate` 是一 SKU 一行的旧快照,不能充当变价历史。完整成本价变更仍读取聚水潭经典开放平台“商品操作日志查询”接口 `worklog.query`,只镜像操作内容含“采购价”的记录到 `jushuitan_cost_price_history_rows`;经典接口必须在服务器私有配置中使用同一应用的 `JUSHUITAN_PARTNER_ID / JUSHUITAN_PARTNER_KEY / JUSHUITAN_TOKEN`,不得把新开放平台的 App Key / App Secret / Access Token 混用。页面在首次同步完成前明确显示待同步或失败,不把 0 条解释为真实无历史。 | diff --git a/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php b/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php index 4046fcdb..eec7cb07 100644 --- a/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php +++ b/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php @@ -112,7 +112,7 @@ class JushuitanShopBalanceRepository { $value = trim(str_replace(['-', '—', '–'], '-', $value)); $value = preg_replace( - '/^(支付宝|天猫|淘宝|阿里巴巴|1688|京东商城|京东|拼多多|抖音|快手|小红书|唯品会|得物|淘工厂)[\s\-_::]+/u', + '/^(支付宝|天猫|淘宝|阿里巴巴|1688|京东商城|京东|京喜小店|京喜|拼多多|抖音|快手|小红书|唯品会|得物|淘工厂)[\s\-_::]+/u', '', $value, ) ?? $value; @@ -124,7 +124,7 @@ class JushuitanShopBalanceRepository public static function sourcePlatform(string $storeName): ?string { if (preg_match( - '/^(支付宝|天猫|淘宝|阿里巴巴|1688|京东商城|京东|拼多多|抖音|快手|小红书|唯品会|得物|淘工厂)[\s\-—–_::]+/u', + '/^(支付宝|天猫|淘宝|阿里巴巴|1688|京东商城|京东|京喜小店|京喜|拼多多|抖音|快手|小红书|唯品会|得物|淘工厂)[\s\-—–_::]+/u', trim($storeName), $matches, ) === 1) { diff --git a/modules/EcommerceFinance/Services/JushuitanShopBalanceImporter.php b/modules/EcommerceFinance/Services/JushuitanShopBalanceImporter.php index a1b442af..9b5dd533 100644 --- a/modules/EcommerceFinance/Services/JushuitanShopBalanceImporter.php +++ b/modules/EcommerceFinance/Services/JushuitanShopBalanceImporter.php @@ -45,15 +45,34 @@ class JushuitanShopBalanceImporter 'xlsx', 'xls' => $this->workbookRows($realPath), default => throw new InvalidArgumentException('店铺余额仅支持聚水潭接口 JSON 或官方 XLSX/XLS 导出文件。'), }; - [$selectedDate, $storeField, $accountField, $balanceField, $platformField] = $this->fields($headers, $requestedDate); - $rows = $this->normalizeRows( - $dataRows, - $selectedDate, - $storeField, - $accountField, - $balanceField, - $platformField, - ); + $walletFields = $this->jdWalletFields($headers); + if ($walletFields !== null) { + if ($requestedDate === null) { + throw new InvalidArgumentException('京东钱包余额缺少截止日期,请使用 --balance-date 指定导出截止日期。'); + } + + [$storeField, $accountField, $settledAtField, $recordCodeField, $balanceField] = $walletFields; + $selectedDate = $requestedDate; + $rows = $this->normalizeJdWalletRows( + $dataRows, + $selectedDate, + $storeField, + $accountField, + $settledAtField, + $recordCodeField, + $balanceField, + ); + } else { + [$selectedDate, $storeField, $accountField, $balanceField, $platformField] = $this->fields($headers, $requestedDate); + $rows = $this->normalizeRows( + $dataRows, + $selectedDate, + $storeField, + $accountField, + $balanceField, + $platformField, + ); + } if ($rows === []) { throw new InvalidArgumentException('聚水潭店铺余额文件中没有可导入的数据行,已保留原有快照。'); @@ -153,6 +172,7 @@ class JushuitanShopBalanceImporter foreach (array_slice($matrix, 0, 20, true) as $rowNumber => $row) { $values = array_map(static fn (mixed $value): string => trim((string) $value), $row); $hasBalanceColumn = in_array('平台实际期末余额', $values, true) + || in_array('账户余额', $values, true) || count(array_filter($values, fn (string $value): bool => $this->dateFromHeader($value, null) !== null)) > 0; if (in_array('店铺', $values, true) && $hasBalanceColumn) { $headerNumber = $rowNumber; @@ -160,7 +180,7 @@ class JushuitanShopBalanceImporter } } if ($headerNumber === null) { - throw new InvalidArgumentException('聚水潭导出文件中未找到“店铺”和日期列。'); + throw new InvalidArgumentException('聚水潭导出文件中未找到“店铺”和余额列。'); } $headers = []; @@ -176,6 +196,40 @@ class JushuitanShopBalanceImporter ))]; } + /** @return array{0:string,1:?string,2:string,3:string,4:string}|null */ + private function jdWalletFields(array $headers): ?array + { + $fields = []; + + foreach ($headers as $header) { + $title = trim((string) ($header['title'] ?? '')); + $field = trim((string) ($header['fld'] ?? '')); + if ($title !== '' && $field !== '') { + $fields[$title] = $field; + } + } + + if (! isset($fields['费用结算时间']) && ! isset($fields['记录编码']) && ! isset($fields['账户余额'])) { + return null; + } + + $missing = array_values(array_filter( + ['店铺', '费用结算时间', '记录编码', '账户余额'], + static fn (string $title): bool => ! isset($fields[$title]), + )); + if ($missing !== []) { + throw new InvalidArgumentException('京东钱包余额数据缺少“' . implode('”“', $missing) . '”列。'); + } + + return [ + $fields['店铺'], + $fields['账户名称'] ?? null, + $fields['费用结算时间'], + $fields['记录编码'], + $fields['账户余额'], + ]; + } + /** @return array{0:string,1:string,2:?string,3:string,4:?string} */ private function fields(array $headers, ?string $requestedDate): array { @@ -289,6 +343,124 @@ class JushuitanShopBalanceImporter return array_values($rows); } + /** @return list> */ + private function normalizeJdWalletRows( + array $dataRows, + string $balanceDate, + string $storeField, + ?string $accountField, + string $settledAtField, + string $recordCodeField, + string $balanceField, + ): array { + $cutoff = CarbonImmutable::createFromFormat( + '!Y-m-d', + $balanceDate, + (string) config('app.timezone', 'Asia/Shanghai'), + )->endOfDay(); + $latestByStore = []; + + foreach ($dataRows as $row) { + if (! is_array($row)) { + continue; + } + + $storeName = trim((string) ($row[$storeField] ?? '')); + if ($storeName === '' || $storeName === '总计') { + continue; + } + if (mb_strlen($storeName) > 255) { + throw new InvalidArgumentException('聚水潭店铺名称超过 255 个字符:' . mb_substr($storeName, 0, 40)); + } + + $settledAt = $this->settledAt($row[$settledAtField] ?? null, $storeName); + if ($settledAt->isAfter($cutoff)) { + continue; + } + + $recordCode = trim((string) ($row[$recordCodeField] ?? '')); + if (preg_match('/^\d+$/', $recordCode) !== 1) { + throw new InvalidArgumentException("店铺 {$storeName} 的记录编码不是有效非负整数。"); + } + + $sourcePlatform = JushuitanShopBalanceRepository::sourcePlatform($storeName) ?? ''; + $normalizedName = JushuitanShopBalanceRepository::normalizeStoreName($storeName); + $storeKey = hash('sha256', implode("\0", [$sourcePlatform, $normalizedName])); + $candidate = [ + 'settled_at' => $settledAt, + 'record_code' => $recordCode, + 'balance' => $this->amount($row[$balanceField] ?? null, $storeName, $balanceDate), + 'account_id' => $accountField === null ? '' : trim((string) ($row[$accountField] ?? '')), + 'source_platform' => $sourcePlatform, + 'store_name' => $storeName, + 'normalized_store_name' => $normalizedName, + ]; + $current = $latestByStore[$storeKey] ?? null; + + if ($current !== null) { + $comparison = $settledAt->getTimestamp() <=> $current['settled_at']->getTimestamp(); + if ($comparison === 0) { + $comparison = $this->compareNumericStrings($recordCode, $current['record_code']); + if ($comparison === 0 && $candidate['balance'] !== $current['balance']) { + throw new InvalidArgumentException("店铺 {$storeName} 的同一京东钱包记录存在冲突余额。"); + } + } + if ($comparison <= 0) { + continue; + } + } + + $latestByStore[$storeKey] = $candidate; + } + + return array_values(array_map(static function (array $row): array { + $accountId = $row['account_id']; + $rowKey = hash('sha256', implode("\0", [ + $row['source_platform'], + $row['normalized_store_name'], + $accountId, + ])); + + return [ + 'source_platform' => $row['source_platform'], + 'store_name' => $row['store_name'], + 'normalized_store_name' => $row['normalized_store_name'], + 'platform_account_id' => $accountId === '' ? null : mb_substr($accountId, 0, 191), + 'balance' => $row['balance'], + 'source_row_key' => $rowKey, + ]; + }, $latestByStore)); + } + + private function settledAt(mixed $value, string $storeName): CarbonImmutable + { + $raw = trim((string) $value); + + try { + $settledAt = CarbonImmutable::createFromFormat( + '!Y-m-d H:i:s', + $raw, + (string) config('app.timezone', 'Asia/Shanghai'), + ); + } catch (\Throwable) { + throw new InvalidArgumentException("店铺 {$storeName} 的费用结算时间格式无效。"); + } + + if (! $settledAt instanceof CarbonImmutable || $settledAt->format('Y-m-d H:i:s') !== $raw) { + throw new InvalidArgumentException("店铺 {$storeName} 的费用结算时间格式无效。"); + } + + return $settledAt; + } + + private function compareNumericStrings(string $left, string $right): int + { + $left = ltrim($left, '0') ?: '0'; + $right = ltrim($right, '0') ?: '0'; + + return strlen($left) <=> strlen($right) ?: strcmp($left, $right); + } + private function amount(mixed $value, string $storeName, string $date): float { $normalized = str_replace([',', ',', '¥', '¥'], '', trim((string) $value)); diff --git a/tests/Unit/EcommerceFinance/JushuitanShopBalanceRepositoryTest.php b/tests/Unit/EcommerceFinance/JushuitanShopBalanceRepositoryTest.php index 1feb3bf7..7e582fc7 100644 --- a/tests/Unit/EcommerceFinance/JushuitanShopBalanceRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/JushuitanShopBalanceRepositoryTest.php @@ -208,6 +208,95 @@ class JushuitanShopBalanceRepositoryTest extends TestCase $this->assertSame(350.25, (float) $rows->first()->balance); } + public function testItImportsTheLatestJdWalletBalanceAtTheRequestedCutoff(): void + { + $path = $this->xlsxFile([ + ['记录编码', '店铺', '账户名称', '费用结算时间', '账户余额'], + ['90000000000000000001', '京东-同名钱包店', '京东账户', '2026-09-13 22:00:00', 10], + ['90000000000000000002', '京东-同名钱包店', '京东账户', '2026-09-14 22:00:00', 20], + ['100000000000000000003', '京东-同名钱包店', '京东账户', '2026-09-14 22:00:00', 25.5], + ['100000000000000000004', '京东-同名钱包店', '京东账户', '2026-09-15 00:00:00', 999], + ['200000000000000000001', '京喜-京喜钱包店', '京喜账户', '2026-09-14 16:00:00', 30.75], + ['300000000000000000001', '京喜小店-制造店', '京喜小店账户', '2026-09-14 17:00:00', 40.25], + ]); + + $result = app(JushuitanShopBalanceImporter::class)->import( + $path, + '2026-09-14', + 'jd-wallet', + ); + $matches = app(JushuitanShopBalanceRepository::class)->matchesForStores([ + (object) ['id' => 40, 'store_platform' => '京东', 'shop_name' => '同名钱包店', 'erp_store_name' => 'ERP京东店'], + (object) ['id' => 41, 'store_platform' => '京喜', 'shop_name' => '京喜钱包店', 'erp_store_name' => 'ERP京喜店'], + (object) ['id' => 42, 'store_platform' => '京喜', 'shop_name' => '制造店', 'erp_store_name' => 'ERP京喜小店'], + ], '2026-09-14'); + + $this->assertSame(3, $result['total']); + $this->assertSame(25.5, $matches[40]['balance']); + $this->assertSame('京东', $matches[40]['source_platform']); + $this->assertSame(30.75, $matches[41]['balance']); + $this->assertSame('京喜', $matches[41]['source_platform']); + $this->assertSame(40.25, $matches[42]['balance']); + $this->assertSame('京喜小店', $matches[42]['source_platform']); + } + + public function testJdWalletValidationFailureKeepsThePreviousSnapshot(): void + { + $validPath = $this->xlsxFile([ + ['记录编码', '店铺', '账户名称', '费用结算时间', '账户余额'], + ['1', '京东-保留旧余额店', '京东账户', '2026-09-14 12:00:00', 88.88], + ]); + $invalidPath = $this->xlsxFile([ + ['记录编码', '店铺', '账户名称', '账户余额'], + ['2', '京东-保留旧余额店', '京东账户', 99.99], + ]); + + app(JushuitanShopBalanceImporter::class)->import($validPath, '2026-09-14', 'jd-wallet'); + + try { + app(JushuitanShopBalanceImporter::class)->import($invalidPath, '2026-09-14', 'jd-wallet'); + $this->fail('缺少费用结算时间的京东钱包文件不应被导入。'); + } catch (\InvalidArgumentException $exception) { + $this->assertStringContainsString('费用结算时间', $exception->getMessage()); + } + + $row = DB::connection('business_mirror') + ->table(JushuitanShopBalanceRepository::TABLE) + ->where('balance_date', '2026-09-14') + ->where('source_scope', 'jd-wallet') + ->sole(); + + $this->assertSame('京东-保留旧余额店', $row->store_name); + $this->assertSame(88.88, (float) $row->balance); + } + + public function testJdWalletRowsAfterTheCutoffKeepThePreviousSnapshot(): void + { + $validPath = $this->xlsxFile([ + ['记录编码', '店铺', '费用结算时间', '账户余额'], + ['1', '京东-截止日前店铺', '2026-09-14 23:59:59', 66.66], + ]); + $futurePath = $this->xlsxFile([ + ['记录编码', '店铺', '费用结算时间', '账户余额'], + ['2', '京东-截止日前店铺', '2026-09-15 00:00:00', 77.77], + ]); + + app(JushuitanShopBalanceImporter::class)->import($validPath, '2026-09-14', 'jd-wallet'); + + try { + app(JushuitanShopBalanceImporter::class)->import($futurePath, '2026-09-14', 'jd-wallet'); + $this->fail('截止日期后没有可用数据时不应替换旧快照。'); + } catch (\InvalidArgumentException $exception) { + $this->assertStringContainsString('已保留原有快照', $exception->getMessage()); + } + + $this->assertSame(66.66, (float) DB::connection('business_mirror') + ->table(JushuitanShopBalanceRepository::TABLE) + ->where('balance_date', '2026-09-14') + ->where('source_scope', 'jd-wallet') + ->value('balance')); + } + /** @param array $payload */ private function jsonFile(array $payload): string { @@ -217,4 +306,17 @@ class JushuitanShopBalanceRepositoryTest extends TestCase return $path; } + + /** @param list> $rows */ + private function xlsxFile(array $rows): string + { + $spreadsheet = new Spreadsheet(); + $spreadsheet->getActiveSheet()->fromArray($rows); + $path = sys_get_temp_dir() . '/jushuitan-shop-balance-' . uniqid('', true) . '.xlsx'; + (new Xlsx($spreadsheet))->save($path); + $spreadsheet->disconnectWorksheets(); + $this->temporaryFiles[] = $path; + + return $path; + } }