feat: add non-destructive EJ medicine sync
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__, 2) . '/vendor/autoload.php';
|
||||
|
||||
use app\common\service\pharmacy\EjMedicineIncrementalPushService;
|
||||
|
||||
$passed = 0;
|
||||
$assertSame = static function (mixed $expected, mixed $actual, string $message) use (&$passed): void {
|
||||
if ($expected !== $actual) {
|
||||
throw new RuntimeException($message . '\nExpected: ' . var_export($expected, true) . '\nActual: ' . var_export($actual, true));
|
||||
}
|
||||
++$passed;
|
||||
};
|
||||
$assertTrue = static function (bool $actual, string $message) use (&$passed): void {
|
||||
if (!$actual) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
++$passed;
|
||||
};
|
||||
$canonicalize = null;
|
||||
$canonicalize = static function (mixed $value) use (&$canonicalize): mixed {
|
||||
if (!is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (array_is_list($value)) {
|
||||
return array_map($canonicalize, $value);
|
||||
}
|
||||
ksort($value, SORT_STRING);
|
||||
foreach ($value as $key => $child) {
|
||||
$value[$key] = $canonicalize($child);
|
||||
}
|
||||
return $value;
|
||||
};
|
||||
$canonicalPayloadHash = static fn (array $payload): string => hash(
|
||||
'sha256',
|
||||
json_encode($canonicalize($payload), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
|
||||
$sourceRows = [
|
||||
[
|
||||
'id' => 101,
|
||||
'name' => '增量药材甲',
|
||||
'unit' => '克',
|
||||
'settlement_price' => '1.100000',
|
||||
'retail_price' => '1.200000',
|
||||
'status' => 1,
|
||||
],
|
||||
[
|
||||
'id' => 202,
|
||||
'name' => '增量药材乙',
|
||||
'unit' => '克',
|
||||
'settlement_price' => '2.100000',
|
||||
'retail_price' => '2.200000',
|
||||
'status' => 1,
|
||||
],
|
||||
[
|
||||
'id' => 303,
|
||||
'name' => '人工停用映射药材',
|
||||
'unit' => '克',
|
||||
'settlement_price' => '3.100000',
|
||||
'retail_price' => '3.200000',
|
||||
'status' => 1,
|
||||
],
|
||||
];
|
||||
|
||||
EjMedicineIncrementalPushService::assertCommandGate(false, '', 100);
|
||||
$blocked = false;
|
||||
try {
|
||||
EjMedicineIncrementalPushService::assertCommandGate(true, '', 100);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
$blocked = str_contains($exception->getMessage(), 'INCREMENTAL_NO_DELETE');
|
||||
}
|
||||
$assertTrue($blocked, 'apply mode must require the non-destructive confirmation token');
|
||||
foreach ([0, 501] as $invalidBatchSize) {
|
||||
try {
|
||||
EjMedicineIncrementalPushService::assertCommandGate(false, '', $invalidBatchSize);
|
||||
throw new RuntimeException('invalid incremental batch size unexpectedly accepted');
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
$assertTrue(str_contains($exception->getMessage(), 'batch-size'), 'invalid batch size must return an actionable error');
|
||||
}
|
||||
}
|
||||
|
||||
$plan = EjMedicineIncrementalPushService::plan(
|
||||
100,
|
||||
static fn (): array => $sourceRows,
|
||||
static fn (array $localIds): array => [
|
||||
['id' => 1, 'local_medicine_id' => 101, 'medicine_code' => 'EJ-101', 'status' => 1, 'delete_time' => null],
|
||||
['id' => 2, 'local_medicine_id' => 303, 'medicine_code' => 'EJ-303', 'status' => 0, 'delete_time' => 123],
|
||||
]
|
||||
);
|
||||
$assertSame(3, $plan['source_count'], 'dry-run must count every active local medicine');
|
||||
$assertSame(2, $plan['candidate_count'], 'dry-run must replay active mappings and add never-mapped medicines');
|
||||
$assertSame(1, $plan['batch_count'], 'dry-run must report the remote batch count');
|
||||
$assertSame(1, $plan['mapped_count'], 'dry-run must count existing active mappings');
|
||||
$assertSame(1, $plan['unmapped_count'], 'dry-run must identify local medicines without an active mapping');
|
||||
$assertSame(1, $plan['preserved_inactive'], 'dry-run must preserve manually disabled or unlinked mappings');
|
||||
$assertSame(0, $plan['remote_delete_count'], 'incremental push must never plan remote deletes');
|
||||
$assertSame(0, $plan['local_delete_count'], 'incremental push must never plan local projection deletes');
|
||||
|
||||
$capturedPayloads = [];
|
||||
$capturedProjection = [];
|
||||
$heldLockToken = '';
|
||||
$lockAcquireCount = 0;
|
||||
$lockRenewCount = 0;
|
||||
$lockReleaseCount = 0;
|
||||
$result = EjMedicineIncrementalPushService::execute(
|
||||
1,
|
||||
'zyt-inc-contract',
|
||||
static fn (): array => $sourceRows,
|
||||
static function (array $payload) use (&$capturedPayloads, $canonicalPayloadHash): array {
|
||||
$capturedPayloads[] = $payload;
|
||||
$sourceId = (string) $payload['items'][0]['source_medicine_id'];
|
||||
$action = $sourceId === '101' ? 'existing' : 'created';
|
||||
return [
|
||||
'http_status' => $action === 'created' ? 201 : 200,
|
||||
'body' => [
|
||||
'code' => 0,
|
||||
'message' => 'ok',
|
||||
'data' => [
|
||||
'source_system' => 'zyt',
|
||||
'import_id' => $payload['import_id'],
|
||||
'payload_hash' => $canonicalPayloadHash($payload),
|
||||
'idempotent' => false,
|
||||
'item_count' => 1,
|
||||
'created_count' => $action === 'created' ? 1 : 0,
|
||||
'existing_count' => $action === 'existing' ? 1 : 0,
|
||||
'items' => [[
|
||||
'source_medicine_id' => $sourceId,
|
||||
'medicine_code' => 'EJ-' . $sourceId,
|
||||
'catalog_version' => $sourceId === '101' ? 10 : 11,
|
||||
'action' => $action,
|
||||
]],
|
||||
],
|
||||
],
|
||||
'request_id' => 'request-' . $sourceId,
|
||||
];
|
||||
},
|
||||
static function (array $rows) use (&$capturedProjection): array {
|
||||
$capturedProjection = $rows;
|
||||
return [
|
||||
'catalog_created' => 1,
|
||||
'catalog_updated' => 1,
|
||||
'mapping_created' => 1,
|
||||
'mapping_updated' => 0,
|
||||
'mapping_unchanged' => 1,
|
||||
];
|
||||
},
|
||||
static fn (array $localIds): array => [
|
||||
['id' => 1, 'local_medicine_id' => 101, 'medicine_code' => 'EJ-101', 'status' => 1, 'delete_time' => null],
|
||||
['id' => 2, 'local_medicine_id' => 303, 'medicine_code' => 'EJ-303', 'status' => 0, 'delete_time' => 123],
|
||||
],
|
||||
static function (string $token) use (&$heldLockToken, &$lockAcquireCount): bool {
|
||||
++$lockAcquireCount;
|
||||
if ($heldLockToken !== '') {
|
||||
return false;
|
||||
}
|
||||
$heldLockToken = $token;
|
||||
return true;
|
||||
},
|
||||
static function (string $token) use (&$heldLockToken, &$lockRenewCount): bool {
|
||||
++$lockRenewCount;
|
||||
return $heldLockToken !== '' && hash_equals($heldLockToken, $token);
|
||||
},
|
||||
static function (string $token) use (&$heldLockToken, &$lockReleaseCount): void {
|
||||
++$lockReleaseCount;
|
||||
if ($heldLockToken !== '' && hash_equals($heldLockToken, $token)) {
|
||||
$heldLockToken = '';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
$assertSame(2, count($capturedPayloads), 'apply must send deterministic bounded batches');
|
||||
$assertTrue(
|
||||
str_starts_with((string) $capturedPayloads[0]['import_id'], 'zyt-inc-contract-0001-'),
|
||||
'incremental import id must include the caller-provided run id and batch ordinal'
|
||||
);
|
||||
$assertSame(['101', '202'], array_column($capturedProjection, 'local_medicine_id'), 'projection upsert must retain source identity');
|
||||
$assertSame(['EJ-101', 'EJ-202'], array_column($capturedProjection, 'medicine_code'), 'projection upsert must use EJ-returned codes');
|
||||
$assertSame(1, $result['remote_created'], 'result must report remotely created medicines');
|
||||
$assertSame(1, $result['remote_existing'], 'result must report idempotently existing medicines');
|
||||
$assertSame(1, $result['preserved_inactive'], 'apply must not reactivate an operator-disabled mapping');
|
||||
$assertSame(0, $result['remote_delete_count'], 'apply result must prove no EJ medicine was deleted');
|
||||
$assertSame(0, $result['local_delete_count'], 'apply result must prove no ZYT projection was deleted');
|
||||
$assertSame(1, $lockAcquireCount, 'apply must acquire one shared EJ synchronization lease');
|
||||
$assertSame(5, $lockRenewCount, 'apply must renew its lease around every remote batch and before projection writes');
|
||||
$assertSame(1, $lockReleaseCount, 'apply must release its synchronization lease');
|
||||
$assertSame('', $heldLockToken, 'successful apply must not leave the synchronization lease held');
|
||||
|
||||
$candidateItems = array_merge($capturedPayloads[0]['items'], $capturedPayloads[1]['items']);
|
||||
$defaultBatches = EjMedicineIncrementalPushService::buildBatches($candidateItems, 1, '');
|
||||
$retryBatches = EjMedicineIncrementalPushService::buildBatches(array_reverse($candidateItems), 1, '');
|
||||
$assertSame(
|
||||
array_column($defaultBatches, 'import_id'),
|
||||
array_column($retryBatches, 'import_id'),
|
||||
'default incremental import ids must be deterministic for identical content'
|
||||
);
|
||||
$assertTrue(
|
||||
str_starts_with((string) $defaultBatches[0]['import_id'], 'zyt-incremental-0001-'),
|
||||
'default run id must be stable so an interrupted apply can safely retry'
|
||||
);
|
||||
|
||||
$responseFor = static function (
|
||||
array $payload,
|
||||
string $medicineCode,
|
||||
string $sourceSystem = 'zyt',
|
||||
?string $responsePayloadHash = null
|
||||
) use ($canonicalPayloadHash): array {
|
||||
$sourceId = (string) $payload['items'][0]['source_medicine_id'];
|
||||
return [
|
||||
'http_status' => 200,
|
||||
'body' => ['code' => 0, 'message' => 'ok', 'data' => [
|
||||
'source_system' => $sourceSystem,
|
||||
'import_id' => $payload['import_id'],
|
||||
'payload_hash' => $responsePayloadHash ?? $canonicalPayloadHash($payload),
|
||||
'idempotent' => true,
|
||||
'item_count' => 1,
|
||||
'created_count' => 0,
|
||||
'existing_count' => 1,
|
||||
'items' => [[
|
||||
'source_medicine_id' => $sourceId,
|
||||
'medicine_code' => $medicineCode,
|
||||
'catalog_version' => 99,
|
||||
'action' => 'existing',
|
||||
]],
|
||||
]],
|
||||
'request_id' => 'validation-' . $sourceId,
|
||||
];
|
||||
};
|
||||
$alwaysAcquire = static fn (string $token): bool => $token !== '';
|
||||
$alwaysRenew = static fn (string $token): bool => $token !== '';
|
||||
$releaseNoop = static function (string $token): void {};
|
||||
|
||||
foreach ([
|
||||
'source_system' => static fn (array $payload): array => $responseFor($payload, 'EJ-202', 'other'),
|
||||
'payload_hash' => static fn (array $payload): array => $responseFor($payload, 'EJ-202', 'zyt', str_repeat('0', 64)),
|
||||
] as $field => $invalidImporter) {
|
||||
$projectionCalled = false;
|
||||
try {
|
||||
EjMedicineIncrementalPushService::execute(
|
||||
1,
|
||||
'response-validation',
|
||||
static fn (): array => [$sourceRows[1]],
|
||||
$invalidImporter,
|
||||
static function () use (&$projectionCalled): array {
|
||||
$projectionCalled = true;
|
||||
return [];
|
||||
},
|
||||
static fn (array $localIds): array => [],
|
||||
$alwaysAcquire,
|
||||
$alwaysRenew,
|
||||
$releaseNoop
|
||||
);
|
||||
throw new RuntimeException("invalid {$field} unexpectedly accepted");
|
||||
} catch (RuntimeException $exception) {
|
||||
$assertTrue(str_contains($exception->getMessage(), $field), "incremental response must reject invalid {$field}");
|
||||
}
|
||||
$assertSame(false, $projectionCalled, "invalid {$field} must block local projection writes");
|
||||
}
|
||||
|
||||
$projectionCalled = false;
|
||||
try {
|
||||
EjMedicineIncrementalPushService::execute(
|
||||
1,
|
||||
'mapping-mismatch',
|
||||
static fn (): array => [$sourceRows[0]],
|
||||
static fn (array $payload): array => $responseFor($payload, 'EJ-DIFFERENT'),
|
||||
static function () use (&$projectionCalled): array {
|
||||
$projectionCalled = true;
|
||||
return [];
|
||||
},
|
||||
static fn (array $localIds): array => [
|
||||
['id' => 1, 'local_medicine_id' => 101, 'medicine_code' => 'EJ-101', 'status' => 1, 'delete_time' => null],
|
||||
],
|
||||
$alwaysAcquire,
|
||||
$alwaysRenew,
|
||||
$releaseNoop
|
||||
);
|
||||
throw new RuntimeException('active mapping code mismatch unexpectedly accepted');
|
||||
} catch (DomainException $exception) {
|
||||
$assertTrue(str_contains($exception->getMessage(), '不一致'), 'active mapping replay must reject a different EJ code');
|
||||
}
|
||||
$assertSame(false, $projectionCalled, 'active mapping mismatch must not overwrite its local projection');
|
||||
|
||||
$sourceLoaded = false;
|
||||
$failedAcquireReleased = false;
|
||||
try {
|
||||
EjMedicineIncrementalPushService::execute(
|
||||
1,
|
||||
'concurrent-run',
|
||||
static function () use (&$sourceLoaded): array {
|
||||
$sourceLoaded = true;
|
||||
return [];
|
||||
},
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
static fn (string $token): bool => false,
|
||||
$alwaysRenew,
|
||||
static function (string $token) use (&$failedAcquireReleased): void { $failedAcquireReleased = true; }
|
||||
);
|
||||
throw new RuntimeException('concurrent incremental run unexpectedly acquired the lease');
|
||||
} catch (DomainException $exception) {
|
||||
$assertTrue(str_contains($exception->getMessage(), '正在执行'), 'concurrent apply must fail with a business error');
|
||||
}
|
||||
$assertSame(false, $sourceLoaded, 'concurrent apply must stop before reading or pushing source medicines');
|
||||
$assertSame(false, $failedAcquireReleased, 'a failed lock acquisition must not release another run lease');
|
||||
|
||||
$leaseRenewals = 0;
|
||||
$leaseFailureReleased = false;
|
||||
$projectionCalled = false;
|
||||
try {
|
||||
EjMedicineIncrementalPushService::execute(
|
||||
1,
|
||||
'lease-loss',
|
||||
static fn (): array => [$sourceRows[1]],
|
||||
static fn (array $payload): array => $responseFor($payload, 'EJ-202'),
|
||||
static function () use (&$projectionCalled): array {
|
||||
$projectionCalled = true;
|
||||
return [];
|
||||
},
|
||||
static fn (array $localIds): array => [],
|
||||
$alwaysAcquire,
|
||||
static function () use (&$leaseRenewals): bool {
|
||||
++$leaseRenewals;
|
||||
return $leaseRenewals === 1;
|
||||
},
|
||||
static function (string $token) use (&$leaseFailureReleased): void { $leaseFailureReleased = true; }
|
||||
);
|
||||
throw new RuntimeException('expired synchronization lease unexpectedly allowed projection');
|
||||
} catch (DomainException $exception) {
|
||||
$assertTrue(str_contains($exception->getMessage(), '锁已失效'), 'lease loss must return an actionable retry error');
|
||||
}
|
||||
$assertSame(false, $projectionCalled, 'lease loss after a remote response must block local projection writes');
|
||||
$assertSame(true, $leaseFailureReleased, 'lease loss must still release the caller-owned lock token');
|
||||
|
||||
$serviceSource = (string) file_get_contents(dirname(__DIR__, 2) . '/app/common/service/pharmacy/EjMedicineIncrementalPushService.php');
|
||||
$assertTrue(!str_contains($serviceSource, '->delete('), 'incremental service must not call model delete');
|
||||
$assertTrue(!str_contains($serviceSource, '->delete()'), 'incremental service must not call model delete without arguments');
|
||||
|
||||
echo "incremental EJ medicine push tests passed: {$passed}\n";
|
||||
@@ -39,9 +39,13 @@ $assertTrue(
|
||||
$bootstrapItemPath = dirname(__DIR__, 2) . '/app/common/service/pharmacy/EjMedicineBootstrapItem.php';
|
||||
$bootstrapServicePath = dirname(__DIR__, 2) . '/app/common/service/pharmacy/EjMedicineBootstrapService.php';
|
||||
$bootstrapCommandPath = dirname(__DIR__, 2) . '/app/command/EjPharmacyBootstrapMedicines.php';
|
||||
$incrementalServicePath = dirname(__DIR__, 2) . '/app/common/service/pharmacy/EjMedicineIncrementalPushService.php';
|
||||
$incrementalCommandPath = dirname(__DIR__, 2) . '/app/command/EjPharmacyPushMedicines.php';
|
||||
$assertTrue(is_file($bootstrapItemPath), 'ZYT must provide a dedicated EJ medicine bootstrap item normalizer');
|
||||
$assertTrue(is_file($bootstrapServicePath), 'ZYT must provide an atomic EJ medicine bootstrap service');
|
||||
$assertTrue(is_file($bootstrapCommandPath), 'ZYT must provide the ej-pharmacy:bootstrap-medicines command');
|
||||
$assertTrue(is_file($incrementalServicePath), 'ZYT must provide a non-destructive EJ medicine incremental push service');
|
||||
$assertTrue(is_file($incrementalCommandPath), 'ZYT must provide the ej-pharmacy:push-medicines command');
|
||||
|
||||
$clientSource = (string) file_get_contents(dirname(__DIR__, 2) . '/app/common/service/pharmacy/EjPharmacyClient.php');
|
||||
$configSource = (string) file_get_contents(dirname(__DIR__, 2) . '/config/ej_pharmacy.php');
|
||||
@@ -62,6 +66,34 @@ $assertTrue(
|
||||
&& str_contains((string) file_get_contents($bootstrapCommandPath), 'RESET_TEST_CATALOG'),
|
||||
'bootstrap command must be registered and guard destructive replacement with the exact confirmation token'
|
||||
);
|
||||
$incrementalServiceSource = (string) file_get_contents($incrementalServicePath);
|
||||
$incrementalCommandSource = (string) file_get_contents($incrementalCommandPath);
|
||||
$assertTrue(
|
||||
str_contains($consoleSource, "'ej-pharmacy:push-medicines'")
|
||||
&& str_contains($incrementalCommandSource, 'INCREMENTAL_NO_DELETE')
|
||||
&& str_contains($incrementalCommandSource, "addOption('apply'")
|
||||
&& str_contains($incrementalCommandSource, 'dry-run'),
|
||||
'incremental push command must be registered, dry-run by default, and require the non-delete confirmation token'
|
||||
);
|
||||
$assertTrue(
|
||||
!str_contains($incrementalServiceSource, '->delete(')
|
||||
&& str_contains($incrementalServiceSource, "'remote_delete_count' => 0")
|
||||
&& str_contains($incrementalServiceSource, "'local_delete_count' => 0"),
|
||||
'incremental medicine push must preserve EJ-only medicines and unrelated local projections'
|
||||
);
|
||||
$assertTrue(
|
||||
str_contains($incrementalServiceSource, "private const DEFAULT_RUN_ID = 'zyt-incremental'")
|
||||
&& str_contains($incrementalServiceSource, 'private static function acquireLock(')
|
||||
&& str_contains($incrementalServiceSource, "->where('lock_token', '')")
|
||||
&& str_contains($incrementalServiceSource, "'lock_expires_at'"),
|
||||
'incremental medicine apply must use deterministic retry ids and share the EJ synchronization lease'
|
||||
);
|
||||
$assertTrue(
|
||||
str_contains($incrementalServiceSource, "['source_system']")
|
||||
&& str_contains($incrementalServiceSource, "['payload_hash']")
|
||||
&& str_contains($incrementalServiceSource, '增量同步未改写该映射'),
|
||||
'incremental medicine apply must validate response identity and never rewrite an active mapping to a different code'
|
||||
);
|
||||
$assertTrue(
|
||||
str_contains($configSource, "'catalog_sync_enabled'")
|
||||
&& str_contains($configSource, "env('EJ_PHARMACY_CATALOG_SYNC_ENABLED', false)"),
|
||||
|
||||
Reference in New Issue
Block a user