Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
d7e6e50a04
30
app/Console/Commands/WarmOrderDataShopCandidates.php
Normal file
30
app/Console/Commands/WarmOrderDataShopCandidates.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\EcommerceFinance\Repositories\OrderDataMirrorRepository;
|
||||
use Throwable;
|
||||
|
||||
class WarmOrderDataShopCandidates extends Command
|
||||
{
|
||||
protected $signature = 'order-data:warm-shop-candidates';
|
||||
|
||||
protected $description = '预热订单售后费用明细的店铺候选(要扫两张百万级订单宽表,不能放在页面请求里)';
|
||||
|
||||
public function handle(OrderDataMirrorRepository $orders): int
|
||||
{
|
||||
try {
|
||||
$names = $orders->refreshShopNamesCache();
|
||||
$this->info(sprintf('店铺候选已预热,共 %d 个店铺。', count($names)));
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('店铺候选预热失败。', ['exception' => $exception::class]);
|
||||
$this->error('店铺候选预热失败:' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$connection = (string) config('jushuitan.connection', 'jushuitan_local');
|
||||
// 旧迁移可能在目标表不存在时跳过,却已记为完成。用独立版本校准实际结构。
|
||||
if (! Schema::connection($connection)->hasTable('jst_oms_shipping')) {
|
||||
throw new RuntimeException('发货镜像表不存在,不能将合并字段迁移标为完成。');
|
||||
}
|
||||
$migration = require base_path('modules/EcommerceFinance/Database/Migrations/2026_10_05_000001_add_merged_cols_to_jst_oms_shipping.php');
|
||||
$migration->up();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// 字段属于原迁移;校准回滚保留原迁移及镜像数据。
|
||||
}
|
||||
};
|
||||
@ -181,6 +181,9 @@ CRON_TZ=Asia/Shanghai
|
||||
* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-catalog --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1
|
||||
# 店铺经营报表按 15 分钟刷新;单轮覆盖 50000 行回看窗口,避免空闲时持续重读千万级账单。
|
||||
7,22,37,52 * * * * root /usr/bin/flock -w 600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-store-report --max-batches=12 --quiet >/dev/null 2>&1
|
||||
# 订单售后费用明细的店铺候选:完整列表要扫两张百万级订单宽表(约 2 分钟),放定时任务预热;
|
||||
# 页面冷缓存时先用毫秒级的店铺目录兜底,避免请求一直不结束、页面一直转圈。
|
||||
45 * * * * root /usr/bin/flock -w 600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan order-data:warm-shop-candidates --quiet >/dev/null 2>&1
|
||||
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1
|
||||
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1
|
||||
0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1
|
||||
|
||||
@ -185,6 +185,9 @@ CRON_TZ=Asia/Shanghai
|
||||
* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-catalog --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1
|
||||
# 店铺经营报表按 15 分钟刷新;单轮覆盖 50000 行回看窗口,避免空闲时持续重读千万级账单。
|
||||
7,22,37,52 * * * * root /usr/bin/flock -w 600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-store-report --max-batches=12 --quiet >/dev/null 2>&1
|
||||
# 订单售后费用明细的店铺候选:完整列表要扫两张百万级订单宽表(约 2 分钟),放定时任务预热;
|
||||
# 页面冷缓存时先用毫秒级的店铺目录兜底,避免请求一直不结束、页面一直转圈。
|
||||
45 * * * * root /usr/bin/flock -w 600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan order-data:warm-shop-candidates --quiet >/dev/null 2>&1
|
||||
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1
|
||||
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1
|
||||
0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1
|
||||
|
||||
@ -61,7 +61,9 @@ class SyncJushuitanShipping extends Command
|
||||
|
||||
$expressRows = (int) ($result['processed'] ?? 0);
|
||||
$autoRows = (int) ($result['autoship'] ?? 0);
|
||||
$summary = sprintf('发货明细 %d 条,代发仓 %d 条,合计处理 %d 条明细', $expressRows, $autoRows, $expressRows + $autoRows);
|
||||
$outboundRows = (int) ($result['outbound'] ?? 0);
|
||||
$purchaseOutRows = (int) ($result['purchase_out'] ?? 0);
|
||||
$summary = sprintf('发货明细 %d 条,代发仓 %d 条,销售出库补漏 %d 条,采购出库补漏 %d 条,合计处理 %d 条明细', $expressRows, $autoRows, $outboundRows, $purchaseOutRows, $expressRows + $autoRows + $outboundRows + $purchaseOutRows);
|
||||
$packageSummary = isset($result['unique_packages'])
|
||||
? sprintf('唯一包裹源键 %d 个,包裹记录处理 %d 次;', $result['unique_packages'], $result['packages'] ?? 0)
|
||||
: '';
|
||||
|
||||
@ -58,13 +58,32 @@ class OrderDataMirrorRepository
|
||||
}
|
||||
|
||||
// 完整候选要扫订单宽表(shop_name 是 LONGTEXT,非淘系约 30 秒、淘系约
|
||||
// 2 分钟),不能卡在页面请求里。冷缓存时先返回毫秒级的店铺目录,
|
||||
// 完整列表放到响应结束后算好再缓存 24 小时。
|
||||
$this->warmShopNamesCache();
|
||||
// 2 分钟)。mod_php 下"响应结束后再算"并不会让浏览器提前结束请求,
|
||||
// 页面会一直转圈,所以这里只返回毫秒级的店铺目录,完整列表交给
|
||||
// order-data:warm-shop-candidates 定时预热。
|
||||
return $this->shopDirectoryNames();
|
||||
}
|
||||
|
||||
$directory = $this->shopDirectoryNames();
|
||||
/**
|
||||
* 重新计算并缓存完整店铺候选(店铺目录 ∪ 订单镜像里出现过的店铺)。
|
||||
*
|
||||
* 只允许定时任务调用:单次要扫两张百万级宽表。
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public function refreshShopNamesCache(): array
|
||||
{
|
||||
$names = array_values(array_unique(array_merge(
|
||||
$this->computedShopNames(),
|
||||
$this->shopDirectoryNames(),
|
||||
)));
|
||||
sort($names, SORT_STRING);
|
||||
|
||||
return $directory !== [] ? $directory : $this->computedShopNames();
|
||||
if ($names !== []) {
|
||||
Cache::put(self::SHOP_NAMES_CACHE_KEY, $names, now()->addHours(24));
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
/** 聚水潭店铺目录(约 1300 行):毫秒级返回,作为冷缓存时的候选兜底。 */
|
||||
@ -162,32 +181,11 @@ class OrderDataMirrorRepository
|
||||
return array_keys($names);
|
||||
}
|
||||
|
||||
private function warmShopNamesCache(): void
|
||||
{
|
||||
app()->terminating(function (): void {
|
||||
$lock = Cache::lock(self::SHOP_NAMES_CACHE_KEY . ':warm', 300);
|
||||
if (! $lock->get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$names = $this->computedShopNames();
|
||||
if ($names !== []) {
|
||||
Cache::put(self::SHOP_NAMES_CACHE_KEY, $names, now()->addHours(24));
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 非淘系 + 淘系两个来源合并去重(淘系店铺名形如"天猫-xx/淘宝-xx")。
|
||||
*
|
||||
* 分开各自 DISTINCT 再在 PHP 合并:UNION 会让 MariaDB 把两个上百万行的结果
|
||||
* 一起物化去重再排序,单次要两三分钟。这里只在响应结束后或缓存不可用时执行。
|
||||
* 一起物化去重再排序,单次要两三分钟。只在定时预热时执行。
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
@ -377,7 +375,7 @@ class OrderDataMirrorRepository
|
||||
$prefix = $single !== null ? $this->likePrefix($single) : null;
|
||||
$digits = [];
|
||||
foreach ($needles as $needle) {
|
||||
if (ctype_digit($needle)) {
|
||||
if (ctype_digit((string) $needle)) {
|
||||
$digits[] = (int) $needle;
|
||||
}
|
||||
}
|
||||
@ -773,12 +771,16 @@ class OrderDataMirrorRepository
|
||||
continue;
|
||||
}
|
||||
|
||||
$values[$part] = true;
|
||||
// 键加 'v' 前缀:纯数字单号直接当数组键会被 PHP 转成 int,
|
||||
// 后面按数值去比对就用不上 so_id / outer_oi_id 的前缀索引了。
|
||||
if (! isset($values['v' . $part])) {
|
||||
$values['v' . $part] = $part;
|
||||
}
|
||||
if (count($values) >= self::BATCH_FILTER_LIMIT) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($values);
|
||||
return array_values($values);
|
||||
}
|
||||
}
|
||||
|
||||
@ -569,12 +569,15 @@ class OrderDataRepository
|
||||
continue;
|
||||
}
|
||||
|
||||
$values[$part] = true;
|
||||
// 键加 'v' 前缀:纯数字单号当数组键会被 PHP 转成 int,会丢掉原字符串。
|
||||
if (! isset($values['v' . $part])) {
|
||||
$values['v' . $part] = $part;
|
||||
}
|
||||
if (count($values) >= 50) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($values);
|
||||
return array_values($values);
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,12 +49,18 @@ class ShippingOrderRepository
|
||||
// 合并:按包裹(source_id)归并
|
||||
$base = $this->filteredQuery($filters);
|
||||
$total = (clone $base)->distinct()->count('source_id');
|
||||
$pageKeys = (clone $base)->select('source_id')->selectRaw('MAX(ship_time) as ship_time')
|
||||
->groupBy('source_id')->orderByDesc('ship_time')->orderByDesc('source_id')
|
||||
->forPage($page, $perPage)->pluck('source_id')->all();
|
||||
if ($pageKeys === []) {
|
||||
return ['data' => [], 'total' => $total];
|
||||
}
|
||||
$rows = (clone $base)
|
||||
->whereIn('source_id', $pageKeys)
|
||||
->groupBy('source_id')
|
||||
->selectRaw($this->mergedSelectRaw())
|
||||
->orderByDesc('ship_time')
|
||||
->orderByDesc('source_id')
|
||||
->forPage($page, $perPage)
|
||||
->get()->map(fn ($r) => (array) $r)->all();
|
||||
|
||||
return ['data' => $rows, 'total' => $total];
|
||||
@ -157,7 +163,12 @@ class ShippingOrderRepository
|
||||
if ($last !== null) {
|
||||
$query->where('source_id', '>', $last);
|
||||
}
|
||||
$rows = $query->groupBy('source_id')->selectRaw($this->mergedSelectRaw())
|
||||
$keys = (clone $query)->select('source_id')->distinct()
|
||||
->orderBy('source_id')->limit($batchSize)->pluck('source_id')->all();
|
||||
if ($keys === []) {
|
||||
return;
|
||||
}
|
||||
$rows = $query->whereIn('source_id', $keys)->groupBy('source_id')->selectRaw($this->mergedSelectRaw())
|
||||
->orderBy('source_id')->limit($batchSize)->get()->all();
|
||||
if ($rows === []) {
|
||||
return;
|
||||
|
||||
@ -36,6 +36,11 @@ class BusinessMirrorScheduleTest extends TestCase
|
||||
$contents,
|
||||
$script,
|
||||
);
|
||||
$this->assertStringContainsString(
|
||||
'45 * * * * root /usr/bin/flock -w 600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan order-data:warm-shop-candidates --quiet',
|
||||
$contents,
|
||||
$script,
|
||||
);
|
||||
$minuteLines = array_values(array_filter(
|
||||
preg_split('/\R/', $contents) ?: [],
|
||||
static fn (string $line): bool => str_starts_with($line, '* * * * * root'),
|
||||
|
||||
@ -147,7 +147,17 @@ class OrderDataMirrorRepositoryTest extends TestCase
|
||||
$names = $this->repository($db)->shopNames();
|
||||
sort($names);
|
||||
|
||||
$this->assertSame(['京东-乙店', '拼多多-甲店'], $names);
|
||||
// 冷缓存只返回毫秒级的店铺目录,不能为了下拉候选去扫订单宽表。
|
||||
$db->table('ops_jst_shop_info')->insert(['shop_id' => 1, 'shop_name' => '抖音-丙店']);
|
||||
$directory = $this->repository($db)->shopNames();
|
||||
sort($directory);
|
||||
$this->assertSame(['抖音-丙店'], $directory);
|
||||
|
||||
// 完整候选由定时预热写入:店铺目录 ∪ 订单镜像里出现过的店铺。
|
||||
$names = $this->repository($db)->refreshShopNamesCache();
|
||||
sort($names);
|
||||
|
||||
$this->assertSame(['京东-乙店', '抖音-丙店', '拼多多-甲店'], $names);
|
||||
}
|
||||
|
||||
public function test_after_sales_includes_taobao_orders_and_their_refunds(): void
|
||||
@ -193,8 +203,8 @@ class OrderDataMirrorRepositoryTest extends TestCase
|
||||
$this->assertSame(1.0, $taobao['post_return_quantity']);
|
||||
$this->assertArrayNotHasKey('source_branch', $taobao);
|
||||
|
||||
// 店铺候选也要包含淘系店铺。
|
||||
$names = $this->repository($db)->shopNames();
|
||||
// 店铺候选也要包含淘系店铺(完整候选由定时预热写入)。
|
||||
$names = $this->repository($db)->refreshShopNamesCache();
|
||||
$this->assertContains('天猫-卓禾家具旗舰店', $names);
|
||||
}
|
||||
|
||||
@ -232,6 +242,20 @@ class OrderDataMirrorRepositoryTest extends TestCase
|
||||
$this->assertSame(['SO-2026-0001', 'SO-2026-0003'], $this->sortedOnlineOrders($skus['rows']));
|
||||
}
|
||||
|
||||
public function test_batch_order_numbers_keep_their_exact_text_form(): void
|
||||
{
|
||||
$db = DB::connection('business_mirror');
|
||||
$this->insertOrder($db, 1, '0001234', '拼多多-甲店');
|
||||
$this->insertOrder($db, 2, '1234', '拼多多-乙店');
|
||||
|
||||
$result = $this->repository($db)->afterSales([
|
||||
'from' => '', 'to' => '', 'order_no' => "0001234\n9999999", 'shop_name' => '', 'sku_id' => '',
|
||||
], 1, 25);
|
||||
|
||||
// 纯数字单号不能被 PHP 当数组键转成 int,否则前导零的单号会串到别的订单。
|
||||
$this->assertSame(['0001234'], $this->sortedOnlineOrders($result['rows']));
|
||||
}
|
||||
|
||||
public function test_promotion_expenses_filters_by_multiple_shops(): void
|
||||
{
|
||||
$db = DB::connection('business_mirror');
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ShippingMergedColumnsMigrationTest extends TestCase
|
||||
{
|
||||
public function test_reconciliation_repairs_missing_columns_and_can_run_again(): void
|
||||
{
|
||||
$schema = DB::connection('business_mirror')->getSchemaBuilder();
|
||||
$columns = ['m_name', 'm_volume_cm3', 'm_length_cm', 'm_width_cm', 'm_height_cm'];
|
||||
$schema->table('jst_oms_shipping', fn ($table) => $table->dropColumn($columns));
|
||||
$migration = require base_path('database/migrations/2026_10_07_150000_reconcile_shipping_merged_columns.php');
|
||||
$migration->up();
|
||||
$migration->up();
|
||||
foreach ($columns as $column) {
|
||||
$this->assertTrue($schema->hasColumn('jst_oms_shipping', $column));
|
||||
}
|
||||
}
|
||||
|
||||
public function test_missing_target_is_reported_instead_of_marked_complete(): void
|
||||
{
|
||||
DB::connection('business_mirror')->getSchemaBuilder()->drop('jst_oms_shipping');
|
||||
$migration = require base_path('database/migrations/2026_10_07_150000_reconcile_shipping_merged_columns.php');
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('发货镜像表不存在');
|
||||
$migration->up();
|
||||
}
|
||||
}
|
||||
@ -844,7 +844,7 @@ class ShippingReplicaSynchronizerTest extends TestCase
|
||||
|
||||
$this->artisan('jushuitan:sync-shipping', [
|
||||
'--from' => '2026-09-01', '--to' => '2026-09-05', '--skip-freight' => true,
|
||||
])->expectsOutputToContain('唯一包裹源键 2 个,包裹记录处理 2 次;发货明细 1 条,代发仓 1 条,合计处理 2 条明细')
|
||||
])->expectsOutputToContain('唯一包裹源键 2 个,包裹记录处理 2 次;发货明细 1 条,代发仓 1 条,销售出库补漏 0 条,采购出库补漏 0 条,合计处理 2 条明细')
|
||||
->assertExitCode(0);
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user