Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
701bc42f44
@ -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)"
|
||||
|
||||
|
||||
@ -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)"
|
||||
|
||||
|
||||
@ -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<string>
|
||||
*/
|
||||
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<string,mixed> $row
|
||||
* @return list<string>
|
||||
*/
|
||||
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<int,array<string,mixed>> $rows
|
||||
* @return \Generator<int,array<string,mixed>>
|
||||
|
||||
57
tests/Unit/Deployment/DeploymentSyncLockTest.php
Normal file
57
tests/Unit/Deployment/DeploymentSyncLockTest.php
Normal file
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Deployment;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class DeploymentSyncLockTest extends TestCase
|
||||
{
|
||||
public function test_container_replacement_waits_for_sync_and_fails_safely_on_lock_timeout(): void
|
||||
{
|
||||
foreach (['activate-release.sh', 'activate-incremental-release.sh'] as $script) {
|
||||
$contents = file_get_contents(dirname(__DIR__, 3) . '/deploy/' . $script);
|
||||
$start = strpos($contents, 'exec 8>/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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user