diff --git a/app/Console/Commands/RunAccountMappingResync.php b/app/Console/Commands/RunAccountMappingResync.php new file mode 100644 index 00000000..4e418371 --- /dev/null +++ b/app/Console/Commands/RunAccountMappingResync.php @@ -0,0 +1,107 @@ +lock('store-receipt:auto-resync', 86400); + if (! $lock->get()) { + // 已有进程在跑;本次新入队的任务会在当前进程下一轮循环或每分钟兜底时执行。 + return self::SUCCESS; + } + + try { + // 进程崩溃遗留的 running 任务超过 60 分钟视为可重试。 + DB::table(AccountMappingResyncDispatcher::JOBS_TABLE) + ->where('status', 'running') + ->where('updated_at', '<', now()->subMinutes(60)) + ->update(['status' => 'pending', 'updated_at' => now()]); + + while (true) { + $job = DB::table(AccountMappingResyncDispatcher::JOBS_TABLE) + ->where('status', 'pending') + ->orderBy('id') + ->first(); + if ($job === null) { + break; + } + + DB::table(AccountMappingResyncDispatcher::JOBS_TABLE) + ->where('id', $job->id) + ->where('status', 'pending') + ->update([ + 'status' => 'running', + 'attempts' => DB::raw('attempts + 1'), + 'updated_at' => now(), + ]); + + try { + $process = Process::fromShellCommandline( + implode(' ', [ + escapeshellarg(PHP_BINARY ?: 'php'), + 'artisan', + 'store-receipt:order-match:sync', + escapeshellarg((string) $job->month), + '--platform=' . escapeshellarg((string) $job->platform), + ]), + base_path() + ); + $process->setTimeout(7200); + $process->run(); + $output = trim($process->getOutput() . "\n" . $process->getErrorOutput()); + + // 同步期间又保存了映射时,dispatchFor 已把状态改回 pending; + // 这里只在仍为 running 时写 done,不能吞掉新请求。 + DB::table(AccountMappingResyncDispatcher::JOBS_TABLE) + ->where('id', $job->id) + ->where('status', 'running') + ->update([ + 'status' => $process->isSuccessful() ? 'done' : 'failed', + 'message' => mb_substr( + $output !== '' ? $output : ($process->isSuccessful() ? 'OK' : '同步命令执行失败'), + 0, + 1000 + ), + 'updated_at' => now(), + ]); + } catch (Throwable $exception) { + Log::error('科目映射表自动重跑失败:' . $exception->getMessage()); + DB::table(AccountMappingResyncDispatcher::JOBS_TABLE) + ->where('id', $job->id) + ->where('status', 'running') + ->update([ + 'status' => 'failed', + 'message' => mb_substr($exception->getMessage(), 0, 1000), + 'updated_at' => now(), + ]); + } + } + } finally { + $lock->release(); + } + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Business/AccountMappingActions.php b/app/Http/Controllers/Business/AccountMappingActions.php index 9e6618c4..72a8e048 100644 --- a/app/Http/Controllers/Business/AccountMappingActions.php +++ b/app/Http/Controllers/Business/AccountMappingActions.php @@ -12,6 +12,7 @@ use Modules\EcommerceFinance\Services\AccountMappingCoa; use Modules\EcommerceFinance\Services\AccountMappingStore; use Modules\EcommerceFinance\Services\AccountMappingValidator; use Modules\EcommerceFinance\Services\AccountMappingWorkbookImporter; +use Modules\EcommerceFinance\Services\AccountMappingResyncDispatcher; use RuntimeException; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -74,27 +75,36 @@ class AccountMappingActions extends Controller $this->store->create($values, $this->userId()); - return response()->json(['message' => '已新增 1 条规则。'] + $this->payload()); + $hint = $this->dispatchResync([(string) ($values['platform'] ?? '')]); + + return response()->json(['message' => '已新增 1 条规则。' . $hint] + $this->payload()); } public function update(Request $request, int $id): JsonResponse { - if ($this->store->find($id) === null) { + $before = $this->store->find($id); + if ($before === null) { throw ValidationException::withMessages(['id' => '这条规则已经不存在了,请刷新页面。']); } - $this->store->update($id, $this->payloadValues($request), $this->userId()); + $values = $this->payloadValues($request); + $this->store->update($id, $values, $this->userId()); - return response()->json(['message' => '已保存。'] + $this->payload()); + $hint = $this->dispatchResync([(string) ($before['platform'] ?? ''), (string) ($values['platform'] ?? '')]); + + return response()->json(['message' => '已保存。' . $hint] + $this->payload()); } public function destroy(int $id): JsonResponse { + $before = $this->store->find($id); if (! $this->store->destroy($id, $this->userId())) { throw ValidationException::withMessages(['id' => '这条规则已经不存在了,请刷新页面。']); } - return response()->json(['message' => '已删除 1 条规则。'] + $this->payload()); + $hint = $this->dispatchResync([(string) ($before['platform'] ?? '')]); + + return response()->json(['message' => '已删除 1 条规则。' . $hint] + $this->payload()); } /** @@ -181,21 +191,24 @@ class AccountMappingActions extends Controller } $restored = $this->store->create($before, $this->userId()); + $hint = $this->dispatchResync([(string) ($restored['platform'] ?? '')]); return response()->json([ - 'message' => '已恢复「' . $restored['platform'] . ' · ' . $restored['item'] . '」(新记录 #' . (int) $restored['id'] . ')。', + 'message' => '已恢复「' . $restored['platform'] . ' · ' . $restored['item'] . '」(新记录 #' . (int) $restored['id'] . ')。' . $hint, ] + $this->payload()); } if ($action === 'update') { - if ($this->store->find($ruleId) === null) { + $current = $this->store->find($ruleId); + if ($current === null) { throw ValidationException::withMessages(['history' => '这条规则已经被删掉了,请用同一条的「删除」记录恢复。']); } $this->store->update($ruleId, $before, $this->userId()); + $hint = $this->dispatchResync([(string) ($current['platform'] ?? ''), (string) ($before['platform'] ?? '')]); return response()->json([ - 'message' => '已恢复成这次修改前的内容(记录 #' . $ruleId . ')。', + 'message' => '已恢复成这次修改前的内容(记录 #' . $ruleId . ')。' . $hint, ] + $this->payload()); } @@ -227,6 +240,11 @@ class AccountMappingActions extends Controller $parsed = $this->importer->parse($file); $result = $this->store->importRows($parsed['rows'], $request->boolean('overwrite'), $this->userId()); $message = "导入完成:识别 {$result['total']} 行,新增 {$result['created']} 条,更新 {$result['updated']} 条,跳过重复 {$result['skipped']} 条。"; + $platforms = array_values(array_unique(array_map( + static fn (array $row): string => (string) ($row['platform'] ?? ''), + $parsed['rows'] + ))); + $message .= $this->dispatchResync($platforms); return response()->json(['message' => $message, 'result' => $result] + $this->payload()); } @@ -302,7 +320,7 @@ class AccountMappingActions extends Controller { $total = $this->store->reset($this->userId()); - return response()->json(['message' => "已恢复为初始 {$total} 条规则。"] + $this->payload()); + return response()->json(['message' => "已恢复为初始 {$total} 条规则。" . $this->dispatchResyncAll()] + $this->payload()); } /** 账套科目(一级 + 各一级下的明细),供下拉使用 */ @@ -406,6 +424,44 @@ class AccountMappingActions extends Controller return user_id() ? (int) user_id() : null; } + /** + * 保存映射后把受影响的平台排进后台重跑队列。 + * + * 映射表本身是实时读取的,但业绩明细的优惠/冲减/退款金额是同步时落库的, + * 所以保存成功后需要自动重跑对应平台和月份。队列创建失败不能把已经保存成功的规则回滚。 + * + * @param list $platforms + */ + private function dispatchResync(array $platforms): string + { + try { + $result = app(AccountMappingResyncDispatcher::class)->dispatchFor($platforms); + } catch (\Throwable $exception) { + report($exception); + + return ' 业绩明细自动重跑任务创建失败,请稍后重试保存或联系管理员。'; + } + + return $result['jobs'] > 0 + ? " 业绩明细已自动排队重跑:{$result['jobs']} 个平台×月份任务,后台依次执行。" + : ''; + } + + private function dispatchResyncAll(): string + { + try { + $result = app(AccountMappingResyncDispatcher::class)->dispatchAll(); + } catch (\Throwable $exception) { + report($exception); + + return ' 业绩明细自动重跑任务创建失败,请稍后重试或联系管理员。'; + } + + return $result['jobs'] > 0 + ? " 业绩明细已自动排队重跑:{$result['jobs']} 个平台×月份任务,后台依次执行。" + : ''; + } + /** * 原始操作记录 + 页面/CSV 直接可用的字段(变更内容与操作人只在服务端算一次)。 * diff --git a/database/migrations/2026_10_19_150000_create_account_mapping_resync_jobs.php b/database/migrations/2026_10_19_150000_create_account_mapping_resync_jobs.php new file mode 100644 index 00000000..74c2fe5d --- /dev/null +++ b/database/migrations/2026_10_19_150000_create_account_mapping_resync_jobs.php @@ -0,0 +1,33 @@ +bigIncrements('id'); + $table->string('platform', 64); + $table->string('month', 7); + $table->string('status', 16)->default('pending'); + $table->unsignedInteger('attempts')->default(0); + $table->text('message')->nullable(); + $table->timestamps(); + + $table->unique(['platform', 'month'], 'ec_account_mapping_resync_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('ec_account_mapping_resync_jobs'); + } +}; diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh index abe0128c..b61cf0fe 100644 --- a/deploy/activate-incremental-release.sh +++ b/deploy/activate-incremental-release.sh @@ -205,6 +205,9 @@ CRON_TZ=Asia/Shanghai # 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。 15 8 * * * 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-supplier-management --quiet >/dev/null 2>&1 */2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1 +# 科目映射表保存后自动重跑业绩明细。页面保存时会立即起后台进程,这里每分钟兜底一次, +# 防止容器重启、请求进程退出或并发时遗漏队列任务。 +* * * * * root /usr/bin/flock -n /run/caiwu-account-mapping-resync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-receipt:auto-resync --quiet >/dev/null 2>&1 28 1 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan jushuitan:sync-shipping --days=3 >/dev/null 2>&1 # 头条放心购(店铺收款账户账单 › 抖音账户)资金流水:每天 09:30 定点把该渠道所用 # 原始账单镜像追平一次,保证上班前当日/前一日流水已进系统,而不是等每分钟轮转慢慢走。 diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh index fc040f90..66912e3c 100644 --- a/deploy/activate-release.sh +++ b/deploy/activate-release.sh @@ -209,6 +209,9 @@ CRON_TZ=Asia/Shanghai # 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。 15 8 * * * 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-supplier-management --quiet >/dev/null 2>&1 */2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1 +# 科目映射表保存后自动重跑业绩明细。页面保存时会立即起后台进程,这里每分钟兜底一次, +# 防止容器重启、请求进程退出或并发时遗漏队列任务。 +* * * * * root /usr/bin/flock -n /run/caiwu-account-mapping-resync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-receipt:auto-resync --quiet >/dev/null 2>&1 28 1 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan jushuitan:sync-shipping --days=3 >/dev/null 2>&1 # 头条放心购(店铺收款账户账单 › 抖音账户)资金流水:每天 09:30 定点把该渠道所用 # 原始账单镜像追平一次,保证上班前当日/前一日流水已进系统,而不是等每分钟轮转慢慢走。 diff --git a/modules/EcommerceFinance/Services/AccountMappingResyncDispatcher.php b/modules/EcommerceFinance/Services/AccountMappingResyncDispatcher.php new file mode 100644 index 00000000..e69e7acd --- /dev/null +++ b/modules/EcommerceFinance/Services/AccountMappingResyncDispatcher.php @@ -0,0 +1,118 @@ + [['taobao-alipay', '淘系支付宝'], ['taobao-wechat', '淘系微信']], + '天猫' => [['taobao-alipay', '淘系支付宝'], ['taobao-wechat', '淘系微信']], + '阿里巴巴' => [['taobao-alipay', '淘系支付宝']], + '拼多多' => [['pinduoduo', '拼多多']], + '京喜' => [['jingxi', '京喜账单']], + '京东' => [['jd-transactions', '京东账单'], ['jd-wallet', '京东网银钱包']], + '抖音' => [['douyin', '抖音']], + '得物' => [['dewu', '得物']], + ]; + + /** + * @param list $mappingPlatforms 科目映射表里的平台名 + * @return array{jobs:int} + */ + public function dispatchFor(array $mappingPlatforms): array + { + $targets = []; + foreach ($mappingPlatforms as $platform) { + foreach (self::PLATFORM_MAP[trim((string) $platform)] ?? [] as [$key, $label]) { + $targets[$key] = $label; + } + } + if ($targets === []) { + return ['jobs' => 0]; + } + + $mirror = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + $months = $mirror->table('store_receipt_order_match_rows') + ->whereIn('platform', array_values($targets)) + ->selectRaw("DATE_FORMAT(report_month, '%Y-%m') as ym") + ->distinct() + ->orderBy('ym') + ->pluck('ym') + ->all(); + if ($months === []) { + return ['jobs' => 0]; + } + + $now = now(); + $jobs = 0; + foreach ($targets as $key => $label) { + foreach ($months as $month) { + // running 时再次保存映射,会把状态改回 pending;当前进程完成时发现状态已变, + // 不会覆盖成 done,于是会紧接着再跑一遍,避免漏掉刚保存的新规则。 + $affected = DB::table(self::JOBS_TABLE)->updateOrInsert( + ['platform' => (string) $key, 'month' => (string) $month], + [ + 'status' => 'pending', + 'message' => null, + 'updated_at' => $now, + 'created_at' => $now, + ] + ); + if ($affected) { + $jobs++; + } + } + } + + if ($jobs > 0) { + $this->spawnWorker(); + } + + return ['jobs' => $jobs]; + } + + /** @return array{jobs:int} 所有已接入平台一起重跑(用于「恢复初始 112 条」) */ + public function dispatchAll(): array + { + return $this->dispatchFor(array_keys(self::PLATFORM_MAP)); + } + + /** + * 起一个后台进程处理队列。 + * + * 已有进程在跑时新进程会拿不到锁直接退出;正在跑的进程处理完当前任务后会继续取新任务。 + */ + public function spawnWorker(): void + { + try { + $php = escapeshellarg(PHP_BINARY ?: 'php'); + $command = 'cd ' . escapeshellarg(base_path()) + . ' && nohup ' . $php . ' artisan store-receipt:auto-resync >/dev/null 2>&1 &'; + + Process::fromShellCommandline($command, base_path()) + ->disableOutput() + ->setTimeout(10) + ->run(); + } catch (Throwable $exception) { + // 后台进程起不来时任务仍留在队列里,可手动执行 store-receipt:auto-resync 补跑。 + Log::warning('科目映射表自动重跑进程启动失败:' . $exception->getMessage()); + } + } +} diff --git a/tests/Unit/EcommerceFinance/AccountMappingResyncDispatcherTest.php b/tests/Unit/EcommerceFinance/AccountMappingResyncDispatcherTest.php new file mode 100644 index 00000000..640c3f92 --- /dev/null +++ b/tests/Unit/EcommerceFinance/AccountMappingResyncDispatcherTest.php @@ -0,0 +1,33 @@ +getConstant('PLATFORM_MAP'); + + $this->assertSame( + [['taobao-alipay', '淘系支付宝'], ['taobao-wechat', '淘系微信']], + $map['淘宝'] + ); + $this->assertSame( + [['taobao-alipay', '淘系支付宝'], ['taobao-wechat', '淘系微信']], + $map['天猫'] + ); + $this->assertSame([['taobao-alipay', '淘系支付宝']], $map['阿里巴巴']); + } + + public function test_platform_without_receipt_sync_is_a_noop(): void + { + $result = app(AccountMappingResyncDispatcher::class)->dispatchFor(['唯品会JX']); + + $this->assertSame(['jobs' => 0], $result); + } +}