435 lines
17 KiB
PHP
435 lines
17 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
require dirname(__DIR__, 2) . '/vendor/autoload.php';
|
||
|
||
use app\adminapi\http\middleware\AuthMiddleware;
|
||
use app\api\controller\EjPharmacyCallbackController;
|
||
use app\common\service\pharmacy\EjPharmacyCallbackRetryException;
|
||
use app\common\service\pharmacy\EjPharmacyCallbackFailureTransition;
|
||
use app\common\service\pharmacy\EjPharmacyCallbackWorkflow;
|
||
use app\common\service\pharmacy\PharmacyLogisticsValue;
|
||
use app\common\service\pharmacy\EjPharmacyShipmentPolicy;
|
||
use app\common\service\pharmacy\EjPharmacyTrackingPolicy;
|
||
$passed = 0;
|
||
|
||
$assertSame = static function (mixed $expected, mixed $actual, string $message) use (&$passed): void {
|
||
if ($expected !== $actual) {
|
||
throw new \RuntimeException(sprintf(
|
||
"%s\nExpected: %s\nActual: %s",
|
||
$message,
|
||
var_export($expected, true),
|
||
var_export($actual, true)
|
||
));
|
||
}
|
||
++$passed;
|
||
};
|
||
|
||
$responseStatus = static fn ($response): int => $response->getCode();
|
||
|
||
final class CallbackContractResponse
|
||
{
|
||
public function __construct(private readonly int $status)
|
||
{
|
||
}
|
||
|
||
public function getCode(): int
|
||
{
|
||
return $this->status;
|
||
}
|
||
}
|
||
|
||
$validPayload = [
|
||
'event_id' => 'evt-integration-1',
|
||
'pharmacy_order_no' => 'EJ-1001',
|
||
'source_order_no' => 'PO-1001',
|
||
];
|
||
|
||
$makeController = static function (string $body, EjPharmacyCallbackWorkflow $workflow): EjPharmacyCallbackController {
|
||
return new class($body, $workflow) extends EjPharmacyCallbackController {
|
||
public function __construct(
|
||
private readonly string $testBody,
|
||
private readonly EjPharmacyCallbackWorkflow $testWorkflow
|
||
) {
|
||
}
|
||
|
||
protected function callbackBody(): string
|
||
{
|
||
return $this->testBody;
|
||
}
|
||
|
||
protected function isAuthenticCallback(string $body): bool
|
||
{
|
||
return true;
|
||
}
|
||
|
||
protected function callbackWorkflow(): EjPharmacyCallbackWorkflow
|
||
{
|
||
return $this->testWorkflow;
|
||
}
|
||
|
||
protected function callbackResponse(array $payload, int $httpStatus)
|
||
{
|
||
return new CallbackContractResponse($httpStatus);
|
||
}
|
||
|
||
protected function logCallbackFailure(string $message): void
|
||
{
|
||
}
|
||
};
|
||
};
|
||
|
||
$processedCalls = 0;
|
||
$processedWorkflow = new EjPharmacyCallbackWorkflow(
|
||
static fn (): array => ['id' => 1, 'process_status' => 'PROCESSED'],
|
||
static fn (): array => [],
|
||
static fn (): ?array => null,
|
||
static function () use (&$processedCalls): array {
|
||
++$processedCalls;
|
||
return ['process_status' => 'PROCESSED'];
|
||
},
|
||
static function (): void {},
|
||
static fn (): bool => false
|
||
);
|
||
$processedResponse = $makeController(
|
||
json_encode($validPayload, JSON_THROW_ON_ERROR),
|
||
$processedWorkflow
|
||
)->webhook();
|
||
$assertSame(200, $responseStatus($processedResponse), 'processed callbacks must return HTTP 200');
|
||
$assertSame(0, $processedCalls, 'only a PROCESSED inbox may return immediate success');
|
||
|
||
$pendingCalls = 0;
|
||
$pendingWorkflow = new EjPharmacyCallbackWorkflow(
|
||
static fn (): array => ['id' => 2, 'process_status' => 'PENDING'],
|
||
static fn (): array => [],
|
||
static fn (): ?array => null,
|
||
static function () use (&$pendingCalls): array {
|
||
++$pendingCalls;
|
||
return ['process_status' => 'PROCESSED'];
|
||
},
|
||
static function (): void {},
|
||
static fn (): bool => false
|
||
);
|
||
$pendingResponse = $makeController(json_encode($validPayload, JSON_THROW_ON_ERROR), $pendingWorkflow)->webhook();
|
||
$assertSame(200, $responseStatus($pendingResponse), 'a successfully retried PENDING inbox must return HTTP 200');
|
||
$assertSame(1, $pendingCalls, 'a PENDING inbox must retry business processing');
|
||
|
||
$failedCalls = 0;
|
||
$failedWorkflow = new EjPharmacyCallbackWorkflow(
|
||
static fn (): array => ['id' => 3, 'process_status' => 'FAILED'],
|
||
static fn (): array => [],
|
||
static fn (): ?array => null,
|
||
static function () use (&$failedCalls): array {
|
||
++$failedCalls;
|
||
return ['process_status' => 'PROCESSED'];
|
||
},
|
||
static function (): void {},
|
||
static fn (): bool => false
|
||
);
|
||
$failedResponse = $makeController(json_encode($validPayload, JSON_THROW_ON_ERROR), $failedWorkflow)->webhook();
|
||
$assertSame(200, $responseStatus($failedResponse), 'a successfully retried FAILED inbox must return HTTP 200');
|
||
$assertSame(1, $failedCalls, 'a FAILED inbox must retry business processing');
|
||
|
||
$retryWorkflow = new EjPharmacyCallbackWorkflow(
|
||
static fn (): array => ['id' => 4, 'process_status' => 'PENDING'],
|
||
static fn (): array => [],
|
||
static fn (): ?array => null,
|
||
static function (): never {
|
||
throw new EjPharmacyCallbackRetryException('业务订单关联尚未建立');
|
||
},
|
||
static function (): void {},
|
||
static fn (): bool => false
|
||
);
|
||
$retryResponse = $makeController(json_encode($validPayload, JSON_THROW_ON_ERROR), $retryWorkflow)->webhook();
|
||
$assertSame(503, $responseStatus($retryResponse), 'a missing order association must ask EJ to retry');
|
||
|
||
$runtimeWorkflow = new EjPharmacyCallbackWorkflow(
|
||
static function (): never {
|
||
throw new \RuntimeException('database unavailable');
|
||
},
|
||
static fn (): array => [],
|
||
static fn (): ?array => null,
|
||
static fn (): array => ['process_status' => 'PROCESSED'],
|
||
static function (): void {},
|
||
static fn (): bool => false
|
||
);
|
||
$runtimeResponse = $makeController(json_encode($validPayload, JSON_THROW_ON_ERROR), $runtimeWorkflow)->webhook();
|
||
$assertSame(500, $responseStatus($runtimeResponse), 'database and unknown failures must return HTTP 500');
|
||
|
||
$unusedWorkflow = new EjPharmacyCallbackWorkflow(
|
||
static fn (): ?array => null,
|
||
static fn (): array => [],
|
||
static fn (): ?array => null,
|
||
static fn (): array => ['process_status' => 'PROCESSED'],
|
||
static function (): void {},
|
||
static fn (): bool => false
|
||
);
|
||
$malformedResponse = $makeController('{', $unusedWorkflow)->webhook();
|
||
$assertSame(400, $responseStatus($malformedResponse), 'malformed callback JSON must return HTTP 400');
|
||
|
||
$missingFieldResponse = $makeController(
|
||
json_encode(['event_id' => 'evt-missing'], JSON_THROW_ON_ERROR),
|
||
$unusedWorkflow
|
||
)->webhook();
|
||
$assertSame(422, $responseStatus($missingFieldResponse), 'business-invalid callback JSON must return HTTP 422');
|
||
|
||
$duplicateReloads = 0;
|
||
$duplicateProcesses = 0;
|
||
$duplicateWorkflow = new EjPharmacyCallbackWorkflow(
|
||
static fn (): ?array => null,
|
||
static function (): never {
|
||
throw new \RuntimeException('SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry');
|
||
},
|
||
static function () use (&$duplicateReloads): array {
|
||
++$duplicateReloads;
|
||
return ['id' => 5, 'process_status' => 'PENDING'];
|
||
},
|
||
static function () use (&$duplicateProcesses): array {
|
||
++$duplicateProcesses;
|
||
return ['process_status' => 'PROCESSED'];
|
||
},
|
||
static function (): void {},
|
||
static fn (\Throwable $exception): bool => str_contains($exception->getMessage(), '1062')
|
||
);
|
||
$duplicateResponse = $makeController(json_encode($validPayload, JSON_THROW_ON_ERROR), $duplicateWorkflow)->webhook();
|
||
$assertSame(200, $responseStatus($duplicateResponse), 'a duplicate inbox insert race must be reloaded and processed');
|
||
$assertSame(1, $duplicateReloads, 'a duplicate inbox insert must reload the winning row once');
|
||
$assertSame(1, $duplicateProcesses, 'a reloaded PENDING duplicate must be processed once');
|
||
|
||
$callbackRaceState = 'PENDING';
|
||
$callbackRaceUpdated = EjPharmacyCallbackFailureTransition::apply(
|
||
7,
|
||
'late failure after concurrent success',
|
||
static function (int $inboxId, array $values, string $protectedStatus) use (&$callbackRaceState): bool {
|
||
$callbackRaceState = 'PROCESSED';
|
||
if ($callbackRaceState === $protectedStatus) {
|
||
return false;
|
||
}
|
||
$callbackRaceState = (string) $values['process_status'];
|
||
return true;
|
||
}
|
||
);
|
||
$assertSame(false, $callbackRaceUpdated, 'failure CAS must report no update after concurrent callback success');
|
||
$assertSame('PROCESSED', $callbackRaceState, 'concurrent callback success must never be overwritten as FAILED');
|
||
|
||
$assertSame(
|
||
'SF',
|
||
PharmacyLogisticsValue::normalize("\u{200B}\u{00A0}SF\u{3000}\u{FEFF}", 32, '快递公司'),
|
||
'logistics values must normalize Unicode edge whitespace'
|
||
);
|
||
|
||
$assertSame(
|
||
true,
|
||
EjPharmacyShipmentPolicy::isShippedEvent(['event_type' => 'ORDER_SHIPPED', 'status' => 'PROCESSING']),
|
||
'ORDER_SHIPPED event type must trigger local shipment fulfillment'
|
||
);
|
||
$assertSame(
|
||
true,
|
||
EjPharmacyShipmentPolicy::isShippedEvent(['event_type' => 'ORDER_UPDATED', 'status' => 'SHIPPED']),
|
||
'SHIPPED remote status must trigger local shipment fulfillment'
|
||
);
|
||
$assertSame(
|
||
false,
|
||
EjPharmacyShipmentPolicy::isShippedEvent(['event_type' => 'ORDER_UPDATED', 'status' => 'PROCESSING']),
|
||
'non-shipment callbacks must not change local fulfillment'
|
||
);
|
||
$assertSame(
|
||
true,
|
||
EjPharmacyShipmentPolicy::isCompletedEvent(['event_type' => 'WORKFLOW_STEP_COMPLETED', 'status' => 'COMPLETED', 'final' => true]),
|
||
'a final EJ workflow callback must be recognized as order completion'
|
||
);
|
||
$assertSame(
|
||
3,
|
||
EjPharmacyShipmentPolicy::nextFulfillmentStatus(
|
||
2,
|
||
['event_type' => 'WORKFLOW_STEP_COMPLETED', 'status' => 'COMPLETED', 'final' => true]
|
||
),
|
||
'a final EJ workflow callback must advance ZYT fulfillment to completed'
|
||
);
|
||
$assertSame(
|
||
2,
|
||
EjPharmacyShipmentPolicy::nextFulfillmentStatus(
|
||
2,
|
||
['event_type' => 'REVIEW_REJECTED', 'status' => 'REJECTED'],
|
||
2
|
||
),
|
||
'EJ review rejection must restore the fulfillment status captured before upload'
|
||
);
|
||
$assertSame(
|
||
2,
|
||
EjPharmacyShipmentPolicy::nextFulfillmentStatus(
|
||
2,
|
||
['event_type' => 'INVENTORY_SHORTAGE', 'status' => 'STOCK_SHORTAGE'],
|
||
2
|
||
),
|
||
'EJ inventory shortage must not become a ZYT customer refusal'
|
||
);
|
||
$assertSame(
|
||
2,
|
||
EjPharmacyShipmentPolicy::nextFulfillmentStatus(
|
||
9,
|
||
['event_type' => 'REVIEW_REJECTED', 'status' => 'REJECTED']
|
||
),
|
||
'legacy EJ rejection rows already marked as ZYT refusal must reopen to uploadable fulfillment'
|
||
);
|
||
foreach ([1, 2] as $pendingFulfillment) {
|
||
$assertSame(
|
||
5,
|
||
EjPharmacyShipmentPolicy::nextFulfillmentStatus($pendingFulfillment, ['event_type' => 'ORDER_SHIPPED']),
|
||
"shipment callback must advance fulfillment {$pendingFulfillment} to shipped"
|
||
);
|
||
}
|
||
foreach ([3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as $protectedFulfillment) {
|
||
$assertSame(
|
||
$protectedFulfillment,
|
||
EjPharmacyShipmentPolicy::nextFulfillmentStatus($protectedFulfillment, ['status' => 'SHIPPED']),
|
||
"shipment callback must not regress/overwrite protected fulfillment {$protectedFulfillment}"
|
||
);
|
||
}
|
||
|
||
$sameTrackingDecision = EjPharmacyTrackingPolicy::select(
|
||
['id' => 10, 'order_id' => 7, 'tracking_number' => 'SF-OLD'],
|
||
null,
|
||
7,
|
||
'SF-OLD'
|
||
);
|
||
$assertSame('REUSE_CURRENT', $sameTrackingDecision['action'], 'same-number callbacks must reuse the active order tracking');
|
||
$replacementDecision = EjPharmacyTrackingPolicy::select(
|
||
['id' => 10, 'order_id' => 7, 'tracking_number' => 'SF-OLD'],
|
||
null,
|
||
7,
|
||
'SF-NEW'
|
||
);
|
||
$assertSame('CREATE', $replacementDecision['action'], 'a replacement number must get a fresh tracking row');
|
||
$assertSame(true, $replacementDecision['archive_current'], 'replacing a number must archive the old active tracking');
|
||
$historicalDecision = EjPharmacyTrackingPolicy::select(
|
||
['id' => 10, 'order_id' => 7, 'tracking_number' => 'SF-CURRENT'],
|
||
['id' => 8, 'order_id' => 7, 'tracking_number' => 'SF-OLD'],
|
||
7,
|
||
'SF-OLD'
|
||
);
|
||
$assertSame('REUSE_MATCHING', $historicalDecision['action'], 'a same-order historical number may be promoted without duplicating its traces');
|
||
try {
|
||
EjPharmacyTrackingPolicy::select(
|
||
['id' => 10, 'order_id' => 7, 'tracking_number' => 'SF-OLD'],
|
||
['id' => 99, 'order_id' => 8, 'tracking_number' => 'SF-NEW'],
|
||
7,
|
||
'SF-NEW'
|
||
);
|
||
throw new RuntimeException('cross-order tracking ownership conflict unexpectedly accepted');
|
||
} catch (DomainException $exception) {
|
||
$assertSame(true, str_contains($exception->getMessage(), '其他订单'), 'cross-order tracking numbers must be rejected without rebinding');
|
||
}
|
||
|
||
$overlongWorkflowCalls = 0;
|
||
$overlongWorkflow = new EjPharmacyCallbackWorkflow(
|
||
static function () use (&$overlongWorkflowCalls): array {
|
||
++$overlongWorkflowCalls;
|
||
return ['id' => 6, 'process_status' => 'PENDING'];
|
||
},
|
||
static fn (): array => [],
|
||
static fn (): ?array => null,
|
||
static fn (): array => ['process_status' => 'PROCESSED'],
|
||
static function (): void {},
|
||
static fn (): bool => false
|
||
);
|
||
$overlongResponse = $makeController(
|
||
json_encode($validPayload + ['tracking_number' => str_repeat('运', 101)], JSON_THROW_ON_ERROR),
|
||
$overlongWorkflow
|
||
)->webhook();
|
||
$assertSame(422, $responseStatus($overlongResponse), 'overlong logistics values must return HTTP 422');
|
||
$assertSame(0, $overlongWorkflowCalls, 'logistics validation must finish before any inbox/workflow read or write');
|
||
|
||
$middleware = new AuthMiddleware();
|
||
$aliasMethod = new ReflectionMethod($middleware, 'matchPermissionAlias');
|
||
$assertSame(
|
||
true,
|
||
$aliasMethod->invoke($middleware, 'tcm.prescriptionorder/uploadtopharmacy', ['tcm.prescriptionorder/submitgancaorecipel']),
|
||
'the historical permission must grant the unified upload URI'
|
||
);
|
||
$assertSame(
|
||
true,
|
||
$aliasMethod->invoke($middleware, 'tcm.prescriptionorder/submitgancaorecipel', ['tcm.prescriptionorder/uploadtopharmacy']),
|
||
'the unified permission must grant the historical upload URI'
|
||
);
|
||
$assertSame(
|
||
false,
|
||
$aliasMethod->invoke($middleware, 'tcm.prescriptionorder/export', ['tcm.prescriptionorder/uploadtopharmacy']),
|
||
'the upload alias must not expand to unrelated URIs'
|
||
);
|
||
$assertSame(
|
||
false,
|
||
$aliasMethod->invoke($middleware, 'tcm.prescriptionorder/confirmgancaosubmission', ['tcm.prescriptionorder/uploadtopharmacy']),
|
||
'ordinary pharmacy upload permission must not grant manual Gancao reconciliation'
|
||
);
|
||
|
||
$controllerSource = (string) file_get_contents(
|
||
dirname(__DIR__, 2) . '/app/api/controller/EjPharmacyCallbackController.php'
|
||
);
|
||
$assertSame(
|
||
2,
|
||
substr_count($controllerSource, 'PharmacyLogisticsValue::normalize('),
|
||
'carrier and tracking values must each be normalized exactly once before persistence'
|
||
);
|
||
$assertSame(
|
||
0,
|
||
substr_count($controllerSource, "(string) (\$payload['tracking_number']"),
|
||
'order, tracking, and trace writes must not consume the raw tracking number'
|
||
);
|
||
$assertSame(
|
||
true,
|
||
strpos($controllerSource, '$this->normalizeLogistics($payload)')
|
||
< strpos($controllerSource, '$this->callbackWorkflow()->handle($payload)'),
|
||
'callback logistics must be normalized before the workflow can write its inbox row'
|
||
);
|
||
$assertSame(
|
||
true,
|
||
str_contains($controllerSource, 'EjPharmacyCallbackFailureTransition::apply(')
|
||
&& str_contains($controllerSource, "where('process_status', '<>', \$protectedStatus)"),
|
||
'callback failure persistence must use a conditional status CAS that protects PROCESSED'
|
||
);
|
||
$versionGateStart = strpos($controllerSource, 'if ($versionAdvanced)');
|
||
$versionGateEnd = strpos($controllerSource, '$inboxModel->save', $versionGateStart);
|
||
$versionGatedSource = substr($controllerSource, $versionGateStart, $versionGateEnd - $versionGateStart);
|
||
$assertSame(
|
||
true,
|
||
str_contains($versionGatedSource, 'EjPharmacyShipmentPolicy::nextFulfillmentStatus('),
|
||
'shipment fulfillment advancement must occur inside the callback version gate transaction'
|
||
);
|
||
$assertSame(
|
||
true,
|
||
str_contains($versionGatedSource, 'self::syncLogistics('),
|
||
'ExpressTracking synchronization must remain inside the callback version gate'
|
||
);
|
||
$assertSame(
|
||
true,
|
||
str_contains($versionGatedSource, 'ExpressTrackingService::applyAssistantReleaseForShippedPrescriptionOrder('),
|
||
'EJ shipment callbacks must reuse the existing shipped-order assistant release linkage'
|
||
);
|
||
$assertSame(
|
||
true,
|
||
str_contains($controllerSource, "where('order_id', (int) \$order->id)")
|
||
&& str_contains($controllerSource, 'EjPharmacyTrackingPolicy::select(')
|
||
&& str_contains($controllerSource, "'order_type' => 'prescription_history'"),
|
||
'EJ callbacks must isolate replacement numbers and reject cross-order tracking ownership conflicts'
|
||
);
|
||
$assertSame(
|
||
true,
|
||
str_contains($controllerSource, "\$log->action = 'ej_pharmacy_callback'")
|
||
&& str_contains($controllerSource, '操作人:')
|
||
&& str_contains($controllerSource, '版本已处理,保留回传日志'),
|
||
'every unique EJ callback must be written to the prescription order operation timeline with operator and version context'
|
||
);
|
||
$assertSame(
|
||
true,
|
||
str_contains($controllerSource, '订单药房流转制作中')
|
||
&& str_contains($controllerSource, '订单已完成')
|
||
&& str_contains($controllerSource, '流程:')
|
||
&& str_contains($controllerSource, '药房:洛阳药房')
|
||
&& str_contains($controllerSource, "implode(\$isWorkflowStep ? ' | ' : ';', \$summaryParts)"),
|
||
'EJ workflow callback logs must use the same production-flow presentation as Gancao callbacks'
|
||
);
|
||
|
||
echo "zyt pharmacy callback/auth integration tests passed: {$passed}\n";
|