343 lines
14 KiB
PHP
343 lines
14 KiB
PHP
<?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";
|