first commit
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
<?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(
|
||||
false,
|
||||
EjPharmacyShipmentPolicy::isCompletedEvent(['event_type' => 'WORKFLOW_STEP_COMPLETED', 'status' => 'COMPLETED', 'final' => true]),
|
||||
'a final EJ workflow callback must remain a process update, not order completion'
|
||||
);
|
||||
$assertSame(
|
||||
2,
|
||||
EjPharmacyShipmentPolicy::nextFulfillmentStatus(
|
||||
2,
|
||||
['event_type' => 'WORKFLOW_STEP_COMPLETED', 'status' => 'COMPLETED', 'final' => true]
|
||||
),
|
||||
'a final EJ workflow callback must not change ZYT fulfillment'
|
||||
);
|
||||
$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, "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";
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createLatestRequestGuard } from '../../../admin/src/views/pharmacy/medicine_mapping/latest-request.mjs'
|
||||
|
||||
const list = createLatestRequestGuard()
|
||||
const firstList = list.next({ page_no: 1, local_name: 'A' })
|
||||
const secondList = list.next({ page_no: 2, local_name: 'B' })
|
||||
assert.deepEqual(firstList.snapshot, { page_no: 1, local_name: 'A' })
|
||||
assert.equal(list.isLatest(firstList), false)
|
||||
assert.equal(list.isLatest(secondList), true)
|
||||
|
||||
const catalog = createLatestRequestGuard()
|
||||
const rowA = catalog.next({ localMedicineId: 1, keyword: 'A' })
|
||||
catalog.invalidate()
|
||||
const rowB = catalog.next({ localMedicineId: 2, keyword: 'B' })
|
||||
assert.equal(catalog.isLatest(rowA), false)
|
||||
assert.equal(catalog.isLatest(rowB), true)
|
||||
assert.deepEqual(rowB.snapshot, { localMedicineId: 2, keyword: 'B' })
|
||||
|
||||
const statusRequests = createLatestRequestGuard()
|
||||
const mountedStatus = statusRequests.next({ source: 'mount' })
|
||||
const syncedStatus = statusRequests.next({ source: 'sync' })
|
||||
const savedStatus = statusRequests.next({ source: 'save' })
|
||||
const unlinkedStatus = statusRequests.next({ source: 'unlink' })
|
||||
assert.equal(statusRequests.isLatest(mountedStatus), false)
|
||||
assert.equal(statusRequests.isLatest(syncedStatus), false)
|
||||
assert.equal(statusRequests.isLatest(savedStatus), false)
|
||||
assert.equal(statusRequests.isLatest(unlinkedStatus), true)
|
||||
list.next({ page_no: 3, local_name: 'C' })
|
||||
catalog.invalidate()
|
||||
assert.equal(statusRequests.isLatest(unlinkedStatus), true)
|
||||
|
||||
const page = readFileSync(
|
||||
new URL('../../../admin/src/views/pharmacy/medicine_mapping/index.vue', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
assert.match(page, /medicineMappingLists\(ticket\.snapshot\)/)
|
||||
assert.match(page, /listRequests\.isLatest\(ticket\)/)
|
||||
assert.match(page, /catalogRequests\.invalidate\(\)/)
|
||||
assert.match(page, /catalogRequests\.isLatest\(ticket\)/)
|
||||
assert.match(page, /localMedicineId/)
|
||||
assert.match(page, /const initialTicket = await searchCatalogNow\(rowSnapshot\.local_name, localMedicineId\)/)
|
||||
assert.match(page, /catalogRequests\.isLatest\(initialTicket\)/)
|
||||
assert.match(page, /const statusRequests = createLatestRequestGuard/)
|
||||
|
||||
const loadStatusStart = page.indexOf('const loadStatus = async () => {')
|
||||
const loadStatusEnd = page.indexOf('\nconst search =', loadStatusStart)
|
||||
assert.ok(loadStatusStart >= 0 && loadStatusEnd > loadStatusStart)
|
||||
const loadStatusSource = page.slice(loadStatusStart, loadStatusEnd)
|
||||
assert.match(loadStatusSource, /const ticket = statusRequests\.next\(undefined\)/)
|
||||
assert.match(loadStatusSource, /const nextStatus = await medicineMappingStatus\(\)/)
|
||||
assert.match(
|
||||
loadStatusSource,
|
||||
/if \(statusRequests\.isLatest\(ticket\)\) \{\s*status\.value = nextStatus\s*\}/
|
||||
)
|
||||
assert.match(
|
||||
loadStatusSource,
|
||||
/finally\s*\{\s*if \(statusRequests\.isLatest\(ticket\)\) \{\s*statusLoading\.value = false\s*\}/
|
||||
)
|
||||
assert.equal(page.match(/loadStatus\(\)/g)?.length, 4)
|
||||
|
||||
console.log('zyt mapping latest-request behavior and production wiring passed: 25')
|
||||
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__, 2) . '/vendor/autoload.php';
|
||||
|
||||
use app\common\service\pharmacy\EjMedicineBootstrapService;
|
||||
|
||||
$hostname = trim((string) (getenv('ZYT_BOOTSTRAP_TEST_DB_HOST') ?: ''));
|
||||
$port = (int) (getenv('ZYT_BOOTSTRAP_TEST_DB_PORT') ?: 3306);
|
||||
$username = (string) (getenv('ZYT_BOOTSTRAP_TEST_DB_USER') ?: '');
|
||||
$configuredDatabase = trim((string) (getenv('ZYT_BOOTSTRAP_TEST_DB_DATABASE') ?: ''));
|
||||
$passwordOverride = getenv('ZYT_BOOTSTRAP_TEST_DB_PASSWORD');
|
||||
$password = $passwordOverride === false ? '' : $passwordOverride;
|
||||
if ($hostname === '' || $username === '') {
|
||||
fwrite(STDOUT, "medicine bootstrap MySQL integration skipped: database credentials unavailable\n");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$databaseName = 'zyt_bootstrap_test_' . bin2hex(random_bytes(8));
|
||||
$admin = null;
|
||||
$pdo = null;
|
||||
$temporaryTables = false;
|
||||
$stage = 'connect';
|
||||
try {
|
||||
$admin = new PDO(
|
||||
sprintf('mysql:host=%s;port=%d;charset=utf8mb4', $hostname, $port),
|
||||
$username,
|
||||
$password,
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]
|
||||
);
|
||||
$stage = 'create_database';
|
||||
try {
|
||||
$admin->exec("CREATE DATABASE `{$databaseName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||
} catch (PDOException $exception) {
|
||||
if ($configuredDatabase === '' || !in_array((string) $exception->getCode(), ['42000', '1044'], true)) {
|
||||
throw $exception;
|
||||
}
|
||||
$temporaryTables = true;
|
||||
$databaseName = $configuredDatabase;
|
||||
}
|
||||
$stage = 'test';
|
||||
$pdo = new PDO(
|
||||
sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $hostname, $port, $databaseName),
|
||||
$username,
|
||||
$password,
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]
|
||||
);
|
||||
$tableKind = $temporaryTables ? 'CREATE TEMPORARY TABLE' : 'CREATE TABLE';
|
||||
$pdo->exec("{$tableKind} projection_catalog (
|
||||
medicine_code varchar(32) NOT NULL PRIMARY KEY,
|
||||
local_medicine_id bigint unsigned NOT NULL,
|
||||
name varchar(120) NOT NULL
|
||||
) ENGINE=InnoDB");
|
||||
$pdo->exec("{$tableKind} projection_mapping (
|
||||
local_medicine_id bigint unsigned NOT NULL PRIMARY KEY,
|
||||
medicine_code varchar(32) NOT NULL UNIQUE,
|
||||
operator_id bigint unsigned NOT NULL,
|
||||
operator_name varchar(80) NOT NULL
|
||||
) ENGINE=InnoDB");
|
||||
foreach (['submissions', 'callbacks', 'business_links'] as $table) {
|
||||
$pdo->exec("{$tableKind} `{$table}` (id bigint unsigned NOT NULL PRIMARY KEY) ENGINE=InnoDB");
|
||||
}
|
||||
$pdo->exec("INSERT INTO projection_catalog VALUES ('OLD001', 999, '旧投影')");
|
||||
$pdo->exec("INSERT INTO projection_mapping VALUES (999, 'OLD001', 8, 'old-operator')");
|
||||
|
||||
$transaction = static function (callable $operation) use ($pdo): array {
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$result = $operation();
|
||||
$pdo->commit();
|
||||
return $result;
|
||||
} catch (Throwable $exception) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $exception;
|
||||
}
|
||||
};
|
||||
$referenceCounter = static function () use ($pdo): array {
|
||||
return [
|
||||
'submissions' => (int) $pdo->query('SELECT COUNT(*) FROM submissions')->fetchColumn(),
|
||||
'callbacks' => (int) $pdo->query('SELECT COUNT(*) FROM callbacks')->fetchColumn(),
|
||||
'business_links' => (int) $pdo->query('SELECT COUNT(*) FROM business_links')->fetchColumn(),
|
||||
];
|
||||
};
|
||||
$referenceLocker = static function () use ($pdo): void {
|
||||
foreach (['submissions', 'callbacks', 'business_links'] as $table) {
|
||||
$pdo->query("SELECT id FROM `{$table}` ORDER BY id FOR UPDATE")->fetchAll();
|
||||
}
|
||||
};
|
||||
$locker = static function () use ($pdo): void {
|
||||
$pdo->query('SELECT medicine_code FROM projection_catalog ORDER BY medicine_code FOR UPDATE')->fetchAll();
|
||||
$pdo->query('SELECT local_medicine_id FROM projection_mapping ORDER BY local_medicine_id FOR UPDATE')->fetchAll();
|
||||
};
|
||||
$verifier = static function () use ($pdo): array {
|
||||
return [
|
||||
'catalog' => (int) $pdo->query('SELECT COUNT(*) FROM projection_catalog')->fetchColumn(),
|
||||
'active_mappings' => (int) $pdo->query('SELECT COUNT(*) FROM projection_mapping')->fetchColumn(),
|
||||
'unmapped' => (int) $pdo->query(
|
||||
'SELECT COUNT(*) FROM projection_catalog c LEFT JOIN projection_mapping m '
|
||||
. 'ON m.medicine_code = c.medicine_code WHERE m.local_medicine_id IS NULL'
|
||||
)->fetchColumn(),
|
||||
];
|
||||
};
|
||||
$rows = [
|
||||
['local_medicine_id' => 1, 'medicine_code' => 'EJ000001', 'name' => '黄芪'],
|
||||
['local_medicine_id' => 2, 'medicine_code' => 'EJ000002', 'name' => '党参'],
|
||||
];
|
||||
$replacer = static function (array $nextRows) use ($pdo): void {
|
||||
$pdo->exec('DELETE FROM projection_mapping');
|
||||
$pdo->exec('DELETE FROM projection_catalog');
|
||||
$catalog = $pdo->prepare(
|
||||
'INSERT INTO projection_catalog (medicine_code,local_medicine_id,name) VALUES (?,?,?)'
|
||||
);
|
||||
$mapping = $pdo->prepare(
|
||||
'INSERT INTO projection_mapping (local_medicine_id,medicine_code,operator_id,operator_name) VALUES (?,?,0,?)'
|
||||
);
|
||||
foreach ($nextRows as $row) {
|
||||
$catalog->execute([$row['medicine_code'], $row['local_medicine_id'], $row['name']]);
|
||||
$mapping->execute([$row['local_medicine_id'], $row['medicine_code'], 'system-bootstrap']);
|
||||
}
|
||||
};
|
||||
|
||||
$pdo->exec('INSERT INTO submissions VALUES (1)');
|
||||
try {
|
||||
EjMedicineBootstrapService::replaceProjectionWith(
|
||||
$rows,
|
||||
$transaction,
|
||||
$referenceLocker,
|
||||
$referenceCounter,
|
||||
$locker,
|
||||
$replacer,
|
||||
$verifier
|
||||
);
|
||||
throw new RuntimeException('nonzero MySQL reference gate unexpectedly allowed replacement');
|
||||
} catch (RuntimeException $exception) {
|
||||
if (!str_contains($exception->getMessage(), '业务引用')) {
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
$pdo->exec('DELETE FROM submissions');
|
||||
if ((string) $pdo->query('SELECT medicine_code FROM projection_catalog')->fetchColumn() !== 'OLD001') {
|
||||
throw new RuntimeException('nonzero MySQL reference gate mutated the old projection');
|
||||
}
|
||||
|
||||
$result = EjMedicineBootstrapService::replaceProjectionWith(
|
||||
$rows,
|
||||
$transaction,
|
||||
$referenceLocker,
|
||||
$referenceCounter,
|
||||
$locker,
|
||||
$replacer,
|
||||
$verifier
|
||||
);
|
||||
if ($result !== ['catalog' => 2, 'active_mappings' => 2, 'unmapped' => 0]) {
|
||||
throw new RuntimeException('successful MySQL projection replacement did not verify exactly');
|
||||
}
|
||||
$operators = $pdo->query(
|
||||
'SELECT CONCAT(operator_id, ":", operator_name) FROM projection_mapping ORDER BY local_medicine_id'
|
||||
)->fetchAll(PDO::FETCH_COLUMN);
|
||||
if ($operators !== ['0:system-bootstrap', '0:system-bootstrap']) {
|
||||
throw new RuntimeException('bootstrap mappings did not preserve the system operator identity');
|
||||
}
|
||||
|
||||
$pdo->exec('DELETE FROM projection_mapping');
|
||||
$pdo->exec('DELETE FROM projection_catalog');
|
||||
$pdo->exec("INSERT INTO projection_catalog VALUES ('OLD002', 998, '回滚旧投影')");
|
||||
$pdo->exec("INSERT INTO projection_mapping VALUES (998, 'OLD002', 7, 'rollback-operator')");
|
||||
try {
|
||||
EjMedicineBootstrapService::replaceProjectionWith(
|
||||
$rows,
|
||||
$transaction,
|
||||
$referenceLocker,
|
||||
$referenceCounter,
|
||||
$locker,
|
||||
static function (array $nextRows) use ($replacer): void {
|
||||
$replacer($nextRows);
|
||||
throw new RuntimeException('forced MySQL replacement failure');
|
||||
},
|
||||
$verifier
|
||||
);
|
||||
throw new RuntimeException('forced MySQL replacement failure unexpectedly committed');
|
||||
} catch (RuntimeException $exception) {
|
||||
if ($exception->getMessage() !== 'forced MySQL replacement failure') {
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
$rolledBack = $pdo->query(
|
||||
'SELECT c.medicine_code,c.local_medicine_id,c.name,m.operator_id,m.operator_name '
|
||||
. 'FROM projection_catalog c JOIN projection_mapping m USING (medicine_code)'
|
||||
)->fetch();
|
||||
if ($rolledBack !== [
|
||||
'medicine_code' => 'OLD002',
|
||||
'local_medicine_id' => 998,
|
||||
'name' => '回滚旧投影',
|
||||
'operator_id' => 7,
|
||||
'operator_name' => 'rollback-operator',
|
||||
]) {
|
||||
throw new RuntimeException('MySQL rollback did not restore the old projection exactly');
|
||||
}
|
||||
|
||||
fwrite(STDOUT, $temporaryTables
|
||||
? "medicine bootstrap MySQL integration passed: temporary_tables\n"
|
||||
: "medicine bootstrap MySQL integration passed: temporary_database\n");
|
||||
} catch (PDOException $exception) {
|
||||
if ($stage === 'test') {
|
||||
throw $exception;
|
||||
}
|
||||
fwrite(STDOUT, sprintf(
|
||||
"medicine bootstrap MySQL integration skipped: temporary database unavailable stage=%s sqlstate=%s\n",
|
||||
$stage,
|
||||
(string) $exception->getCode()
|
||||
));
|
||||
exit(0);
|
||||
} finally {
|
||||
$pdo = null;
|
||||
if ($admin instanceof PDO && !$temporaryTables) {
|
||||
try {
|
||||
$admin->exec("DROP DATABASE IF EXISTS `{$databaseName}`");
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__, 2) . '/vendor/autoload.php';
|
||||
|
||||
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
|
||||
|
||||
$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;
|
||||
};
|
||||
|
||||
$resolveNames = new ReflectionMethod(PrescriptionOrderLogic::class, 'resolvePrescriptionNamesForExport');
|
||||
|
||||
$mainHerb = ['name' => '黄芪', 'dosage' => 10, 'formula_type' => '主方'];
|
||||
$auxHerb = ['name' => '龙骨', 'dosage' => 15, 'formula_type' => '辅方'];
|
||||
|
||||
$assertSame(
|
||||
['主方名', ''],
|
||||
$resolveNames->invoke(null, [
|
||||
'prescription_name' => '主方名',
|
||||
'herbs' => [$mainHerb],
|
||||
'aux_usage' => ['prescription_name' => '已删除的辅方名'],
|
||||
], [], []),
|
||||
'export must ignore a stale auxiliary prescription name when no auxiliary herbs exist'
|
||||
);
|
||||
|
||||
$assertSame(
|
||||
['主方名', ''],
|
||||
$resolveNames->invoke(null, [
|
||||
'prescription_name' => '主方名',
|
||||
'herbs' => json_encode([$mainHerb], JSON_UNESCAPED_UNICODE),
|
||||
'aux_usage' => json_encode(['library_name' => '已删除的处方库辅方名'], JSON_UNESCAPED_UNICODE),
|
||||
], [], []),
|
||||
'JSON-backed export data must ignore a stale auxiliary library name when no auxiliary herbs exist'
|
||||
);
|
||||
|
||||
$assertSame(
|
||||
['主方名', ''],
|
||||
$resolveNames->invoke(null, [
|
||||
'prescription_name' => '主方名',
|
||||
'creator_id' => 7,
|
||||
'herbs' => [$mainHerb],
|
||||
'aux_usage' => ['prescription_name' => '残留名称', 'usage_days' => 7],
|
||||
], [
|
||||
7 => [
|
||||
'辅方' => ['龙骨:15' => '不应命中的处方库辅方名'],
|
||||
],
|
||||
], [
|
||||
'辅方' => ['龙骨:15' => '不应命中的公开辅方名'],
|
||||
]),
|
||||
'stale auxiliary usage and library indexes must not imply that an auxiliary formula exists'
|
||||
);
|
||||
|
||||
$assertSame(
|
||||
['主方名', '持久化辅方名'],
|
||||
$resolveNames->invoke(null, [
|
||||
'prescription_name' => '主方名',
|
||||
'herbs' => [$mainHerb, $auxHerb],
|
||||
'aux_usage' => json_encode(['prescription_name' => '持久化辅方名'], JSON_UNESCAPED_UNICODE),
|
||||
], [], []),
|
||||
'export must keep the persisted auxiliary prescription name when auxiliary herbs exist'
|
||||
);
|
||||
|
||||
$assertSame(
|
||||
['主方名', '处方库辅方名'],
|
||||
$resolveNames->invoke(null, [
|
||||
'prescription_name' => '主方名',
|
||||
'creator_id' => 7,
|
||||
'herbs' => [$mainHerb, $auxHerb],
|
||||
], [
|
||||
7 => [
|
||||
'辅方' => ['龙骨:15' => '处方库辅方名'],
|
||||
],
|
||||
], []),
|
||||
'export must still resolve an auxiliary prescription name from the library when auxiliary herbs exist'
|
||||
);
|
||||
|
||||
$assertSame(
|
||||
['系统代开', '失眠(辅方)'],
|
||||
$resolveNames->invoke(null, [
|
||||
'prescription_name' => '系统代开',
|
||||
'creator_id' => 99,
|
||||
'herbs' => [$mainHerb, $auxHerb],
|
||||
], [], [], [
|
||||
'辅方' => ['龙骨:15' => '失眠(辅方)'],
|
||||
]),
|
||||
'export must resolve auxiliary name via cross-doctor libAny when creator has no private/public hit'
|
||||
);
|
||||
|
||||
$assertSame(
|
||||
['系统代开', '辅方'],
|
||||
$resolveNames->invoke(null, [
|
||||
'prescription_name' => '系统代开',
|
||||
'creator_id' => 99,
|
||||
'herbs' => [$mainHerb, $auxHerb],
|
||||
], [], [], []),
|
||||
'export must still mark 辅方 when auxiliary herbs exist but no library/persisted name matches'
|
||||
);
|
||||
|
||||
fwrite(STDOUT, sprintf("Prescription order export name regression tests passed: %d\n", $passed));
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$routeSource = (string) file_get_contents(dirname(__DIR__, 2) . '/app/api/route/app.php');
|
||||
|
||||
$assertTrue(
|
||||
!str_contains($routeSource, 'Controller@'),
|
||||
'API routes must use controller dispatch so InitMiddleware receives controller and action names'
|
||||
);
|
||||
$assertTrue(
|
||||
str_contains($routeSource, "'EjPharmacyCallback/webhook'"),
|
||||
'ej pharmacy webhook must use ThinkPHP controller dispatch'
|
||||
);
|
||||
$assertTrue(
|
||||
str_contains($routeSource, "'QywxExternalContactCallback/notify'"),
|
||||
'QYWX callback must use ThinkPHP controller dispatch'
|
||||
);
|
||||
|
||||
$controllerSource = (string) file_get_contents(dirname(__DIR__, 2) . '/app/adminapi/controller/tcm/PrescriptionOrderController.php');
|
||||
$migrationSource = (string) file_get_contents(dirname(__DIR__, 2) . '/sql/1.9.20260721/luoyang_pharmacy_erp.sql');
|
||||
$assertTrue(
|
||||
str_contains($controllerSource, 'confirmGancaoSubmission'),
|
||||
'admin API must expose an actionable Gancao reconciliation endpoint'
|
||||
);
|
||||
$assertTrue(
|
||||
str_contains($controllerSource, 'public function ddcode()'),
|
||||
'admin API must expose a dedicated tracking correction endpoint'
|
||||
);
|
||||
$assertTrue(
|
||||
str_contains($migrationSource, '`lease_expires_at`'),
|
||||
'pharmacy submission claims must persist an explicit lease expiry'
|
||||
);
|
||||
$assertTrue(
|
||||
str_contains($migrationSource, 'zyt_pharmacy_submission_claim_audit'),
|
||||
'manual submission reconciliation must have an append-only audit table'
|
||||
);
|
||||
|
||||
$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';
|
||||
$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');
|
||||
|
||||
$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');
|
||||
$consoleSource = (string) file_get_contents(dirname(__DIR__, 2) . '/config/console.php');
|
||||
$syncServiceSource = (string) file_get_contents(dirname(__DIR__, 2) . '/app/common/service/pharmacy/EjMedicineCatalogSyncService.php');
|
||||
$mappingLogicSource = (string) file_get_contents(dirname(__DIR__, 2) . '/app/adminapi/logic/pharmacy/MedicineMappingLogic.php');
|
||||
$mappingPageSource = (string) file_get_contents(dirname(__DIR__, 3) . '/admin/src/views/pharmacy/medicine_mapping/index.vue');
|
||||
$mappingApiSource = (string) file_get_contents(dirname(__DIR__, 3) . '/admin/src/api/pharmacy.ts');
|
||||
$exampleEnvSource = (string) file_get_contents(dirname(__DIR__, 2) . '/.example.env');
|
||||
|
||||
$assertTrue(
|
||||
str_contains($clientSource, 'function importMedicines(')
|
||||
&& str_contains($clientSource, "'/api/openapi/v1/medicine-imports'"),
|
||||
'EJ client must post structured medicine import batches through the existing HMAC transport'
|
||||
);
|
||||
$assertTrue(
|
||||
str_contains($consoleSource, "'ej-pharmacy:bootstrap-medicines'")
|
||||
&& str_contains((string) file_get_contents($bootstrapCommandPath), 'RESET_TEST_CATALOG'),
|
||||
'bootstrap command must be registered and guard destructive replacement with the exact confirmation token'
|
||||
);
|
||||
$assertTrue(
|
||||
str_contains($configSource, "'catalog_sync_enabled'")
|
||||
&& str_contains($configSource, "env('EJ_PHARMACY_CATALOG_SYNC_ENABLED', false)"),
|
||||
'legacy EJ catalog sync must default disabled'
|
||||
);
|
||||
$assertTrue(
|
||||
str_contains($exampleEnvSource, 'EJ_PHARMACY_CATALOG_SYNC_ENABLED = false'),
|
||||
'example environment must explicitly disable legacy catalog sync'
|
||||
);
|
||||
$assertTrue(
|
||||
strpos($syncServiceSource, "Config::get('ej_pharmacy.catalog_sync_enabled', false)")
|
||||
< strpos($syncServiceSource, 'ensureStateRow()'),
|
||||
'disabled catalog sync must fail before any synchronization state write'
|
||||
);
|
||||
$assertTrue(
|
||||
str_contains($mappingLogicSource, "'sync_enabled'")
|
||||
&& str_contains($mappingApiSource, 'sync_enabled: boolean'),
|
||||
'mapping status must expose the legacy sync feature gate end to end'
|
||||
);
|
||||
$assertTrue(
|
||||
str_contains($mappingPageSource, 'v-if="status.sync_enabled"'),
|
||||
'mapping page must hide the incremental sync button when legacy sync is disabled'
|
||||
);
|
||||
$assertTrue(
|
||||
str_contains($migrationSource, 'UNIQUE KEY `uk_medicine_code` (`medicine_code`)'),
|
||||
'bootstrap mapping projection must reject duplicate remote medicine codes at the database boundary'
|
||||
);
|
||||
$bootstrapServiceSource = (string) file_get_contents($bootstrapServicePath);
|
||||
$assertTrue(
|
||||
strpos($bootstrapServiceSource, "Db::name('ej_pharmacy_submission')")
|
||||
< strpos($bootstrapServiceSource, "'submissions' => (int) Db::name('ej_pharmacy_submission')->count()"),
|
||||
'bootstrap replacement must lock reference sources before checking the zero-reference gate'
|
||||
);
|
||||
$assertTrue(
|
||||
str_contains($bootstrapServiceSource, "Db::name('doctor_medicine')->where('id', '>=', 0)")
|
||||
&& str_contains($bootstrapServiceSource, '本地药材源快照在远端导入期间发生变化'),
|
||||
'bootstrap replacement must lock and revalidate the complete local medicine source snapshot'
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user