From db2ee47fd89c470dd2b3811779039f6b0f1e7b62 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Wed, 7 Oct 2026 15:00:48 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E5=AE=B9=E5=99=A8=E5=89=8D=E7=AD=89=E5=BE=85=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=E5=90=8C=E6=AD=A5=E5=AE=8C=E6=88=90=20(#284)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/activate-incremental-release.sh | 8 +++ deploy/activate-release.sh | 8 +++ .../Deployment/DeploymentSyncLockTest.php | 57 +++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 tests/Unit/Deployment/DeploymentSyncLockTest.php 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/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); + } + } + } +} From 1cbc8312a6cbbfc09e97deda222de969121ea71e Mon Sep 17 00:00:00 2001 From: qiuxiang <394405259@qq.com> Date: Wed, 7 Oct 2026 15:24:58 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E5=BA=97=E9=93=BA=E6=94=B6=E6=AC=BE?= =?UTF-8?q?=E8=B4=A6=E6=88=B7=E8=B4=A6=E5=8D=95=EF=BC=9A=E6=80=BB=E8=B4=A6?= =?UTF-8?q?=E5=A4=87=E6=B3=A8=E4=BC=98=E5=85=88=E5=8F=96=E3=80=8C=E7=A7=91?= =?UTF-8?q?=E7=9B=AE=E6=98=A0=E5=B0=84=E8=A1=A8=E3=80=8D=E7=9A=84=E8=B4=A2?= =?UTF-8?q?=E5=8A=A1=E5=A4=87=E6=B3=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 账单项目在「科目映射表」里能匹到(同平台+同项目)时,总账备注取该规则的「财务备注」; 多规则时按业务备注/商品名称关键词取最具体的一条;匹不到则保留原有总账备注映射。规则按平台+项目预索引,避免逐行扫全表。 --- .../Services/AccountLedgerRemarkMap.php | 135 +++++++++++++++++- 1 file changed, 132 insertions(+), 3 deletions(-) diff --git a/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php b/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php index 2c93405c..2cecbefb 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,20 +244,44 @@ 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; } @@ -266,6 +293,59 @@ class AccountLedgerRemarkMap 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; + } + $index[$platform][$item][] = $rule; + } + } catch (Throwable) { + $index = []; + } + + return $this->accountMappingIndex = $index; + } + + /** 同平台同账单项目的「财务备注」;没有规则或财务备注为空时返回 null(由调用方回落)。 */ + private function accountMappingRemark(string $platform, string $item, array $businessRemarks, string $goodsName): ?string + { + $rules = $this->accountMappingIndex()[$platform][$item] ?? []; + if ($rules === []) { + return null; + } + + if (count($rules) === 1) { + $remark = trim((string) ($rules[0]['memo'] ?? '')); + + return $remark === '' ? null : $remark; + } + + // 同一账单项目有多条规则时,用映射表自己的匹配器(按商品名称/业务备注关键词取最具体的) + try { + $result = app(AccountMappingMatcher::class)->match($platform, $item, $businessRemarks, $goodsName); + } catch (Throwable) { + return null; + } + + $remark = trim((string) ($result['rule']['memo'] ?? '')); + + return $remark === '' ? null : $remark; + } + /** * 给一行账单补上 payload.ledger_remark(内部字段名 ledger_remark)。 * @@ -279,6 +359,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 +375,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> From 0933941533019dc968092e1a6c0d3bbbdaecc36c Mon Sep 17 00:00:00 2001 From: qiuxiang <394405259@qq.com> Date: Wed, 7 Oct 2026 15:30:56 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E6=80=BB=E8=B4=A6=E5=A4=87=E6=B3=A8?= =?UTF-8?q?=E5=8F=96=E7=A7=91=E7=9B=AE=E6=98=A0=E5=B0=84=E8=A1=A8=EF=BC=9A?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0/=E8=B4=A6=E5=8D=95=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E9=94=AE=E5=81=9A=E5=A4=A7=E5=B0=8F=E5=86=99=E4=B8=8E=E7=A9=BA?= =?UTF-8?q?=E6=A0=BC=E5=BD=92=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 科目映射表平台写「唯品会JX」、页签内部键「唯品会jx」,原样索引匹配不到;统一小写去空格后索引与查找。 --- .../Services/AccountLedgerRemarkMap.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php b/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php index 2cecbefb..b7853f5f 100644 --- a/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php +++ b/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php @@ -311,7 +311,8 @@ class AccountLedgerRemarkMap if ($platform === '' || $item === '') { continue; } - $index[$platform][$item][] = $rule; + // 平台/账单项目做大小写与空格归一:映射表里写的是「唯品会JX」,页签内部键是「唯品会jx」 + $index[$this->normalizeKey($platform)][$this->normalizeKey($item)][] = $rule; } } catch (Throwable) { $index = []; @@ -323,7 +324,7 @@ class AccountLedgerRemarkMap /** 同平台同账单项目的「财务备注」;没有规则或财务备注为空时返回 null(由调用方回落)。 */ private function accountMappingRemark(string $platform, string $item, array $businessRemarks, string $goodsName): ?string { - $rules = $this->accountMappingIndex()[$platform][$item] ?? []; + $rules = $this->accountMappingIndex()[$this->normalizeKey($platform)][$this->normalizeKey($item)] ?? []; if ($rules === []) { return null; } @@ -346,6 +347,11 @@ class AccountLedgerRemarkMap return $remark === '' ? null : $remark; } + private function normalizeKey(string $value): string + { + return mb_strtolower(preg_replace('/\s+/u', '', $value) ?? $value); + } + /** * 给一行账单补上 payload.ledger_remark(内部字段名 ledger_remark)。 * From ae694004cfdab921b7f067cf9b7a17f331f3c2bb Mon Sep 17 00:00:00 2001 From: qiuxiang <394405259@qq.com> Date: Wed, 7 Oct 2026 15:58:42 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E6=80=BB=E8=B4=A6=E5=A4=87=E6=B3=A8?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=EF=BC=9A=E8=B4=A6=E5=8D=95=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8E=BB=20wx=20=E5=89=8D=E7=BC=80=E5=86=8D?= =?UTF-8?q?=E6=9F=A5=E4=B8=80=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 先原样匹配,不中则去掉开头 wx 前缀(wx基础软件服务费 → 基础软件服务费)再匹配;原样命中优先,不影响既有结果。 科目映射表优先级链路与回落原总账备注链路都按此顺序匹配。 --- .../Services/AccountLedgerRemarkMap.php | 74 ++++++++++++++----- 1 file changed, 56 insertions(+), 18 deletions(-) diff --git a/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php b/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php index b7853f5f..a84d9e25 100644 --- a/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php +++ b/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php @@ -285,8 +285,11 @@ class AccountLedgerRemarkMap 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]; + } } } @@ -324,27 +327,62 @@ class AccountLedgerRemarkMap /** 同平台同账单项目的「财务备注」;没有规则或财务备注为空时返回 null(由调用方回落)。 */ private function accountMappingRemark(string $platform, string $item, array $businessRemarks, string $goodsName): ?string { - $rules = $this->accountMappingIndex()[$this->normalizeKey($platform)][$this->normalizeKey($item)] ?? []; - if ($rules === []) { - return null; + $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; + } } - if (count($rules) === 1) { - $remark = trim((string) ($rules[0]['memo'] ?? '')); + return null; + } - return $remark === '' ? null : $remark; + /** + * 账单项目的查找候选:原样优先,其次去掉 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; + } } - // 同一账单项目有多条规则时,用映射表自己的匹配器(按商品名称/业务备注关键词取最具体的) - try { - $result = app(AccountMappingMatcher::class)->match($platform, $item, $businessRemarks, $goodsName); - } catch (Throwable) { - return null; - } - - $remark = trim((string) ($result['rule']['memo'] ?? '')); - - return $remark === '' ? null : $remark; + return array_values(array_unique($keys)); } private function normalizeKey(string $value): string