接入聚胜算京东钱包店铺余额 #192
@ -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 条解释为真实无历史。 |
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user