diff --git a/artifacts/ej-medicine-incremental-sync/DIFF_FILE b/artifacts/ej-medicine-incremental-sync/DIFF_FILE index 14ac381bc..55a9fb951 100644 --- a/artifacts/ej-medicine-incremental-sync/DIFF_FILE +++ b/artifacts/ej-medicine-incremental-sync/DIFF_FILE @@ -1,10 +1,10 @@ -### ZYT /Users/long/Work/zyt commit 17e9e7b6b +### ZYT /Users/long/Work/zyt through eec320440 diff --git a/docs/ej-pharmacy-incremental-medicine-sync.md b/docs/ej-pharmacy-incremental-medicine-sync.md new file mode 100644 -index 000000000..8a3ff0821 +index 000000000..89fbabafa --- /dev/null +++ b/docs/ej-pharmacy-incremental-medicine-sync.md -@@ -0,0 +1,46 @@ +@@ -0,0 +1,49 @@ +# EJ 药材非破坏性增量同步 + +`ej-pharmacy:push-medicines` 将 ZYT 中启用且未删除的药材,通过现有 HMAC OpenAPI 增量推送到 EJ。 @@ -13,6 +13,9 @@ index 000000000..8a3ff0821 + +- EJ 只执行 `POST /api/openapi/v1/medicine-imports` 的新增/幂等确认,不删除或清空 EJ 药材。 +- EJ 中由其他来源或人工录入的药材保持不变。 ++- ZYT 已同步且在 EJ 中仍正常启用的药材保持名称、价格、库存和编码不变。 ++- ZYT 已同步但在 EJ 中被软删除的药材恢复原记录和原 `medicine_code`;被停用的药材重新启用。 ++- ZYT 后续新增、且从未同步过的药材追加到 EJ,并初始化零库存。 +- ZYT 只增量写入或更新 EJ 返回的目录投影,不清空目录和映射。 +- ZYT 中已停用或软删除的人工映射不重新启用。 +- 命令默认 dry-run;只有同时提供 `--apply` 和确认令牌才执行远端导入。 @@ -50,7 +53,7 @@ index 000000000..8a3ff0821 +- `ej-pharmacy:sync-catalog`:EJ → ZYT,拉取 EJ 目录变化。 +- `ej-pharmacy:bootstrap-medicines`:一次性初始化并替换本地投影,不用于已有业务数据的生产环境增量同步。 + -+如果 EJ 已存在 source 映射,但对应药材被停用或软删除,EJ 会返回冲突;此时需要先在 EJ 药材管理中恢复该药材,再重新执行增量命令。 ++同步范围以 `source_system=zyt` 和 `source_medicine_id` 标识来源。恢复和重新启用只作用于原来由 ZYT 同步过去的药材,因此不会修改 EJ 自己新增的药材。 diff --git a/server/app/command/EjPharmacyPushMedicines.php b/server/app/command/EjPharmacyPushMedicines.php new file mode 100644 index 000000000..cb97a8ff2 @@ -1142,13 +1145,25 @@ index 2b8aaa14c..613322a96 100644 $assertTrue( str_contains($configSource, "'catalog_sync_enabled'") && str_contains($configSource, "env('EJ_PHARMACY_CATALOG_SYNC_ENABLED', false)"), -### EJ /Users/long/Work/ej commit 2637ffa +### EJ /Users/long/Work/ej through 495b02341ac940d7f7b3dac5254fb6714b41311b diff --git a/server/app/common/service/pharmacy/MedicineImportService.php b/server/app/common/service/pharmacy/MedicineImportService.php -index ab0fbc3..c7aeafa 100644 +index ab0fbc3..0942365 100644 --- a/server/app/common/service/pharmacy/MedicineImportService.php +++ b/server/app/common/service/pharmacy/MedicineImportService.php -@@ -65,8 +65,6 @@ final class MedicineImportService - return self::batchReplay($input, $sourceRows, $medicineRows); +@@ -7,6 +7,7 @@ namespace app\common\service\pharmacy; + use app\common\model\pharmacy\Medicine; + use app\common\model\pharmacy\MedicineImportBatch; + use app\common\model\pharmacy\MedicineSource; ++use app\common\model\pharmacy\Sequence; + use app\common\model\pharmacy\Stock; + use app\common\model\pharmacy\Warehouse; + use DomainException; +@@ -62,11 +63,9 @@ final class MedicineImportService + if (!hash_equals((string) $batch->payload_hash, $input['payload_hash'])) { + throw new DomainException('Medicine import identity conflicts with a different payload'); + } +- return self::batchReplay($input, $sourceRows, $medicineRows); ++ return self::batchReplay($input, $sourceRows, $medicineRows, $sequence); } - self::assertCatalogBelongsToBootstrap($medicineRows, $sourceRows); @@ -1156,7 +1171,27 @@ index ab0fbc3..c7aeafa 100644 $createdCount = 0; $existingCount = 0; $results = []; -@@ -149,20 +147,6 @@ final class MedicineImportService +@@ -74,17 +73,12 @@ final class MedicineImportService + $sourceId = $item['source_medicine_id']; + $itemHash = MedicineImportPayload::itemHash($item); + if (isset($sourceRows[$sourceId])) { +- $results[] = self::existingResult($sourceRows[$sourceId], $itemHash, $medicineRows); ++ $results[] = self::existingResult($sourceRows[$sourceId], $item, $itemHash, $medicineRows, $sequence); + ++$existingCount; + continue; + } + +- $catalogVersion = CatalogVersionAllocator::next( +- static fn (): int => (int) $sequence->current_value, +- static function (int $next) use ($sequence): void { +- $sequence->save(['current_value' => $next]); +- } +- ); ++ $catalogVersion = self::nextCatalogVersion($sequence); + $medicine = Medicine::create([ + 'medicine_code' => 'EJ' . strtoupper(bin2hex(random_bytes(6))), + 'name' => $item['name'], +@@ -149,28 +143,18 @@ final class MedicineImportService return self::response($input, $results, $createdCount, $existingCount, false); } @@ -1177,20 +1212,289 @@ index ab0fbc3..c7aeafa 100644 /** * @param array $input * @param array> $sourceRows + * @param array> $medicineRows + * @return array + */ +- private static function batchReplay(array $input, array $sourceRows, array $medicineRows): array +- { ++ private static function batchReplay( ++ array $input, ++ array $sourceRows, ++ array $medicineRows, ++ Sequence $sequence ++ ): array { + $results = []; + foreach ($input['items'] as $item) { + $sourceId = $item['source_medicine_id']; +@@ -179,26 +163,55 @@ final class MedicineImportService + } + $results[] = self::existingResult( + $sourceRows[$sourceId], ++ $item, + MedicineImportPayload::itemHash($item), +- $medicineRows ++ $medicineRows, ++ $sequence + ); + } + return self::response($input, $results, 0, count($results), true); + } + +- /** @param array $source @param array> $medicineRows */ +- private static function existingResult(array $source, string $itemHash, array $medicineRows): array +- { ++ /** ++ * @param array $source ++ * @param array $item ++ * @param array> $medicineRows ++ */ ++ private static function existingResult( ++ array $source, ++ array $item, ++ string $itemHash, ++ array &$medicineRows, ++ Sequence $sequence ++ ): array { + if (!hash_equals((string) $source['payload_hash'], $itemHash)) { + throw new DomainException('Source medicine identity conflicts with a different payload'); + } +- $medicine = $medicineRows[(int) $source['medicine_id']] ?? null; +- if ($medicine === null || $medicine['delete_time'] !== null) { +- throw new DomainException('Source medicine mapping points to a deleted or missing medicine'); ++ $medicineId = (int) $source['medicine_id']; ++ $medicine = $medicineRows[$medicineId] ?? null; ++ if ($medicine === null) { ++ throw new DomainException('Source medicine mapping points to a missing medicine'); + } + if (!hash_equals((string) $source['medicine_code'], (string) $medicine['medicine_code'])) { + throw new RuntimeException('Source medicine mapping code is inconsistent'); + } ++ ++ if ($medicine['delete_time'] !== null || (int) $medicine['status'] !== (int) $item['status']) { ++ $catalogVersion = self::nextCatalogVersion($sequence); ++ $updateTime = time(); ++ Db::name('pharmacy_medicine')->where('id', $medicineId)->update([ ++ 'delete_time' => null, ++ 'status' => (int) $item['status'], ++ 'catalog_version' => $catalogVersion, ++ 'update_time' => $updateTime, ++ ]); ++ $medicine['delete_time'] = null; ++ $medicine['status'] = (int) $item['status']; ++ $medicine['catalog_version'] = $catalogVersion; ++ $medicine['update_time'] = $updateTime; ++ $medicineRows[$medicineId] = $medicine; ++ } ++ + return [ + 'source_medicine_id' => (string) $source['source_medicine_id'], + 'medicine_code' => (string) $medicine['medicine_code'], +@@ -207,6 +220,16 @@ final class MedicineImportService + ]; + } + ++ private static function nextCatalogVersion(Sequence $sequence): int ++ { ++ return CatalogVersionAllocator::next( ++ static fn (): int => (int) $sequence->current_value, ++ static function (int $next) use ($sequence): void { ++ $sequence->save(['current_value' => $next]); ++ } ++ ); ++ } ++ + /** @param array $input @param list> $items */ + private static function response( + array $input, diff --git a/server/tests/pharmacy/medicine_import_mysql_integration.php b/server/tests/pharmacy/medicine_import_mysql_integration.php -index 34b6b7a..4b0b065 100644 +index 34b6b7a..9464b18 100644 --- a/server/tests/pharmacy/medicine_import_mysql_integration.php +++ b/server/tests/pharmacy/medicine_import_mysql_integration.php -@@ -360,7 +360,7 @@ try { +@@ -2,6 +2,7 @@ + + declare(strict_types=1); + ++use app\adminapi\logic\pharmacy\MedicineLogic; + use app\command\PharmacyResetTestCatalog; + use app\common\service\pharmacy\MedicineCatalogResetService; + use app\common\service\pharmacy\MedicineImportPayload; +@@ -297,6 +298,21 @@ try { + $assertSame(2, $count('pharmacy_medicine_import_batch'), 'source replay in another batch must record that batch once'); + $assertSame(1, $count('pharmacy_medicine'), 'source replay must not add a medicine'); + $assertSame(1, $count('pharmacy_stock'), 'source replay must not add stock'); ++ $assertSame( ++ $medicineOne, ++ Db::name('pharmacy_medicine')->where('id', (int) $medicineOne['id'])->find(), ++ 'enabled mapped medicine replay must preserve the medicine row byte for byte' ++ ); ++ $assertSame( ++ $sourceOne, ++ Db::name('pharmacy_medicine_source')->where('id', (int) $sourceOne['id'])->find(), ++ 'enabled mapped medicine replay must preserve its source mapping byte for byte' ++ ); ++ $assertSame( ++ $stockOne, ++ Db::name('pharmacy_stock')->where('id', (int) $stockOne['id'])->find(), ++ 'enabled mapped medicine replay must preserve its stock row byte for byte' ++ ); + $normalizedDifferentBatchReplay = MedicineImportPayload::normalize($differentBatchReplay); + $secondBatchRow = Db::name('pharmacy_medicine_import_batch') + ->where('merchant_id', 9001) +@@ -340,6 +356,104 @@ try { + $assertSame(2, $count('pharmacy_medicine_import_batch'), 'source conflict must roll back the batch'); + ++$passed; + ++ $medicineCountBeforeRestore = $count('pharmacy_medicine'); ++ $sourceCountBeforeRestore = $count('pharmacy_medicine_source'); ++ $stockCountBeforeRestore = $count('pharmacy_stock'); ++ $sourceBeforeRestore = Db::name('pharmacy_medicine_source')->where('id', (int) $sourceOne['id'])->find(); ++ $stockBeforeRestore = Db::name('pharmacy_stock')->where('id', (int) $stockOne['id'])->find(); ++ $assertSame(true, MedicineLogic::delete((int) $medicineOne['id']), 'admin delete must soft-delete the mapped medicine'); ++ $softDeleted = Db::name('pharmacy_medicine')->where('id', (int) $medicineOne['id'])->find(); ++ $assertSame(0, (int) $softDeleted['status'], 'admin delete must disable the mapped medicine'); ++ $assertTrue($softDeleted['delete_time'] !== null, 'admin delete must retain the mapped medicine as soft-deleted'); ++ $sequenceAfterSoftDelete = (int) Db::name('pharmacy_sequence') ++ ->where('sequence_name', 'medicine_catalog') ++ ->value('current_value'); ++ ++ $restoreSoftDeletedBatch = $batchOne; ++ $restoreSoftDeletedBatch['import_id'] = 'integration-batch-restore-soft-deleted'; ++ $restoredSoftDeletedResult = MedicineImportService::import(9001, $restoreSoftDeletedBatch); ++ $restoredSoftDeleted = Db::name('pharmacy_medicine')->where('id', (int) $medicineOne['id'])->find(); ++ $assertSame(false, $restoredSoftDeletedResult['idempotent'], 'soft-delete restoration in a new batch must not be idempotent'); ++ $assertSame(0, $restoredSoftDeletedResult['created_count'], 'soft-delete restoration must not create a medicine'); ++ $assertSame(1, $restoredSoftDeletedResult['existing_count'], 'soft-delete restoration must count the mapping as existing'); ++ $assertSame('existing', $restoredSoftDeletedResult['items'][0]['action'], 'soft-delete restoration must preserve the existing action'); ++ $assertSame((int) $medicineOne['id'], (int) $restoredSoftDeleted['id'], 'soft-delete restoration must reuse the original medicine id'); ++ $assertSame((string) $medicineOne['medicine_code'], (string) $restoredSoftDeleted['medicine_code'], 'soft-delete restoration must reuse the original medicine code'); ++ $assertSame(null, $restoredSoftDeleted['delete_time'], 'soft-delete restoration must clear delete_time'); ++ $assertSame(1, (int) $restoredSoftDeleted['status'], 'soft-delete restoration must re-enable the medicine'); ++ $assertSame($sequenceAfterSoftDelete + 1, (int) $restoredSoftDeleted['catalog_version'], 'soft-delete restoration must publish a new catalog version'); ++ $assertSame((int) $restoredSoftDeleted['catalog_version'], $restoredSoftDeletedResult['items'][0]['catalog_version'], 'soft-delete response must return the restored catalog version'); ++ $assertSame($medicineCountBeforeRestore, $count('pharmacy_medicine'), 'soft-delete restoration must not add a medicine row'); ++ $assertSame($sourceCountBeforeRestore, $count('pharmacy_medicine_source'), 'soft-delete restoration must not add a source mapping'); ++ $assertSame($stockCountBeforeRestore, $count('pharmacy_stock'), 'soft-delete restoration must not add a stock row'); ++ $assertSame($sourceBeforeRestore, Db::name('pharmacy_medicine_source')->where('id', (int) $sourceOne['id'])->find(), 'soft-delete restoration must not mutate the source mapping'); ++ $assertSame($stockBeforeRestore, Db::name('pharmacy_stock')->where('id', (int) $stockOne['id'])->find(), 'soft-delete restoration must not mutate stock'); ++ $softDeletedComparable = $softDeleted; ++ $restoredSoftDeletedComparable = $restoredSoftDeleted; ++ foreach (['delete_time', 'status', 'catalog_version', 'update_time'] as $restoredField) { ++ unset($softDeletedComparable[$restoredField], $restoredSoftDeletedComparable[$restoredField]); ++ } ++ $assertSame($softDeletedComparable, $restoredSoftDeletedComparable, 'soft-delete restoration must preserve every other medicine field'); ++ ++$passed; ++ ++ $assertTrue(is_array(MedicineLogic::edit([ ++ 'id' => (int) $restoredSoftDeleted['id'], ++ 'name' => (string) $restoredSoftDeleted['name'], ++ 'brand' => (string) $restoredSoftDeleted['brand'], ++ 'unit' => (string) $restoredSoftDeleted['unit'], ++ 'settlement_price' => (string) $restoredSoftDeleted['settlement_price'], ++ 'retail_price' => (string) $restoredSoftDeleted['retail_price'], ++ 'status' => 0, ++ ])), 'admin edit must disable the mapped medicine without deleting it'); ++ $disabledMedicine = Db::name('pharmacy_medicine')->where('id', (int) $medicineOne['id'])->find(); ++ $assertSame(null, $disabledMedicine['delete_time'], 'admin edit must leave the mapped medicine undeleted'); ++ $assertSame(0, (int) $disabledMedicine['status'], 'admin edit must disable the mapped medicine'); ++ $sequenceAfterDisable = (int) Db::name('pharmacy_sequence') ++ ->where('sequence_name', 'medicine_catalog') ++ ->value('current_value'); ++ ++ $restoreDisabledBatch = $batchOne; ++ $restoreDisabledBatch['import_id'] = 'integration-batch-restore-disabled'; ++ $restoredDisabledResult = MedicineImportService::import(9001, $restoreDisabledBatch); ++ $restoredDisabled = Db::name('pharmacy_medicine')->where('id', (int) $medicineOne['id'])->find(); ++ $assertSame(false, $restoredDisabledResult['idempotent'], 'disabled restoration in a new batch must not be idempotent'); ++ $assertSame(0, $restoredDisabledResult['created_count'], 'disabled restoration must not create a medicine'); ++ $assertSame(1, $restoredDisabledResult['existing_count'], 'disabled restoration must count the mapping as existing'); ++ $assertSame('existing', $restoredDisabledResult['items'][0]['action'], 'disabled restoration must preserve the existing action'); ++ $assertSame((int) $disabledMedicine['id'], (int) $restoredDisabled['id'], 'disabled restoration must reuse the original medicine id'); ++ $assertSame((string) $disabledMedicine['medicine_code'], (string) $restoredDisabled['medicine_code'], 'disabled restoration must reuse the original medicine code'); ++ $assertSame(null, $restoredDisabled['delete_time'], 'disabled restoration must leave delete_time clear'); ++ $assertSame(1, (int) $restoredDisabled['status'], 'disabled restoration must re-enable the medicine'); ++ $assertSame($sequenceAfterDisable + 1, (int) $restoredDisabled['catalog_version'], 'disabled restoration must publish a new catalog version'); ++ $assertSame((int) $restoredDisabled['catalog_version'], $restoredDisabledResult['items'][0]['catalog_version'], 'disabled response must return the restored catalog version'); ++ $assertSame($medicineCountBeforeRestore, $count('pharmacy_medicine'), 'disabled restoration must not add a medicine row'); ++ $assertSame($sourceCountBeforeRestore, $count('pharmacy_medicine_source'), 'disabled restoration must not add a source mapping'); ++ $assertSame($stockCountBeforeRestore, $count('pharmacy_stock'), 'disabled restoration must not add a stock row'); ++ $assertSame($sourceBeforeRestore, Db::name('pharmacy_medicine_source')->where('id', (int) $sourceOne['id'])->find(), 'disabled restoration must not mutate the source mapping'); ++ $assertSame($stockBeforeRestore, Db::name('pharmacy_stock')->where('id', (int) $stockOne['id'])->find(), 'disabled restoration must not mutate stock'); ++ ++$passed; ++ ++ $assertSame(true, MedicineLogic::delete((int) $medicineOne['id']), 'admin delete must prepare the batch replay restoration case'); ++ $sequenceBeforeBatchReplayRestore = (int) Db::name('pharmacy_sequence') ++ ->where('sequence_name', 'medicine_catalog') ++ ->value('current_value'); ++ $batchCountBeforeReplayRestore = $count('pharmacy_medicine_import_batch'); ++ $replayRestoredResult = MedicineImportService::import(9001, $restoreDisabledBatch); ++ $replayRestored = Db::name('pharmacy_medicine')->where('id', (int) $medicineOne['id'])->find(); ++ $assertSame(true, $replayRestoredResult['idempotent'], 'same completed batch replay must remain idempotent'); ++ $assertSame(0, $replayRestoredResult['created_count'], 'batch replay restoration must not create a medicine'); ++ $assertSame(1, $replayRestoredResult['existing_count'], 'batch replay restoration must count the mapping as existing'); ++ $assertSame('existing', $replayRestoredResult['items'][0]['action'], 'batch replay restoration must preserve the existing action'); ++ $assertSame((int) $medicineOne['id'], (int) $replayRestored['id'], 'batch replay restoration must reuse the original medicine id'); ++ $assertSame((string) $medicineOne['medicine_code'], (string) $replayRestored['medicine_code'], 'batch replay restoration must reuse the original medicine code'); ++ $assertSame(null, $replayRestored['delete_time'], 'batch replay restoration must clear delete_time'); ++ $assertSame(1, (int) $replayRestored['status'], 'batch replay restoration must re-enable the medicine'); ++ $assertSame($sequenceBeforeBatchReplayRestore + 1, (int) $replayRestored['catalog_version'], 'batch replay restoration must publish a new catalog version'); ++ $assertSame($batchCountBeforeReplayRestore, $count('pharmacy_medicine_import_batch'), 'batch replay restoration must not add a batch row'); ++ $assertSame($sourceBeforeRestore, Db::name('pharmacy_medicine_source')->where('id', (int) $sourceOne['id'])->find(), 'batch replay restoration must not mutate the source mapping'); ++ $assertSame($stockBeforeRestore, Db::name('pharmacy_stock')->where('id', (int) $stockOne['id'])->find(), 'batch replay restoration must not mutate stock'); ++ ++$passed; ++ + $continuation = [ + 'source_system' => 'zyt', + 'import_id' => 'integration-batch-0004', +@@ -360,7 +474,47 @@ try { $assertSame(0, (int) $medicineTwo['status'], 'continued import disabled status must persist'); ++$passed; - Db::name('pharmacy_medicine')->insert([ ++ $assertTrue(is_array(MedicineLogic::edit([ ++ 'id' => (int) $replayRestored['id'], ++ 'name' => (string) $replayRestored['name'], ++ 'brand' => (string) $replayRestored['brand'], ++ 'unit' => (string) $replayRestored['unit'], ++ 'settlement_price' => (string) $replayRestored['settlement_price'], ++ 'retail_price' => (string) $replayRestored['retail_price'], ++ 'status' => 0, ++ ])), 'admin edit must prepare the transactional restoration rollback case'); ++ $disabledBeforeRollback = Db::name('pharmacy_medicine')->where('id', (int) $medicineOne['id'])->find(); ++ $sequenceBeforeRollback = (int) Db::name('pharmacy_sequence') ++ ->where('sequence_name', 'medicine_catalog') ++ ->value('current_value'); ++ $batchCountBeforeRollback = $count('pharmacy_medicine_import_batch'); ++ $rollbackBatch = [ ++ 'source_system' => 'zyt', ++ 'import_id' => 'integration-batch-restore-rollback', ++ 'items' => [$itemOne, $continuation['items'][0]], ++ ]; ++ $rollbackBatch['items'][1]['retail_price'] = '9.9999'; ++ $restoreRolledBack = false; ++ try { ++ MedicineImportService::import(9001, $rollbackBatch); ++ } catch (DomainException $exception) { ++ $restoreRolledBack = str_contains($exception->getMessage(), 'Source medicine identity conflicts'); ++ } ++ $assertTrue($restoreRolledBack, 'later source conflict must reject a batch after an earlier restoration attempt'); ++ $assertSame( ++ $disabledBeforeRollback, ++ Db::name('pharmacy_medicine')->where('id', (int) $medicineOne['id'])->find(), ++ 'source conflict must roll back the earlier medicine restoration byte for byte' ++ ); ++ $assertSame( ++ $sequenceBeforeRollback, ++ (int) Db::name('pharmacy_sequence')->where('sequence_name', 'medicine_catalog')->value('current_value'), ++ 'source conflict must roll back the catalog version allocation' ++ ); ++ $assertSame($batchCountBeforeRollback, $count('pharmacy_medicine_import_batch'), 'source conflict must not persist a partial restoration batch'); ++ ++$passed; ++ + $unrelatedMedicineId = Db::name('pharmacy_medicine')->insertGetId([ 'medicine_code' => 'IT_UNRELATED', 'name' => 'Unrelated old medicine', 'brand' => '', -@@ -375,14 +375,13 @@ try { +@@ -375,14 +529,13 @@ try { $unrelatedAttempt = $continuation; $unrelatedAttempt['import_id'] = 'integration-batch-0005'; $unrelatedAttempt['items'][0]['source_medicine_id'] = '103'; diff --git a/artifacts/ej-medicine-incremental-sync/VERIFICATION.txt b/artifacts/ej-medicine-incremental-sync/VERIFICATION.txt index 85e640df4..c47ec355a 100644 --- a/artifacts/ej-medicine-incremental-sync/VERIFICATION.txt +++ b/artifacts/ej-medicine-incremental-sync/VERIFICATION.txt @@ -5,9 +5,10 @@ BRANCH_ZYT=codex/ej-medicine-incremental-sync BRANCH_EJ=codex/ej-additive-medicine-import ZYT_COMMIT=17e9e7b6b ZYT_PUSH=origin/codex/ej-medicine-incremental-sync -EJ_COMMIT=2637ffa +EJ_COMMIT=495b02341ac940d7f7b3dac5254fb6714b41311b EJ_PUSH=origin/codex/ej-additive-medicine-import CHANGED_BRANCH_FIELD=ZYT ej-pharmacy:push-medicines add-only command + EJ medicine-imports unrelated-catalog preservation +CLARIFIED_BEHAVIOR=restore soft-deleted ZYT-origin EJ medicine in place; re-enable disabled ZYT-origin medicine; append new ZYT medicine; preserve active existing and EJ-only medicines ARTIFACTS: MODIFIED_FILE=/Users/long/Work/zyt/artifacts/ej-medicine-incremental-sync/MODIFIED_FILE @@ -77,9 +78,9 @@ EXIT_STATUS=0 MODIFIED_6: COMMAND=cd /Users/long/Work/ej/server && php tests/pharmacy/medicine_import_mysql_integration.php INPUT=isolated temporary database containing an unrelated EJ medicine plus a new ZYT source medicine -LITERAL_OUTPUT=medicine import MySQL integration passed: 11 (temporary_database) +LITERAL_OUTPUT=medicine import MySQL integration passed: 15 (temporary_database) EXIT_STATUS=0 -MODIFIED_RESULT=unrelated EJ medicine preserved byte-for-byte; one new medicine/source mapping appended; temporary database cleaned +MODIFIED_RESULT=unrelated EJ medicine and active existing ZYT medicine preserved byte-for-byte; soft-deleted medicine restored in place with original id/code/stock; disabled medicine re-enabled; one new medicine appended; temporary database cleaned MODIFIED_7: COMMAND=php -l on ZYT service, ZYT command, ZYT console config, and EJ MedicineImportService @@ -88,11 +89,10 @@ LITERAL_OUTPUT=No syntax errors detected EXIT_STATUS=0 ROLLBACK: -COMMAND=ZYT_ROOT=/tmp/ej-sync-rollback.HWTG7D/zyt EJ_ROOT=/tmp/ej-sync-rollback.HWTG7D/ej /Users/long/Work/zyt/artifacts/ej-medicine-incremental-sync/ROLLBACK.sh -INPUT=detached copies at ZYT 27fbef932 and EJ a7439cc with both diffs applied; BEFORE_ZYT=6; BEFORE_EJ=3 +COMMAND=ZYT_ROOT=/tmp/ej-sync-rollback-latest.eAdq7T/zyt EJ_ROOT=/tmp/ej-sync-rollback-latest.eAdq7T/ej /Users/long/Work/zyt/artifacts/ej-medicine-incremental-sync/ROLLBACK.sh +INPUT=detached copies at ZYT 27fbef932 and EJ a7439cc with clarified diffs applied; BEFORE_ZYT=6; BEFORE_EJ=3 LITERAL_OUTPUT=ROLLBACK_OK: ZYT incremental push removed; EJ unrelated-catalog preflight restored. EXIT_STATUS=0 ROLLBACK_RESULT=AFTER_ZYT=0; AFTER_EJ=0; both copies restored to clean base behavior/status - -CURRENT_STATUS=both feature commits and ZYT verification artifacts are pushed; EJ retains only pre-existing unrelated deletions; production apply was not run +CURRENT_STATUS=clarified behavior commits are pushed and verification tracks the latest diffs; EJ retains only pre-existing unrelated deletions; production apply was not run RESTORED_BEHAVIOR=ROLLBACK removes the ZYT push command/service/docs/tests and restores EJ bootstrap-only unrelated-catalog rejection