接入聚胜算京东钱包店铺余额 #192

Merged
yutu merged 1 commits from feature/92451-jusheng-balance into main 2026-09-15 20:16:26 +08:00
4 changed files with 287 additions and 13 deletions

View File

@ -26,7 +26,7 @@
| 抖音按月校准 | 抖音账户对已核对的完整月份读取 `douyin_authoritative_bills` 中胜算原始明细,`douyin_authoritative_periods` 原子选择有效版本;该月份不再叠加聚水潭拆分行。未校准月份继续读取原始账单镜像。导入命令为 `business-mirror:import-douyin-authoritative <CSV路径> <YYYY-MM> --count=<核对笔数> --income=<收入> --expense=<支出>`,只接受完整月份和精确核对值,保留旧版本、文件摘要及原镜像供回滚。生产执行前备份状态与受影响月份镜像,并使用 `/run/caiwu-upstream-sync.lock`。实际财务数据只放私有目录,不进入 Git。 |
| 已接入待首次同步 | 店铺收款账单的拼多多账户读取 `jiansun.dbo.jst_originalBillInfo` 中固定 `channel = PinduoduoImport` 的独立切片镜像 `js_jst_pdd_receiving_bill_rows`;不使用慢一天的 `operations_replica` 副本或营销账户 `pdd_spread_bills`。 |
| 已接入待首次同步 | 店铺收款账单的得物账户读取 `jiansun.dbo.jst_originalBillInfo` 中固定 `channel IN (Dewu, DewuTK, DewuKJ)` 的独立切片镜像 `js_jst_dewu_receiving_bill_rows`;主账单、退货退款订单和扣减其他费用明细统一展示,`Dewubzj` 保证金继续排除。 |
| 已完成首次导入 | 提现管理的每日店铺余额读取聚水潭“平台原始账单[新] → 店铺余额表”接口 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 条解释为真实无历史。 |

View File

@ -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) {

View File

@ -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<array<string,mixed>> */
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));

View File

@ -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<string,mixed> $payload */
private function jsonFile(array $payload): string
{
@ -217,4 +306,17 @@ class JushuitanShopBalanceRepositoryTest extends TestCase
return $path;
}
/** @param list<list<mixed>> $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;
}
}