diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh index 038b0f15..08949bad 100644 --- a/deploy/activate-incremental-release.sh +++ b/deploy/activate-incremental-release.sh @@ -65,6 +65,14 @@ if ! flock -n 9; then exit 1 fi +# 容器替换会终止其中的同步进程;与生产刷新任务共享同一把锁。 +exec 8>/run/caiwu-upstream-sync.lock +echo "Waiting for active upstream synchronization before replacing the application container" +if ! flock -w 1800 8; then + echo "Upstream synchronization is still running; application container was not replaced" >&2 + exit 1 +fi + PREVIOUS_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)" PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)" diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh index 6260783d..84a97c2f 100644 --- a/deploy/activate-release.sh +++ b/deploy/activate-release.sh @@ -69,6 +69,14 @@ if ! flock -n 9; then exit 1 fi +# 容器替换会终止其中的同步进程;与生产刷新任务共享同一把锁。 +exec 8>/run/caiwu-upstream-sync.lock +echo "Waiting for active upstream synchronization before replacing the application container" +if ! flock -w 1800 8; then + echo "Upstream synchronization is still running; application container was not replaced" >&2 + exit 1 +fi + PREVIOUS_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)" PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)" diff --git a/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php b/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php index 2c93405c..a84d9e25 100644 --- a/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php +++ b/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php @@ -17,6 +17,9 @@ class AccountLedgerRemarkMap { public const CACHE_KEY = 'ec_account_ledger_remarks_map_v1'; + /** 「平台 + 账单项目」→ 科目映射表规则(请求内缓存,避免逐行扫描全部规则)。 */ + private ?array $accountMappingIndex = null; + /** 页签默认平台(店铺名没有平台前缀时使用)。 */ private const TAB_PLATFORM = [ 'taobao-alipay' => '淘宝', @@ -241,31 +244,152 @@ class AccountLedgerRemarkMap return $tabPlatform; } - /** 查总账备注;没有匹配返回 null。 */ - public function remark(?string $storeName, ?string $tabPlatform, ?string $billItem): ?string + /** + * 查总账备注;没有匹配返回 null。 + * + * 取值顺序: + * ①「财务规则 › 科目映射表」里同平台同账单项目的「财务备注」(匹配器按业务备注/商品名称关键词取最具体的一条) + * ② 上面没匹到时,回落到本表(账单项目 → 总账备注)原有的口径 + */ + public function remark( + ?string $storeName, + ?string $tabPlatform, + ?string $billItem, + array $businessRemarks = [], + string $goodsName = '', + ): ?string { $item = trim((string) $billItem); if ($item === '') { return null; } + $platforms = [$this->platform($storeName, $tabPlatform), $tabPlatform]; + + foreach ($platforms as $platform) { + if ($platform === null || $platform === '') { + continue; + } + $fromRule = $this->accountMappingRemark($platform, $item, $businessRemarks, $goodsName); + if ($fromRule !== null) { + return $fromRule; + } + } + $map = $this->map(); if ($map === []) { return null; } - foreach ([$this->platform($storeName, $tabPlatform), $tabPlatform] as $platform) { + foreach ($platforms as $platform) { if ($platform === null || $platform === '') { continue; } - if (isset($map[$platform][$item])) { - return $map[$platform][$item]; + // 先按原样匹配,再按「去掉 wx 前缀」的账单项目匹配(淘系部分行带 wx 前缀) + foreach ($this->itemLookupKeys($item) as $candidate) { + if (isset($map[$platform][$candidate])) { + return $map[$platform][$candidate]; + } } } return null; } + /** 科目映射表按「平台 + 账单项目」预索引,避免逐行扫全部规则。 */ + private function accountMappingIndex(): array + { + if ($this->accountMappingIndex !== null) { + return $this->accountMappingIndex; + } + + $index = []; + try { + foreach (app(AccountMappingStore::class)->all() as $rule) { + if (! is_array($rule)) { + continue; + } + $platform = trim((string) ($rule['platform'] ?? '')); + $item = trim((string) ($rule['item'] ?? '')); + if ($platform === '' || $item === '') { + continue; + } + // 平台/账单项目做大小写与空格归一:映射表里写的是「唯品会JX」,页签内部键是「唯品会jx」 + $index[$this->normalizeKey($platform)][$this->normalizeKey($item)][] = $rule; + } + } catch (Throwable) { + $index = []; + } + + return $this->accountMappingIndex = $index; + } + + /** 同平台同账单项目的「财务备注」;没有规则或财务备注为空时返回 null(由调用方回落)。 */ + private function accountMappingRemark(string $platform, string $item, array $businessRemarks, string $goodsName): ?string + { + $index = $this->accountMappingIndex(); + $platformKey = $this->normalizeKey($platform); + + // 先按原样匹配,再按「去掉 wx 前缀」的账单项目匹配(科目映射表里部分项目不带 wx 前缀) + foreach ($this->itemLookupKeys($item) as $candidate) { + $rules = $index[$platformKey][$this->normalizeKey($candidate)] ?? []; + if ($rules === []) { + continue; + } + + if (count($rules) === 1) { + $remark = trim((string) ($rules[0]['memo'] ?? '')); + if ($remark !== '') { + return $remark; + } + + continue; + } + + // 同一账单项目有多条规则时,用映射表自己的匹配器(按商品名称/业务备注关键词取最具体的) + try { + $result = app(AccountMappingMatcher::class)->match($platform, $candidate, $businessRemarks, $goodsName); + } catch (Throwable) { + $result = null; + } + + $remark = trim((string) ($result['rule']['memo'] ?? '')); + if ($remark !== '') { + return $remark; + } + } + + return null; + } + + /** + * 账单项目的查找候选:原样优先,其次去掉 wx 前缀。 + * + * 淘系同一笔费用在不同页签/导出里会出现带 wx 前缀(wx基础软件服务费)与不带前缀(基础软件服务费)两种写法, + * 按口径应当是同一个总账备注;去掉前缀后再查一次即可命中同一批规则。 + * + * @return list + */ + private function itemLookupKeys(string $item): array + { + $item = trim($item); + $keys = [$item]; + + if ($item !== '' && stripos($item, 'wx') === 0) { + $withoutPrefix = ltrim(mb_substr($item, 2)); + if ($withoutPrefix !== '') { + $keys[] = $withoutPrefix; + } + } + + return array_values(array_unique($keys)); + } + + private function normalizeKey(string $value): string + { + return mb_strtolower(preg_replace('/\s+/u', '', $value) ?? $value); + } + /** * 给一行账单补上 payload.ledger_remark(内部字段名 ledger_remark)。 * @@ -279,6 +403,8 @@ class AccountLedgerRemarkMap $row['store_name'] ?? null, $tabPlatform, $row['bill_item'] ?? null, + $this->businessRemarks($row), + $this->goodsName($row), ); if ($remark === null) { return $row; @@ -293,6 +419,53 @@ class AccountLedgerRemarkMap return $row; } + /** + * 账单上的业务备注(用于科目映射表的「业务备注关键词」条件)。 + * + * @param array $row + * @return list + */ + private function businessRemarks(array $row): array + { + $payload = is_array($row['payload'] ?? null) ? $row['payload'] : []; + + $values = [ + $row['custom_remark'] ?? null, + $row['finance_remark'] ?? null, + $payload['second_remark'] ?? null, + $payload['business_description'] ?? null, + $payload['move_summary'] ?? null, + $payload['settle_remark'] ?? null, + $payload['tx_remark'] ?? null, + $payload['cost_name'] ?? null, + ]; + + $remarks = []; + foreach ($values as $value) { + $text = trim((string) $value); + if ($text !== '') { + $remarks[] = $text; + } + } + + return $remarks; + } + + /** 商品名称(用于科目映射表的「商品名称关键词」条件)。 */ + private function goodsName(array $row): string + { + $payload = is_array($row['payload'] ?? null) ? $row['payload'] : []; + + foreach (['goods_name', 'product_name', 'goods_title'] as $key) { + $value = trim((string) ($payload[$key] ?? '')); + if ($value !== '') { + return $value; + } + } + + return ''; + } + /** * @param iterable> $rows * @return \Generator> diff --git a/tests/Unit/Deployment/DeploymentSyncLockTest.php b/tests/Unit/Deployment/DeploymentSyncLockTest.php new file mode 100644 index 00000000..2e6b71f4 --- /dev/null +++ b/tests/Unit/Deployment/DeploymentSyncLockTest.php @@ -0,0 +1,57 @@ +/run/caiwu-upstream-sync.lock'); + $end = strpos($contents, 'PREVIOUS_IMAGE=', $start ?: 0); + $this->assertNotFalse($start, $script); + $this->assertNotFalse($end, $script); + $this->assertLessThan(strpos($contents, 'docker compose -f compose.jiangling.yml up'), $end); + $guard = substr($contents, $start, $end - $start); + + $directory = sys_get_temp_dir() . '/caiwu-deploy-lock-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $holder = new Process(['sh', '-c', 'exec 8>"$1"; flock 8; touch "$2"; sleep 2', 'holder', $directory . '/sync.lock', $directory . '/held']); + try { + $holder->start(); + $deadline = microtime(true) + 3; + while (! file_exists($directory . '/held') && microtime(true) < $deadline) { + usleep(10000); + } + $this->assertFileExists($directory . '/held'); + $guard = str_replace('/run/caiwu-upstream-sync.lock', $directory . '/sync.lock', $guard); + $guard .= "touch '$directory/activated'\n"; + + $timeout = new Process(['sh', '-c', str_replace('-w 1800', '-w 0', $guard)]); + $timeout->run(); + $this->assertSame(1, $timeout->getExitCode()); + $this->assertStringContainsString('application container was not replaced', $timeout->getErrorOutput()); + $this->assertFileDoesNotExist($directory . '/activated'); + + $release = new Process(['sh', '-c', $guard]); + $release->start(); + usleep(100000); + $this->assertTrue($release->isRunning()); + $this->assertFileDoesNotExist($directory . '/activated'); + $release->wait(); + $this->assertTrue($release->isSuccessful(), $release->getErrorOutput()); + $this->assertFileExists($directory . '/activated'); + } finally { + $holder->stop(); + foreach (glob($directory . '/*') as $file) { + unlink($file); + } + rmdir($directory); + } + } + } +}